From edabcd8b43a3dd370478975675598be3f2627e44 Mon Sep 17 00:00:00 2001 From: Develer Date: Mon, 1 Dec 2025 13:43:07 +0100 Subject: [PATCH] init POC commit --- eslint-suppressions.json | 5 - .../QueryOperationRow/QueryOperationRow.tsx | 72 ++-- .../PanelDataPane/PanelDataPane.tsx | 359 ++++++++++++++++-- .../PanelDataTransformationsTab.tsx | 104 ++--- .../PanelDataPane/QueryTransformCard.tsx | 217 +++++++++++ .../PanelDataPane/QueryTransformList.tsx | 138 +++++++ .../PanelDataPane/SceneQueryDetailView.tsx | 216 +++++++++++ .../TransformationOperationRow.tsx | 1 + public/locales/en-US/grafana.json | 26 +- 9 files changed, 997 insertions(+), 141 deletions(-) create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformCard.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelDataPane/SceneQueryDetailView.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e3e822abdc4..b2c2b36f6a3 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1873,11 +1873,6 @@ "count": 1 } }, - "public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx b/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx index fbfb98b5f72..09717052c44 100644 --- a/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx +++ b/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx @@ -24,6 +24,7 @@ export interface QueryOperationRowProps { collapsable?: boolean; disabled?: boolean; expanderMessages?: ExpanderMessages; + hideHeader?: boolean; } export type QueryOperationRowRenderProp = ((props: QueryOperationRowRenderProps) => React.ReactNode) | React.ReactNode; @@ -48,9 +49,10 @@ export function QueryOperationRow({ index, id, expanderMessages, + hideHeader = false, }: QueryOperationRowProps) { const [isContentVisible, setIsContentVisible] = useState(isOpen !== undefined ? isOpen : true); - const styles = useStyles2(getQueryOperationRowStyles); + const styles = useStyles2(getQueryOperationRowStyles, hideHeader); const onRowToggle = useCallback(() => { setIsContentVisible(!isContentVisible); }, [isContentVisible, setIsContentVisible]); @@ -113,22 +115,24 @@ export function QueryOperationRow({ return ( <>
-
- -
+ {!hideHeader && ( +
+ +
+ )} {isContentVisible &&
{children}
}
@@ -140,32 +144,34 @@ export function QueryOperationRow({ return (
- + {!hideHeader && ( + + )} {isContentVisible &&
{children}
}
); } -const getQueryOperationRowStyles = (theme: GrafanaTheme2) => { +const getQueryOperationRowStyles = (theme: GrafanaTheme2, hideHeader?: boolean) => { return { wrapper: css({ marginBottom: theme.spacing(2), }), content: css({ - marginTop: theme.spacing(0.5), - marginLeft: theme.spacing(3), + marginTop: hideHeader ? 0 : theme.spacing(0.5), + marginLeft: hideHeader ? 0 : theme.spacing(3), }), }; }; 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 de5d15aca86..29313dc1986 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx @@ -1,7 +1,9 @@ import { css } from '@emotion/css'; +import { useCallback, useMemo, useState } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { DataTransformerConfig, GrafanaTheme2, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { Trans } from '@grafana/i18n'; import { SceneComponentProps, SceneObjectBase, @@ -10,16 +12,22 @@ import { SceneObjectUrlSyncConfig, SceneObjectUrlValues, VizPanel, + SceneDataTransformer, } from '@grafana/scenes'; -import { Container, ScrollContainer, TabContent, TabsBar, useStyles2 } from '@grafana/ui'; +import { Container, ScrollContainer, useStyles2 } from '@grafana/ui'; import { getConfig } from 'app/core/config'; 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 { getQueryRunnerFor } from '../../utils/utils'; + import { PanelDataAlertingTab } from './PanelDataAlertingTab'; import { PanelDataQueriesTab } from './PanelDataQueriesTab'; -import { PanelDataTransformationsTab } from './PanelDataTransformationsTab'; +import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab'; +import { QueryTransformList, QueryTransformItem } from './QueryTransformList'; +import { SceneQueryDetailView } from './SceneQueryDetailView'; +import { TransformationsDrawer } from './TransformationsDrawer'; import { PanelDataPaneTab, TabId } from './types'; export interface PanelDataPaneState extends SceneObjectState { @@ -63,33 +71,291 @@ export class PanelDataPane extends SceneObjectBase { return; } if (typeof values.tab === 'string') { - this.setState({ tab: values.tab as TabId }); + const tabValue = values.tab; + // Check if the value is a valid TabId + if (tabValue === TabId.Queries || tabValue === TabId.Transformations || tabValue === TabId.Alert) { + this.setState({ tab: tabValue }); + } } } } function PanelDataPaneRendered({ model }: SceneComponentProps) { - const { tab, tabs } = model.useState(); + const { tabs, panelRef } = model.useState(); const styles = useStyles2(getStyles); + const [selectedId, setSelectedId] = useState(null); + const [transformDrawerOpen, setTransformDrawerOpen] = useState(false); - if (!tabs || !tabs.length) { - return; - } + const panel = panelRef.resolve(); - const currentTab = tabs.find((t) => t.tabId === tab); + // Subscribe to query runner state changes + const queryRunner = getQueryRunnerFor(panel); + const queryRunnerState = queryRunner?.useState(); + + // Subscribe to data transformer state changes + const dataTransformer = panel.state.$data instanceof SceneDataTransformer ? panel.state.$data : null; + const transformerState = dataTransformer?.useState(); + + // Build combined items list + const items: QueryTransformItem[] = useMemo(() => { + const result: QueryTransformItem[] = []; + + // Add queries + const queries = queryRunnerState?.queries || []; + queries.forEach((query, index) => { + if ('refId' in query) { + result.push({ + id: `query-${query.refId}`, + type: 'query', + data: query, + index, + }); + } + }); + + // Add transformations + const rawTransformations = transformerState?.transformations || []; + const transformations = Array.isArray(rawTransformations) + ? rawTransformations.filter( + (t): t is DataTransformerConfig => + t !== null && typeof t === 'object' && 'id' in t && typeof t.id === 'string' + ) + : []; + transformations.forEach((transform, index) => { + result.push({ + id: `transform-${index}`, + type: 'transform', + data: transform, + index, + }); + }); + + return result; + }, [queryRunnerState?.queries, transformerState?.transformations]); + + // Auto-select first item if nothing is selected + const effectiveSelectedId = useMemo(() => { + if (selectedId === null && items.length > 0) { + return items[0].id; + } + return selectedId; + }, [selectedId, items]); + + const selectedItem = useMemo(() => { + return items.find((item) => item.id === effectiveSelectedId); + }, [items, effectiveSelectedId]); + + const handleSelect = useCallback((id: string) => { + setSelectedId(id); + }, []); + + const handleAddQuery = useCallback(() => { + const queriesTab = tabs.find((t) => t.tabId === TabId.Queries); + if (queriesTab instanceof PanelDataQueriesTab) { + queriesTab.addQueryClick(); + // Select the new query after a short delay + setTimeout(() => { + const newQueries = getQueryRunnerFor(panel)?.state.queries || []; + if (newQueries.length > 0) { + setSelectedId(`query-${newQueries[newQueries.length - 1].refId}`); + } + }, 100); + } + }, [tabs, panel]); + + const handleAddTransform = useCallback(() => { + setTransformDrawerOpen(true); + }, []); + + const handleTransformationAdd = useCallback( + (selected: SelectableValue) => { + if (!selected.value) { + return; + } + + const transformsTab = tabs.find((t) => t.tabId === TabId.Transformations); + if (transformsTab instanceof PanelDataTransformationsTab) { + const transformer = transformsTab.getDataTransformer(); + const rawTransformations = transformer.state.transformations || []; + const transformations = Array.isArray(rawTransformations) + ? rawTransformations.filter( + (t): t is DataTransformerConfig => + t !== null && typeof t === 'object' && 'id' in t && typeof t.id === 'string' + ) + : []; + + const newTransformation: DataTransformerConfig = { + id: selected.value, + options: {}, + }; + + transformsTab.onChangeTransformations([...transformations, newTransformation]); + setTransformDrawerOpen(false); + + // Select the newly added transformation + setTimeout(() => { + setSelectedId(`transform-${transformations.length}`); + }, 100); + } + }, + [tabs] + ); + + const handleDuplicateQuery = useCallback( + (index: number) => { + if (queryRunner) { + const queries = queryRunner.state.queries || []; + const queryToDuplicate = queries[index]; + if (queryToDuplicate) { + // Create a copy with a new refId + const existingRefIds = queries.map((q) => q.refId); + let newRefId = queryToDuplicate.refId; + let counter = 1; + while (existingRefIds.includes(newRefId)) { + newRefId = `${queryToDuplicate.refId}_${counter}`; + counter++; + } + + const duplicatedQuery = { + ...queryToDuplicate, + refId: newRefId, + }; + + const newQueries = [...queries, duplicatedQuery]; + queryRunner.setState({ queries: newQueries }); + queryRunner.runQueries(); + + // Select the new query + setSelectedId(`query-${newRefId}`); + } + } + }, + [queryRunner] + ); + + const handleRemoveQuery = useCallback( + (index: number) => { + if (queryRunner) { + const queries = queryRunner.state.queries || []; + const newQueries = queries.filter((_, i) => i !== index); + queryRunner.setState({ queries: newQueries }); + queryRunner.runQueries(); + + // Clear selection if removing the selected query + if (selectedId === `query-${queries[index]?.refId}`) { + setSelectedId(null); + } + } + }, + [queryRunner, selectedId] + ); + + const handleToggleQueryVisibility = 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); + if (transformsTab instanceof PanelDataTransformationsTab) { + const transformer = transformsTab.getDataTransformer(); + const rawTransformations = transformer.state.transformations || []; + const transformations = Array.isArray(rawTransformations) + ? rawTransformations.filter( + (t): t is DataTransformerConfig => + t !== null && typeof t === 'object' && 'id' in t && typeof t.id === 'string' + ) + : []; + const newTransformations = transformations.filter((_, i) => i !== index); + transformsTab.onChangeTransformations(newTransformations); + + // Clear selection if removing the selected transformation + if (selectedId === `transform-${index}`) { + setSelectedId(null); + } + } + }, + [tabs, selectedId] + ); + + const renderDetailPanel = useCallback(() => { + if (!selectedItem) { + return ( +
+

+ + Select a query or transformation to edit + +

+
+ ); + } + + if (selectedItem.type === 'query' && 'refId' in selectedItem.data) { + const query = selectedItem.data; + return ( +
+ + + +
+ ); + } else { + const transformsTab = tabs.find((t) => t.tabId === TabId.Transformations); + if (transformsTab instanceof PanelDataTransformationsTab && 'id' in selectedItem.data) { + return ( +
+ + + + + +
+ ); + } + } + + return null; + }, [selectedItem, tabs, styles, panel]); + + // Get data for transformations drawer + const sourceData = queryRunner?.useState(); + const series = sourceData?.data?.series || []; return (
- - {tabs.map((t) => t.renderTab({ active: t.tabId === tab, onChangeTab: () => model.onChangeTab(t) }))} - -
- - - {currentTab && } - - +
+
+
+ +
+
{renderDetailPanel()}
+
+ setTransformDrawerOpen(false)} + onTransformationAdd={handleTransformationAdd} + series={series} + />
); } @@ -118,22 +384,67 @@ function getStyles(theme: GrafanaTheme2) { height: '100%', width: '100%', }), - tabBorder: css({ + unifiedLayout: css({ + flex: 1, + minHeight: 0, background: theme.colors.background.primary, border: `1px solid ${theme.colors.border.weak}`, borderLeft: 'none', borderBottom: 'none', borderTopRightRadius: theme.shape.radius.default, - flexGrow: 1, overflow: 'hidden', }), - tabContent: css({ - padding: theme.spacing(2), + splitLayout: css({ + display: 'flex', height: '100%', + width: '100%', }), - tabsBar: css({ + leftPanel: css({ + width: '25%', + minWidth: '200px', + maxWidth: '400px', flexShrink: 0, - paddingLeft: theme.spacing(2), + borderRight: `1px solid ${theme.colors.border.weak}`, + }), + rightPanel: css({ + flex: 1, + minWidth: 0, + overflow: 'hidden', + }), + detailContent: css({ + display: 'flex', + flexDirection: 'column', + height: '100%', + background: theme.colors.background.primary, + }), + detailHeader: css({ + padding: theme.spacing(2), + borderBottom: `1px solid ${theme.colors.border.weak}`, + background: theme.colors.background.secondary, + '& h3': { + margin: 0, + marginBottom: theme.spacing(0.5), + fontSize: theme.typography.h4.fontSize, + fontWeight: theme.typography.fontWeightMedium, + }, + }), + detailHeaderSubtext: css({ + margin: 0, + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + }), + detailBody: css({ + flex: 1, + overflow: 'auto', + minHeight: 0, + }), + emptyState: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + color: theme.colors.text.secondary, + fontSize: theme.typography.h5.fontSize, }), }; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx index af68e86adc4..d58eedcd9d6 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx @@ -3,8 +3,7 @@ import { DragDropContext, DropResult, Droppable } from '@hello-pangea/dnd'; import { useCallback, useMemo, useState } from 'react'; import { DataTransformerConfig, GrafanaTheme2, PanelData } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { Trans, t } from '@grafana/i18n'; +import { t } from '@grafana/i18n'; import { SceneObjectBase, SceneComponentProps, @@ -14,7 +13,7 @@ import { VizPanel, SceneObjectState, } from '@grafana/scenes'; -import { Button, ButtonGroup, ConfirmModal, Tab, useStyles2 } from '@grafana/ui'; +import { Tab, useStyles2 } from '@grafana/ui'; import { TransformationOperationRows } from 'app/features/dashboard/components/TransformationsEditor/TransformationOperationRows'; import { ExpressionQueryType } from 'app/features/expressions/types'; @@ -69,7 +68,6 @@ export class PanelDataTransformationsTab } export function PanelDataTransformationsTabRendered({ model }: SceneComponentProps) { - const styles = useStyles2(getStyles); const sourceData = model.getQueryRunner().useState(); const { data, transformations: transformsWrongType } = model.getDataTransformer().useState(); @@ -84,7 +82,6 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro }, [transformsWrongType]); const [drawerOpen, setDrawerOpen] = useState(false); - const [confirmModalOpen, setConfirmModalOpen] = useState(false); const openDrawer = () => setDrawerOpen(true); const closeDrawer = () => setDrawerOpen(false); @@ -163,46 +160,6 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro return ( <> - - - - - { - model.onChangeTransformations([]); - setConfirmModalOpen(false); - }} - onDismiss={() => setConfirmModalOpen(false)} - /> {transformationsDrawer} ); @@ -216,6 +173,7 @@ interface TransformationEditorProps { function TransformationsEditor({ transformations, model, data }: TransformationEditorProps) { const transformationEditorRows = transformations.map((t, i) => ({ id: `${i} - ${t.id}`, transformation: t })); + const styles = useStyles2(getStyles); const onDragEnd = (result: DropResult) => { if (!result || !result.destination) { @@ -234,37 +192,39 @@ function TransformationsEditor({ transformations, model, data }: TransformationE }; return ( - - - {(provided) => { - return ( -
- { - const newTransformations = transformations.slice(); - newTransformations[index] = transformation; - model.onChangeTransformations(newTransformations); - }} - onRemove={(index) => { - const newTransformations = transformations.slice(); - newTransformations.splice(index, 1); - model.onChangeTransformations(newTransformations); - }} - configs={transformationEditorRows} - data={data} - > - {provided.placeholder} -
- ); - }} -
-
+
+ + + {(provided) => { + return ( +
+ { + const newTransformations = transformations.slice(); + newTransformations[index] = transformation; + model.onChangeTransformations(newTransformations); + }} + onRemove={(index) => { + const newTransformations = transformations.slice(); + newTransformations.splice(index, 1); + model.onChangeTransformations(newTransformations); + }} + configs={transformationEditorRows} + data={data} + > + {provided.placeholder} +
+ ); + }} +
+
+
); } const getStyles = (theme: GrafanaTheme2) => ({ - removeAll: css({ - marginLeft: theme.spacing(2), + container: css({ + padding: theme.spacing(2), }), }); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformCard.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformCard.tsx new file mode 100644 index 00000000000..e0924b14f97 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformCard.tsx @@ -0,0 +1,217 @@ +import { css } from '@emotion/css'; +import { memo } from 'react'; + +import { DataTransformerConfig, GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { SceneDataQuery } from '@grafana/scenes'; +import { Icon, IconButton, Stack, useStyles2 } from '@grafana/ui'; + +interface QueryTransformCardProps { + item: SceneDataQuery | DataTransformerConfig; + type: 'query' | 'transform'; + index: number; + isSelected: boolean; + onClick: () => void; + onDuplicate?: () => void; + onRemove?: () => void; + onToggleVisibility?: () => void; +} + +export const QueryTransformCard = memo( + ({ item, type, index, isSelected, onClick, onDuplicate, onRemove, onToggleVisibility }: QueryTransformCardProps) => { + const styles = useStyles2(getStyles); + + const getName = (): string => { + if (type === 'query' && 'refId' in item) { + return item.refId || `Query ${index + 1}`; + } else if ('id' in item) { + return item.id.replace(/-/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase()); + } + return ''; + }; + + const getDatasourceName = (): string => { + if (type === 'query' && 'datasource' in item && item.datasource) { + try { + const dsSettings = getDataSourceSrv().getInstanceSettings(item.datasource); + return dsSettings?.name || 'Unknown datasource'; + } catch { + return 'Unknown datasource'; + } + } + return ''; + }; + + const isHidden = type === 'query' && 'hide' in item && item.hide; + const icon = type === 'query' ? 'database' : 'process'; + const typeLabel = type === 'query' ? 'Query' : 'Transformation'; + const name = getName(); + const datasourceName = getDatasourceName(); + + const handleAction = (e: React.MouseEvent, action: () => void) => { + e.stopPropagation(); + action(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }; + + return ( +
+ {/* Header with type and action icons */} +
+
+ + {typeLabel} +
+
+ + {type === 'query' && onToggleVisibility && ( + handleAction(e, onToggleVisibility)} + className={styles.actionButton} + /> + )} + {type === 'query' && onDuplicate && ( + handleAction(e, onDuplicate)} + className={styles.actionButton} + /> + )} + {onRemove && ( + handleAction(e, onRemove)} + className={styles.actionButton} + /> + )} + +
+
+ + {/* Content: Name and datasource */} +
+
{name}
+ {datasourceName &&
{datasourceName}
} +
+
+ ); + } +); + +QueryTransformCard.displayName = 'QueryTransformCard'; + +const getStyles = (theme: GrafanaTheme2) => { + const actionsClass = 'actions-container'; + + return { + card: css({ + cursor: 'pointer', + marginBottom: theme.spacing(1), + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + overflow: 'hidden', + background: theme.colors.background.primary, + [`&:hover .${actionsClass}`]: { + opacity: 1, + }, + '&:hover': { + borderColor: theme.colors.border.strong, + }, + }), + cardSelected: css({ + borderColor: theme.colors.primary.border, + boxShadow: `0 0 0 1px ${theme.colors.primary.border}`, + }), + headerQuery: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: theme.spacing(1, 1.5), + background: theme.colors.primary.transparent, + borderBottom: `1px solid ${theme.colors.border.weak}`, + }), + headerTransform: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: theme.spacing(1, 1.5), + background: theme.isDark + ? `${theme.visualization.getColorByName('orange')}20` + : `${theme.visualization.getColorByName('orange')}15`, + borderBottom: `1px solid ${theme.colors.border.weak}`, + }), + headerLeft: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + flex: 1, + minWidth: 0, + }), + headerIcon: css({ + color: theme.colors.text.secondary, + flexShrink: 0, + }), + typeLabel: css({ + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightMedium, + color: theme.colors.text.secondary, + textTransform: 'uppercase', + letterSpacing: '0.05em', + }), + actions: css({ + opacity: 0, + flexShrink: 0, + }), + actionsClass, + actionButton: css({ + '&:hover': { + background: theme.colors.action.hover, + }, + }), + content: css({ + padding: theme.spacing(1.5), + }), + name: css({ + fontSize: theme.typography.h5.fontSize, + fontWeight: theme.typography.fontWeightMedium, + color: theme.colors.text.primary, + marginBottom: theme.spacing(0.5), + }), + datasource: css({ + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + }), + }; +}; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx new file mode 100644 index 00000000000..26ea518417b --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx @@ -0,0 +1,138 @@ +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 { QueryTransformCard } from './QueryTransformCard'; + +export interface QueryTransformItem { + id: string; + type: 'query' | 'transform'; + data: SceneDataQuery | DataTransformerConfig; + index: number; +} + +interface QueryTransformListProps { + items: QueryTransformItem[]; + selectedId: string | null; + onSelect: (id: string) => void; + onAddQuery: () => void; + onAddTransform: () => void; + onDuplicateQuery?: (index: number) => void; + onRemoveQuery?: (index: number) => void; + onToggleQueryVisibility?: (index: number) => void; + onRemoveTransform?: (index: number) => void; +} + +export const QueryTransformList = memo( + ({ + items, + selectedId, + onSelect, + onAddQuery, + onAddTransform, + onDuplicateQuery, + onRemoveQuery, + onToggleQueryVisibility, + onRemoveTransform, + }: QueryTransformListProps) => { + const styles = useStyles2(getStyles); + + const queries = items.filter((item) => item.type === 'query'); + const transforms = items.filter((item) => item.type === 'transform'); + + return ( +
+
+ +
+ {queries.length > 0 && ( +
+ {queries.map((item) => ( + onSelect(item.id)} + onDuplicate={onDuplicateQuery ? () => onDuplicateQuery(item.index) : undefined} + onRemove={onRemoveQuery ? () => onRemoveQuery(item.index) : undefined} + onToggleVisibility={ + onToggleQueryVisibility ? () => onToggleQueryVisibility(item.index) : undefined + } + /> + ))} +
+ )} + + {transforms.length > 0 && ( +
+ {transforms.map((item) => ( + onSelect(item.id)} + onRemove={onRemoveTransform ? () => onRemoveTransform(item.index) : undefined} + /> + ))} +
+ )} +
+
+
+ +
+ + + + +
+
+ ); + } +); + +QueryTransformList.displayName = 'QueryTransformList'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + display: 'flex', + flexDirection: 'column', + height: '100%', + background: theme.colors.background.primary, + borderRight: `1px solid ${theme.colors.border.weak}`, + overflow: 'hidden', + }), + scrollContainer: css({ + flex: 1, + minHeight: 0, + overflow: 'auto', + }), + content: css({ + padding: theme.spacing(2), + }), + section: css({ + '&:not(:first-child)': { + marginTop: theme.spacing(2), + }, + }), + actions: css({ + padding: theme.spacing(2), + borderTop: `1px solid ${theme.colors.border.weak}`, + background: theme.colors.background.canvas, + flexShrink: 0, + }), + }; +}; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/SceneQueryDetailView.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/SceneQueryDetailView.tsx new file mode 100644 index 00000000000..430cbe57c2a --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/SceneQueryDetailView.tsx @@ -0,0 +1,216 @@ +import { css } from '@emotion/css'; +import { useCallback, useEffect, useState } from 'react'; + +import { + CoreApp, + DataQuery, + DataSourceApi, + DataSourceInstanceSettings, + DataSourcePluginContextProvider, + GrafanaTheme2, + PanelData, + TimeRange, +} from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { SceneDataQuery, VizPanel } from '@grafana/scenes'; +import { ErrorBoundaryAlert, useStyles2 } from '@grafana/ui'; +import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow'; +import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; +import { QueryErrorAlert } from 'app/features/query/components/QueryErrorAlert'; + +import { getQueryRunnerFor } from '../../utils/utils'; + +interface SceneQueryDetailViewProps { + panel: VizPanel; + query: SceneDataQuery; + queryIndex: number; +} + +export function SceneQueryDetailView({ panel, query, queryIndex }: SceneQueryDetailViewProps) { + 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 timeRange: TimeRange | undefined = queryRunner?.state.$timeRange?.state.value; + + // Load datasource + useEffect(() => { + const loadDatasource = async () => { + try { + const ds = await getDataSourceSrv().get(query.datasource); + const settings = getDataSourceSrv().getInstanceSettings(query.datasource); + setDatasource(ds); + setDsSettings(settings || null); + } catch (error) { + console.error('Failed to load datasource:', error); + // Fallback to default datasource + const defaultDs = await getDataSourceSrv().get(); + const defaultSettings = getDataSourceSrv().getInstanceSettings(null); + setDatasource(defaultDs); + setDsSettings(defaultSettings || null); + } + }; + + loadDatasource(); + }, [query.datasource]); + + // Subscribe to panel data + useEffect(() => { + if (!queryRunnerState?.data) { + return; + } + // Filter data for this specific query + const panelData = queryRunnerState.data; + const filteredSeries = panelData.series.filter((s) => s.refId === query.refId); + const filteredData = { + ...panelData, + series: filteredSeries, + error: panelData.errors?.find((e) => e.refId === query.refId), + }; + setData(filteredData); + }, [queryRunnerState?.data, query.refId]); + + const handleDataSourceChange = useCallback( + async (newDsSettings: DataSourceInstanceSettings) => { + try { + const newDs = await getDataSourceSrv().get(newDsSettings.uid); + setDatasource(newDs); + setDsSettings(newDsSettings); + + // Update the query with the new datasource + if (queryRunner) { + const queries = queryRunner.state.queries || []; + const newQueries = queries.map((q, idx) => { + if (idx === queryIndex) { + // Get default query for new datasource + const defaultQuery = newDs.getDefaultQuery?.(CoreApp.PanelEditor) || {}; + return { + ...defaultQuery, + ...q, + datasource: { uid: newDsSettings.uid, type: newDsSettings.type }, + refId: q.refId, + }; + } + return q; + }); + + queryRunner.setState({ queries: newQueries }); + queryRunner.runQueries(); + } + } catch (error) { + console.error('Failed to change datasource:', error); + } + }, + [queryRunner, queryIndex] + ); + + const handleQueryChange = useCallback( + (updatedQuery: DataQuery) => { + if (queryRunner) { + const queries = queryRunner.state.queries || []; + const newQueries = queries.map((q, idx) => (idx === queryIndex ? updatedQuery : q)); + queryRunner.setState({ queries: newQueries }); + } + }, + [queryRunner, queryIndex] + ); + + const handleRunQuery = useCallback(() => { + if (queryRunner) { + queryRunner.runQueries(); + } + }, [queryRunner]); + + const renderQueryEditor = () => { + if (!datasource || !dsSettings) { + return ( +
+ Loading data source... +
+ ); + } + + const QueryEditor = datasource.components?.QueryEditor; + if (!QueryEditor) { + return ( +
+ + This data source does not have a query editor + +
+ ); + } + + return ( + + + + + + ); + }; + + const error = data?.error || data?.errors?.find((e) => e.refId === query.refId); + + return ( +
+
+ +
+ + +
+ {renderQueryEditor()} + {error && } +
+
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + padding: theme.spacing(2), + }), + datasourceSection: css({ + paddingBottom: theme.spacing(2), + }), + queryContent: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + }), + noEditor: css({ + padding: theme.spacing(2), + textAlign: 'center', + color: theme.colors.text.secondary, + }), + }; +}; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx index 18a2954d77e..0563bf3df3c 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx @@ -227,6 +227,7 @@ export const TransformationOperationRow = ({ draggable actions={renderActions} disabled={disabled} + hideHeader={true} expanderMessages={{ close: 'Collapse transformation row', open: 'Expand transformation row', diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d9efaadc4d6..6ceb8e3cead 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -6180,6 +6180,9 @@ "text-loading-rules": "Loading rules...", "title-dashboard-not-saved": "Dashboard not saved" }, + "panel-data-pane": { + "empty-state": "Select a query or transformation to edit" + }, "panel-data-queries-tab": { "tab-label": "Queries" }, @@ -6190,13 +6193,6 @@ "panel-data-transformations-tab": { "tab-label": "Transformations" }, - "panel-data-transformations-tab-rendered": { - "add-another-transformation": "Add another transformation", - "body-delete-all-transformations": "By deleting all transformations, you will go back to the main selection screen.", - "confirmText-delete-all": "Delete all", - "delete-all-transformations": "Delete all transformations", - "title-delete-all-transformations": "Delete all transformations?" - }, "panel-edit-controls": { "table-view-aria-label-toggletableview": "Toggle table view", "table-view-label-table-view": "Table view" @@ -6242,6 +6238,17 @@ "query-editor": { "query": "Query" }, + "query-transform-card": { + "duplicate": "Duplicate query", + "hide-response": "Hide response", + "remove-query": "Remove query", + "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.", @@ -6352,6 +6359,11 @@ "title-same-as-folder": "Dashboard name cannot be the same as the folder name", "title-validation-failed": "Dashboard title validation failed." }, + "scene-query-detail-view": { + "loading": "Loading data source...", + "no-editor": "This data source does not have a query editor", + "select-datasource": "Select data source" + }, "scenes-new-rule-from-panel-button": { "body-no-alerting-capable-query-found": "Cannot create alerts from this panel because no query to an alerting capable datasource is found.", "new-alert-rule": "New alert rule",