From cea57a7a314cebca127c3439934bc0104169653c Mon Sep 17 00:00:00 2001 From: Praveen K B Date: Fri, 17 Apr 2026 11:27:39 +0530 Subject: [PATCH 1/4] Added alerting UX & sidebar to explore data --- src/components/FilterBuilder.tsx | 232 +++++++++++ src/components/QueryEditor.tsx | 596 +++++++++++++++++++++-------- src/components/StreamInfoPanel.tsx | 184 +++++++++ src/datasource.ts | 110 ++++-- src/types.ts | 27 +- src/utils/fieldTypes.ts | 237 ++++++++++++ src/utils/queryBuilder.ts | 243 ++++++++++++ src/utils/sqlNormalize.ts | 100 +++++ 8 files changed, 1537 insertions(+), 192 deletions(-) create mode 100644 src/components/FilterBuilder.tsx create mode 100644 src/components/StreamInfoPanel.tsx create mode 100644 src/utils/fieldTypes.ts create mode 100644 src/utils/queryBuilder.ts create mode 100644 src/utils/sqlNormalize.ts 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..cf6dd9e 100644 --- a/src/components/QueryEditor.tsx +++ b/src/components/QueryEditor.tsx @@ -1,185 +1,475 @@ -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, ChangeEvent, useState, useCallback, useEffect, useMemo } from 'react'; +import { css } from '@emotion/css'; +import { CoreApp, GrafanaTheme2, QueryEditorProps, SelectableValue } from '@grafana/data'; +import { AsyncSelect, InlineField, RadioButtonGroup, Select, useStyles2, MultiSelect } from '@grafana/ui'; import { DataSource } from '../datasource'; -import { SchemaFields, MyDataSourceOptions, MyQuery } from '../types'; +import { SchemaFields, MyDataSourceOptions, MyQuery, FilterCondition, QueryEditorMode, StreamStatsResponse } from '../types'; +import { buildFieldTypeMap, FieldTypeMap, typeDisplayName, getAggregateOptions } from '../utils/fieldTypes'; +import { buildSqlFromFilters, buildMonitorSql } from '../utils/queryBuilder'; +import { FilterBuilder } from './FilterBuilder'; +import { StreamInfoPanel } from './StreamInfoPanel'; + +const ALL_ROWS_VALUE = ''; interface Props extends QueryEditorProps { payload?: string; } -export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQuery, query }) => { - const { queryText } = query; - //const [stream, setStream] = React.useState>(); +const MODE_OPTIONS = [ + { label: 'Builder', value: 'builder' as QueryEditorMode }, + { label: 'Monitor', value: 'monitor' as QueryEditorMode }, + { label: 'Code', value: 'code' as QueryEditorMode }, +]; + +export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQuery, query, app }) => { + const styles = useStyles2(getStyles); - const loadAsyncOptions = React.useCallback(() => { + const isAlerting = app === CoreApp.UnifiedAlerting || app === CoreApp.CloudAlerting; + const editorMode = isAlerting ? 'monitor' : (query.editorMode || datasource.defaultEditorMode || 'builder'); + const filters = query.filters || []; + const selectedColumns = query.selectedColumns || []; + + const [selectedStream, setSelectedStream] = useState>( + query.stream ? { label: query.stream, value: query.stream } : ({} as SelectableValue) + ); + const [schemaFields, setSchemaFields] = useState([]); + const [stats, setStats] = 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 streams for dropdown + const loadAsyncOptions = useCallback(() => { return datasource.listStreams().then( - (result) => { - const stream = result.map((data) => ({ label: data.name, value: data.name })); - return stream; - }, + (result) => result.map((data) => ({ label: data.name, value: data.name })), (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); + // Load schema when stream changes + useEffect(() => { + const streamName = selectedStream?.value; + if (streamName) { + datasource + .getStreamSchema(streamName) + .then((result) => { + if (result.fields) { + setSchemaFields(result.fields as SchemaFields[]); } - ); + }) + .catch(() => setSchemaFields([])); + } else { + setSchemaFields([]); + } + }, [datasource, selectedStream?.value]); + + // Load stats when stream changes + useEffect(() => { + const streamName = selectedStream?.value; + if (streamName) { + datasource.getStreamStats(streamName).then(setStats).catch(() => setStats({})); + } else { + setStats({}); + } + }, [datasource, 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); + } else if (editorMode === 'monitor') { + newQuery.filters = []; + newQuery.monitorField = ALL_ROWS_VALUE; + newQuery.monitorAggregate = 'COUNT'; + newQuery.queryText = buildMonitorSql(streamName, ALL_ROWS_VALUE, 'COUNT', [], fieldTypeMap); } - return ''; + onChange(newQuery); }, - [datasource, schema] + [query, onChange, 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); + } 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); + } + onChange(newQuery); + }, + [query, onChange, selectedStream, fieldTypeMap] + ); + + // Handle filter changes (builder mode) + const onFiltersChange = useCallback( + (newFilters: FilterCondition[]) => { + if (!selectedStream?.value) { + return; } - return ''; + const sql = buildSqlFromFilters(selectedStream.value, newFilters, selectedColumns, fieldTypeMap); + onChange({ ...query, filters: newFilters, queryText: sql }); }, - [datasource, count] + [query, onChange, selectedStream, selectedColumns, fieldTypeMap] ); - const onQueryTextChange = (event: ChangeEvent) => { - onChange({ ...query, queryText: event.target.value }); - }; + // 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 }); + }, + [query, onChange, 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; - React.useEffect(() => { - const getData = setTimeout(() => { + const sql = buildMonitorSql(selectedStream.value, field, agg, filters, fieldTypeMap); + onChange({ ...query, monitorField: field, monitorAggregate: agg, queryText: sql }); + }, + [query, onChange, selectedStream, filters, fieldTypeMap] + ); + + // 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 }); + }, + [query, onChange, selectedStream, filters, fieldTypeMap] + ); + + // 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 }); + }, + [query, onChange, selectedStream, 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]); + + // Handle SQL text change (code mode) + const onQueryTextChange = useCallback( + (event: ChangeEvent) => { + onChange({ ...query, queryText: event.target.value }); + }, + [query, onChange] + ); + + // Debounced query execution — only run if there's a valid query + useEffect(() => { + if (!query.queryText || !query.queryText.trim()) { + return; + } + const timer = setTimeout(() => { onRunQuery(); }, 2000); - return () => clearTimeout(getData); - }, [onRunQuery, queryText]); + return () => clearTimeout(timer); + }, [onRunQuery, query.queryText]); - React.useEffect(() => { - loadStreamSchema(selectedStream?.value); - }, [loadStreamSchema, selectedStream]); - - React.useEffect(() => { - loadStreamStats(selectedStream?.value); - }, [loadStreamStats, selectedStream]); + // 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); - }} - /> - - {/* -