add expressions

This commit is contained in:
Develer
2025-12-01 17:30:15 +01:00
parent d3e8bdd928
commit a2882ebd85
7 changed files with 446 additions and 32 deletions
@@ -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 = (
<Menu>
<Menu.Item
label={t('dashboard-scene.add-data-item-menu.add-query', 'Query')}
icon="database"
onClick={onAddQuery}
/>
<Menu.Item
label={t('dashboard-scene.add-data-item-menu.add-transformation', 'Transformation')}
icon="process"
onClick={onAddTransform}
/>
<Menu.Group label={t('dashboard-scene.add-data-item-menu.expressions-group', 'Expression')}>
{expressionTypes.map((expr) => (
<Menu.Item
key={expr.type}
label={expr.label}
icon="calculator-alt"
onClick={() => onAddExpression(expr.type)}
/>
))}
</Menu.Group>
</Menu>
);
return (
<div className={styles.container}>
<Dropdown overlay={menu} placement="top-start">
<Button icon="plus" variant="secondary" fullWidth>
<Trans i18nKey="dashboard-scene.add-data-item-menu.add-button">Add</Trans>
</Button>
</Dropdown>
</div>
);
});
AddDataItemMenu.displayName = 'AddDataItemMenu';
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
width: '100%',
}),
};
};
@@ -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)
<QueryDetailView panel={panel} query={query} queryIndex={selectedItem.index} />
</ScrollContainer>
);
} else if (selectedItem.type === 'expression' && 'refId' in selectedItem.data) {
const data = selectedItem.data;
if (isExpressionQuery(data)) {
return (
<ScrollContainer>
<ExpressionDetailView panel={panel} expression={data} expressionIndex={selectedItem.index} />
</ScrollContainer>
);
}
} else {
const transformsTab = tabs.find((t) => t.tabId === TabId.Transformations);
if (transformsTab instanceof PanelDataTransformationsTab && 'id' in selectedItem.data) {
@@ -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<DataSourceApi | null>(null);
const [dsSettings, setDsSettings] = useState<DataSourceInstanceSettings | null>(null);
const [data, setData] = useState<PanelData | undefined>();
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 (
<div className={styles.loading}>
<p>
<Trans i18nKey="dashboard-scene.expression-detail-view.loading">Loading expression editor...</Trans>
</p>
</div>
);
}
return (
<div className={styles.container}>
<QueryOperationRow
id={`expression-${expression.refId}`}
index={expressionIndex}
draggable={false}
collapsable={false}
isOpen={true}
hideHeader={true}
>
<div className={styles.expressionContent}>
<DataSourcePluginContextProvider instanceSettings={dsSettings}>
<ErrorBoundaryAlert boundaryName="expression-editor">
<ExpressionQueryEditor
query={expression}
queries={allQueries}
// @ts-ignore - Expression datasource is compatible at runtime
datasource={datasource}
onChange={handleExpressionChange}
onRunQuery={handleRunQuery}
data={data}
app={CoreApp.PanelEditor}
/>
</ErrorBoundaryAlert>
</DataSourcePluginContextProvider>
</div>
</QueryOperationRow>
</div>
);
}
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,
}),
};
};
@@ -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<PanelDataPane>) {
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<PanelDataPane>) {
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<string>) => {
if (!selected.value) {
@@ -261,6 +309,67 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
[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<PanelDataPane>) {
onSelect={handleSelect}
onAddQuery={handleAddQuery}
onAddTransform={handleAddTransform}
onAddExpression={handleAddExpression}
onDuplicateQuery={handleDuplicateQuery}
onRemoveQuery={handleRemoveQuery}
onToggleQueryVisibility={handleToggleQueryVisibility}
onDuplicateExpression={handleDuplicateExpression}
onRemoveExpression={handleRemoveExpression}
onToggleExpressionVisibility={handleToggleExpressionVisibility}
onRemoveTransform={handleRemoveTransform}
/>
</div>
@@ -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 */}
<div className={type === 'query' ? styles.headerQuery : styles.headerTransform}>
<div
className={
type === 'query'
? styles.headerQuery
: type === 'expression'
? styles.headerExpression
: styles.headerTransform
}
>
<div className={styles.headerLeft}>
<Icon name={icon} className={styles.headerIcon} />
<span className={styles.typeLabel}>{typeLabel}</span>
</div>
<div className={`${styles.actions} ${styles.actionsClass}`}>
<Stack gap={0.5}>
{type === 'query' && onToggleVisibility && (
{(type === 'query' || type === 'expression') && onToggleVisibility && (
<IconButton
name={isHidden ? 'eye-slash' : 'eye'}
size="sm"
@@ -92,7 +103,7 @@ export const QueryTransformCard = memo(
className={styles.actionButton}
/>
)}
{type === 'query' && onDuplicate && (
{(type === 'query' || type === 'expression') && onDuplicate && (
<IconButton
name="copy"
size="sm"
@@ -172,6 +183,16 @@ const getStyles = (theme: GrafanaTheme2) => {
: `${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',
@@ -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(
</div>
)}
{expressions.length > 0 && (
<div className={styles.section}>
{expressions.map((item) => (
<QueryTransformCard
key={item.id}
item={item.data}
type="expression"
index={item.index}
isSelected={selectedId === item.id}
onClick={() => onSelect(item.id)}
onDuplicate={onDuplicateExpression ? () => onDuplicateExpression(item.index) : undefined}
onRemove={onRemoveExpression ? () => onRemoveExpression(item.index) : undefined}
onToggleVisibility={
onToggleExpressionVisibility ? () => onToggleExpressionVisibility(item.index) : undefined
}
/>
))}
</div>
)}
{transforms.length > 0 && (
<div className={styles.section}>
{transforms.map((item) => (
@@ -89,14 +119,7 @@ export const QueryTransformList = memo(
</div>
<div className={styles.actions}>
<Stack direction="column" gap={1}>
<Button icon="plus" variant="secondary" onClick={onAddQuery} fullWidth>
<Trans i18nKey="dashboard-scene.query-transform-list.add-query">Add query</Trans>
</Button>
<Button icon="plus" variant="secondary" onClick={onAddTransform} fullWidth>
<Trans i18nKey="dashboard-scene.query-transform-list.add-transformation">Add transformation</Trans>
</Button>
</Stack>
<AddDataItemMenu onAddQuery={onAddQuery} onAddTransform={onAddTransform} onAddExpression={onAddExpression} />
</div>
</div>
);
+15 -4
View File
@@ -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</1> ).",
"description-optional": "Optional, if you want to extract part of a series name or metric node segment.",