Skip to content
97 changes: 65 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down
32 changes: 28 additions & 4 deletions cmd/promql.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ import (
"time"

internalHTTP "pb/pkg/http"
"pb/pkg/model"

tea "github.com/charmbracelet/bubbletea"
"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{
Expand Down Expand Up @@ -62,9 +64,10 @@ 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")

// flags: labels
promqlLabelsCmd.Flags().StringP("dataset", "d", defaultMetricsStream, "Metrics dataset")
Expand Down Expand Up @@ -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,
}
Expand All @@ -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)
Expand Down
42 changes: 41 additions & 1 deletion cmd/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
}

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions pkg/model/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading