From fe03860cc29965ff72d730533db21c51bf5efe05 Mon Sep 17 00:00:00 2001 From: Pratik Date: Wed, 6 May 2026 20:37:58 +0530 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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...") From 837b47061af232c8444e35b5acef572a6506386f Mon Sep 17 00:00:00 2001 From: Pratik Date: Thu, 14 May 2026 16:43:51 +0530 Subject: [PATCH 8/9] feat: interactive PromQL TUI, API key login, profile add fix, and interactive UX polish --- README.md | 97 ++-- cmd/promql.go | 28 +- main.go | 2 +- pkg/model/login/login.go | 10 +- pkg/model/promql.go | 1048 ++++++++++++++++++++++++++++++++++++++ pkg/model/query.go | 34 +- pkg/model/timeinput.go | 74 ++- 7 files changed, 1227 insertions(+), 66 deletions(-) create mode 100644 pkg/model/promql.go diff --git a/README.md b/README.md index 6c847fa..887397b 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ pb login 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 +- **Choose auth** — Username & Password, or API key - **Enter credentials** - **Name the profile** — e.g. `local`, `staging`, `prod` @@ -81,7 +81,7 @@ Profiles are stored in `~/.config/pb/config.toml` (macOS/Linux) or `%AppData%\pb ```bash 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 add staging https://staging.example.com admin password # 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 @@ -94,6 +94,69 @@ When you remove the default profile: - 2+ remaining → an interactive picker lets you choose the new default - 0 remaining → default is cleared +## Interactive Mode + +`pb` ships two full-screen terminal UIs — one for SQL, one for PromQL. Both open with `-i`. + +### SQL Interactive (`pb query run -i`) + +```bash +pb query run -i # open blank — write query inside +pb query run "SELECT * FROM backend" --from=1h -i # open with query pre-filled +``` + +Navigate panels with `Tab` / `Shift+Tab`: + +``` +[ Query ] → [ Time ] → [ Table ] +``` + +| Key | Action | +|-----|--------| +| `Tab` / `Shift+Tab` | Move between panels | +| `Enter` (Time panel) | Open time range picker | +| `Ctrl+R` | Run query | +| `Ctrl+B` | Fetch previous page | +| `Ctrl+C` | Exit | + +**Table panel:** + +| Key | Action | +|-----|--------| +| `↑` / `↓` | Scroll rows | +| `Shift+↑` / `Shift+↓` | Previous / next page | +| `←` / `→` | Scroll columns | +| `/` | Filter rows | +| `Esc` | Clear filter | + +--- + +### PromQL Interactive (`pb query promql run -i`) + +```bash +pb query promql run -i # open blank — write expression inside +pb query promql run "http_requests_total" --dataset otel_metrics --from=1h -i # open with expression pre-filled +``` + +Navigate panels with `Tab` / `Shift+Tab`: + +``` +[ Dataset ] → [ Query ] → [ Time ] → [ Step ] → [ Table ] +``` + +| Key | Action | +|-----|--------| +| `Tab` / `Shift+Tab` | Move between panels | +| `Enter` (Dataset panel) | Open dataset picker | +| `Enter` (Time panel) | Open time range / evaluation time picker | +| `Space` (Step panel) | Toggle range / instant mode | +| `Ctrl+R` | Run query | +| `Ctrl+C` | Exit | + +**Table panel** — same keys as SQL interactive (↑ ↓ rows, ← → columns, `/` filter). + +--- + ### SQL Query Query a dataset and print results to stdout. @@ -117,12 +180,6 @@ pb query run "SELECT * FROM backend" \ pb query run "SELECT * FROM backend" --from=1h --output json | jq . ``` -**Interactive table view** — navigate, filter, and paginate results in the terminal: - -```bash -pb query run "SELECT * FROM backend" --from=1h -i -``` - **Save a query for later:** ```bash @@ -135,30 +192,6 @@ pb query list # list and apply saved queries > 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. diff --git a/cmd/promql.go b/cmd/promql.go index 4e7c879..45ea24c 100644 --- a/cmd/promql.go +++ b/cmd/promql.go @@ -26,7 +26,9 @@ import ( "time" internalHTTP "pb/pkg/http" + "pb/pkg/model" + tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" ) @@ -65,6 +67,7 @@ func init() { 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") + promqlRunCmd.Flags().BoolP("interactive", "i", false, "Open interactive TUI") // flags: labels promqlLabelsCmd.Flags().StringP("dataset", "d", defaultMetricsStream, "Metrics dataset") @@ -185,7 +188,7 @@ var promqlRunCmd = &cobra.Command{ 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), + Args: cobra.MaximumNArgs(1), PreRunE: PreRunDefaultProfile, RunE: runPromqlQuery, } @@ -209,19 +212,40 @@ type promqlSeries struct { } func runPromqlQuery(cmd *cobra.Command, args []string) error { - expr := args[0] + var expr string + if len(args) > 0 { + 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") + interactive, _ := cmd.Flags().GetBool("interactive") toTime, err := parseTimeStr(toStr) if err != nil { return fmt.Errorf("invalid --to: %w", err) } + if interactive { + startTime, err := parseTimeStr(fromStr) + if err != nil { + return fmt.Errorf("invalid --from: %w", err) + } + m := model.NewPromqlModel(DefaultProfile, expr, startTime, toTime, step, stream, instant) + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err = p.Run() + return err + } + + if strings.TrimSpace(expr) == "" { + fmt.Println("Please enter a PromQL expression") + fmt.Printf("Example:\n pb query promql run \"http_requests_total\" --dataset otel_metrics\n pb query promql run -i\n") + return nil + } + params := url.Values{} params.Set("query", expr) params.Set("stream", stream) diff --git a/main.go b/main.go index 2cef4cc..45af08a 100644 --- a/main.go +++ b/main.go @@ -70,7 +70,7 @@ var profile = &cobra.Command{ Use: "profile", Short: "Manage different Parseable targets", Long: "\nuse profile command to configure different Parseable instances. Each profile takes a URL and credentials.", - PersistentPreRunE: combinedPreRun, + PersistentPreRunE: analytics.CheckAndCreateULID, PersistentPostRun: func(cmd *cobra.Command, args []string) { if os.Getenv("PB_ANALYTICS") == "disable" { return diff --git a/pkg/model/login/login.go b/pkg/model/login/login.go index 0b26ef4..2b89012 100644 --- a/pkg/model/login/login.go +++ b/pkg/model/login/login.go @@ -100,7 +100,7 @@ func New() Model { passwordInput.EchoMode = textinput.EchoPassword passwordInput.EchoCharacter = '•' - tokenInput := newInput("paste token here", 512) + tokenInput := newInput("paste API key here", 512) profileInput := newInput("e.g. local, staging, prod", 64) profileInput.SetValue("default") @@ -259,7 +259,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyEnter: if strings.TrimSpace(m.tokenInput.Value()) == "" { - m.errMsg = "Token is required" + m.errMsg = "API key is required" return m, nil } m.errMsg = "" @@ -433,7 +433,7 @@ func (m Model) View() string { b.WriteString(breadcrumb("Self-hosted")) b.WriteString(labelStyle.Render("Authentication")) b.WriteString("\n\n") - authEntries := []string{"Username & Password", "Token"} + authEntries := []string{"Username & Password", "API key"} for i, entry := range authEntries { if i == m.authIndex { b.WriteString(selectedStyle.Render(" ❯ " + entry)) @@ -465,7 +465,7 @@ func (m Model) View() string { case stepEnterToken: b.WriteString(breadcrumb("Self-hosted")) - b.WriteString(labelStyle.Render("Token")) + b.WriteString(labelStyle.Render("API key")) b.WriteString("\n\n ") b.WriteString(m.tokenInput.View()) b.WriteString("\n\n") @@ -508,7 +508,7 @@ func (m Model) View() string { } if m.Profile.Token != "" { b.WriteString(labelStyle.Render(" Auth: ")) - b.WriteString(normalStyle.Render("token (stored)")) + b.WriteString(normalStyle.Render("API key (stored)")) b.WriteString("\n") } b.WriteString("\n") diff --git a/pkg/model/promql.go b/pkg/model/promql.go new file mode 100644 index 0000000..39eeb3f --- /dev/null +++ b/pkg/model/promql.go @@ -0,0 +1,1048 @@ +// 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 model + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "pb/pkg/config" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + table "github.com/evertras/bubble-table/table" + "golang.org/x/term" +) + +// ─── constants ─────────────────────────────────────────────────────────────── + +const ( + promqlTimestampKey = "timestamp" + promqlMetricKey = "metric" + promqlValueKey = "value" + promqlTimestampWidth = 20 + + // header panel outer widths (inner = outer - 2 for borders) + datasetPanelOuter = 30 + timePanelOuter = 38 + stepModePanelOuter = 14 + + // spotlight modal width + spotlightWidth = 58 + spotlightMaxItems = 12 +) + +// overlay states (overlayNone and overlayInputs are defined in query.go) +const overlayDataset uint = 2 + +var PromqlNavigationMap = []string{"dataset", "query", "time", "step", "table"} + +var promqlAdditionalKeyBinds = []key.Binding{runQueryKey} + +// ─── response types ────────────────────────────────────────────────────────── + +type promqlRespModel struct { + Status string `json:"status"` + Data promqlDataModel `json:"data"` + Error string `json:"error,omitempty"` + ErrorType string `json:"errorType,omitempty"` +} + +type promqlDataModel struct { + ResultType string `json:"resultType"` + Result []promqlSeriesModel `json:"result"` +} + +type promqlSeriesModel struct { + Metric map[string]string `json:"metric"` + Value []any `json:"value,omitempty"` + Values [][]any `json:"values,omitempty"` +} + +// ─── message types ─────────────────────────────────────────────────────────── + +// PromqlFetchData is the message returned by NewPromqlFetchTask. +type PromqlFetchData struct { + status FetchResult + resultType string + rows []table.Row + seriesCount int + metricWidth int + valueWidth int + errMsg string +} + +// datasetListMsg carries the list of streams fetched from the server. +type datasetListMsg struct { + datasets []string + errMsg string +} + +// ─── model ─────────────────────────────────────────────────────────────────── + +// PromqlModel is the Bubble Tea model for interactive PromQL queries. +type PromqlModel struct { + width, height int + table table.Model + query textarea.Model + timeRange TimeInputModel + profile config.Profile + help help.Model + status StatusBar + spinner spinner.Model + + loading bool + hasQueried bool + overlay uint + focused int + dataRows []table.Row + fetchErrMsg string + lastResultType string + seriesCount int + + // query parameters + dataset string + step string + instant bool + + // step panel state + stepInput textinput.Model + + // dataset spotlight state + spotlightFilter textinput.Model + allDatasets []string + filteredDatasets []string + datasetSelectedIdx int + datasetsLoading bool +} + +func (m *PromqlModel) focusSelected() { + m.query.Blur() + m.table = m.table.Focused(false) + m.spotlightFilter.Blur() + m.stepInput.Blur() + switch m.currentFocus() { + case "query": + m.query.Focus() + case "step": + m.stepInput.Focus() + case "table": + m.table = m.table.Focused(true) + } +} + +func (m *PromqlModel) currentFocus() string { + return PromqlNavigationMap[m.focused] +} + +func (m *PromqlModel) queryWidth() int { + w := m.width - datasetPanelOuter - timePanelOuter - stepModePanelOuter - 2 + if w < 30 { + w = 30 + } + return w +} + +// ─── constructor ───────────────────────────────────────────────────────────── + +func NewPromqlModel(profile config.Profile, expr string, startTime, endTime time.Time, step, dataset string, instant bool) PromqlModel { + w, h, _ := term.GetSize(int(os.Stdout.Fd())) + + inputs := NewTimeInputModel(startTime, endTime) + inputs.SetInstant(instant) + + columns := []table.Column{ + table.NewColumn(promqlTimestampKey, "timestamp", promqlTimestampWidth), + table.NewFlexColumn(promqlMetricKey, "metric", 1), + table.NewColumn(promqlValueKey, "value", 10), + } + + pageSize := h - 14 + if pageSize < 5 { + pageSize = 5 + } + + tbl := table.New(columns). + WithRows([]table.Row{}). + Filtered(true). + HeaderStyle(headerStyle). + SelectableRows(false). + Border(customBorder). + Focused(false). + WithKeyMap(tableKeyBinds). + WithPageSize(pageSize). + WithBaseStyle(tableStyle). + WithMissingDataIndicatorStyled(table.StyledCell{ + Style: lipgloss.NewStyle().Foreground(StandardSecondary), + Data: "╌", + }).WithTargetWidth(w) + + qw := w - datasetPanelOuter - timePanelOuter - stepModePanelOuter - 2 + if qw < 30 { + qw = 30 + } + q := textarea.New() + q.MaxHeight = 0 + q.MaxWidth = 0 + q.SetHeight(2) + q.SetWidth(qw) + q.ShowLineNumbers = true + q.SetValue(expr) + q.Placeholder = "write your PromQL expression here..." + q.KeyMap = textAreaKeyMap + q.Focus() + + si := textinput.New() + si.SetValue(step) + si.Width = stepModePanelOuter - 10 + si.Blur() + + sf := textinput.New() + sf.Placeholder = "search datasets..." + sf.Width = spotlightWidth - 6 + sf.Blur() + + hlp := help.New() + hlp.Styles.FullDesc = lipgloss.NewStyle().Foreground(FocusSecondary) + + stat := NewStatusBar(profile.URL, w) + + sp := spinner.New() + sp.Spinner = spinner.Line + sp.Style = lipgloss.NewStyle().Foreground(FocusPrimary) + + hasQuery := strings.TrimSpace(expr) != "" + return PromqlModel{ + width: w, + height: h, + table: tbl, + query: q, + timeRange: inputs, + overlay: overlayNone, + profile: profile, + help: hlp, + spinner: sp, + loading: hasQuery, + hasQueried: hasQuery, + status: stat, + dataset: dataset, + step: step, + instant: instant, + + stepInput: si, + spotlightFilter: sf, + } +} + +// ─── bubbletea lifecycle ───────────────────────────────────────────────────── + +func (m PromqlModel) Init() tea.Cmd { + if strings.TrimSpace(m.query.Value()) == "" { + return m.spinner.Tick + } + return tea.Batch( + m.spinner.Tick, + NewPromqlFetchTask(m.profile, m.query.Value(), m.dataset, m.step, + m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc(), m.instant), + ) +} + +func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd 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 + m.help.Width = m.width + m.status.width = m.width + m.query.SetWidth(m.queryWidth()) + m.stepInput.Width = stepModePanelOuter - 10 + m.spotlightFilter.Width = spotlightWidth - 6 + m.updateTableColumns(0, 0) // reflow columns to new terminal width + return m, nil + + case datasetListMsg: + m.datasetsLoading = false + if msg.errMsg != "" { + m.status.Error = "could not load datasets: " + msg.errMsg + } else { + m.allDatasets = msg.datasets + m.filteredDatasets = msg.datasets + m.datasetSelectedIdx = 0 + // pre-select current dataset + for i, ds := range m.filteredDatasets { + if ds == m.dataset { + m.datasetSelectedIdx = i + break + } + } + } + return m, nil + + case PromqlFetchData: + m.loading = false + m.status.Info = "" + if msg.status == fetchOk { + m.fetchErrMsg = "" + m.status.Error = "" + m.dataRows = msg.rows + m.lastResultType = msg.resultType + m.seriesCount = msg.seriesCount + mode := "range" + if m.instant { + mode = "instant" + } + m.status.Info = fmt.Sprintf("%d rows %d series %s step=%s ds=%s", + len(m.dataRows), m.seriesCount, mode, m.step, m.dataset) + m.updateTableColumns(msg.metricWidth, msg.valueWidth) + } 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 + + case tea.KeyMsg: + // ── dataset spotlight overlay ──────────────────────────────────────── + if m.overlay == overlayDataset { + switch msg.Type { + case tea.KeyEsc: + m.overlay = overlayNone + m.spotlightFilter.SetValue("") + m.spotlightFilter.Blur() + m.focusSelected() + return m, nil + + case tea.KeyEnter: + if len(m.filteredDatasets) > 0 { + m.dataset = m.filteredDatasets[m.datasetSelectedIdx] + } + m.overlay = overlayNone + m.spotlightFilter.SetValue("") + m.spotlightFilter.Blur() + m.focusSelected() + return m, nil + + case tea.KeyUp: + if m.datasetSelectedIdx > 0 { + m.datasetSelectedIdx-- + } + return m, nil + + case tea.KeyDown: + if m.datasetSelectedIdx < len(m.filteredDatasets)-1 { + m.datasetSelectedIdx++ + } + return m, nil + + default: + prev := m.spotlightFilter.Value() + m.spotlightFilter, cmd = m.spotlightFilter.Update(msg) + cmds = append(cmds, cmd) + if m.spotlightFilter.Value() != prev { + m.filteredDatasets = filterDatasets(m.allDatasets, m.spotlightFilter.Value()) + m.datasetSelectedIdx = 0 + } + return m, tea.Batch(cmds...) + } + } + + // ── time overlay ───────────────────────────────────────────────────── + if m.overlay == overlayInputs { + if msg.Type == tea.KeyEnter { + m.overlay = overlayNone + m.focusSelected() + m.status.Error = "" + m.status.Info = "" + m.loading = true + m.hasQueried = true + return m, tea.Batch(m.spinner.Tick, + NewPromqlFetchTask(m.profile, m.query.Value(), m.dataset, m.step, + m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc(), m.instant)) + } + m.timeRange, cmd = m.timeRange.Update(msg) + return m, cmd + } + + // ── main navigation ────────────────────────────────────────────────── + if msg.Type == tea.KeyTab { + m.focused++ + if m.focused > len(PromqlNavigationMap)-1 { + m.focused = 0 + } + m.focusSelected() + return m, nil + } + if msg.Type == tea.KeyShiftTab { + m.focused-- + if m.focused < 0 { + m.focused = len(PromqlNavigationMap) - 1 + } + m.focusSelected() + return m, nil + } + + // Enter on dataset → open spotlight + if msg.Type == tea.KeyEnter && m.currentFocus() == "dataset" { + m.overlay = overlayDataset + m.spotlightFilter.Focus() + m.datasetsLoading = true + return m, tea.Batch(fetchDatasetList(m.profile)) + } + + // Enter on time → open time overlay + if msg.Type == tea.KeyEnter && m.currentFocus() == "time" { + m.overlay = overlayInputs + return m, nil + } + + // Ctrl+R → run query + if msg.Type == tea.KeyCtrlR { + m.overlay = overlayNone + m.status.Error = "" + m.status.Info = "" + m.loading = true + m.hasQueried = true + return m, tea.Batch(m.spinner.Tick, + NewPromqlFetchTask(m.profile, m.query.Value(), m.dataset, m.step, + m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc(), m.instant)) + } + + // Space on step panel toggles instant/range mode + if msg.Type == tea.KeySpace && m.currentFocus() == "step" { + m.instant = !m.instant + m.timeRange.SetInstant(m.instant) + if m.instant { + // default end to now-1h so instant query lands within data range + m.timeRange.SetEnd(time.Now().Add(-1 * time.Hour)) + } else { + // switching back to range: reset end to now so presets work correctly + m.timeRange.SetEnd(time.Now()) + } + return m, nil + } + + switch msg.Type { + case tea.KeyCtrlC: + return m, tea.Quit + default: + switch m.currentFocus() { + case "query": + m.query, cmd = m.query.Update(msg) + cmds = append(cmds, cmd) + case "step": + m.stepInput, cmd = m.stepInput.Update(msg) + m.step = m.stepInput.Value() + cmds = append(cmds, cmd) + case "table": + m.table, cmd = m.table.Update(msg) + cmds = append(cmds, cmd) + } + } + } + + return m, tea.Batch(cmds...) +} + +// ─── view ──────────────────────────────────────────────────────────────────── + +func (m PromqlModel) View() string { + if m.width == 0 || m.height == 0 { + return "" + } + + // ── header panels ──────────────────────────────────────────────────────── + dsName := m.dataset + if len(dsName) > datasetPanelOuter-4 { + dsName = dsName[:datasetPanelOuter-7] + "..." + } + datasetPane := lipgloss.JoinVertical(lipgloss.Left, + baseBoldUnderlinedStyle.Render(" dataset "), + dsName, + ) + + mode := "range" + modeColor := lipgloss.AdaptiveColor{Light: "28", Dark: "82"} // green = range + if m.instant { + mode = "instant" + modeColor = lipgloss.AdaptiveColor{Light: "208", Dark: "214"} // orange = instant + } + modeLabel := lipgloss.NewStyle().Foreground(modeColor).Bold(true).Render(mode) + + var stepRow string + if m.instant { + dimmed := lipgloss.NewStyle().Foreground(StandardSecondary).Render("--") + stepRow = fmt.Sprintf("%s %s", baseBoldUnderlinedStyle.Render(" step "), dimmed) + } else if m.currentFocus() == "step" { + stepRow = fmt.Sprintf("%s %s", baseBoldUnderlinedStyle.Render(" step "), m.stepInput.View()) + } else { + stepRow = fmt.Sprintf("%s %s", baseBoldUnderlinedStyle.Render(" step "), m.step) + } + stepModePane := lipgloss.JoinVertical(lipgloss.Left, + stepRow, + fmt.Sprintf("%s %s", baseBoldUnderlinedStyle.Render(" mode "), modeLabel), + ) + + 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()), + ) + + // pick border styles based on focused panel + dsOuter, queryOuter, timeOuter, stepOuter := &borderedStyle, &borderedStyle, &borderedStyle, &borderedStyle + tableOuter := lipgloss.NewStyle() + switch m.currentFocus() { + case "dataset": + dsOuter = &borderedFocusStyle + case "query": + queryOuter = &borderedFocusStyle + case "time": + timeOuter = &borderedFocusStyle + case "step": + stepOuter = &borderedFocusStyle + case "table": + tableOuter = tableOuter.Border(lipgloss.DoubleBorder(), false, false, false, true). + BorderForeground(FocusPrimary) + } + + // render fixed panels first so we can measure their real widths + dsRendered := dsOuter.Render(datasetPane) + timeRendered := timeOuter.Render(timePane) + stepRendered := stepOuter.Render(stepModePane) + fixedW := lipgloss.Width(dsRendered) + lipgloss.Width(timeRendered) + lipgloss.Width(stepRendered) + queryW := m.width - fixedW + if queryW < 30 { + queryW = 30 + } + m.query.SetWidth(queryW - 2) // -2 for query panel border + header := lipgloss.JoinHorizontal(lipgloss.Top, + dsRendered, + queryOuter.Render(m.query.View()), + timeRendered, + stepRendered, + ) + headerHeight := lipgloss.Height(header) + + if m.loading { + m.status.Info = "" + m.status.Error = "" + } + statusView := m.status.View() + statusHeight := lipgloss.Height(statusView) + + // ── help ───────────────────────────────────────────────────────────────── + var helpKeys [][]key.Binding + switch m.overlay { + case overlayNone: + switch m.currentFocus() { + case "dataset": + helpKeys = [][]key.Binding{ + {key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "pick dataset"))}, + {promqlAdditionalKeyBinds[0]}, + } + case "query": + helpKeys = TextAreaHelpKeys{}.FullHelp() + case "time": + timeHint := "edit time range" + if m.instant { + timeHint = "set evaluation time (instant)" + } + helpKeys = [][]key.Binding{ + {key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", timeHint))}, + {promqlAdditionalKeyBinds[0]}, + } + case "step": + helpKeys = [][]key.Binding{ + { + key.NewBinding(key.WithKeys("type"), key.WithHelp("type", "edit step (e.g. 15s, 5m, 1h)")), + key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle range/instant")), + }, + { + promqlAdditionalKeyBinds[0], + }, + } + case "table": + helpKeys = tableHelpBinds.FullHelp() + helpKeys = append(helpKeys, promqlAdditionalKeyBinds) + } + case overlayInputs: + helpKeys = m.timeRange.FullHelp() + helpKeys = append(helpKeys, promqlAdditionalKeyBinds) + case overlayDataset: + helpKeys = [][]key.Binding{{ + key.NewBinding(key.WithKeys("↑/↓"), key.WithHelp("↑/↓", "navigate")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), + }} + } + helpView := m.help.FullHelpView(helpKeys) + helpHeight := lipgloss.Height(helpView) + + // ── result area ────────────────────────────────────────────────────────── + tableAvail := m.height - headerHeight - helpHeight - statusHeight + pageSize := tableAvail - 6 + if pageSize < 1 { + pageSize = 1 + } + + displayRows := make([]table.Row, pageSize) + copy(displayRows, m.dataRows) + m.table = m.table.WithPageSize(pageSize).WithRows(displayRows).WithTargetWidth(m.width) + + availW := m.width + if availW < 0 { + availW = 0 + } + availH := tableAvail - 2 + if availH < 0 { + availH = 0 + } + tableOuter = tableOuter.Width(m.width) + + var resultPane string + if !m.hasQueried { + 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 a PromQL expression 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 { + 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 != "" { + errStyle := lipgloss.NewStyle(). + Padding(1, 2). + Foreground(lipgloss.AdaptiveColor{Light: "#9B2226", Dark: "#FF6B6B"}). + Width(m.width) + 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()) + } + + // ── compose main or overlay view ───────────────────────────────────────── + var mainView string + switch m.overlay { + case overlayNone: + mainView = lipgloss.JoinVertical(lipgloss.Left, header, resultPane) + case overlayInputs: + timeView := m.timeRange.View() + mainView = lipgloss.Place(m.width, m.height-helpHeight-statusHeight, + lipgloss.Center, lipgloss.Center, timeView, + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(StandardSecondary), + ) + case overlayDataset: + // render the normal content behind, then paint spotlight on top + behind := lipgloss.JoinVertical(lipgloss.Left, header, resultPane) + spotlight := m.renderSpotlight() + mainView = lipgloss.Place(m.width, m.height-helpHeight-statusHeight, + lipgloss.Center, lipgloss.Center, spotlight, + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(StandardSecondary), + ) + _ = behind + } + + 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) +} + +// renderSpotlight builds the dataset picker modal. +func (m PromqlModel) renderSpotlight() string { + innerW := spotlightWidth - 2 + + titleStyle := lipgloss.NewStyle(). + Foreground(FocusPrimary). + Bold(true). + Width(innerW). + Align(lipgloss.Center) + title := titleStyle.Render("Select Dataset") + + searchStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(FocusSecondary). + Width(innerW-2). + Padding(0, 1) + searchBar := searchStyle.Render(m.spotlightFilter.View()) + + var listLines []string + if m.datasetsLoading { + loadStyle := lipgloss.NewStyle(). + Foreground(StandardSecondary). + Width(innerW). + Align(lipgloss.Center). + Padding(1, 0) + listLines = append(listLines, loadStyle.Render(m.spinner.View()+" loading…")) + } else if len(m.filteredDatasets) == 0 { + emptyStyle := lipgloss.NewStyle(). + Foreground(StandardSecondary). + Width(innerW). + Align(lipgloss.Center). + Padding(1, 0) + listLines = append(listLines, emptyStyle.Render("no datasets found")) + } else { + limit := len(m.filteredDatasets) + if limit > spotlightMaxItems { + limit = spotlightMaxItems + } + // scroll window around selected index + start := 0 + if m.datasetSelectedIdx >= spotlightMaxItems { + start = m.datasetSelectedIdx - spotlightMaxItems + 1 + } + for i := start; i < start+limit && i < len(m.filteredDatasets); i++ { + ds := m.filteredDatasets[i] + if i == m.datasetSelectedIdx { + row := lipgloss.NewStyle(). + Background(FocusPrimary). + Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). + Width(innerW). + Padding(0, 1). + Bold(true). + Render("▸ " + ds) + listLines = append(listLines, row) + } else { + row := lipgloss.NewStyle(). + Width(innerW). + Padding(0, 1). + Render(" " + ds) + listLines = append(listLines, row) + } + } + if len(m.filteredDatasets) > spotlightMaxItems { + more := lipgloss.NewStyle(). + Foreground(StandardSecondary). + Width(innerW). + Align(lipgloss.Right). + Render(fmt.Sprintf(" +%d more", len(m.filteredDatasets)-spotlightMaxItems)) + listLines = append(listLines, more) + } + } + + body := lipgloss.JoinVertical(lipgloss.Left, + title, + searchBar, + strings.Join(listLines, "\n"), + ) + + modal := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(FocusPrimary). + Padding(0, 1). + Width(spotlightWidth). + Render(body) + + return modal +} + +// updateTableColumns rebuilds table columns. valueWidth is inferred from data; +// the metric column is a flex column that fills all remaining width automatically. +func (m *PromqlModel) updateTableColumns(_, valueWidth int) { + if valueWidth < len(promqlValueKey) { + valueWidth = len(promqlValueKey) + } + columns := []table.Column{ + table.NewColumn(promqlTimestampKey, "timestamp", promqlTimestampWidth), + table.NewFlexColumn(promqlMetricKey, "metric", 1).WithFiltered(true), + table.NewColumn(promqlValueKey, "value", valueWidth).WithFiltered(true), + } + m.table = m.table.WithColumns(columns).WithTargetWidth(m.width).WithRows(m.dataRows) +} + +// ─── async commands ─────────────────────────────────────────────────────────── + +// fetchDatasetList loads all streams from the server for the spotlight picker. +func fetchDatasetList(profile config.Profile) tea.Cmd { + return func() tea.Msg { + reqURL, err := url.JoinPath(profile.URL, "api/v1/logstream") + if err != nil { + return datasetListMsg{errMsg: err.Error()} + } + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return datasetListMsg{errMsg: err.Error()} + } + if profile.Token != "" { + req.Header.Set("Authorization", "Bearer "+profile.Token) + } else { + req.SetBasicAuth(profile.Username, profile.Password) + } + resp, err := client.Do(req) + if err != nil { + return datasetListMsg{errMsg: err.Error()} + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + var items []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(body, &items); err != nil { + return datasetListMsg{errMsg: err.Error()} + } + datasets := make([]string, len(items)) + for i, item := range items { + datasets[i] = item.Name + } + sort.Strings(datasets) + return datasetListMsg{datasets: datasets} + } +} + +// NewPromqlFetchTask returns a Bubble Tea command that fetches PromQL data asynchronously. +func NewPromqlFetchTask(profile config.Profile, expr, dataset, step, startTime, endTime string, instant bool) tea.Cmd { + return func() (msg tea.Msg) { + res := PromqlFetchData{status: fetchErr} + defer func() { + if r := recover(); r != nil { + res.errMsg = fmt.Sprintf("panic: %v", r) + msg = res + } + }() + + params := url.Values{} + params.Set("query", expr) + params.Set("stream", dataset) + + var apiPath string + if instant { + apiPath = "prometheus/api/v1/query" + params.Set("time", endTime) + } else { + apiPath = "prometheus/api/v1/query_range" + params.Set("start", startTime) + params.Set("end", endTime) + params.Set("step", step) + } + + body, err := promqlModelFetch(profile, apiPath, params) + if err != nil { + res.errMsg = err.Error() + return res + } + + var result promqlRespModel + if err := json.Unmarshal(body, &result); err != nil { + res.errMsg = fmt.Sprintf("failed to parse response: %s", err) + return res + } + if result.Status == "error" { + res.errMsg = fmt.Sprintf("%s: %s", result.ErrorType, result.Error) + return res + } + + rows, seriesCount, metricWidth, valueWidth := promqlResultToRows(result) + res.status = fetchOk + res.resultType = result.Data.ResultType + res.rows = rows + res.seriesCount = seriesCount + res.metricWidth = metricWidth + res.valueWidth = valueWidth + return res + } +} + +func promqlModelFetch(profile config.Profile, path string, params url.Values) ([]byte, error) { + reqURL, err := url.JoinPath(profile.URL, path) + if err != nil { + return nil, err + } + if len(params) > 0 { + reqURL += "?" + params.Encode() + } + + client := &http.Client{ + Timeout: 120 * time.Second, + Transport: &http.Transport{ + TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper), + }, + } + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + if profile.Token != "" { + req.Header.Set("Authorization", "Bearer "+profile.Token) + } else { + req.SetBasicAuth(profile.Username, profile.Password) + } + + resp, err := 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() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + errMsg := strings.TrimSpace(string(body)) + if errMsg == "" { + errMsg = resp.Status + } + return nil, fmt.Errorf("HTTP %s: %s", resp.Status, errMsg) + } + return body, nil +} + +// ─── data conversion ────────────────────────────────────────────────────────── + +func promqlResultToRows(result promqlRespModel) (rows []table.Row, seriesCount, metricWidth, valueWidth int) { + metricWidth = len(promqlMetricKey) + valueWidth = len(promqlValueKey) + + for _, series := range result.Data.Result { + metricStr := promqlModelFormatLabels(series.Metric) + if len(metricStr) > metricWidth { + metricWidth = len(metricStr) + } + + switch result.Data.ResultType { + case "vector": + if len(series.Value) == 2 { + ts := promqlModelFormatTS(series.Value[0]) + val := fmt.Sprintf("%v", series.Value[1]) + if len(val) > valueWidth { + valueWidth = len(val) + } + rows = append(rows, table.NewRow(table.RowData{ + promqlTimestampKey: ts, + promqlMetricKey: metricStr, + promqlValueKey: val, + })) + } + case "matrix": + for _, pt := range series.Values { + if len(pt) == 2 { + ts := promqlModelFormatTS(pt[0]) + val := fmt.Sprintf("%v", pt[1]) + if len(val) > valueWidth { + valueWidth = len(val) + } + rows = append(rows, table.NewRow(table.RowData{ + promqlTimestampKey: ts, + promqlMetricKey: metricStr, + promqlValueKey: val, + })) + } + } + } + } + + seriesCount = len(result.Data.Result) + return +} + +func promqlModelFormatLabels(m map[string]string) string { + name := m["__name__"] + var labels []string + for k, v := range m { + if k != "__name__" { + labels = append(labels, k+`="`+v+`"`) + } + } + sort.Strings(labels) + if len(labels) == 0 { + return name + } + if name == "" { + return "{" + strings.Join(labels, ", ") + "}" + } + return fmt.Sprintf("%s{%s}", name, strings.Join(labels, ", ")) +} + +func promqlModelFormatTS(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) +} + +// filterDatasets returns entries in all that contain query (case-insensitive). +func filterDatasets(all []string, query string) []string { + if query == "" { + return all + } + q := strings.ToLower(query) + var out []string + for _, ds := range all { + if strings.Contains(strings.ToLower(ds), q) { + out = append(out, ds) + } + } + return out +} diff --git a/pkg/model/query.go b/pkg/model/query.go index cea57f4..2cad7d2 100644 --- a/pkg/model/query.go +++ b/pkg/model/query.go @@ -141,13 +141,13 @@ type QueryModel struct { func (m *QueryModel) focusSelected() { m.query.Blur() - m.table.Focused(false) + m.table = m.table.Focused(false) switch m.currentFocus() { case "query": m.query.Focus() case "table": - m.table.Focused(true) + m.table = m.table.Focused(true) } } @@ -184,7 +184,7 @@ func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime t WithMissingDataIndicatorStyled(table.StyledCell{ Style: lipgloss.NewStyle().Foreground(StandardSecondary), Data: "╌", - }).WithMaxTotalWidth(100) + }).WithMaxTotalWidth(w) query := textarea.New() query.MaxHeight = 0 @@ -254,7 +254,6 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.help.Width = m.width m.status.width = m.width m.table = m.table.WithMaxTotalWidth(m.width) - m.query.SetWidth(int(m.width - 41)) return m, nil case FetchData: @@ -382,9 +381,16 @@ func (m QueryModel) View() string { BorderForeground(FocusPrimary) } + // render time first so query gets exactly the remaining width + timeRendered := timeOuter.Render(timePane) + queryW := m.width - lipgloss.Width(timeRendered) + if queryW < 30 { + queryW = 30 + } + m.query.SetWidth(queryW - 2) // -2 for query panel border header := lipgloss.JoinHorizontal(lipgloss.Top, queryOuter.Render(m.query.View()), - timeOuter.Render(timePane), + timeRendered, ) headerHeight := lipgloss.Height(header) @@ -430,10 +436,11 @@ func (m QueryModel) View() string { displayRows := make([]table.Row, pageSize) copy(displayRows, m.dataRows) - m.table = m.table.WithPageSize(pageSize).WithRows(displayRows) + m.table = m.table.WithPageSize(pageSize).WithRows(displayRows).WithMaxTotalWidth(m.width) + tableOuter = tableOuter.Width(m.width) // Step 4: compose main view. - availW := m.width - 6 + availW := m.width if availW < 0 { availW = 0 } @@ -476,7 +483,7 @@ func (m QueryModel) View() string { errStyle := lipgloss.NewStyle(). Padding(1, 2). Foreground(lipgloss.AdaptiveColor{Light: "#9B2226", Dark: "#FF6B6B"}). - Width(m.width - 6) + Width(m.width) rendered := errStyle.Render(m.fetchErrMsg) lines := strings.Split(rendered, "\n") maxLines := tableAvail - 2 @@ -496,7 +503,12 @@ func (m QueryModel) View() string { case overlayNone: mainView = lipgloss.JoinVertical(lipgloss.Left, header, resultPane) case overlayInputs: - mainView = m.timeRange.View() + timeView := m.timeRange.View() + mainView = lipgloss.Place(m.width, m.height-helpHeight-statusHeight, + lipgloss.Center, lipgloss.Center, timeView, + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(StandardSecondary), + ) } // Pin help+status to the bottom by padding the main view to fill remaining height. @@ -720,7 +732,7 @@ func (m *QueryModel) UpdateTable(data FetchData) { } } - // Build table.Columns from scaled specs. + // Build table.Columns from scaled specs (all fixed-width for horizontal scroll support). columns := make([]table.Column, 0, len(specs)) for _, s := range specs { col := table.NewColumn(s.key, s.title, s.width) @@ -735,7 +747,7 @@ func (m *QueryModel) UpdateTable(data FetchData) { m.dataRows[i] = table.NewRow(rowJSON) } - m.table = m.table.WithColumns(columns) + m.table = m.table.WithColumns(columns).WithMaxTotalWidth(m.width) m.table = m.table.WithRows(m.dataRows) } diff --git a/pkg/model/timeinput.go b/pkg/model/timeinput.go index 7144ba7..77ccb8b 100644 --- a/pkg/model/timeinput.go +++ b/pkg/model/timeinput.go @@ -59,10 +59,23 @@ var endHelpBinds = endTimeKeyBind{ } type TimeInputModel struct { - start datetime.Model - end datetime.Model - list list.Model - focus int + start datetime.Model + end datetime.Model + list list.Model + focus int + instant bool // when true: hides start, presets move end backwards from now +} + +// SetInstant switches between range (start+end) and instant (end only) mode. +func (m *TimeInputModel) SetInstant(v bool) { + m.instant = v + // stay on list so arrow keys immediately work on presets + m.focus = 0 + m.focusSelected() + if v { + // pre-select "1 Hour" in the list to match the default end=now-1h + m.list.Select(1) + } } func (m *TimeInputModel) StartValueUtc() string { @@ -81,6 +94,12 @@ func (m *TimeInputModel) SetEnd(t time.Time) { m.end.SetTime(t) } +// FocusEnd jumps directly to the end-time field — used by instant mode. +func (m *TimeInputModel) FocusEnd() { + m.focus = 2 // index of "end" in rangeNavigationMap + m.focusSelected() +} + func (m *TimeInputModel) focusSelected() { m.start.Blur() m.end.Blur() @@ -94,17 +113,32 @@ func (m *TimeInputModel) focusSelected() { } func (m *TimeInputModel) Navigate(key tea.KeyMsg) { + n := len(rangeNavigationMap) switch key.String() { case "shift+tab": if m.focus == 0 { - m.focus = len(rangeNavigationMap) + m.focus = n } m.focus-- + // instant mode: skip "start" + if m.instant && m.currentFocus() == "start" { + if m.focus == 0 { + m.focus = n + } + m.focus-- + } case "tab": - if m.focus == len(rangeNavigationMap)-1 { + if m.focus == n-1 { m.focus = -1 } m.focus++ + // instant mode: skip "start" + if m.instant && m.currentFocus() == "start" { + if m.focus == n-1 { + m.focus = -1 + } + m.focus++ + } default: return } @@ -160,7 +194,12 @@ func (m TimeInputModel) Update(msg tea.Msg) (TimeInputModel, tea.Cmd) { case "list": m.list, cmd = m.list.Update(key) duration := m.list.SelectedItem().(timeDurationItem).duration - m.SetStart(m.end.Time().Add(duration)) + if m.instant { + // preset moves end backwards from now + m.SetEnd(time.Now().Add(duration)) + } else { + m.SetStart(m.end.Time().Add(duration)) + } case "start": m.start, cmd = m.start.Update(key) case "end": @@ -177,7 +216,6 @@ func (m TimeInputModel) View() string { endStyle := &borderedStyle switch m.currentFocus() { - case "list": listStyle = &borderedFocusStyle case "start": @@ -187,16 +225,22 @@ func (m TimeInputModel) View() string { } list := lipgloss.NewStyle().PaddingLeft(1).Render(m.list.View()) - left := listStyle.Render(lipgloss.PlaceHorizontal(27, lipgloss.Left, list)) - right := fmt.Sprintf("%s\n\n%s", - startStyle.Render(m.start.View()), - endStyle.Render(m.end.View()), - ) center := baseStyle.Render("│\n│\n│\n│") center = lipgloss.PlaceHorizontal(5, lipgloss.Center, center) - page := lipgloss.JoinHorizontal(lipgloss.Center, left, center, right) + var right string + if m.instant { + // instant mode: only show end time, no start + label := lipgloss.NewStyle().Inherit(baseStyle).Bold(true). + Foreground(FocusSecondary).Render(" evaluation time ") + right = fmt.Sprintf("%s\n%s", label, endStyle.Render(m.end.View())) + } else { + right = fmt.Sprintf("%s\n\n%s", + startStyle.Render(m.start.View()), + endStyle.Render(m.end.View()), + ) + } - return page + return lipgloss.JoinHorizontal(lipgloss.Center, left, center, right) } From a6125e65e94e376759ffc1c8e73b4487fe8901ae Mon Sep 17 00:00:00 2001 From: Pratik Date: Fri, 15 May 2026 15:11:41 +0530 Subject: [PATCH 9/9] fix: add promql builder and modules --- cmd/promql.go | 4 +- cmd/query.go | 42 +- pkg/model/promql.go | 971 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 952 insertions(+), 65 deletions(-) diff --git a/cmd/promql.go b/cmd/promql.go index 45ea24c..8e77439 100644 --- a/cmd/promql.go +++ b/cmd/promql.go @@ -32,7 +32,7 @@ import ( "github.com/spf13/cobra" ) -const defaultMetricsStream = "otel_metrics" +const defaultMetricsStream = "select-dataset" // PromqlCmd is the parent command for all PromQL operations. var PromqlCmd = &cobra.Command{ @@ -64,7 +64,7 @@ func init() { 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().String("step", "1m", "Resolution step for range queries (e.g. 15s, 1m)") promqlRunCmd.Flags().StringP("output", "o", "text", "Output format: text or json") promqlRunCmd.Flags().Bool("instant", false, "Instant query — evaluate at --to time only") promqlRunCmd.Flags().BoolP("interactive", "i", false, "Open interactive TUI") diff --git a/cmd/query.go b/cmd/query.go index 133716f..ec6c192 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -72,6 +72,40 @@ var query = &cobra.Command{ return err } + usePromql, _ := command.Flags().GetBool("promql") + if usePromql && interactive { + start, _ := command.Flags().GetString(startFlag) + if !command.Flags().Changed(startFlag) { + start = "1h" + } + end, _ := command.Flags().GetString(endFlag) + if end == "" { + end = defaultEnd + } + startT, err := parseTimeStr(start) + if err != nil { + return fmt.Errorf("invalid --from: %w", err) + } + endT, err := parseTimeStr(end) + if err != nil { + return fmt.Errorf("invalid --to: %w", err) + } + dataset, _ := command.Flags().GetString("dataset") + step, _ := command.Flags().GetString("step") + instant, _ := command.Flags().GetBool("instant") + var expr string + if len(args) > 0 { + expr = args[0] + } + m := model.NewPromqlModel(DefaultProfile, expr, startT, endT, step, dataset, instant) + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err = p.Run() + 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") @@ -88,7 +122,9 @@ var query = &cobra.Command{ command.Annotations["error"] = err.Error() return err } - if start == "" { + if interactive && !command.Flags().Changed(startFlag) { + start = "1h" + } else if start == "" { start = defaultStart } @@ -154,6 +190,10 @@ func init() { 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") + query.Flags().Bool("promql", false, "Open PromQL interactive mode (use with -i)") + query.Flags().StringP("dataset", "d", defaultMetricsStream, "Metrics dataset (PromQL mode)") + query.Flags().String("step", "1m", "Resolution step for PromQL range queries") + query.Flags().Bool("instant", false, "PromQL instant query") } // parseTimeStr converts a CLI time string to time.Time. diff --git a/pkg/model/promql.go b/pkg/model/promql.go index 39eeb3f..7f02d16 100644 --- a/pkg/model/promql.go +++ b/pkg/model/promql.go @@ -16,6 +16,7 @@ package model import ( + "context" "crypto/tls" "encoding/json" "fmt" @@ -55,10 +56,13 @@ const ( // spotlight modal width spotlightWidth = 58 spotlightMaxItems = 12 + + builderMaxItems = 10 ) // overlay states (overlayNone and overlayInputs are defined in query.go) const overlayDataset uint = 2 +const overlayBuilder uint = 3 var PromqlNavigationMap = []string{"dataset", "query", "time", "step", "table"} @@ -103,6 +107,30 @@ type datasetListMsg struct { errMsg string } +// builder message types — one per column so Update() can route them unambiguously. +type builderMetricsMsg struct { + items []string + errMsg string +} +type builderLabelsMsg struct { + metric string // which metric these labels belong to (for cache keying) + items []string + errMsg string +} +type builderValuesMsg struct { + metric string // context for cache keying + label string // context for cache keying + items []string + errMsg string +} + +// cacheMetricsMsg is returned by the background metrics pre-fetch (not the builder fetch). +type cacheMetricsMsg struct { + dataset string + items []string + errMsg string +} + // ─── model ─────────────────────────────────────────────────────────────────── // PromqlModel is the Bubble Tea model for interactive PromQL queries. @@ -139,6 +167,36 @@ type PromqlModel struct { filteredDatasets []string datasetSelectedIdx int datasetsLoading bool + + // pre-fetch cache: warmed in background after dataset selection + cacheDataset string + cacheMetrics []string + cacheLabels map[string][]string // metric → label names + cacheValues map[string]map[string][]string // metric → label → values + + // query builder — 3-column panel (metrics | labels | values) + builderCol int + builderMetric string // currently highlighted metric (drives label/value fetch) + builderLabel string // currently selected label for preview + builderValue string // currently selected value for preview + builderMetrics []string + builderLabels []string + builderValues []string + builderMetricsFiltered []string + builderLabelsFiltered []string + builderValuesFiltered []string + builderMetricsIdx int + builderLabelsIdx int + builderValuesIdx int + builderMetricsLoading bool + builderLabelsLoading bool + builderValuesLoading bool + builderFilter textinput.Model + cancelLabels context.CancelFunc // aborts in-flight labels request; nil when idle + cancelValues context.CancelFunc // aborts in-flight values request; nil when idle + + // query panel mode toggle: "code" (raw textarea) or "builder" (expression breadcrumb + overlay) + queryMode string } func (m *PromqlModel) focusSelected() { @@ -209,15 +267,16 @@ func NewPromqlModel(profile config.Profile, expr string, startTime, endTime time q := textarea.New() q.MaxHeight = 0 q.MaxWidth = 0 - q.SetHeight(2) + q.SetHeight(1) q.SetWidth(qw) - q.ShowLineNumbers = true + q.ShowLineNumbers = false q.SetValue(expr) q.Placeholder = "write your PromQL expression here..." q.KeyMap = textAreaKeyMap q.Focus() si := textinput.New() + si.Prompt = "" si.SetValue(step) si.Width = stepModePanelOuter - 10 si.Blur() @@ -227,6 +286,11 @@ func NewPromqlModel(profile config.Profile, expr string, startTime, endTime time sf.Width = spotlightWidth - 6 sf.Blur() + bf := textinput.New() + bf.Placeholder = "search..." + bf.Width = 30 + bf.Blur() + hlp := help.New() hlp.Styles.FullDesc = lipgloss.NewStyle().Foreground(FocusSecondary) @@ -256,20 +320,23 @@ func NewPromqlModel(profile config.Profile, expr string, startTime, endTime time stepInput: si, spotlightFilter: sf, + builderFilter: bf, + queryMode: "code", } } // ─── bubbletea lifecycle ───────────────────────────────────────────────────── func (m PromqlModel) Init() tea.Cmd { - if strings.TrimSpace(m.query.Value()) == "" { - return m.spinner.Tick + cmds := []tea.Cmd{m.spinner.Tick} + if strings.TrimSpace(m.query.Value()) != "" { + cmds = append(cmds, NewPromqlFetchTask(m.profile, m.query.Value(), m.dataset, m.step, + m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc(), m.instant)) } - return tea.Batch( - m.spinner.Tick, - NewPromqlFetchTask(m.profile, m.query.Value(), m.dataset, m.step, - m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc(), m.instant), - ) + if m.dataset != "" { + cmds = append(cmds, fetchCacheMetrics(m.profile, m.dataset)) + } + return tea.Batch(cmds...) } func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -293,6 +360,8 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.query.SetWidth(m.queryWidth()) m.stepInput.Width = stepModePanelOuter - 10 m.spotlightFilter.Width = spotlightWidth - 6 + colW := builderColWidth(m.width) + m.builderFilter.Width = colW*3 + 8 m.updateTableColumns(0, 0) // reflow columns to new terminal width return m, nil @@ -314,6 +383,94 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case cacheMetricsMsg: + if msg.errMsg == "" && len(msg.items) > 0 && msg.dataset == m.dataset { + m.cacheDataset = msg.dataset + m.cacheMetrics = msg.items + if m.overlay == overlayBuilder && m.builderMetricsLoading { + // builder is open and waiting — feed it; labels wait for user navigation + m.builderMetricsLoading = false + m.builderMetrics = msg.items + m.builderMetricsFiltered = msg.items + m.builderMetricsIdx = 0 + m.builderMetric = msg.items[0] + } + } + return m, nil + + case builderMetricsMsg: + m.builderMetricsLoading = false + if msg.errMsg != "" { + m.status.Error = "could not load metrics: " + msg.errMsg + return m, nil + } + m.cacheDataset = m.dataset + m.cacheMetrics = msg.items + m.builderMetrics = msg.items + m.builderMetricsFiltered = msg.items + m.builderMetricsIdx = 0 + if len(m.builderMetrics) > 0 { + m.builderMetric = m.builderMetrics[0] + } + return m, nil + + case builderLabelsMsg: + m.builderLabelsLoading = false + m.cancelLabels = nil + // always cache, even if builder has moved on + if msg.metric != "" && msg.errMsg == "" { + if m.cacheLabels == nil { + m.cacheLabels = make(map[string][]string) + } + m.cacheLabels[msg.metric] = msg.items + } + // discard if user already navigated to a different metric + if msg.metric != m.builderCurrentMetric() { + return m, nil + } + if msg.errMsg != "" || len(msg.items) == 0 { + m.builderLabels = []string{"(any)"} + m.builderLabelsFiltered = []string{"(any)"} + m.builderLabelsIdx = 0 + m.builderValues = []string{"(any)"} + m.builderValuesFiltered = []string{"(any)"} + return m, nil + } + labels := append([]string{"(any)"}, msg.items...) + m.builderLabels = labels + m.builderLabelsFiltered = labels + m.builderLabelsIdx = 1 + // Values are fetched on Enter in col 1 — not auto-triggered here + return m, nil + + case builderValuesMsg: + m.builderValuesLoading = false + m.cancelValues = nil + // cache non-sentinel results (sentinel = "(any)" label short-circuit returns empty metric/label) + if msg.metric != "" && msg.label != "" && msg.errMsg == "" { + if m.cacheValues == nil { + m.cacheValues = make(map[string]map[string][]string) + } + if m.cacheValues[msg.metric] == nil { + m.cacheValues[msg.metric] = make(map[string][]string) + } + m.cacheValues[msg.metric][msg.label] = msg.items + } + // update display only when the arrival still matches what the user is viewing + curMetric := m.builderCurrentMetric() + curLabel := m.builderCurrentLabel() + if msg.metric != "" && (msg.metric != curMetric || msg.label != curLabel) { + return m, nil + } + values := append([]string{"(any)"}, msg.items...) + if msg.errMsg != "" || len(msg.items) == 0 { + values = []string{"(any)"} + } + m.builderValues = values + m.builderValuesFiltered = values + m.builderValuesIdx = 0 + return m, nil + case PromqlFetchData: m.loading = false m.status.Info = "" @@ -342,6 +499,200 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: + // ── global shortcuts (work from any state when no overlay is open) ─── + if m.overlay == overlayNone { + switch msg.Type { + case tea.KeyCtrlD: + m.overlay = overlayDataset + m.spotlightFilter.Focus() + m.datasetsLoading = true + return m, fetchMetricDatasets(m.profile) + case tea.KeyCtrlB: + // Toggle query panel between Code and Builder mode, focusing the query panel. + if m.queryMode == "builder" { + m.queryMode = "code" + } else { + m.queryMode = "builder" + } + for i, p := range PromqlNavigationMap { + if p == "query" { + m.focused = i + break + } + } + m.focusSelected() + return m, nil + case tea.KeyCtrlQ: + for i, p := range PromqlNavigationMap { + if p == "query" { + m.focused = i + break + } + } + m.focusSelected() + return m, nil + } + } + + // ── builder overlay ────────────────────────────────────────────────── + if m.overlay == overlayBuilder { + switch msg.Type { + case tea.KeyEsc: + m.overlay = overlayNone + m.builderFilter.SetValue("") + m.builderFilter.Blur() + m.focusSelected() + return m, nil + + // Ctrl+R inside builder: build expression with current selections and run immediately. + case tea.KeyCtrlR: + expr := buildPromqlExpr(m.builderCurrentMetric(), m.builderCurrentLabel(), m.builderCurrentValue()) + newM, cmd := m.runQueryFromBuilder(expr) + return newM, cmd + + // Enter: wizard progression — each column confirms the selection and moves to the next. + // On the final column (Values) it also runs the query. + case tea.KeyEnter: + switch m.builderCol { + case 0: // confirm metric → fetch labels → move to Labels column + metric := m.builderCurrentMetric() + if metric == "" { + return m, nil + } + m.builderMetric = metric + m.builderLabels, m.builderLabelsFiltered = nil, nil + m.builderValues, m.builderValuesFiltered = nil, nil + m.builderLabelsIdx, m.builderValuesIdx = 0, 0 + m.builderCol = 1 + m.builderFilter.SetValue("") + // cancel any previous in-flight labels request + if m.cancelLabels != nil { + m.cancelLabels() + } + // cache hit — show instantly + if labels, ok := m.cacheLabels[metric]; ok { + full := append([]string{"(any)"}, labels...) + m.builderLabels = full + m.builderLabelsFiltered = full + m.builderLabelsIdx = 1 + m.builderLabelsLoading = false + m.cancelLabels = nil + return m, nil + } + m.builderLabelsLoading = true + ctx, cancel := context.WithCancel(context.Background()) + m.cancelLabels = cancel + return m, fetchBuilderLabelsCtx(ctx, m.profile, m.dataset, metric, m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc()) + + case 1: // confirm label → fetch values → move to Values column (or run if "(any)") + label := m.builderCurrentLabel() + m.builderLabel = label + m.builderFilter.SetValue("") + if label == "" || label == "(any)" { + // no label filter — build expr and run + expr := buildPromqlExpr(m.builderCurrentMetric(), "", "") + newM, cmd := m.runQueryFromBuilder(expr) + return newM, cmd + } + m.builderValues, m.builderValuesFiltered = nil, nil + m.builderValuesIdx = 0 + m.builderCol = 2 + // cancel any previous in-flight values request + if m.cancelValues != nil { + m.cancelValues() + } + // cache hit + if m.cacheValues != nil { + if metricVals, ok := m.cacheValues[m.builderCurrentMetric()]; ok { + if vals, ok2 := metricVals[label]; ok2 { + full := append([]string{"(any)"}, vals...) + m.builderValues = full + m.builderValuesFiltered = full + m.builderValuesIdx = 1 + m.builderValuesLoading = false + m.cancelValues = nil + return m, nil + } + } + } + m.builderValuesLoading = true + ctx2, cancel2 := context.WithCancel(context.Background()) + m.cancelValues = cancel2 + return m, fetchBuilderValuesCtx(ctx2, m.profile, m.dataset, m.builderCurrentMetric(), label, m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc()) + + case 2: // confirm value → build expression + run query + close + expr := buildPromqlExpr(m.builderCurrentMetric(), m.builderCurrentLabel(), m.builderCurrentValue()) + newM, cmd := m.runQueryFromBuilder(expr) + return newM, cmd + } + return m, nil + + case tea.KeyTab: + m.builderCol = (m.builderCol + 1) % 3 + m.builderFilter.SetValue("") + return m, nil + + case tea.KeyShiftTab: + m.builderCol = (m.builderCol + 2) % 3 + m.builderFilter.SetValue("") + return m, nil + + case tea.KeyUp: + switch m.builderCol { + case 0: + if m.builderMetricsIdx > 0 { + m.builderMetricsIdx-- + } + case 1: + if m.builderLabelsIdx > 0 { + m.builderLabelsIdx-- + } + case 2: + if m.builderValuesIdx > 0 { + m.builderValuesIdx-- + } + } + return m, nil + + case tea.KeyDown: + switch m.builderCol { + case 0: + if m.builderMetricsIdx < len(m.builderMetricsFiltered)-1 { + m.builderMetricsIdx++ + } + case 1: + if m.builderLabelsIdx < len(m.builderLabelsFiltered)-1 { + m.builderLabelsIdx++ + } + case 2: + if m.builderValuesIdx < len(m.builderValuesFiltered)-1 { + m.builderValuesIdx++ + } + } + return m, nil + + default: + prev := m.builderFilter.Value() + m.builderFilter, cmd = m.builderFilter.Update(msg) + cmds = append(cmds, cmd) + if m.builderFilter.Value() != prev { + filter := m.builderFilter.Value() + switch m.builderCol { + case 0: + m.builderMetricsFiltered = filterDatasets(m.builderMetrics, filter) + m.builderMetricsIdx = 0 + case 1: + m.builderLabelsFiltered = filterBuilderList(m.builderLabels, filter) + m.builderLabelsIdx = 0 + case 2: + m.builderValuesFiltered = filterBuilderList(m.builderValues, filter) + m.builderValuesIdx = 0 + } + } + return m, tea.Batch(cmds...) + } + } + // ── dataset spotlight overlay ──────────────────────────────────────── if m.overlay == overlayDataset { switch msg.Type { @@ -354,7 +705,20 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyEnter: if len(m.filteredDatasets) > 0 { - m.dataset = m.filteredDatasets[m.datasetSelectedIdx] + newDS := m.filteredDatasets[m.datasetSelectedIdx] + if newDS != m.dataset { + m.dataset = newDS + // clear stale cache and warm fresh one in background + m.cacheDataset = "" + m.cacheMetrics = nil + m.cacheLabels = nil + m.cacheValues = nil + m.overlay = overlayNone + m.spotlightFilter.SetValue("") + m.spotlightFilter.Blur() + m.focusSelected() + return m, fetchCacheMetrics(m.profile, newDS) + } } m.overlay = overlayNone m.spotlightFilter.SetValue("") @@ -426,7 +790,7 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.overlay = overlayDataset m.spotlightFilter.Focus() m.datasetsLoading = true - return m, tea.Batch(fetchDatasetList(m.profile)) + return m, fetchMetricDatasets(m.profile) } // Enter on time → open time overlay @@ -435,6 +799,11 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + // Enter on query panel in builder mode → open builder overlay + if msg.Type == tea.KeyEnter && m.currentFocus() == "query" && m.queryMode == "builder" { + return m, m.openBuilderOverlay() + } + // Ctrl+R → run query if msg.Type == tea.KeyCtrlR { m.overlay = overlayNone @@ -467,8 +836,10 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { default: switch m.currentFocus() { case "query": - m.query, cmd = m.query.Update(msg) - cmds = append(cmds, cmd) + if m.queryMode == "code" { + m.query, cmd = m.query.Update(msg) + cmds = append(cmds, cmd) + } case "step": m.stepInput, cmd = m.stepInput.Update(msg) m.step = m.stepInput.Value() @@ -483,6 +854,66 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +// runQueryFromBuilder sets the expression, closes the builder overlay, and fires a query. +// The query panel stays in builder mode so the expression is shown as a breadcrumb. +func (m *PromqlModel) runQueryFromBuilder(expr string) (PromqlModel, tea.Cmd) { + if expr != "" { + m.query.SetValue(expr) + } + m.overlay = overlayNone + m.builderFilter.SetValue("") + m.builderFilter.Blur() + // return focus to query panel, stay in builder mode + for i, p := range PromqlNavigationMap { + if p == "query" { + m.focused = i + break + } + } + m.focusSelected() + if m.query.Value() == "" { + return *m, nil + } + m.status.Error = "" + m.status.Info = "" + m.loading = true + m.hasQueried = true + return *m, tea.Batch(m.spinner.Tick, + NewPromqlFetchTask(m.profile, m.query.Value(), m.dataset, m.step, + m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc(), m.instant)) +} + +// openBuilderOverlay transitions to the builder overlay, seeding state from the cache. +func (m *PromqlModel) openBuilderOverlay() tea.Cmd { + m.overlay = overlayBuilder + m.builderCol = 0 + m.builderMetric, m.builderLabel, m.builderValue = "", "", "" + m.builderMetricsIdx = 0 + m.builderLabelsIdx, m.builderValuesIdx = 0, 0 + m.builderLabels, m.builderLabelsFiltered = nil, nil + m.builderValues, m.builderValuesFiltered = nil, nil + m.builderLabelsLoading = false + m.builderValuesLoading = false + m.builderFilter.SetValue("") + m.builderFilter.Focus() + + if m.dataset == "" { + m.builderMetrics, m.builderMetricsFiltered = nil, nil + m.builderMetricsLoading = false + return nil + } + if m.cacheDataset == m.dataset && len(m.cacheMetrics) > 0 { + m.builderMetrics = m.cacheMetrics + m.builderMetricsFiltered = m.cacheMetrics + m.builderMetricsLoading = false + m.builderMetric = m.cacheMetrics[0] + return nil + } + m.builderMetrics, m.builderMetricsFiltered = nil, nil + m.builderMetricsLoading = true + return fetchCacheMetrics(m.profile, m.dataset) +} + // ─── view ──────────────────────────────────────────────────────────────────── func (m PromqlModel) View() string { @@ -492,12 +923,18 @@ func (m PromqlModel) View() string { // ── header panels ──────────────────────────────────────────────────────── dsName := m.dataset - if len(dsName) > datasetPanelOuter-4 { - dsName = dsName[:datasetPanelOuter-7] + "..." + var dsNameRendered string + if dsName == "" { + dsNameRendered = lipgloss.NewStyle().Foreground(StandardSecondary).Render("select dataset") + } else { + if len(dsName) > datasetPanelOuter-4 { + dsName = dsName[:datasetPanelOuter-7] + "..." + } + dsNameRendered = dsName } datasetPane := lipgloss.JoinVertical(lipgloss.Left, baseBoldUnderlinedStyle.Render(" dataset "), - dsName, + dsNameRendered, ) mode := "range" @@ -553,10 +990,46 @@ func (m PromqlModel) View() string { if queryW < 30 { queryW = 30 } - m.query.SetWidth(queryW - 2) // -2 for query panel border + innerW := queryW - 2 // subtract border + m.query.SetWidth(innerW) + + // ── query panel: toggle row + mode-aware content ────────────────────────── + activeTabStyle := lipgloss.NewStyle().Foreground(FocusPrimary).Bold(true) + inactiveTabStyle := lipgloss.NewStyle().Foreground(StandardSecondary) + var codeLabel, builderLabel string + if m.queryMode == "builder" { + codeLabel = inactiveTabStyle.Render("Code") + builderLabel = activeTabStyle.Render("Builder") + } else { + codeLabel = activeTabStyle.Render("Code") + builderLabel = inactiveTabStyle.Render("Builder") + } + toggleRow := lipgloss.NewStyle(). + Width(innerW). + Align(lipgloss.Right). + Render(codeLabel + inactiveTabStyle.Render(" | ") + builderLabel) + + var queryPanelContent string + if m.queryMode == "builder" { + expr := m.query.Value() + var exprDisplay string + if expr == "" { + exprDisplay = lipgloss.NewStyle(). + Foreground(StandardSecondary).Width(innerW). + Render("press Enter to open builder...") + } else { + exprDisplay = lipgloss.NewStyle(). + Foreground(FocusPrimary).Bold(true).Width(innerW). + Render(expr) + } + queryPanelContent = lipgloss.JoinVertical(lipgloss.Left, toggleRow, exprDisplay) + } else { + queryPanelContent = lipgloss.JoinVertical(lipgloss.Left, toggleRow, m.query.View()) + } + header := lipgloss.JoinHorizontal(lipgloss.Top, dsRendered, - queryOuter.Render(m.query.View()), + queryOuter.Render(queryPanelContent), timeRendered, stepRendered, ) @@ -580,7 +1053,19 @@ func (m PromqlModel) View() string { {promqlAdditionalKeyBinds[0]}, } case "query": - helpKeys = TextAreaHelpKeys{}.FullHelp() + if m.queryMode == "builder" { + helpKeys = [][]key.Binding{ + { + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "open builder")), + key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "switch to code mode")), + }, + {promqlAdditionalKeyBinds[0]}, + } + } else { + helpKeys = append(TextAreaHelpKeys{}.FullHelp(), + []key.Binding{key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "switch to builder mode"))}, + ) + } case "time": timeHint := "edit time range" if m.instant { @@ -593,7 +1078,7 @@ func (m PromqlModel) View() string { case "step": helpKeys = [][]key.Binding{ { - key.NewBinding(key.WithKeys("type"), key.WithHelp("type", "edit step (e.g. 15s, 5m, 1h)")), + key.NewBinding(key.WithKeys("type"), key.WithHelp("type", "edit step (e.g. 15s, 5m)")), key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle range/instant")), }, { @@ -613,6 +1098,17 @@ func (m PromqlModel) View() string { key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), }} + case overlayBuilder: + helpKeys = [][]key.Binding{ + { + key.NewBinding(key.WithKeys("↑/↓"), key.WithHelp("↑/↓", "navigate")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select → next / run")), + }, + { + key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("ctrl+r", "run with current")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), + }, + } } helpView := m.help.FullHelpView(helpKeys) helpHeight := lipgloss.Height(helpView) @@ -692,7 +1188,6 @@ func (m PromqlModel) View() string { lipgloss.WithWhitespaceForeground(StandardSecondary), ) case overlayDataset: - // render the normal content behind, then paint spotlight on top behind := lipgloss.JoinVertical(lipgloss.Left, header, resultPane) spotlight := m.renderSpotlight() mainView = lipgloss.Place(m.width, m.height-helpHeight-statusHeight, @@ -701,6 +1196,15 @@ func (m PromqlModel) View() string { lipgloss.WithWhitespaceForeground(StandardSecondary), ) _ = behind + case overlayBuilder: + behind := lipgloss.JoinVertical(lipgloss.Left, header, resultPane) + builder := m.renderBuilder() + mainView = lipgloss.Place(m.width, m.height-helpHeight-statusHeight, + lipgloss.Center, lipgloss.Center, builder, + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(StandardSecondary), + ) + _ = behind } mainHeight := lipgloss.Height(mainView) @@ -816,47 +1320,6 @@ func (m *PromqlModel) updateTableColumns(_, valueWidth int) { m.table = m.table.WithColumns(columns).WithTargetWidth(m.width).WithRows(m.dataRows) } -// ─── async commands ─────────────────────────────────────────────────────────── - -// fetchDatasetList loads all streams from the server for the spotlight picker. -func fetchDatasetList(profile config.Profile) tea.Cmd { - return func() tea.Msg { - reqURL, err := url.JoinPath(profile.URL, "api/v1/logstream") - if err != nil { - return datasetListMsg{errMsg: err.Error()} - } - client := &http.Client{Timeout: 10 * time.Second} - req, err := http.NewRequest("GET", reqURL, nil) - if err != nil { - return datasetListMsg{errMsg: err.Error()} - } - if profile.Token != "" { - req.Header.Set("Authorization", "Bearer "+profile.Token) - } else { - req.SetBasicAuth(profile.Username, profile.Password) - } - resp, err := client.Do(req) - if err != nil { - return datasetListMsg{errMsg: err.Error()} - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - - var items []struct { - Name string `json:"name"` - } - if err := json.Unmarshal(body, &items); err != nil { - return datasetListMsg{errMsg: err.Error()} - } - datasets := make([]string, len(items)) - for i, item := range items { - datasets[i] = item.Name - } - sort.Strings(datasets) - return datasetListMsg{datasets: datasets} - } -} - // NewPromqlFetchTask returns a Bubble Tea command that fetches PromQL data asynchronously. func NewPromqlFetchTask(profile config.Profile, expr, dataset, step, startTime, endTime string, instant bool) tea.Cmd { return func() (msg tea.Msg) { @@ -1046,3 +1509,387 @@ func filterDatasets(all []string, query string) []string { } return out } + +// filterBuilderList filters a column list, always keeping "(any)" at index 0. +func filterBuilderList(all []string, query string) []string { + if query == "" { + return all + } + q := strings.ToLower(query) + var out []string + for _, item := range all { + if item == "(any)" { + continue + } + if strings.Contains(strings.ToLower(item), q) { + out = append(out, item) + } + } + if len(all) > 0 && all[0] == "(any)" { + return append([]string{"(any)"}, out...) + } + return out +} + +// ─── builder helpers ────────────────────────────────────────────────────────── + +func builderColWidth(w int) int { + cw := (w - 14) / 3 + if cw < 18 { + cw = 18 + } + return cw +} + +func (m PromqlModel) builderCurrentMetric() string { + if len(m.builderMetricsFiltered) == 0 { + return "" + } + idx := m.builderMetricsIdx + if idx < 0 { + idx = 0 + } + if idx >= len(m.builderMetricsFiltered) { + idx = len(m.builderMetricsFiltered) - 1 + } + return m.builderMetricsFiltered[idx] +} + +func (m PromqlModel) builderCurrentLabel() string { + if len(m.builderLabelsFiltered) == 0 { + return "" + } + idx := m.builderLabelsIdx + if idx < 0 { + idx = 0 + } + if idx >= len(m.builderLabelsFiltered) { + idx = len(m.builderLabelsFiltered) - 1 + } + return m.builderLabelsFiltered[idx] +} + +func (m PromqlModel) builderCurrentValue() string { + if len(m.builderValuesFiltered) == 0 { + return "" + } + idx := m.builderValuesIdx + if idx < 0 { + idx = 0 + } + if idx >= len(m.builderValuesFiltered) { + idx = len(m.builderValuesFiltered) - 1 + } + return m.builderValuesFiltered[idx] +} + +func buildPromqlExpr(metric, label, value string) string { + if metric == "" { + return "" + } + if label == "" || label == "(any)" { + return metric + } + if value == "" || value == "(any)" { + return fmt.Sprintf(`%s{%s!=""}`, metric, label) + } + return fmt.Sprintf(`%s{%s="%s"}`, metric, label, value) +} + +// renderBuilderCol renders a single column (Metrics / Labels / Values) for the builder overlay. +func renderBuilderCol(title string, items []string, selectedIdx int, loading, focused bool, colW int) string { + innerW := colW - 2 + + titleStyle := lipgloss.NewStyle().Bold(true).Width(innerW) + if focused { + titleStyle = titleStyle.Foreground(FocusPrimary) + } else { + titleStyle = titleStyle.Foreground(StandardSecondary) + } + + var rows []string + switch { + case loading: + rows = append(rows, lipgloss.NewStyle(). + Foreground(StandardSecondary).Width(innerW). + Render("loading...")) + case len(items) == 0: + rows = append(rows, lipgloss.NewStyle(). + Foreground(StandardSecondary).Width(innerW). + Render("(empty)")) + default: + start := 0 + if selectedIdx >= builderMaxItems { + start = selectedIdx - builderMaxItems + 1 + } + end := start + builderMaxItems + if end > len(items) { + end = len(items) + } + for i := start; i < end; i++ { + item := items[i] + maxLen := innerW - 4 + if maxLen > 3 && len(item) > maxLen { + item = item[:maxLen-3] + "..." + } + if i == selectedIdx { + rows = append(rows, lipgloss.NewStyle(). + Background(FocusPrimary). + Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). + Width(innerW).Padding(0, 1).Bold(true). + Render("▸ "+item)) + } else { + rows = append(rows, lipgloss.NewStyle(). + Width(innerW).Padding(0, 1). + Render(" "+item)) + } + } + } + + content := lipgloss.JoinVertical(lipgloss.Left, + titleStyle.Render(title), + strings.Join(rows, "\n"), + ) + + borderStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Width(colW) + if focused { + borderStyle = borderStyle.BorderForeground(FocusPrimary) + } else { + borderStyle = borderStyle.BorderForeground(StandardSecondary) + } + return borderStyle.Render(content) +} + +// renderBuilder builds the 3-column query builder overlay. +func (m PromqlModel) renderBuilder() string { + colW := builderColWidth(m.width) + + metricsItems := m.builderMetricsFiltered + if m.dataset == "" { + metricsItems = []string{"── select a dataset first ──"} + } + col0 := renderBuilderCol("Metrics", metricsItems, m.builderMetricsIdx, + m.builderMetricsLoading, m.builderCol == 0, colW) + col1 := renderBuilderCol("Labels", m.builderLabelsFiltered, m.builderLabelsIdx, + m.builderLabelsLoading, m.builderCol == 1, colW) + col2 := renderBuilderCol("Values", m.builderValuesFiltered, m.builderValuesIdx, + m.builderValuesLoading, m.builderCol == 2, colW) + + columns := lipgloss.JoinHorizontal(lipgloss.Top, col0, col1, col2) + colsW := lipgloss.Width(columns) + + expr := buildPromqlExpr(m.builderCurrentMetric(), m.builderCurrentLabel(), m.builderCurrentValue()) + dimStyle := lipgloss.NewStyle().Foreground(StandardSecondary) + exprStyle := lipgloss.NewStyle().Foreground(FocusPrimary).Bold(true) + exprLine := dimStyle.Render("Built: ") + exprStyle.Render(expr) + + searchStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(FocusSecondary). + Width(colsW-4). + Padding(0, 1) + searchBar := searchStyle.Render(m.builderFilter.View()) + + titleStyle := lipgloss.NewStyle(). + Foreground(FocusPrimary).Bold(true). + Width(colsW).Align(lipgloss.Center) + title := titleStyle.Render("PromQL Query Builder") + + body := lipgloss.JoinVertical(lipgloss.Left, title, columns, exprLine, searchBar) + + modal := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(FocusPrimary). + Padding(0, 1). + Render(body) + + return modal +} + +// ─── builder async commands ─────────────────────────────────────────────────── + +// fetchMetricDatasets fetches all streams and keeps those whose name contains "metrics" +// (case-insensitive). Falls back to all datasets when none match. +func fetchMetricDatasets(profile config.Profile) tea.Cmd { + return func() tea.Msg { + reqURL, err := url.JoinPath(profile.URL, "api/v1/logstream") + if err != nil { + return datasetListMsg{errMsg: err.Error()} + } + client := &http.Client{Timeout: 15 * time.Second} + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return datasetListMsg{errMsg: err.Error()} + } + if profile.Token != "" { + req.Header.Set("Authorization", "Bearer "+profile.Token) + } else { + req.SetBasicAuth(profile.Username, profile.Password) + } + resp, err := client.Do(req) + if err != nil { + return datasetListMsg{errMsg: err.Error()} + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + var items []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(body, &items); err != nil { + return datasetListMsg{errMsg: err.Error()} + } + + var all, matched []string + for _, item := range items { + all = append(all, item.Name) + if strings.Contains(strings.ToLower(item.Name), "metrics") { + matched = append(matched, item.Name) + } + } + datasets := matched + if len(datasets) == 0 { + datasets = all + } + sort.Strings(datasets) + return datasetListMsg{datasets: datasets} + } +} + +type promqlLabelListResp struct { + Status string `json:"status"` + Data []string `json:"data"` + Error string `json:"error,omitempty"` +} + +// builderHTTPGetCtx performs an authenticated GET with context for cancellation. +// URLs are built manually so that match[] stays as literal brackets — +// url.Values.Encode percent-encodes them to match%5B%5D, which Parseable ignores. +func builderHTTPGetCtx(ctx context.Context, profile config.Profile, rawURL string) ([]byte, error) { + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil) + if err != nil { + return nil, err + } + if profile.Token != "" { + req.Header.Set("Authorization", "Bearer "+profile.Token) + } else { + req.SetBasicAuth(profile.Username, profile.Password) + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + msg := strings.TrimSpace(string(body)) + if msg == "" { + msg = resp.Status + } + return nil, fmt.Errorf("HTTP %s: %s", resp.Status, msg) + } + return body, nil +} + +func fetchBuilderLabelsCtx(ctx context.Context, profile config.Profile, dataset, metric, startTime, endTime string) tea.Cmd { + return func() tea.Msg { + base, err := url.JoinPath(profile.URL, "prometheus/api/v1/labels") + if err != nil { + return builderLabelsMsg{metric: metric, errMsg: err.Error()} + } + rawURL := base + "?stream=" + url.QueryEscape(dataset) + if startTime != "" { + rawURL += "&start=" + url.QueryEscape(startTime) + } + if endTime != "" { + rawURL += "&end=" + url.QueryEscape(endTime) + } + if metric != "" { + rawURL += "&match[]=" + url.QueryEscape(metric) + } + body, err := builderHTTPGetCtx(ctx, profile, rawURL) + if err != nil { + if ctx.Err() != nil { + return nil + } + return builderLabelsMsg{metric: metric, errMsg: err.Error()} + } + var resp promqlLabelListResp + if err := json.Unmarshal(body, &resp); err != nil { + return builderLabelsMsg{metric: metric, errMsg: err.Error()} + } + if resp.Status == "error" { + return builderLabelsMsg{metric: metric, errMsg: resp.Error} + } + var labels []string + for _, l := range resp.Data { + if l != "__name__" { + labels = append(labels, l) + } + } + return builderLabelsMsg{metric: metric, items: labels} + } +} + +func fetchBuilderValuesCtx(ctx context.Context, profile config.Profile, dataset, metric, label, startTime, endTime string) tea.Cmd { + if label == "" || label == "(any)" { + return func() tea.Msg { return builderValuesMsg{} } // sentinel: clear values to [(any)] + } + return func() tea.Msg { + base, err := url.JoinPath(profile.URL, "prometheus/api/v1/label/"+url.PathEscape(label)+"/values") + if err != nil { + return builderValuesMsg{metric: metric, label: label, errMsg: err.Error()} + } + rawURL := base + "?stream=" + url.QueryEscape(dataset) + if startTime != "" { + rawURL += "&start=" + url.QueryEscape(startTime) + } + if endTime != "" { + rawURL += "&end=" + url.QueryEscape(endTime) + } + if metric != "" { + rawURL += "&match[]=" + url.QueryEscape(metric) + } + body, err := builderHTTPGetCtx(ctx, profile, rawURL) + if err != nil { + if ctx.Err() != nil { + return nil + } + return builderValuesMsg{metric: metric, label: label, errMsg: err.Error()} + } + var resp promqlLabelListResp + if err := json.Unmarshal(body, &resp); err != nil { + return builderValuesMsg{metric: metric, label: label, errMsg: err.Error()} + } + if resp.Status == "error" { + return builderValuesMsg{metric: metric, label: label, errMsg: resp.Error} + } + return builderValuesMsg{metric: metric, label: label, items: resp.Data} + } +} + +// fetchCacheMetrics is the background pre-fetch fired on dataset selection. +func fetchCacheMetrics(profile config.Profile, dataset string) tea.Cmd { + return func() tea.Msg { + params := url.Values{} + params.Set("stream", dataset) + body, err := promqlModelFetch(profile, "prometheus/api/v1/label/__name__/values", params) + if err != nil { + return cacheMetricsMsg{dataset: dataset, errMsg: err.Error()} + } + var resp promqlLabelListResp + if err := json.Unmarshal(body, &resp); err != nil { + return cacheMetricsMsg{dataset: dataset, errMsg: err.Error()} + } + if resp.Status == "error" { + return cacheMetricsMsg{dataset: dataset, errMsg: resp.Error} + } + return cacheMetricsMsg{dataset: dataset, items: resp.Data} + } +}