Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions pkg/model/promql.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@ func nextPromqlMode(mode string) string {
}
}

func isPromqlModeToggleKey(msg tea.KeyMsg) bool {
return msg.Type == tea.KeyCtrlAt || msg.String() == "ctrl+space"
}

func (m PromqlModel) isInstantMode() bool {
return m.mode == promqlModeInstant
}
Expand Down Expand Up @@ -1034,8 +1038,8 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc(), m.mode, m.timeRange.DisplayMode()))
}

// Space on step panel cycles range/instant/both mode.
if msg.Type == tea.KeySpace && m.currentFocus() == "step" {
// Ctrl+Space on step panel cycles range/instant/both mode.
if isPromqlModeToggleKey(msg) && m.currentFocus() == "step" {
prevMode := m.mode
m.mode = nextPromqlMode(m.mode)
m.instant = m.isInstantMode()
Expand All @@ -1045,9 +1049,17 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.timeRange.SetEnd(time.Now().Add(-1 * time.Hour))
m.chartMode = false
} else {
// switching back to range: reset end to now so presets work correctly
if prevMode == promqlModeInstant {
// switching from both back to range: reset end to now so presets work correctly.
// instant -> both keeps the instant evaluation time for table toggle.
switch prevMode {
case promqlModeBoth:
m.timeRange.SetEnd(time.Now())
case promqlModeInstant:
duration := OneHour
if item, ok := m.timeRange.list.SelectedItem().(timeDurationItem); ok {
duration = item.duration
}
m.timeRange.SetStart(m.timeRange.end.Time().Add(duration))
}
m.chartMode = true
}
Expand Down Expand Up @@ -1475,7 +1487,7 @@ func (m PromqlModel) View() string {
lines = lines[:resultsInnerH]
}
inner = strings.Join(lines, "\n")
case len(m.dataRows) == 0:
case len(m.dataRows) == 0 && (!m.chartMode || len(m.chartRows) == 0):
msg := lipgloss.NewStyle().Foreground(p.Faint).Render("no results for this query")
inner = lipgloss.Place(resultsInnerW, resultsInnerH, lipgloss.Center, lipgloss.Center, msg,
lipgloss.WithWhitespaceChars(" "))
Expand Down Expand Up @@ -1836,7 +1848,7 @@ func promqlKeysForFocus(m PromqlModel) []ui.KeyHint {
case "step":
return append([]ui.KeyHint{
{Key: "type", Label: "Edit (15s, 5m, 1h)"},
{Key: "<space>", Label: "Range/instant/both"},
{Key: "<ctrl+space>", Label: "Range/instant/both"},
}, common...)
case "table":
if m.chartMode {
Expand Down Expand Up @@ -2106,7 +2118,8 @@ func NewPromqlModeFetchTask(profile config.Profile, expr, dataset, step, startTi
}()

if mode == promqlModeBoth {
rangeResult, err := fetchPromqlResult(profile, expr, dataset, step, startTime, endTime, false)
rangeStart, rangeEnd := normalizePromqlRangeWindow(startTime, endTime)
rangeResult, err := fetchPromqlResult(profile, expr, dataset, step, rangeStart, rangeEnd, false)
if err != nil {
res.errMsg = err.Error()
return res
Expand Down Expand Up @@ -2150,6 +2163,15 @@ func NewPromqlModeFetchTask(profile config.Profile, expr, dataset, step, startTi
}
}

func normalizePromqlRangeWindow(startTime, endTime string) (string, string) {
start, startOK := parsePromqlStepTime(startTime)
end, endOK := parsePromqlStepTime(endTime)
if !startOK || !endOK || start.Before(end) {
return startTime, endTime
}
return end.Add(OneHour).UTC().Format(time.RFC3339), endTime
}

func fetchPromqlResult(profile config.Profile, expr, dataset, step, startTime, endTime string, instant bool) (promqlRespModel, error) {
var result promqlRespModel

Expand Down
119 changes: 119 additions & 0 deletions pkg/model/promql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package model

import (
"reflect"
"strings"
"testing"
"time"

tea "github.com/charmbracelet/bubbletea"
table "github.com/evertras/bubble-table/table"
"github.com/parseablehq/pb/pkg/config"
)

func TestEscapePromQLValue(t *testing.T) {
Expand Down Expand Up @@ -302,6 +305,122 @@ func TestChartTimeRangeTitleUsesDisplayMode(t *testing.T) {
}
}

func TestPromqlModeToggleUsesCtrlSpace(t *testing.T) {
if !isPromqlModeToggleKey(tea.KeyMsg{Type: tea.KeyCtrlAt}) {
t.Fatal("ctrl+space should toggle PromQL mode")
}
if isPromqlModeToggleKey(tea.KeyMsg{Type: tea.KeySpace}) {
t.Fatal("plain space should not toggle PromQL mode")
}
}

func TestPromqlInstantToBothPreservesEndTime(t *testing.T) {
start := time.Date(2026, 1, 2, 8, 0, 0, 0, time.UTC)
end := time.Date(2026, 1, 2, 9, 0, 0, 0, time.UTC)
m := NewPromqlModel(config.Profile{}, "", start, end, "1m", "metrics", true)
m.mode = promqlModeInstant
m.focused = 3 // step panel

next, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlAt})
got := next.(PromqlModel)
if got.mode != promqlModeBoth {
t.Fatalf("mode = %q, want %q", got.mode, promqlModeBoth)
}
if !got.timeRange.end.Time().Equal(end) {
t.Fatalf("end time changed to %s, want %s", got.timeRange.end.Time(), end)
}
if wantStart := end.Add(OneHour); !got.timeRange.start.Time().Equal(wantStart) {
t.Fatalf("start time = %s, want %s", got.timeRange.start.Time(), wantStart)
}
}

func TestNormalizePromqlRangeWindowFixesInvalidRange(t *testing.T) {
end := time.Date(2026, 1, 2, 9, 0, 0, 0, time.UTC)
start := end.Add(time.Hour)

gotStart, gotEnd := normalizePromqlRangeWindow(start.Format(time.RFC3339), end.Format(time.RFC3339))
if gotEnd != end.Format(time.RFC3339) {
t.Fatalf("end = %q, want %q", gotEnd, end.Format(time.RFC3339))
}
if gotStart != end.Add(OneHour).Format(time.RFC3339) {
t.Fatalf("start = %q, want %q", gotStart, end.Add(OneHour).Format(time.RFC3339))
}
}

func TestPromqlBothModeRendersChartRowsWhenInstantRowsEmpty(t *testing.T) {
start := time.Date(2026, 1, 2, 9, 0, 0, 0, time.UTC)
end := start.Add(10 * time.Minute)
m := NewPromqlModel(config.Profile{}, "cpu_usage", start, end, "1m", "metrics", false)
m.width = 100
m.height = 40
m.hasQueried = true
m.mode = promqlModeBoth
m.chartMode = true
m.dataRows = nil
m.chartRows = []table.Row{
table.NewRow(table.RowData{
promqlTimestampFullKey: start.Format(time.RFC3339),
promqlMetricKey: `cpu_usage{host="a"}`,
promqlValueKey: "1",
}),
table.NewRow(table.RowData{
promqlTimestampFullKey: end.Format(time.RFC3339),
promqlMetricKey: `cpu_usage{host="a"}`,
promqlValueKey: "2",
}),
}

view := m.View()
if strings.Contains(view, "no results for this query") {
t.Fatalf("both mode should render chart rows when instant rows are empty:\n%s", view)
}
if !strings.Contains(view, "RESULTS | Chart View") {
t.Fatalf("expected chart view, got:\n%s", view)
}
}

func TestPromqlBothModeKeepsInstantRowsForTable(t *testing.T) {
m := PromqlModel{mode: promqlModeBoth}
instantRows := []table.Row{
table.NewRow(table.RowData{
promqlTimestampFullKey: "2026-01-02T09:00:00Z",
promqlMetricKey: `cpu_usage{host="instant"}`,
promqlValueKey: "9",
}),
}
rangeRows := []table.Row{
table.NewRow(table.RowData{
promqlTimestampFullKey: "2026-01-02T08:55:00Z",
promqlMetricKey: `cpu_usage{host="range"}`,
promqlValueKey: "1",
}),
table.NewRow(table.RowData{
promqlTimestampFullKey: "2026-01-02T09:00:00Z",
promqlMetricKey: `cpu_usage{host="range"}`,
promqlValueKey: "2",
}),
}
msg := PromqlFetchData{
status: fetchOk,
resultType: "vector",
chartResult: "matrix",
rows: instantRows,
chartRows: rangeRows,
}

next, _ := m.Update(msg)
got := next.(PromqlModel)
if !got.chartMode {
t.Fatal("both mode should default to chart view")
}
if !reflect.DeepEqual(got.dataRows, instantRows) {
t.Fatalf("table rows = %#v, want instant rows %#v", got.dataRows, instantRows)
}
if !reflect.DeepEqual(got.chartRows, rangeRows) {
t.Fatalf("chart rows = %#v, want range rows %#v", got.chartRows, rangeRows)
}
}

func TestPromqlModelFormatTSUsesLocalTime(t *testing.T) {
oldLocal := time.Local
time.Local = time.FixedZone("IST", 5*60*60+30*60)
Expand Down
Loading