add expressions
This commit is contained in:
@@ -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 { Trans } from '@grafana/i18n';
|
||||||
import { VizPanel } from '@grafana/scenes';
|
import { VizPanel } from '@grafana/scenes';
|
||||||
import { Container, ScrollContainer, useStyles2 } from '@grafana/ui';
|
import { Container, ScrollContainer, useStyles2 } from '@grafana/ui';
|
||||||
|
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||||
|
|
||||||
|
import { ExpressionDetailView } from './ExpressionDetailView';
|
||||||
import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab';
|
import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab';
|
||||||
import { QueryDetailView } from './QueryDetailView';
|
import { QueryDetailView } from './QueryDetailView';
|
||||||
import { QueryTransformItem } from './QueryTransformList';
|
import { QueryTransformItem } from './QueryTransformList';
|
||||||
@@ -40,6 +42,15 @@ export const DetailView = memo(({ selectedItem, panel, tabs }: DetailViewProps)
|
|||||||
<QueryDetailView panel={panel} query={query} queryIndex={selectedItem.index} />
|
<QueryDetailView panel={panel} query={query} queryIndex={selectedItem.index} />
|
||||||
</ScrollContainer>
|
</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 {
|
} else {
|
||||||
const transformsTab = tabs.find((t) => t.tabId === TabId.Transformations);
|
const transformsTab = tabs.find((t) => t.tabId === TabId.Transformations);
|
||||||
if (transformsTab instanceof PanelDataTransformationsTab && 'id' in selectedItem.data) {
|
if (transformsTab instanceof PanelDataTransformationsTab && 'id' in selectedItem.data) {
|
||||||
|
|||||||
+147
@@ -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 { getRulesPermissions } from 'app/features/alerting/unified/utils/access-control';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
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 { getQueryRunnerFor } from '../../utils/utils';
|
||||||
|
|
||||||
import { DetailView } from './DetailView';
|
import { DetailView } from './DetailView';
|
||||||
@@ -99,16 +102,25 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
|
|||||||
const items: QueryTransformItem[] = useMemo(() => {
|
const items: QueryTransformItem[] = useMemo(() => {
|
||||||
const result: QueryTransformItem[] = [];
|
const result: QueryTransformItem[] = [];
|
||||||
|
|
||||||
// Add queries
|
// Add queries and expressions (using actual array index)
|
||||||
const queries = queryRunnerState?.queries || [];
|
const queries = queryRunnerState?.queries || [];
|
||||||
queries.forEach((query, index) => {
|
queries.forEach((query, actualIndex) => {
|
||||||
if ('refId' in query) {
|
if ('refId' in query) {
|
||||||
result.push({
|
if (isExpressionQuery(query)) {
|
||||||
id: `query-${query.refId}`,
|
result.push({
|
||||||
type: 'query',
|
id: `expression-${query.refId}`,
|
||||||
data: query,
|
type: 'expression',
|
||||||
index,
|
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);
|
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(
|
const handleTransformationAdd = useCallback(
|
||||||
(selected: SelectableValue<string>) => {
|
(selected: SelectableValue<string>) => {
|
||||||
if (!selected.value) {
|
if (!selected.value) {
|
||||||
@@ -261,6 +309,67 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
|
|||||||
[queryRunner]
|
[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(
|
const handleRemoveTransform = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
const transformsTab = tabs.find((t) => t.tabId === TabId.Transformations);
|
const transformsTab = tabs.find((t) => t.tabId === TabId.Transformations);
|
||||||
@@ -300,9 +409,13 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
|
|||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onAddQuery={handleAddQuery}
|
onAddQuery={handleAddQuery}
|
||||||
onAddTransform={handleAddTransform}
|
onAddTransform={handleAddTransform}
|
||||||
|
onAddExpression={handleAddExpression}
|
||||||
onDuplicateQuery={handleDuplicateQuery}
|
onDuplicateQuery={handleDuplicateQuery}
|
||||||
onRemoveQuery={handleRemoveQuery}
|
onRemoveQuery={handleRemoveQuery}
|
||||||
onToggleQueryVisibility={handleToggleQueryVisibility}
|
onToggleQueryVisibility={handleToggleQueryVisibility}
|
||||||
|
onDuplicateExpression={handleDuplicateExpression}
|
||||||
|
onRemoveExpression={handleRemoveExpression}
|
||||||
|
onToggleExpressionVisibility={handleToggleExpressionVisibility}
|
||||||
onRemoveTransform={handleRemoveTransform}
|
onRemoveTransform={handleRemoveTransform}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+30
-9
@@ -9,7 +9,7 @@ import { Icon, IconButton, Stack, useStyles2 } from '@grafana/ui';
|
|||||||
|
|
||||||
interface QueryTransformCardProps {
|
interface QueryTransformCardProps {
|
||||||
item: SceneDataQuery | DataTransformerConfig;
|
item: SceneDataQuery | DataTransformerConfig;
|
||||||
type: 'query' | 'transform';
|
type: 'query' | 'transform' | 'expression';
|
||||||
index: number;
|
index: number;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -23,8 +23,8 @@ export const QueryTransformCard = memo(
|
|||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const getName = (): string => {
|
const getName = (): string => {
|
||||||
if (type === 'query' && 'refId' in item) {
|
if ((type === 'query' || type === 'expression') && 'refId' in item) {
|
||||||
return item.refId || `Query ${index + 1}`;
|
return item.refId || `${type === 'expression' ? 'Expression' : 'Query'} ${index + 1}`;
|
||||||
} else if ('id' in item) {
|
} else if ('id' in item) {
|
||||||
return item.id.replace(/-/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase());
|
return item.id.replace(/-/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase());
|
||||||
}
|
}
|
||||||
@@ -39,13 +39,16 @@ export const QueryTransformCard = memo(
|
|||||||
} catch {
|
} catch {
|
||||||
return 'Unknown datasource';
|
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 '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const isHidden = type === 'query' && 'hide' in item && item.hide;
|
const isHidden = (type === 'query' || type === 'expression') && 'hide' in item && item.hide;
|
||||||
const icon = type === 'query' ? 'database' : 'process';
|
const icon = type === 'query' ? 'database' : type === 'expression' ? 'calculator-alt' : 'process';
|
||||||
const typeLabel = type === 'query' ? 'Query' : 'Transformation';
|
const typeLabel = type === 'query' ? 'Query' : type === 'expression' ? 'Expression' : 'Transformation';
|
||||||
const name = getName();
|
const name = getName();
|
||||||
const datasourceName = getDatasourceName();
|
const datasourceName = getDatasourceName();
|
||||||
|
|
||||||
@@ -71,14 +74,22 @@ export const QueryTransformCard = memo(
|
|||||||
data-testid={`${type}-card-${index}`}
|
data-testid={`${type}-card-${index}`}
|
||||||
>
|
>
|
||||||
{/* Header with type and action icons */}
|
{/* 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}>
|
<div className={styles.headerLeft}>
|
||||||
<Icon name={icon} className={styles.headerIcon} />
|
<Icon name={icon} className={styles.headerIcon} />
|
||||||
<span className={styles.typeLabel}>{typeLabel}</span>
|
<span className={styles.typeLabel}>{typeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${styles.actions} ${styles.actionsClass}`}>
|
<div className={`${styles.actions} ${styles.actionsClass}`}>
|
||||||
<Stack gap={0.5}>
|
<Stack gap={0.5}>
|
||||||
{type === 'query' && onToggleVisibility && (
|
{(type === 'query' || type === 'expression') && onToggleVisibility && (
|
||||||
<IconButton
|
<IconButton
|
||||||
name={isHidden ? 'eye-slash' : 'eye'}
|
name={isHidden ? 'eye-slash' : 'eye'}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -92,7 +103,7 @@ export const QueryTransformCard = memo(
|
|||||||
className={styles.actionButton}
|
className={styles.actionButton}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{type === 'query' && onDuplicate && (
|
{(type === 'query' || type === 'expression') && onDuplicate && (
|
||||||
<IconButton
|
<IconButton
|
||||||
name="copy"
|
name="copy"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -172,6 +183,16 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
: `${theme.visualization.getColorByName('orange')}15`,
|
: `${theme.visualization.getColorByName('orange')}15`,
|
||||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
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({
|
headerLeft: css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
+34
-11
@@ -2,15 +2,16 @@ import { css } from '@emotion/css';
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { DataTransformerConfig, GrafanaTheme2 } from '@grafana/data';
|
import { DataTransformerConfig, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Trans } from '@grafana/i18n';
|
|
||||||
import { SceneDataQuery } from '@grafana/scenes';
|
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';
|
import { QueryTransformCard } from './QueryTransformCard';
|
||||||
|
|
||||||
export interface QueryTransformItem {
|
export interface QueryTransformItem {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'query' | 'transform';
|
type: 'query' | 'transform' | 'expression';
|
||||||
data: SceneDataQuery | DataTransformerConfig;
|
data: SceneDataQuery | DataTransformerConfig;
|
||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
@@ -21,9 +22,13 @@ interface QueryTransformListProps {
|
|||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onAddQuery: () => void;
|
onAddQuery: () => void;
|
||||||
onAddTransform: () => void;
|
onAddTransform: () => void;
|
||||||
|
onAddExpression: (type: ExpressionQueryType) => void;
|
||||||
onDuplicateQuery?: (index: number) => void;
|
onDuplicateQuery?: (index: number) => void;
|
||||||
onRemoveQuery?: (index: number) => void;
|
onRemoveQuery?: (index: number) => void;
|
||||||
onToggleQueryVisibility?: (index: number) => void;
|
onToggleQueryVisibility?: (index: number) => void;
|
||||||
|
onDuplicateExpression?: (index: number) => void;
|
||||||
|
onRemoveExpression?: (index: number) => void;
|
||||||
|
onToggleExpressionVisibility?: (index: number) => void;
|
||||||
onRemoveTransform?: (index: number) => void;
|
onRemoveTransform?: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,14 +39,19 @@ export const QueryTransformList = memo(
|
|||||||
onSelect,
|
onSelect,
|
||||||
onAddQuery,
|
onAddQuery,
|
||||||
onAddTransform,
|
onAddTransform,
|
||||||
|
onAddExpression,
|
||||||
onDuplicateQuery,
|
onDuplicateQuery,
|
||||||
onRemoveQuery,
|
onRemoveQuery,
|
||||||
onToggleQueryVisibility,
|
onToggleQueryVisibility,
|
||||||
|
onDuplicateExpression,
|
||||||
|
onRemoveExpression,
|
||||||
|
onToggleExpressionVisibility,
|
||||||
onRemoveTransform,
|
onRemoveTransform,
|
||||||
}: QueryTransformListProps) => {
|
}: QueryTransformListProps) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const queries = items.filter((item) => item.type === 'query');
|
const queries = items.filter((item) => item.type === 'query');
|
||||||
|
const expressions = items.filter((item) => item.type === 'expression');
|
||||||
const transforms = items.filter((item) => item.type === 'transform');
|
const transforms = items.filter((item) => item.type === 'transform');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,6 +79,26 @@ export const QueryTransformList = memo(
|
|||||||
</div>
|
</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 && (
|
{transforms.length > 0 && (
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
{transforms.map((item) => (
|
{transforms.map((item) => (
|
||||||
@@ -89,14 +119,7 @@ export const QueryTransformList = memo(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Stack direction="column" gap={1}>
|
<AddDataItemMenu onAddQuery={onAddQuery} onAddTransform={onAddTransform} onAddExpression={onAddExpression} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5798,6 +5798,18 @@
|
|||||||
"label-use-static-key-dimensions": "Use static key dimensions",
|
"label-use-static-key-dimensions": "Use static key dimensions",
|
||||||
"name-allow-custom-values": "Allow custom values"
|
"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": {
|
"add-to-dashboard": {
|
||||||
"message": {
|
"message": {
|
||||||
"could-navigate-selected-dashboard-please-again": "Could not navigate to the selected dashboard. Please try again.",
|
"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-name": "Transform with SQL",
|
||||||
"sql-transformation-description": "Manipulate your data using MySQL-like syntax"
|
"sql-transformation-description": "Manipulate your data using MySQL-like syntax"
|
||||||
},
|
},
|
||||||
|
"expression-detail-view": {
|
||||||
|
"loading": "Loading expression editor..."
|
||||||
|
},
|
||||||
"general-settings-edit-view": {
|
"general-settings-edit-view": {
|
||||||
"editable_options": {
|
"editable_options": {
|
||||||
"label": {
|
"label": {
|
||||||
@@ -6250,10 +6265,6 @@
|
|||||||
"remove-transform": "Remove transformation",
|
"remove-transform": "Remove transformation",
|
||||||
"show-response": "Show response"
|
"show-response": "Show response"
|
||||||
},
|
},
|
||||||
"query-transform-list": {
|
|
||||||
"add-query": "Add query",
|
|
||||||
"add-transformation": "Add transformation"
|
|
||||||
},
|
|
||||||
"query-variable-editor-form": {
|
"query-variable-editor-form": {
|
||||||
"description-examples": "Named capture groups can be used to separate the display text and value (<1>see examples</1> ).",
|
"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.",
|
"description-optional": "Optional, if you want to extract part of a series name or metric node segment.",
|
||||||
|
|||||||
Reference in New Issue
Block a user