diff --git a/pkg/model/promql.go b/pkg/model/promql.go index 38131e2..05c2b1a 100644 --- a/pkg/model/promql.go +++ b/pkg/model/promql.go @@ -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 } @@ -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() @@ -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 } @@ -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(" ")) @@ -1836,7 +1848,7 @@ func promqlKeysForFocus(m PromqlModel) []ui.KeyHint { case "step": return append([]ui.KeyHint{ {Key: "type", Label: "Edit (15s, 5m, 1h)"}, - {Key: "", Label: "Range/instant/both"}, + {Key: "", Label: "Range/instant/both"}, }, common...) case "table": if m.chartMode { @@ -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 @@ -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 diff --git a/pkg/model/promql_test.go b/pkg/model/promql_test.go index e6e283c..3b34f3c 100644 --- a/pkg/model/promql_test.go +++ b/pkg/model/promql_test.go @@ -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) { @@ -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)