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 2e46468..e39414c 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -21,15 +21,17 @@ 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" internalHTTP "pb/pkg/http" + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" ) @@ -47,9 +49,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 +71,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 +90,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 +135,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/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 5a25444..1f5bf69 100644 --- a/pkg/model/query.go +++ b/pkg/model/query.go @@ -25,8 +25,6 @@ import ( "os" "pb/pkg/config" "pb/pkg/iterator" - "strings" - "sync" "time" "github.com/charmbracelet/bubbles/help" @@ -91,14 +89,7 @@ var ( InnerDivider: "║", } - additionalKeyBinds = []key.Binding{ - key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("ctrl r", "(re) run query")), - } - - 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")), - } + additionalKeyBinds = []key.Binding{runQueryKey} QueryNavigationMap = []string{"query", "time", "table"} ) @@ -136,6 +127,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() { @@ -154,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 { - return false - } - count := res.Records[0]["count"].(float64) - return 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())) @@ -204,6 +157,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 +170,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 +190,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 +203,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 +219,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 +262,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 +294,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 +308,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 +321,6 @@ func (m QueryModel) View() string { queryOuter, timeOuter := &borderedStyle, &borderedStyle tableOuter := lipgloss.NewStyle() - switch m.currentFocus() { case "query": queryOuter = &borderedFocusStyle @@ -396,33 +331,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 +351,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 +395,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 +424,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 +444,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 +468,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 +507,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 +532,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) { @@ -650,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 665aa0b..75bc3a5 100644 --- a/pkg/model/tablekeymap.go +++ b/pkg/model/tablekeymap.go @@ -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..eb7076c 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")),