diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/AddDataItemMenu.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/AddDataItemMenu.tsx new file mode 100644 index 00000000000..2ada47cbc53 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/AddDataItemMenu.tsx @@ -0,0 +1,88 @@ +import { css } from '@emotion/css'; +import { memo } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; +import { Button, Dropdown, Menu, useStyles2 } from '@grafana/ui'; +import { ExpressionQueryType } from 'app/features/expressions/types'; + +interface AddDataItemMenuProps { + onAddQuery: () => void; + onAddTransform: () => void; + onAddExpression: (type: ExpressionQueryType) => void; +} + +export const AddDataItemMenu = memo(({ onAddQuery, onAddTransform, onAddExpression }: AddDataItemMenuProps) => { + const styles = useStyles2(getStyles); + + const expressionTypes = [ + { type: ExpressionQueryType.math, label: t('dashboard-scene.add-data-item-menu.expression-math', 'Math') }, + { type: ExpressionQueryType.reduce, label: t('dashboard-scene.add-data-item-menu.expression-reduce', 'Reduce') }, + { + type: ExpressionQueryType.resample, + label: t('dashboard-scene.add-data-item-menu.expression-resample', 'Resample'), + }, + { + type: ExpressionQueryType.classic, + label: t('dashboard-scene.add-data-item-menu.expression-classic', 'Classic condition'), + }, + { + type: ExpressionQueryType.threshold, + label: t('dashboard-scene.add-data-item-menu.expression-threshold', 'Threshold'), + }, + ]; + + // Add SQL if feature flag is enabled + if (config.featureToggles.sqlExpressions) { + expressionTypes.push({ + type: ExpressionQueryType.sql, + label: t('dashboard-scene.add-data-item-menu.expression-sql', 'SQL'), + }); + } + + const menu = ( + + + + + {expressionTypes.map((expr) => ( + onAddExpression(expr.type)} + /> + ))} + + + ); + + return ( +
+ + + +
+ ); +}); + +AddDataItemMenu.displayName = 'AddDataItemMenu'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + width: '100%', + }), + }; +}; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx index 5d2e585ed33..f19d7cb8e8b 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx @@ -5,7 +5,9 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; import { VizPanel } from '@grafana/scenes'; import { Container, ScrollContainer, useStyles2 } from '@grafana/ui'; +import { isExpressionQuery } from 'app/features/expressions/guards'; +import { ExpressionDetailView } from './ExpressionDetailView'; import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab'; import { QueryDetailView } from './QueryDetailView'; import { QueryTransformItem } from './QueryTransformList'; @@ -40,6 +42,15 @@ export const DetailView = memo(({ selectedItem, panel, tabs }: DetailViewProps) ); + } else if (selectedItem.type === 'expression' && 'refId' in selectedItem.data) { + const data = selectedItem.data; + if (isExpressionQuery(data)) { + return ( + + + + ); + } } else { const transformsTab = tabs.find((t) => t.tabId === TabId.Transformations); if (transformsTab instanceof PanelDataTransformationsTab && 'id' in selectedItem.data) { diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/ExpressionDetailView.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/ExpressionDetailView.tsx new file mode 100644 index 00000000000..a319c64758c --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/ExpressionDetailView.tsx @@ -0,0 +1,147 @@ +import { css } from '@emotion/css'; +import { useCallback, useEffect, useState } from 'react'; + +import { + CoreApp, + DataSourceApi, + DataSourceInstanceSettings, + DataSourcePluginContextProvider, + GrafanaTheme2, + PanelData, +} from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { VizPanel } from '@grafana/scenes'; +import { ErrorBoundaryAlert, useStyles2 } from '@grafana/ui'; +import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow'; +import { ExpressionQueryEditor } from 'app/features/expressions/ExpressionQueryEditor'; +import { ExpressionDatasourceUID, ExpressionQuery } from 'app/features/expressions/types'; + +import { getQueryRunnerFor } from '../../utils/utils'; + +interface ExpressionDetailViewProps { + panel: VizPanel; + expression: ExpressionQuery; + expressionIndex: number; +} + +export function ExpressionDetailView({ panel, expression, expressionIndex }: ExpressionDetailViewProps) { + const styles = useStyles2(getStyles); + const [datasource, setDatasource] = useState(null); + const [dsSettings, setDsSettings] = useState(null); + const [data, setData] = useState(); + + const queryRunner = getQueryRunnerFor(panel); + const queryRunnerState = queryRunner?.useState(); + const allQueries = queryRunnerState?.queries || []; + + // Load expression datasource + useEffect(() => { + const loadDatasource = async () => { + try { + const ds = await getDataSourceSrv().get(ExpressionDatasourceUID); + const settings = getDataSourceSrv().getInstanceSettings(ExpressionDatasourceUID); + setDatasource(ds); + setDsSettings(settings || null); + } catch (error) { + console.error('Failed to load expression datasource:', error); + } + }; + + loadDatasource(); + }, []); + + // Subscribe to panel data + useEffect(() => { + if (!queryRunnerState?.data) { + return; + } + // Filter data for this specific expression + const panelData = queryRunnerState.data; + const filteredSeries = panelData.series.filter((s) => s.refId === expression.refId); + const filteredData = { + ...panelData, + series: filteredSeries, + error: panelData.errors?.find((e) => e.refId === expression.refId), + }; + setData(filteredData); + }, [queryRunnerState?.data, expression.refId]); + + const handleExpressionChange = useCallback( + (updatedExpression: ExpressionQuery) => { + if (queryRunner) { + const queries = queryRunner.state.queries || []; + const newQueries = queries.map((q, idx) => (idx === expressionIndex ? updatedExpression : q)); + queryRunner.setState({ queries: newQueries }); + } + }, + [queryRunner, expressionIndex] + ); + + const handleRunQuery = useCallback(() => { + if (queryRunner) { + queryRunner.runQueries(); + } + }, [queryRunner]); + + if (!datasource || !dsSettings) { + return ( +
+

+ Loading expression editor... +

+
+ ); + } + + return ( +
+ +
+ + + + + +
+
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + padding: theme.spacing(2), + }), + expressionContent: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + }), + loading: css({ + padding: theme.spacing(2), + textAlign: 'center', + color: theme.colors.text.secondary, + }), + }; +}; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx index cbfb9b2e1a5..a52ffba22b8 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx @@ -19,6 +19,9 @@ import { contextSrv } from 'app/core/services/context_srv'; import { getRulesPermissions } from 'app/features/alerting/unified/utils/access-control'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; +import { isExpressionQuery } from '../../../expressions/guards'; +import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from '../../../expressions/types'; +import { getDefaults } from '../../../expressions/utils/expressionTypes'; import { getQueryRunnerFor } from '../../utils/utils'; import { DetailView } from './DetailView'; @@ -99,16 +102,25 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { const items: QueryTransformItem[] = useMemo(() => { const result: QueryTransformItem[] = []; - // Add queries + // Add queries and expressions (using actual array index) const queries = queryRunnerState?.queries || []; - queries.forEach((query, index) => { + queries.forEach((query, actualIndex) => { if ('refId' in query) { - result.push({ - id: `query-${query.refId}`, - type: 'query', - data: query, - index, - }); + if (isExpressionQuery(query)) { + result.push({ + id: `expression-${query.refId}`, + type: 'expression', + data: query, + index: actualIndex, // Store actual index in queries array + }); + } else { + result.push({ + id: `query-${query.refId}`, + type: 'query', + data: query, + index: actualIndex, // Store actual index in queries array + }); + } } }); @@ -166,6 +178,42 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { setTransformDrawerOpen(true); }, []); + const handleAddExpression = useCallback( + (type: ExpressionQueryType) => { + if (queryRunner) { + const queries = queryRunner.state.queries || []; + + // Get next available refId + const existingRefIds = queries.map((q) => q.refId); + let nextRefId = 'A'; + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + + for (let i = 0; i < alphabet.length; i++) { + if (!existingRefIds.includes(alphabet[i])) { + nextRefId = alphabet[i]; + break; + } + } + + // Create new expression with defaults + const newExpression: ExpressionQuery = getDefaults({ + refId: nextRefId, + type, + datasource: { uid: ExpressionDatasourceUID, type: '__expr__' }, + }); + + // Add to queries + const newQueries = [...queries, newExpression]; + queryRunner.setState({ queries: newQueries }); + queryRunner.runQueries(); + + // Select the new expression + setSelectedId(`expression-${nextRefId}`); + } + }, + [queryRunner] + ); + const handleTransformationAdd = useCallback( (selected: SelectableValue) => { if (!selected.value) { @@ -261,6 +309,67 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { [queryRunner] ); + // Expression handlers (use actual index in queries array) + const handleDuplicateExpression = useCallback( + (index: number) => { + if (queryRunner) { + const queries = queryRunner.state.queries || []; + const expressionToDuplicate = queries[index]; + + if (expressionToDuplicate && isExpressionQuery(expressionToDuplicate)) { + const existingRefIds = queries.map((q) => q.refId); + let newRefId = expressionToDuplicate.refId; + let counter = 1; + while (existingRefIds.includes(newRefId)) { + newRefId = `${expressionToDuplicate.refId}_${counter}`; + counter++; + } + + const duplicatedExpression = { + ...expressionToDuplicate, + refId: newRefId, + }; + + const newQueries = [...queries, duplicatedExpression]; + queryRunner.setState({ queries: newQueries }); + queryRunner.runQueries(); + + setSelectedId(`expression-${newRefId}`); + } + } + }, + [queryRunner] + ); + + const handleRemoveExpression = useCallback( + (index: number) => { + if (queryRunner) { + const queries = queryRunner.state.queries || []; + const newQueries = queries.filter((_, i) => i !== index); + queryRunner.setState({ queries: newQueries }); + queryRunner.runQueries(); + + const expressionToRemove = queries[index]; + if (expressionToRemove && selectedId === `expression-${expressionToRemove.refId}`) { + setSelectedId(null); + } + } + }, + [queryRunner, selectedId] + ); + + const handleToggleExpressionVisibility = useCallback( + (index: number) => { + if (queryRunner) { + const queries = queryRunner.state.queries || []; + const newQueries = queries.map((q, i) => (i === index ? { ...q, hide: !q.hide } : q)); + queryRunner.setState({ queries: newQueries }); + queryRunner.runQueries(); + } + }, + [queryRunner] + ); + const handleRemoveTransform = useCallback( (index: number) => { const transformsTab = tabs.find((t) => t.tabId === TabId.Transformations); @@ -300,9 +409,13 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { onSelect={handleSelect} onAddQuery={handleAddQuery} onAddTransform={handleAddTransform} + onAddExpression={handleAddExpression} onDuplicateQuery={handleDuplicateQuery} onRemoveQuery={handleRemoveQuery} onToggleQueryVisibility={handleToggleQueryVisibility} + onDuplicateExpression={handleDuplicateExpression} + onRemoveExpression={handleRemoveExpression} + onToggleExpressionVisibility={handleToggleExpressionVisibility} onRemoveTransform={handleRemoveTransform} /> diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformCard.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformCard.tsx index e0924b14f97..ff6f34767bd 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformCard.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformCard.tsx @@ -9,7 +9,7 @@ import { Icon, IconButton, Stack, useStyles2 } from '@grafana/ui'; interface QueryTransformCardProps { item: SceneDataQuery | DataTransformerConfig; - type: 'query' | 'transform'; + type: 'query' | 'transform' | 'expression'; index: number; isSelected: boolean; onClick: () => void; @@ -23,8 +23,8 @@ export const QueryTransformCard = memo( const styles = useStyles2(getStyles); const getName = (): string => { - if (type === 'query' && 'refId' in item) { - return item.refId || `Query ${index + 1}`; + if ((type === 'query' || type === 'expression') && 'refId' in item) { + return item.refId || `${type === 'expression' ? 'Expression' : 'Query'} ${index + 1}`; } else if ('id' in item) { return item.id.replace(/-/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase()); } @@ -39,13 +39,16 @@ export const QueryTransformCard = memo( } catch { return 'Unknown datasource'; } + } else if (type === 'expression' && 'type' in item && typeof item.type === 'string') { + // Show expression type (Math, Reduce, etc.) + return item.type.charAt(0).toUpperCase() + item.type.slice(1); } return ''; }; - const isHidden = type === 'query' && 'hide' in item && item.hide; - const icon = type === 'query' ? 'database' : 'process'; - const typeLabel = type === 'query' ? 'Query' : 'Transformation'; + const isHidden = (type === 'query' || type === 'expression') && 'hide' in item && item.hide; + const icon = type === 'query' ? 'database' : type === 'expression' ? 'calculator-alt' : 'process'; + const typeLabel = type === 'query' ? 'Query' : type === 'expression' ? 'Expression' : 'Transformation'; const name = getName(); const datasourceName = getDatasourceName(); @@ -71,14 +74,22 @@ export const QueryTransformCard = memo( data-testid={`${type}-card-${index}`} > {/* Header with type and action icons */} -
+
{typeLabel}
- {type === 'query' && onToggleVisibility && ( + {(type === 'query' || type === 'expression') && onToggleVisibility && ( )} - {type === 'query' && onDuplicate && ( + {(type === 'query' || type === 'expression') && onDuplicate && ( { : `${theme.visualization.getColorByName('orange')}15`, borderBottom: `1px solid ${theme.colors.border.weak}`, }), + headerExpression: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: theme.spacing(1, 1.5), + background: theme.isDark + ? `${theme.visualization.getColorByName('purple')}20` + : `${theme.visualization.getColorByName('purple')}15`, + borderBottom: `1px solid ${theme.colors.border.weak}`, + }), headerLeft: css({ display: 'flex', alignItems: 'center', diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx index 26ea518417b..7f7fc9500f2 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx @@ -2,15 +2,16 @@ import { css } from '@emotion/css'; import { memo } from 'react'; import { DataTransformerConfig, GrafanaTheme2 } from '@grafana/data'; -import { Trans } from '@grafana/i18n'; import { SceneDataQuery } from '@grafana/scenes'; -import { Button, ScrollContainer, Stack, useStyles2 } from '@grafana/ui'; +import { ScrollContainer, useStyles2 } from '@grafana/ui'; +import { ExpressionQueryType } from 'app/features/expressions/types'; +import { AddDataItemMenu } from './AddDataItemMenu'; import { QueryTransformCard } from './QueryTransformCard'; export interface QueryTransformItem { id: string; - type: 'query' | 'transform'; + type: 'query' | 'transform' | 'expression'; data: SceneDataQuery | DataTransformerConfig; index: number; } @@ -21,9 +22,13 @@ interface QueryTransformListProps { onSelect: (id: string) => void; onAddQuery: () => void; onAddTransform: () => void; + onAddExpression: (type: ExpressionQueryType) => void; onDuplicateQuery?: (index: number) => void; onRemoveQuery?: (index: number) => void; onToggleQueryVisibility?: (index: number) => void; + onDuplicateExpression?: (index: number) => void; + onRemoveExpression?: (index: number) => void; + onToggleExpressionVisibility?: (index: number) => void; onRemoveTransform?: (index: number) => void; } @@ -34,14 +39,19 @@ export const QueryTransformList = memo( onSelect, onAddQuery, onAddTransform, + onAddExpression, onDuplicateQuery, onRemoveQuery, onToggleQueryVisibility, + onDuplicateExpression, + onRemoveExpression, + onToggleExpressionVisibility, onRemoveTransform, }: QueryTransformListProps) => { const styles = useStyles2(getStyles); const queries = items.filter((item) => item.type === 'query'); + const expressions = items.filter((item) => item.type === 'expression'); const transforms = items.filter((item) => item.type === 'transform'); return ( @@ -69,6 +79,26 @@ export const QueryTransformList = memo(
)} + {expressions.length > 0 && ( +
+ {expressions.map((item) => ( + onSelect(item.id)} + onDuplicate={onDuplicateExpression ? () => onDuplicateExpression(item.index) : undefined} + onRemove={onRemoveExpression ? () => onRemoveExpression(item.index) : undefined} + onToggleVisibility={ + onToggleExpressionVisibility ? () => onToggleExpressionVisibility(item.index) : undefined + } + /> + ))} +
+ )} + {transforms.length > 0 && (
{transforms.map((item) => ( @@ -89,14 +119,7 @@ export const QueryTransformList = memo(
- - - - +
); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 4c943e42886..227a7c04f5c 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5798,6 +5798,18 @@ "label-use-static-key-dimensions": "Use static key dimensions", "name-allow-custom-values": "Allow custom values" }, + "add-data-item-menu": { + "add-button": "Add", + "add-query": "Query", + "add-transformation": "Transformation", + "expression-classic": "Classic condition", + "expression-math": "Math", + "expression-reduce": "Reduce", + "expression-resample": "Resample", + "expression-sql": "SQL", + "expression-threshold": "Threshold", + "expressions-group": "Expression" + }, "add-to-dashboard": { "message": { "could-navigate-selected-dashboard-please-again": "Could not navigate to the selected dashboard. Please try again.", @@ -5988,6 +6000,9 @@ "sql-name": "Transform with SQL", "sql-transformation-description": "Manipulate your data using MySQL-like syntax" }, + "expression-detail-view": { + "loading": "Loading expression editor..." + }, "general-settings-edit-view": { "editable_options": { "label": { @@ -6250,10 +6265,6 @@ "remove-transform": "Remove transformation", "show-response": "Show response" }, - "query-transform-list": { - "add-query": "Add query", - "add-transformation": "Add transformation" - }, "query-variable-editor-form": { "description-examples": "Named capture groups can be used to separate the display text and value (<1>see examples ).", "description-optional": "Optional, if you want to extract part of a series name or metric node segment.",