diff --git a/CHANGELOG.md b/CHANGELOG.md index ba8eb48..bcea127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,33 +3,66 @@ ## 1.3.0 (Unreleased) ### Breaking Changes + - Minimum Grafana version bumped to 10.0.0 +- Variable queries now use a typed editor (query type / dataset / label / metric / regex / PromQL). Legacy string-based variable queries are still accepted by `metricFindQuery` for backwards compatibility + +### Added + +- Full PromQL support across Explore, Dashboards and Unified Alerting, on par with Grafana's native Prometheus plugin in Code mode +- Go backend PromQL path (`/prometheus/api/v1/query`, `/prometheus/api/v1/query_range`) so Unified Alerting can evaluate PromQL directly through the plugin backend; handles matrix / vector / scalar result types and both unix and RFC3339 timestamps +- PromQL code editor (Monaco) with syntax highlighting, bracket matching, and UTF-8 metric name support (e.g. `process.cpu.time`) +- Context-aware PromQL autocomplete: metric names, label names and label values scoped to the current metric (via `/labels` and `/label/{name}/values` with `match[]`), aggregation & function snippets, `by` / `without` / `on` / `ignoring` grouping, `@` / `offset` modifiers, and duration completions inside range selectors +- Function signature help and hover documentation for 70+ PromQL functions / aggregations, plus hover docs for metrics (type + help from `/prometheus/api/v1/metadata`, with SQL fallback) +- Inline PromQL parse-error squiggles powered by the Prometheus Lezer grammar +- Per-datasource PromQL query history surfaced as completion items +- Typed variable query editor for PromQL: `label_names`, `label_values(metric, label)`, `metrics(regex)`, and `query_result(expr)` +- Range / Instant / Both toggle for PromQL queries in Explore (instant queries power Stat / Gauge / Table panels) +- Run queries button in Dashboard and Alerting (Explore keeps Grafana's native top-bar Run) +- Local-dev Alertmanager service in `docker-compose.yaml` for alert delivery testing (not part of the plugin release bundle) ### Changed + - Upgraded Grafana compatibility to 12.x - Improved query editor UI - Improved datasource connection handling +- Alerting on metrics datasets offers a Builder / Code toggle: Builder mirrors the logs/traces monitor flow (Field + Aggregate + Filters → SQL) while Code runs the full PromQL editor. Non-metrics datasets keep SQL with a default backfill so `/eval` never fires with an empty query +- Blur on the PromQL editor commits the text without auto-running; execution is explicit (Run queries button, Shift+Enter, or a committed UI action like changing dataset/mode/filter) +- Dataset picker sorts actively-ingesting datasets first, then alphabetically; in PromQL mode the list is filtered to `metrics`-type datasets +- Plugin logo paths switched from remote GitHub URLs to the bundled `img/logo.svg` so logos render without network access + +### Fixed + +- PromQL backend requests now preserve the query string correctly (previously `url.JoinPath` URL-escaped `?`, causing 404s on `/prometheus/api/v1/query_range`) +- Empty PromQL / SQL queries are rejected up front with a clear message instead of hitting the backend and returning a generic 500 +- Scoped label / value fetches use a bounded 6-hour lookback window so Parseable's `match[]` endpoints return promptly instead of hanging +- High CPU during PromQL typing: metric detection moved inside a debounce and uses substring + char-code checks in the hot path +- Removed the 2-second debounced auto-run on keystroke; queries no longer execute until the user commits them ## 1.2.1 (2024-06-11) ### Fixed + - Fixed table header generation from schema and query results (#45) - Improved datasource connection test reliability (#42) ## 1.2.0 (2024-03-14) ### Added + - Alerting support (#37, #38) - Dynamic template variables from Parseable (#31) - Multi-valued variable interpolation in queries (#32) ### Fixed + - Variable formatter handling for edge cases (#36) ## 1.1.0 (2023-06-18) ### Added + - Log stream querying with SQL query editor -- Stream schema discovery and stats +- Dataset schema discovery and stats - Grafana template variable support - Pre-built log view dashboard diff --git a/alertmanager.yml b/alertmanager.yml new file mode 100644 index 0000000..4f7462b --- /dev/null +++ b/alertmanager.yml @@ -0,0 +1,15 @@ +global: + resolve_timeout: 1m + +route: + receiver: default + group_by: ['alertname'] + group_wait: 10s + group_interval: 10s + repeat_interval: 1h + +receivers: + - name: default + webhook_configs: + - url: 'http://localhost:5001' + send_resolved: true diff --git a/docker-compose.yaml b/docker-compose.yaml index 0189f15..b6c4239 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,3 +12,13 @@ services: volumes: - ./dist:/var/lib/grafana/plugins/parseable-parseable-datasource - ./provisioning:/etc/grafana/provisioning + depends_on: + - alertmanager + + alertmanager: + image: prom/alertmanager:latest + container_name: 'parseable-alertmanager' + ports: + - 9093:9093/tcp + volumes: + - ./alertmanager.yml:/etc/alertmanager/alertmanager.yml diff --git a/package.json b/package.json index d6eba1d..4852af5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parseable", - "version": "1.2.1", + "version": "1.3.0", "description": "Parseable is an open source, Kubernetes native, log storage and observability platform. This plugin allows you to visualize your Parseable logs in Grafana.", "scripts": { "build": "webpack -c ./.config/webpack/webpack.config.ts --env production", @@ -81,6 +81,10 @@ "@grafana/runtime": "12.4.2", "@grafana/schema": "12.4.2", "@grafana/ui": "12.4.2", + "@lezer/common": "^1.5.2", + "@lezer/highlight": "^1.2.3", + "@lezer/lr": "^1.4.10", + "@prometheus-io/lezer-promql": "^0.311.2", "react": "18.2.0", "react-dom": "18.2.0", "tslib": "2.8.1" diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index b158282..b434be2 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -10,6 +10,10 @@ import ( "net/http" url2 "net/url" "reflect" + "sort" + "strconv" + "strings" + "time" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" @@ -119,6 +123,19 @@ func (d *Datasource) query(ctx context.Context, pCtx backend.PluginContext, quer return backend.DataResponse{}, fmt.Errorf("unmarshal query: %w", err) } + // Reject empty queries up front with a clear message rather than letting + // them fall through to an endpoint that returns a generic 500. + if grafanaQuery.QueryText == "" { + return backend.DataResponse{}, fmt.Errorf("query is empty: pick a dataset and enter a PromQL expression or a monitor field before running") + } + + // Route PromQL queries to the Prometheus-compatible endpoints. Used by + // Unified Alerting (which always runs through the backend) and by any + // future backend-served PromQL paths. + if grafanaQuery.QueryLanguage == "promql" { + return d.queryPromql(ctx, grafanaQuery, query.TimeRange) + } + queryRequest := parseableQueryRequest{ Query: grafanaQuery.QueryText, StartTime: query.TimeRange.From, @@ -256,3 +273,300 @@ func (d *Datasource) CheckHealth(ctx context.Context, _ *backend.CheckHealthRequ func newHealthCheckErrorf(format string, args ...interface{}) *backend.CheckHealthResult { return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: fmt.Sprintf(format, args...)} } + +// --------------------------------------------------------------------------- +// PromQL query path (used by Unified Alerting and any backend-served flow) +// --------------------------------------------------------------------------- + +// queryPromql dispatches to the range or instant endpoint. Default behaviour +// matches the TypeScript frontend: run range when neither flag is set. +func (d *Datasource) queryPromql(ctx context.Context, gq grafanaQuery, tr backend.TimeRange) (backend.DataResponse, error) { + wantInstant := gq.Instant != nil && *gq.Instant + wantRange := (gq.Range != nil && *gq.Range) || !wantInstant + if wantInstant && !wantRange { + return d.queryPromqlInstant(ctx, gq, tr) + } + return d.queryPromqlRange(ctx, gq, tr) +} + +func (d *Datasource) queryPromqlRange(ctx context.Context, gq grafanaQuery, tr backend.TimeRange) (backend.DataResponse, error) { + start := tr.From.Unix() + end := tr.To.Unix() + step := calculateStep(end - start) + + params := url2.Values{} + params.Set("query", gq.QueryText) + if gq.Stream != "" { + params.Set("stream", gq.Stream) + } + params.Set("start", strconv.FormatInt(start, 10)) + params.Set("end", strconv.FormatInt(end, 10)) + params.Set("step", step) + params.Set("timestamp_format", "unix") + + return d.doPromqlRequest(ctx, "/prometheus/api/v1/query_range", params) +} + +func (d *Datasource) queryPromqlInstant(ctx context.Context, gq grafanaQuery, tr backend.TimeRange) (backend.DataResponse, error) { + params := url2.Values{} + params.Set("query", gq.QueryText) + if gq.Stream != "" { + params.Set("stream", gq.Stream) + } + params.Set("time", strconv.FormatInt(tr.To.Unix(), 10)) + params.Set("timestamp_format", "unix") + + return d.doPromqlRequest(ctx, "/prometheus/api/v1/query", params) +} + +// Build the full URL by parsing the base, setting the path, and attaching +// RawQuery separately. Avoids `url.JoinPath`'s behavior of treating the whole +// second arg as a path — which URL-escapes `?` and yields a 404. +func (d *Datasource) doPromqlRequest(ctx context.Context, path string, params url2.Values) (backend.DataResponse, error) { + ctxLogger := log.DefaultLogger.FromContext(ctx) + + u, err := url2.Parse(d.settings.URL) + if err != nil { + return backend.DataResponse{}, fmt.Errorf("parse base url: %w", err) + } + // Append path, avoiding double-slashes. + basePath := strings.TrimRight(u.Path, "/") + u.Path = basePath + path + u.RawQuery = params.Encode() + apiURL := u.String() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return backend.DataResponse{}, fmt.Errorf("new request: %w", err) + } + req.Header.Set("Accept", "application/json") + + httpResp, err := d.httpClient.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return backend.DataResponse{}, err + } + return backend.DataResponse{}, fmt.Errorf("http client do: %w: %s", errRemoteRequest, err) + } + defer func() { + if cerr := httpResp.Body.Close(); cerr != nil { + ctxLogger.Error("promql: failed to close response body", "err", cerr) + } + }() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return backend.DataResponse{}, fmt.Errorf("%w: read body: %s", errRemoteResponse, err) + } + if httpResp.StatusCode != http.StatusOK { + return backend.DataResponse{}, fmt.Errorf("%w: expected 200 response, got %d: %s", errRemoteResponse, httpResp.StatusCode, string(body)) + } + + var pr promResponse + if err := json.Unmarshal(body, &pr); err != nil { + return backend.DataResponse{}, fmt.Errorf("%w: decode: %s", errRemoteResponse, err) + } + if pr.Status != "success" { + return backend.DataResponse{}, fmt.Errorf("%w: prom error: %s", errRemoteResponse, pr.Error) + } + + frames, err := promResultToFrames(pr.Data.ResultType, pr.Data.Result) + if err != nil { + return backend.DataResponse{}, fmt.Errorf("%w: to frames: %s", errRemoteResponse, err) + } + return backend.DataResponse{Frames: frames}, nil +} + +// Mirror the frontend's calculateStep so backend + frontend produce comparable +// time-series resolutions for the same range. +func calculateStep(durationSec int64) string { + switch { + case durationSec <= 3600: + return "15s" + case durationSec <= 21600: + return "60s" + case durationSec <= 86400: + return "5m" + case durationSec <= 604800: + return "15m" + default: + return "1h" + } +} + +func promResultToFrames(resultType string, raw json.RawMessage) ([]*data.Frame, error) { + switch resultType { + case "matrix": + var series []promMatrixSeries + if err := json.Unmarshal(raw, &series); err != nil { + return nil, fmt.Errorf("matrix decode: %w", err) + } + return matrixToFrames(series), nil + case "vector": + var samples []promVectorSample + if err := json.Unmarshal(raw, &samples); err != nil { + return nil, fmt.Errorf("vector decode: %w", err) + } + return vectorToFrames(samples), nil + case "scalar": + var sample [2]interface{} + if err := json.Unmarshal(raw, &sample); err != nil { + return nil, fmt.Errorf("scalar decode: %w", err) + } + return scalarToFrames(sample), nil + } + return nil, nil +} + +// Build one time-series frame with a common time axis and one value field per +// series. Missing samples are represented as nulls so alert reducers (e.g. +// `last`, `mean`) can handle them consistently. +func matrixToFrames(series []promMatrixSeries) []*data.Frame { + if len(series) == 0 { + return []*data.Frame{data.NewFrame("promql")} + } + + // Union of all timestamps across series, sorted ascending. + tsSet := map[int64]struct{}{} + for _, s := range series { + for _, sample := range s.Values { + if ms, ok := promSampleTimeMs(sample[0]); ok { + tsSet[ms] = struct{}{} + } + } + } + ts := make([]int64, 0, len(tsSet)) + for k := range tsSet { + ts = append(ts, k) + } + sort.Slice(ts, func(i, j int) bool { return ts[i] < ts[j] }) + + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, len(ts)) + timeField.Name = "Time" + for i, ms := range ts { + timeField.Set(i, time.UnixMilli(ms)) + } + + fields := []*data.Field{timeField} + for _, s := range series { + valueMap := map[int64]*float64{} + for _, sample := range s.Values { + ms, ok := promSampleTimeMs(sample[0]) + if !ok { + continue + } + if f, ok := promSampleValue(sample[1]); ok { + valueMap[ms] = &f + } + } + name := buildSeriesName(s.Metric) + f := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, len(ts)) + f.Name = name + f.Labels = data.Labels(s.Metric) + for i, t := range ts { + if v, ok := valueMap[t]; ok { + f.Set(i, v) + } + } + fields = append(fields, f) + } + + frame := data.NewFrame("promql", fields...) + return []*data.Frame{frame} +} + +// Vector (instant) — one frame with Time / Value / Metric-name columns. +func vectorToFrames(samples []promVectorSample) []*data.Frame { + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, len(samples)) + timeField.Name = "Time" + valueField := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, len(samples)) + valueField.Name = "Value" + metricField := data.NewFieldFromFieldType(data.FieldTypeString, len(samples)) + metricField.Name = "Metric" + + for i, s := range samples { + ms, _ := promSampleTimeMs(s.Value[0]) + timeField.Set(i, time.UnixMilli(ms)) + if v, ok := promSampleValue(s.Value[1]); ok { + valueField.Set(i, &v) + } + metricField.Set(i, buildSeriesName(s.Metric)) + } + frame := data.NewFrame("promql", timeField, valueField, metricField) + return []*data.Frame{frame} +} + +func scalarToFrames(sample [2]interface{}) []*data.Frame { + ms, _ := promSampleTimeMs(sample[0]) + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 1) + timeField.Name = "Time" + timeField.Set(0, time.UnixMilli(ms)) + + valueField := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, 1) + valueField.Name = "Value" + if v, ok := promSampleValue(sample[1]); ok { + valueField.Set(0, &v) + } + frame := data.NewFrame("promql", timeField, valueField) + return []*data.Frame{frame} +} + +// Prometheus sample timestamps come as either a JSON number (unix seconds) +// when timestamp_format=unix, or an RFC3339 string otherwise. We negotiate +// unix in our requests so the numeric branch is the hot path. +func promSampleTimeMs(ts interface{}) (int64, bool) { + switch v := ts.(type) { + case float64: + return int64(v * 1000), true + case string: + if f, err := strconv.ParseFloat(v, 64); err == nil { + return int64(f * 1000), true + } + if t, err := time.Parse(time.RFC3339, v); err == nil { + return t.UnixMilli(), true + } + } + return 0, false +} + +// Sample values are always strings in the Prometheus wire format (handles +// NaN/+Inf/-Inf/normal floats alike). +func promSampleValue(val interface{}) (float64, bool) { + switch v := val.(type) { + case float64: + return v, true + case string: + if f, err := strconv.ParseFloat(v, 64); err == nil { + return f, true + } + } + return 0, false +} + +// Produce a grafana-friendly series name: `metric{label="v", …}` or just +// the metric name when there are no non-__name__ labels. +func buildSeriesName(labels map[string]string) string { + name := labels["__name__"] + if len(labels) <= 1 { + if name == "" { + return "value" + } + return name + } + keys := make([]string, 0, len(labels)) + for k := range labels { + if k == "__name__" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf(`%s="%s"`, k, labels[k])) + } + if name == "" { + return "{" + strings.Join(parts, ", ") + "}" + } + return name + "{" + strings.Join(parts, ", ") + "}" +} diff --git a/pkg/plugin/promql_decode_test.go b/pkg/plugin/promql_decode_test.go new file mode 100644 index 0000000..7c9ff92 --- /dev/null +++ b/pkg/plugin/promql_decode_test.go @@ -0,0 +1,52 @@ +package plugin + +import ( + "encoding/json" + "os" + "testing" +) + +// Regression: make sure our matrix / sample decoding works against the real +// Parseable /query_range response shape, including RFC3339 timestamps returned +// even when timestamp_format=unix is requested. +func TestPromMatrixDecode_Parseable(t *testing.T) { + path := os.Getenv("PROM_RESP_JSON") + if path == "" { + path = "/tmp/prom_resp.json" + } + data, err := os.ReadFile(path) + if err != nil { + t.Skipf("skipping: %v (set PROM_RESP_JSON to point at a captured response)", err) + } + var pr promResponse + if err := json.Unmarshal(data, &pr); err != nil { + t.Fatalf("unmarshal promResponse: %v", err) + } + if pr.Status != "success" { + t.Fatalf("status = %q want success", pr.Status) + } + if pr.Data.ResultType != "matrix" { + t.Fatalf("resultType = %q want matrix", pr.Data.ResultType) + } + var series []promMatrixSeries + if err := json.Unmarshal(pr.Data.Result, &series); err != nil { + t.Fatalf("unmarshal matrix: %v", err) + } + if len(series) == 0 { + t.Fatal("no series") + } + frames := matrixToFrames(series) + if len(frames) == 0 { + t.Fatal("no frames") + } + // Expect: 1 frame, Time + one value field per series. + f := frames[0] + if got, want := len(f.Fields), 1+len(series); got != want { + t.Fatalf("fields = %d want %d", got, want) + } + // Time field should have at least one entry. + if f.Fields[0].Len() == 0 { + t.Fatal("time field empty") + } + t.Logf("decoded %d series into frame with %d rows", len(series), f.Fields[0].Len()) +} diff --git a/pkg/plugin/types.go b/pkg/plugin/types.go index feb5eef..7169c8b 100644 --- a/pkg/plugin/types.go +++ b/pkg/plugin/types.go @@ -1,11 +1,18 @@ package plugin -import "time" +import ( + "encoding/json" + "time" +) type apiMetrics []map[string]interface{} type grafanaQuery struct { - QueryText string `json:"queryText"` + QueryText string `json:"queryText"` + QueryLanguage string `json:"queryLanguage,omitempty"` + Stream string `json:"stream,omitempty"` + Range *bool `json:"range,omitempty"` + Instant *bool `json:"instant,omitempty"` } type parseableQueryRequest struct { @@ -13,3 +20,30 @@ type parseableQueryRequest struct { StartTime time.Time `json:"startTime"` EndTime time.Time `json:"endTime"` } + +// --------------------------------------------------------------------------- +// Prometheus-compatible response decoding (used by the PromQL query path) +// --------------------------------------------------------------------------- + +type promResponse struct { + Status string `json:"status"` + Error string `json:"error,omitempty"` + Data promRespData `json:"data"` +} + +type promRespData struct { + ResultType string `json:"resultType"` + Result json.RawMessage `json:"result"` +} + +// Matrix result: [{ metric: {...}, values: [[ts, "val"], ...] }, ...] +type promMatrixSeries struct { + Metric map[string]string `json:"metric"` + Values [][2]interface{} `json:"values"` +} + +// Vector result: [{ metric: {...}, value: [ts, "val"] }, ...] +type promVectorSample struct { + Metric map[string]string `json:"metric"` + Value [2]interface{} `json:"value"` +} diff --git a/src/components/FilterBuilder.tsx b/src/components/FilterBuilder.tsx new file mode 100644 index 0000000..468bb87 --- /dev/null +++ b/src/components/FilterBuilder.tsx @@ -0,0 +1,232 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { Select, AsyncSelect, Button, IconButton, useStyles2 } from '@grafana/ui'; +import { FilterCondition } from '../types'; +import { FieldTypeMap, getOperators, typeDisplayName } from '../utils/fieldTypes'; +import { isNullOperator } from '../utils/queryBuilder'; +import { DataSource } from '../datasource'; + +interface FilterBuilderProps { + filters: FilterCondition[]; + fieldTypeMap: FieldTypeMap; + fieldNames: string[]; + streamName?: string; + datasource: DataSource; + onChange: (filters: FilterCondition[]) => void; +} + +export const FilterBuilder: React.FC = ({ + filters, + fieldTypeMap, + fieldNames, + streamName, + datasource, + onChange, +}) => { + const styles = useStyles2(getStyles); + const [isAdding, setIsAdding] = useState(false); + const [newColumn, setNewColumn] = useState | null>(null); + const [newOperator, setNewOperator] = useState | null>(null); + const [newValue, setNewValue] = useState(''); + + const columnOptions = useMemo(() => { + return fieldNames + .filter((name) => !name.startsWith('p_')) + .map((name) => ({ + label: name, + value: name, + description: typeDisplayName(fieldTypeMap[name]), + })); + }, [fieldNames, fieldTypeMap]); + + // Operators change based on the selected column's type + const operatorOptions = useMemo(() => { + if (!newColumn?.value) { + return []; + } + return getOperators(fieldTypeMap, newColumn.value).map((op) => ({ + label: op.name, + value: op.value, + })); + }, [newColumn, fieldTypeMap]); + + const removeFilter = useCallback( + (index: number) => { + onChange(filters.filter((_, i) => i !== index)); + }, + [filters, onChange] + ); + + const resetAddForm = useCallback(() => { + setNewColumn(null); + setNewOperator(null); + setNewValue(''); + setIsAdding(false); + }, []); + + const addFilter = useCallback(() => { + if (!newColumn?.value || !newOperator?.value) { + return; + } + if (!isNullOperator(newOperator.value) && !newValue.trim()) { + return; + } + + const filter: FilterCondition = { + column: newColumn.value, + operator: newOperator.value, + value: isNullOperator(newOperator.value) ? null : newValue.trim(), + type: fieldTypeMap[newColumn.value] || 'text', + }; + + onChange([...filters, filter]); + resetAddForm(); + }, [newColumn, newOperator, newValue, filters, onChange, resetAddForm, fieldTypeMap]); + + const loadValueSuggestions = useCallback( + async (inputValue: string): Promise>> => { + if (!streamName || !newColumn?.value) { + return []; + } + try { + const values = await datasource.getDistinctValues(streamName, newColumn.value); + return values + .filter((v) => !inputValue || v.toLowerCase().includes(inputValue.toLowerCase())) + .map((v) => ({ label: v, value: v })); + } catch { + return []; + } + }, + [streamName, newColumn, datasource] + ); + + const formatFilterDisplay = (filter: FilterCondition): string => { + if (isNullOperator(filter.operator)) { + return `${filter.column} ${filter.operator}`; + } + return `${filter.column} ${filter.operator} ${filter.value}`; + }; + + return ( +
+
+ {filters.map((filter, index) => ( +
+ {formatFilterDisplay(filter)} + removeFilter(index)} + tooltip="Remove filter" + /> +
+ ))} + + {filters.length > 0 && ( + + )} + + {!isAdding && ( + + )} +
+ + {isAdding && ( +
+ + {newOperator && !isNullOperator(newOperator.value!) && ( + setNewValue(v?.value || '')} + allowCustomValue + onCreateOption={(v) => setNewValue(v)} + placeholder="Value" + width={24} + menuPlacement="bottom" + /> + )} + + +
+ )} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + }), + pillContainer: css({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(0.5), + alignItems: 'center', + }), + pill: css({ + display: 'inline-flex', + alignItems: 'center', + gap: theme.spacing(0.5), + padding: `${theme.spacing(0.25)} ${theme.spacing(1)}`, + background: theme.colors.background.secondary, + border: `1px solid ${theme.colors.border.medium}`, + borderRadius: theme.shape.radius.pill, + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.primary, + maxWidth: 400, + }), + pillText: css({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }), + pillRemove: css({ + flexShrink: 0, + [`&:hover`]: { + color: theme.colors.error.text, + }, + }), + addRow: css({ + display: 'flex', + gap: theme.spacing(0.5), + alignItems: 'center', + flexWrap: 'wrap', + }), +}); diff --git a/src/components/QueryEditor.tsx b/src/components/QueryEditor.tsx index 6719003..740480c 100644 --- a/src/components/QueryEditor.tsx +++ b/src/components/QueryEditor.tsx @@ -1,185 +1,1088 @@ -import React, { ComponentType, ChangeEvent, useState } from 'react'; -import { AsyncSelect, InlineField, InlineFieldRow, Input, SeriesTable, Label } from '@grafana/ui'; -import { QueryEditorProps, SelectableValue } from '@grafana/data'; +import React, { ComponentType, useState, useCallback, useEffect, useMemo } from 'react'; +import { css } from '@emotion/css'; +import { CoreApp, GrafanaTheme2, QueryEditorProps, SelectableValue } from '@grafana/data'; +import { + AsyncSelect, + Button, + CodeEditor, + InlineField, + RadioButtonGroup, + Select, + useStyles2, + MultiSelect, +} from '@grafana/ui'; +import type { Monaco } from '@grafana/ui/dist/types/components/Monaco/types'; import { DataSource } from '../datasource'; -import { SchemaFields, MyDataSourceOptions, MyQuery } from '../types'; +import { + SchemaFields, + MyDataSourceOptions, + MyQuery, + FilterCondition, + QueryEditorMode, + StreamStatsResponse, + MetricInfo, +} from '../types'; +import { buildFieldTypeMap, FieldTypeMap, typeDisplayName, getAggregateOptions } from '../utils/fieldTypes'; +import { buildSqlFromFilters, buildMonitorSql } from '../utils/queryBuilder'; +import { + ensurePromqlCompletionProvider, + ensurePromqlSignatureHelpProvider, + setPromqlCompletionContext, + clearPromqlCompletionCaches, +} from '../utils/promqlCompletions'; +import { ensurePromqlHoverProvider } from '../utils/promqlHover'; +import { attachPromqlErrorMarkers } from '../utils/promqlParser'; +import { getPromqlHistory } from '../utils/promqlHistory'; +import { FilterBuilder } from './FilterBuilder'; +import { StreamInfoPanel } from './StreamInfoPanel'; + +const ALL_ROWS_VALUE = ''; + +// Register PromQL as a custom Monaco language (syntax highlighting + bracket matching) +let promqlRegistered = false; +function ensurePromQLLanguage(monaco: Monaco) { + if (promqlRegistered) { + return; + } + promqlRegistered = true; + + monaco.languages.register({ id: 'promql' }); + + monaco.languages.setMonarchTokensProvider('promql', { + defaultToken: '', + keywords: [ + 'by', + 'without', + 'on', + 'ignoring', + 'group_left', + 'group_right', + 'bool', + 'offset', + 'and', + 'or', + 'unless', + 'start', + 'end', + ], + aggregations: [ + 'sum', + 'avg', + 'min', + 'max', + 'count', + 'stddev', + 'stdvar', + 'topk', + 'bottomk', + 'quantile', + 'count_values', + 'group', + ], + functions: [ + 'rate', + 'irate', + 'increase', + 'delta', + 'idelta', + 'avg_over_time', + 'sum_over_time', + 'min_over_time', + 'max_over_time', + 'count_over_time', + 'last_over_time', + 'stddev_over_time', + 'stdvar_over_time', + 'quantile_over_time', + 'present_over_time', + 'resets', + 'changes', + 'deriv', + 'predict_linear', + 'holt_winters', + 'sort', + 'sort_desc', + 'sort_by_label', + 'sort_by_label_desc', + 'abs', + 'ceil', + 'floor', + 'round', + 'ln', + 'log2', + 'log10', + 'exp', + 'sqrt', + 'sgn', + 'clamp', + 'clamp_min', + 'clamp_max', + 'scalar', + 'vector', + 'time', + 'timestamp', + 'day_of_month', + 'day_of_week', + 'day_of_year', + 'days_in_month', + 'hour', + 'minute', + 'month', + 'year', + 'label_replace', + 'label_join', + 'histogram_quantile', + 'histogram_avg', + 'histogram_count', + 'histogram_sum', + 'histogram_fraction', + 'histogram_stddev', + 'histogram_stdvar', + 'absent', + 'absent_over_time', + 'double_exponential_smoothing', + 'info', + ], + tokenizer: { + root: [ + [/#.*$/, 'comment'], + [/\b\d+(\.\d+)?([eE][+-]?\d+)?[smhdwy]?\b/, 'number'], + [/"[^"]*"/, 'string'], + [/'[^']*'/, 'string'], + [/`[^`]*`/, 'string'], + [ + /[a-zA-Z_]\w*/, + { + cases: { + '@keywords': 'keyword', + '@aggregations': 'keyword', + '@functions': 'type.identifier', + '@default': 'identifier', + }, + }, + ], + [/[{}()\[\]]/, '@brackets'], + [/[=!<>~]+/, 'operator'], + [/@/, 'operator'], + [/,/, 'delimiter'], + ], + }, + } as any); + + monaco.languages.setLanguageConfiguration('promql', { + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'], + ], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], + // Parseable metrics allow OTel-style dotted names (process.cpu.time). + // Tell Monaco those dots are part of a single word so completion filtering + // and word-range detection treat `process.cpu.time` as one token. + wordPattern: /[a-zA-Z_:][a-zA-Z0-9_:.]*/, + }); +} + +// Bootstraps the tokenizer, the completion provider, the signature-help +// provider, and the hover provider for the shared Monaco instance. All are +// idempotent. +function setupPromqlEditor(monaco: Monaco) { + ensurePromQLLanguage(monaco); + ensurePromqlCompletionProvider(monaco); + ensurePromqlSignatureHelpProvider(monaco); + ensurePromqlHoverProvider(monaco); +} interface Props extends QueryEditorProps { payload?: string; } -export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQuery, query }) => { - const { queryText } = query; - //const [stream, setStream] = React.useState>(); +const EXPLORE_MODE_OPTIONS = [ + { label: 'Builder', value: 'builder' as QueryEditorMode }, + { label: 'PromQL', value: 'promql' as QueryEditorMode }, +]; + +export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQuery, query, app }) => { + const styles = useStyles2(getStyles); + + const isAlerting = app === CoreApp.UnifiedAlerting || app === CoreApp.CloudAlerting; + // Dashboard panel (including the edit view) is where we surface our own + // Run queries button. Explore already has Grafana's top-level Run button, + // and Alerting uses its own preview / evaluate controls. + const isDashboard = app === CoreApp.Dashboard || app === CoreApp.PanelEditor; + const rawEditorMode = query.editorMode || datasource.defaultEditorMode || 'builder'; + const editorMode: QueryEditorMode = isAlerting ? 'monitor' : rawEditorMode === 'promql' ? 'promql' : 'builder'; + const filters = query.filters || []; + const selectedColumns = query.selectedColumns || []; - const loadAsyncOptions = React.useCallback(() => { - return datasource.listStreams().then( - (result) => { - const stream = result.map((data) => ({ label: data.name, value: data.name })); - return stream; - }, + const [selectedStream, setSelectedStream] = useState>( + query.stream ? { label: query.stream, value: query.stream } : ({} as SelectableValue) + ); + const [schemaFields, setSchemaFields] = useState([]); + const [stats, setStats] = useState({}); + const [telemetryType, setTelemetryType] = useState(); + const [metricsList, setMetricsList] = useState([]); + const [promLabels, setPromLabels] = useState([]); + const [promMetricNames, setPromMetricNames] = useState([]); + const [promMetadata, setPromMetadata] = useState>({}); + + // Build fieldTypeMap and fieldNames from schema (like Prism's setStreamSchema) + const fieldTypeMap: FieldTypeMap = useMemo(() => buildFieldTypeMap(schemaFields), [schemaFields]); + const fieldNames: string[] = useMemo(() => schemaFields.map((f) => f.name), [schemaFields]); + + // Load datasets for dropdown (from /api/prism/v1/home). Sort matches Prism's + // `getStreamName` in AppSideBar.tsx: actively-ingesting datasets first, then + // the rest, alphabetical within each group. In PromQL mode, restrict to + // metrics-type datasets since PromQL only makes sense against those. + const loadAsyncOptions = useCallback(() => { + return datasource.listDatasets().then( + (result) => + result + .filter((d) => (editorMode === 'promql' ? d.datasetType === 'metrics' : true)) + .sort((a, b) => { + if (a.ingestion && !b.ingestion) { + return -1; + } + if (!a.ingestion && b.ingestion) { + return 1; + } + return a.title.localeCompare(b.title); + }) + .map((d) => ({ + label: d.title, + value: d.title, + description: d.datasetType, + })), (response) => { - //setStream({ label: '', value: '' }); throw new Error(response.statusText); } ); - }, [datasource]); - - const [selectedStream, setSelectedStream] = useState>(); - const [schema = '', setSchema] = React.useState(); - const [count = '', setEventCount] = React.useState(); - const [jsonsize = '', setJsonSize] = React.useState(); - const [parquetsize = '', setParquetSize] = React.useState(); - const [streamname = '', setStreamName] = React.useState(); - const [time = '', setTime] = React.useState(); - //const [fielder, setFielder] = React.useState(); - - const loadStreamSchema = React.useCallback( - (streamName) => { - if (streamName && typeof streamName === 'string') { - return datasource.getStreamSchema(streamName).then( - (result) => { - if (result.fields) { - const schema = result.fields.map((data: SchemaFields) => data.name); - const schemaToText = schema.join(', '); - setSchema(schemaToText); - return schema; - } - return schema; - }, - (response) => { - throw new Error(response.statusText); + }, [datasource, editorMode]); + + // Load stream info (schema + stats + info) when stream changes + useEffect(() => { + const streamName = selectedStream?.value; + if (streamName) { + datasource + .getStreamInfo(streamName) + .then((result) => { + if (result.schema?.fields) { + setSchemaFields(result.schema.fields as SchemaFields[]); + } else { + setSchemaFields([]); + } + setStats(result.stats ?? {}); + const tType = result.info?.telemetryType; + setTelemetryType(tType); + + // Fetch metric names and PromQL metadata for metrics streams + if (tType === 'metrics') { + datasource + .getMetricNames(streamName) + .then(setMetricsList) + .catch(() => setMetricsList([])); + datasource + .getPromLabels(streamName) + .then(setPromLabels) + .catch(() => setPromLabels([])); + datasource + .getPromMetricNames(streamName) + .then(setPromMetricNames) + .catch(() => setPromMetricNames([])); + datasource + .getPromMetadata(streamName) + .then(setPromMetadata) + .catch(() => setPromMetadata({})); + } else { + setMetricsList([]); + setPromLabels([]); + setPromMetricNames([]); + setPromMetadata({}); } - ); + }) + .catch(() => { + setSchemaFields([]); + setStats({}); + setTelemetryType(undefined); + setMetricsList([]); + setPromLabels([]); + setPromMetricNames([]); + setPromMetadata({}); + }); + } else { + setSchemaFields([]); + setStats({}); + setTelemetryType(undefined); + setMetricsList([]); + setPromLabels([]); + setPromMetricNames([]); + setPromMetadata({}); + } + }, [datasource, selectedStream?.value]); + + // Clear completion caches when stream changes so scoped labels/values + // fetched via /series don't leak across datasets. + useEffect(() => { + clearPromqlCompletionCaches(); + }, [selectedStream?.value]); + + // Handle stream change + const onStreamChange = useCallback( + (v: SelectableValue) => { + setSelectedStream(v); + const streamName = v.value || ''; + const newQuery: MyQuery = { ...query, stream: streamName }; + + if (editorMode === 'builder') { + newQuery.filters = []; + newQuery.selectedColumns = []; + newQuery.queryText = buildSqlFromFilters(streamName, [], [], fieldTypeMap); + newQuery.queryLanguage = 'sql'; + } else if (editorMode === 'promql') { + newQuery.queryLanguage = 'promql'; + } else if (editorMode === 'monitor') { + newQuery.filters = []; + newQuery.monitorField = ALL_ROWS_VALUE; + newQuery.monitorAggregate = 'COUNT'; + newQuery.monitorMetric = undefined; + newQuery.monitorMetricType = undefined; + // Clear text + language; the stream-info effect will decide whether + // this is a metrics (PromQL empty) or logs/traces (SQL default) + // stream once telemetry type resolves. Firing onRunQuery here would + // race against that resolution and cause SQL/PromQL to be sent in + // the wrong mode, so monitor mode skips auto-run on stream change. + newQuery.queryText = ''; + newQuery.queryLanguage = undefined; + } + onChange(newQuery); + if (editorMode !== 'monitor') { + onRunQuery(); } - return ''; }, - [datasource, schema] + [query, onChange, onRunQuery, editorMode, fieldTypeMap] ); - const loadStreamStats = React.useCallback( - (streamName) => { - if (streamName) { - return datasource.getStreamStats(streamName).then( - (result) => { - if (result.ingestion) { - const count = result.ingestion.count; - const jsonsize = result.ingestion.size; - const parquetsize = result.storage?.size; - const streamname = result.stream; - const time = result.time; - setJsonSize(jsonsize); - setParquetSize(parquetsize); - setStreamName(streamname); - setEventCount(count); - setTime(time); - return count; - } - return count; - }, - (response) => { - throw new Error(response.statusText); - } - ); + // Handle mode change + const onModeChange = useCallback( + (mode: QueryEditorMode) => { + const newQuery: MyQuery = { ...query, editorMode: mode }; + + if (mode === 'builder' && selectedStream?.value) { + newQuery.filters = []; + newQuery.selectedColumns = []; + newQuery.queryText = buildSqlFromFilters(selectedStream.value, [], [], fieldTypeMap); + newQuery.queryLanguage = 'sql'; + } else if (mode === 'promql') { + newQuery.queryLanguage = 'promql'; + if (query.queryLanguage !== 'promql') { + newQuery.queryText = ''; + } + } else if (mode === 'monitor' && selectedStream?.value) { + const field = query.monitorField ?? ALL_ROWS_VALUE; + const agg = query.monitorAggregate ?? 'COUNT'; + newQuery.filters = query.filters || []; + newQuery.monitorField = field; + newQuery.monitorAggregate = agg; + newQuery.queryText = buildMonitorSql(selectedStream.value, field, agg, newQuery.filters, fieldTypeMap); } - return ''; + onChange(newQuery); + onRunQuery(); + }, + [query, onChange, onRunQuery, selectedStream, fieldTypeMap] + ); + + // Handle filter changes (builder mode) + const onFiltersChange = useCallback( + (newFilters: FilterCondition[]) => { + if (!selectedStream?.value) { + return; + } + const sql = buildSqlFromFilters(selectedStream.value, newFilters, selectedColumns, fieldTypeMap); + onChange({ ...query, filters: newFilters, queryText: sql }); + onRunQuery(); + }, + [query, onChange, onRunQuery, selectedStream, selectedColumns, fieldTypeMap] + ); + + // Handle column selection changes (builder mode) + const onColumnsChange = useCallback( + (cols: Array>) => { + if (!selectedStream?.value) { + return; + } + const colNames = cols.map((c) => c.value!).filter(Boolean); + const sql = buildSqlFromFilters(selectedStream.value, filters, colNames, fieldTypeMap); + onChange({ ...query, selectedColumns: colNames, queryText: sql }); + onRunQuery(); + }, + [query, onChange, onRunQuery, selectedStream, filters, fieldTypeMap] + ); + + // Handle monitor field change + const onMonitorFieldChange = useCallback( + (v: SelectableValue) => { + if (!selectedStream?.value) { + return; + } + const field = v.value ?? ALL_ROWS_VALUE; + // Pick a valid aggregate for the new field + const aggOptions = getAggregateOptions(fieldTypeMap, field); + const currentAgg = query.monitorAggregate || 'COUNT'; + const agg = aggOptions.some((o) => o.value === currentAgg) ? currentAgg : aggOptions[0].value; + + const sql = buildMonitorSql(selectedStream.value, field, agg, filters, fieldTypeMap); + onChange({ ...query, monitorField: field, monitorAggregate: agg, queryText: sql }); + onRunQuery(); }, - [datasource, count] + [query, onChange, onRunQuery, selectedStream, filters, fieldTypeMap] ); - const onQueryTextChange = (event: ChangeEvent) => { - onChange({ ...query, queryText: event.target.value }); - }; + // Handle monitor aggregate change + const onMonitorAggregateChange = useCallback( + (v: SelectableValue) => { + if (!selectedStream?.value) { + return; + } + const agg = v.value || 'COUNT'; + const field = query.monitorField ?? ALL_ROWS_VALUE; + const sql = buildMonitorSql(selectedStream.value, field, agg, filters, fieldTypeMap); + onChange({ ...query, monitorAggregate: agg, queryText: sql }); + onRunQuery(); + }, + [query, onChange, onRunQuery, selectedStream, filters, fieldTypeMap] + ); - React.useEffect(() => { - const getData = setTimeout(() => { + // Handle filter changes in monitor mode + const onMonitorFiltersChange = useCallback( + (newFilters: FilterCondition[]) => { + if (!selectedStream?.value) { + return; + } + const field = query.monitorField ?? ALL_ROWS_VALUE; + const agg = query.monitorAggregate ?? 'COUNT'; + const sql = buildMonitorSql(selectedStream.value, field, agg, newFilters, fieldTypeMap); + onChange({ ...query, filters: newFilters, queryText: sql }); onRunQuery(); - }, 2000); - return () => clearTimeout(getData); - }, [onRunQuery, queryText]); + }, + [query, onChange, onRunQuery, selectedStream, fieldTypeMap] + ); + + const isMetricsStream = telemetryType === 'metrics'; + // Sub-mode for metrics datasets in Alerting: 'builder' mirrors the logs / + // traces monitor builder (Field + Aggregate + Filters → SQL); 'code' runs + // the full PromQL editor. New metrics alerts default to Builder; alerts + // that were saved with PromQL text keep Code so we never wipe prior work. + const metricsAlertMode: 'builder' | 'code' = + query.monitorMetricsMode ?? (query.queryLanguage === 'promql' ? 'code' : 'builder'); + + const onMetricsAlertModeChange = useCallback( + (mode: 'builder' | 'code') => { + if (!selectedStream?.value) { + onChange({ ...query, monitorMetricsMode: mode }); + return; + } + let nextText = ''; + if (mode === 'builder') { + const field = query.monitorField ?? ALL_ROWS_VALUE; + const agg = query.monitorAggregate ?? 'COUNT'; + nextText = buildMonitorSql(selectedStream.value, field, agg, query.filters || [], fieldTypeMap); + onChange({ + ...query, + monitorMetricsMode: 'builder', + monitorField: field, + monitorAggregate: agg, + queryText: nextText, + queryLanguage: 'sql', + }); + } else { + nextText = query.queryLanguage === 'promql' ? query.queryText || '' : ''; + onChange({ + ...query, + monitorMetricsMode: 'code', + queryLanguage: 'promql', + queryText: nextText, + }); + } + // Only auto-run if the resulting query has content. Switching to Code + // with no prior PromQL body leaves the editor empty and would cause + // Alerting's /eval to reject the request with "query is empty". + if (nextText.trim()) { + onRunQuery(); + } + }, + [query, onChange, onRunQuery, selectedStream, fieldTypeMap] + ); + + // -- Metrics alert handlers -- + + // Merged metadata map for metric autocomplete + pickers. Prefers /metadata + // (native Prom endpoint); falls back to SQL-derived metricsList when the + // server hasn't populated metadata for a metric. + const metricMetadata = useMemo((): Record => { + const out: Record = {}; + metricsList.forEach((m) => { + if (m.metric_name) { + out[m.metric_name] = { type: m.metric_type, help: m.metric_description }; + } + }); + Object.keys(promMetadata).forEach((name) => { + const entry = promMetadata[name]; + const existing = out[name] || {}; + out[name] = { + type: entry.type || existing.type, + help: entry.help || existing.help, + }; + }); + return out; + }, [metricsList, promMetadata]); + + // Publish the current state to the Monaco PromQL completion provider so it + // returns context-aware suggestions (metrics, labels scoped to a metric, + // label values, function snippets). Shared across Explore, Dashboards and + // Alerts — same provider, same data. + useEffect(() => { + const streamName = selectedStream?.value || ''; + if (!streamName) { + setPromqlCompletionContext(null); + return; + } + setPromqlCompletionContext({ + streamName, + metricNames: promMetricNames, + metricMetadata, + labels: promLabels, + datasource, + history: getPromqlHistory(datasource.uid), + }); + // Re-read history when the user's query text commits (queries are + // recorded during `datasource.query()` on Run). Reading on every + // invocation of this effect is fine — it's synchronous localStorage. + }, [selectedStream?.value, promMetricNames, promLabels, metricMetadata, datasource, query.queryText]); - React.useEffect(() => { - loadStreamSchema(selectedStream?.value); - }, [loadStreamSchema, selectedStream]); + // Clear context on unmount so stale suggestions don't leak across editors. + useEffect(() => { + return () => { + setPromqlCompletionContext(null); + }; + }, []); + + // Per-keystroke in the Monaco PromQL editor — updates the query spec only. + // Explicit run happens on blur (below), Shift+Enter, or the Run query button. + const onMetricsCodeChange = useCallback( + (value: string) => { + onChange({ ...query, queryText: value, queryLanguage: 'promql' }); + }, + [query, onChange] + ); + + // Blur on the Monaco editor — commit the text only. Running the query is + // user-initiated via the Run queries button, Shift+Enter, or a committed + // UI action (mode/stream/filter change). Matches native Prometheus plugin. + const onMetricsCodeBlur = useCallback( + (value: string) => { + onChange({ ...query, queryText: value, queryLanguage: 'promql' }); + }, + [query, onChange] + ); - React.useEffect(() => { - loadStreamStats(selectedStream?.value); - }, [loadStreamStats, selectedStream]); + // Metrics-dataset alert in Code (PromQL) sub-mode: force queryLanguage to + // 'promql'. Fires on fresh stream selection and when switching back from + // Builder. Clears a stale SQL body so the editor opens empty. + useEffect(() => { + if ( + isAlerting && + isMetricsStream && + selectedStream?.value && + metricsAlertMode === 'code' && + query.queryLanguage !== 'promql' + ) { + onChange({ + ...query, + queryText: query.queryLanguage === 'sql' ? '' : query.queryText || '', + queryLanguage: 'promql', + monitorMetric: undefined, + monitorMetricType: undefined, + }); + } + }, [isAlerting, isMetricsStream, selectedStream?.value, metricsAlertMode, query.queryLanguage]); + + // Metrics-dataset alert in Builder sub-mode: backfill monitor SQL when the + // text is empty or still PromQL, so /eval never fires with a mismatched + // language. Mirrors the non-metrics backfill below. + useEffect(() => { + if ( + isAlerting && + isMetricsStream && + selectedStream?.value && + metricsAlertMode === 'builder' && + (query.queryLanguage !== 'sql' || !query.queryText || !query.queryText.trim()) + ) { + const field = query.monitorField ?? ALL_ROWS_VALUE; + const agg = query.monitorAggregate ?? 'COUNT'; + const sql = buildMonitorSql(selectedStream.value, field, agg, query.filters || [], fieldTypeMap); + onChange({ + ...query, + monitorField: field, + monitorAggregate: agg, + queryText: sql, + queryLanguage: 'sql', + }); + } + }, [isAlerting, isMetricsStream, selectedStream?.value, metricsAlertMode, query.queryLanguage, fieldTypeMap]); + + // Backfill default SQL for non-metrics monitor alerts (logs / traces) if + // queryText is still empty after the telemetry type has resolved. Without + // this, Grafana's /eval fires with an empty query and the backend returns + // 500. The user can still override by picking a field / aggregate. + useEffect(() => { + if ( + isAlerting && + telemetryType !== undefined && + !isMetricsStream && + selectedStream?.value && + (!query.queryText || !query.queryText.trim()) + ) { + const field = query.monitorField ?? ALL_ROWS_VALUE; + const agg = query.monitorAggregate ?? 'COUNT'; + const sql = buildMonitorSql(selectedStream.value, field, agg, query.filters || [], fieldTypeMap); + onChange({ + ...query, + monitorField: field, + monitorAggregate: agg, + queryText: sql, + queryLanguage: 'sql', + }); + } + }, [isAlerting, telemetryType, isMetricsStream, selectedStream?.value, fieldTypeMap]); + + // Monitor field options: "All rows (*)" + all non-internal fields + const monitorFieldOptions = useMemo(() => { + const options: Array> = [{ label: 'All rows (*)', value: ALL_ROWS_VALUE }]; + fieldNames + .filter((name) => !name.startsWith('p_')) + .forEach((name) => { + options.push({ + label: name, + value: name, + description: typeDisplayName(fieldTypeMap[name]), + }); + }); + return options; + }, [fieldNames, fieldTypeMap]); + + // Aggregate options for the currently selected monitor field + const aggregateOptions = useMemo(() => { + const field = query.monitorField ?? ALL_ROWS_VALUE; + return getAggregateOptions(fieldTypeMap, field); + }, [query.monitorField, fieldTypeMap]); + + // Column options for multi-select + const columnOptions = useMemo(() => { + return fieldNames.map((name) => ({ + label: name, + value: name, + description: typeDisplayName(fieldTypeMap[name]), + })); + }, [fieldNames, fieldTypeMap]); return ( - <> - - - - - - { - setSelectedStream(v); - }} - /> - - {/* -