init POC commit

This commit is contained in:
Develer
2025-12-01 13:43:07 +01:00
parent 7fce2d9516
commit edabcd8b43
9 changed files with 997 additions and 141 deletions
-5
View File
@@ -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
@@ -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 (
<>
<div ref={provided.innerRef} className={styles.wrapper} {...provided.draggableProps}>
<div>
<QueryOperationRowHeader
id={id}
actionsElement={actionsElement}
disabled={disabled}
draggable
collapsable={collapsable}
dragHandleProps={provided.dragHandleProps}
headerElement={headerElementRendered}
isContentVisible={isContentVisible}
onRowToggle={onRowToggle}
reportDragMousePosition={reportDragMousePosition}
title={title}
expanderMessages={expanderMessages}
/>
</div>
{!hideHeader && (
<div>
<QueryOperationRowHeader
id={id}
actionsElement={actionsElement}
disabled={disabled}
draggable
collapsable={collapsable}
dragHandleProps={provided.dragHandleProps}
headerElement={headerElementRendered}
isContentVisible={isContentVisible}
onRowToggle={onRowToggle}
reportDragMousePosition={reportDragMousePosition}
title={title}
expanderMessages={expanderMessages}
/>
</div>
)}
{isContentVisible && <div className={styles.content}>{children}</div>}
</div>
</>
@@ -140,32 +144,34 @@ export function QueryOperationRow({
return (
<div className={styles.wrapper}>
<QueryOperationRowHeader
id={id}
actionsElement={actionsElement}
disabled={disabled}
draggable={false}
collapsable={collapsable}
headerElement={headerElementRendered}
isContentVisible={isContentVisible}
onRowToggle={onRowToggle}
reportDragMousePosition={reportDragMousePosition}
title={title}
expanderMessages={expanderMessages}
/>
{!hideHeader && (
<QueryOperationRowHeader
id={id}
actionsElement={actionsElement}
disabled={disabled}
draggable={false}
collapsable={collapsable}
headerElement={headerElementRendered}
isContentVisible={isContentVisible}
onRowToggle={onRowToggle}
reportDragMousePosition={reportDragMousePosition}
title={title}
expanderMessages={expanderMessages}
/>
)}
{isContentVisible && <div className={styles.content}>{children}</div>}
</div>
);
}
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),
}),
};
};
@@ -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<PanelDataPaneState> {
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<PanelDataPane>) {
const { tab, tabs } = model.useState();
const { tabs, panelRef } = model.useState();
const styles = useStyles2(getStyles);
const [selectedId, setSelectedId] = useState<string | null>(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<string>) => {
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 (
<div className={styles.emptyState}>
<p>
<Trans i18nKey="dashboard-scene.panel-data-pane.empty-state">
Select a query or transformation to edit
</Trans>
</p>
</div>
);
}
if (selectedItem.type === 'query' && 'refId' in selectedItem.data) {
const query = selectedItem.data;
return (
<div className={styles.detailContent}>
<ScrollContainer>
<SceneQueryDetailView panel={panel} query={query} queryIndex={selectedItem.index} />
</ScrollContainer>
</div>
);
} else {
const transformsTab = tabs.find((t) => t.tabId === TabId.Transformations);
if (transformsTab instanceof PanelDataTransformationsTab && 'id' in selectedItem.data) {
return (
<div className={styles.detailContent}>
<ScrollContainer>
<Container>
<PanelDataTransformationsTabRendered model={transformsTab} />
</Container>
</ScrollContainer>
</div>
);
}
}
return null;
}, [selectedItem, tabs, styles, panel]);
// Get data for transformations drawer
const sourceData = queryRunner?.useState();
const series = sourceData?.data?.series || [];
return (
<div className={styles.dataPane} data-testid={selectors.components.PanelEditor.DataPane.content}>
<TabsBar hideBorder className={styles.tabsBar}>
{tabs.map((t) => t.renderTab({ active: t.tabId === tab, onChangeTab: () => model.onChangeTab(t) }))}
</TabsBar>
<div className={styles.tabBorder}>
<ScrollContainer>
<TabContent className={styles.tabContent}>
<Container>{currentTab && <currentTab.Component model={currentTab} />}</Container>
</TabContent>
</ScrollContainer>
<div className={styles.unifiedLayout}>
<div className={styles.splitLayout}>
<div className={styles.leftPanel}>
<QueryTransformList
items={items}
selectedId={effectiveSelectedId}
onSelect={handleSelect}
onAddQuery={handleAddQuery}
onAddTransform={handleAddTransform}
onDuplicateQuery={handleDuplicateQuery}
onRemoveQuery={handleRemoveQuery}
onToggleQueryVisibility={handleToggleQueryVisibility}
onRemoveTransform={handleRemoveTransform}
/>
</div>
<div className={styles.rightPanel}>{renderDetailPanel()}</div>
</div>
</div>
<TransformationsDrawer
isOpen={transformDrawerOpen}
onClose={() => setTransformDrawerOpen(false)}
onTransformationAdd={handleTransformationAdd}
series={series}
/>
</div>
);
}
@@ -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,
}),
};
}
@@ -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<PanelDataTransformationsTab>) {
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<boolean>(false);
const [confirmModalOpen, setConfirmModalOpen] = useState<boolean>(false);
const openDrawer = () => setDrawerOpen(true);
const closeDrawer = () => setDrawerOpen(false);
@@ -163,46 +160,6 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro
return (
<>
<TransformationsEditor data={sourceData.data} transformations={transformations} model={model} />
<ButtonGroup>
<Button
icon="plus"
variant="secondary"
onClick={openDrawer}
data-testid={selectors.components.Transforms.addTransformationButton}
>
<Trans i18nKey="dashboard-scene.panel-data-transformations-tab-rendered.add-another-transformation">
Add another transformation
</Trans>
</Button>
<Button
data-testid={selectors.components.Transforms.removeAllTransformationsButton}
className={styles.removeAll}
icon="times"
variant="secondary"
onClick={() => setConfirmModalOpen(true)}
>
<Trans i18nKey="dashboard-scene.panel-data-transformations-tab-rendered.delete-all-transformations">
Delete all transformations
</Trans>
</Button>
</ButtonGroup>
<ConfirmModal
isOpen={confirmModalOpen}
title={t(
'dashboard-scene.panel-data-transformations-tab-rendered.title-delete-all-transformations',
'Delete all transformations?'
)}
body={t(
'dashboard-scene.panel-data-transformations-tab-rendered.body-delete-all-transformations',
'By deleting all transformations, you will go back to the main selection screen.'
)}
confirmText={t('dashboard-scene.panel-data-transformations-tab-rendered.confirmText-delete-all', 'Delete all')}
onConfirm={() => {
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 (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="transformations-list" direction="vertical">
{(provided) => {
return (
<div ref={provided.innerRef} {...provided.droppableProps}>
<TransformationOperationRows
onChange={(index, transformation) => {
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}
></TransformationOperationRows>
{provided.placeholder}
</div>
);
}}
</Droppable>
</DragDropContext>
<div className={styles.container}>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="transformations-list" direction="vertical">
{(provided) => {
return (
<div ref={provided.innerRef} {...provided.droppableProps}>
<TransformationOperationRows
onChange={(index, transformation) => {
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}
></TransformationOperationRows>
{provided.placeholder}
</div>
);
}}
</Droppable>
</DragDropContext>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
removeAll: css({
marginLeft: theme.spacing(2),
container: css({
padding: theme.spacing(2),
}),
});
@@ -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 (
<div
role="button"
tabIndex={0}
className={`${styles.card} ${isSelected ? styles.cardSelected : ''}`}
onClick={onClick}
onKeyDown={handleKeyDown}
data-testid={`${type}-card-${index}`}
>
{/* Header with type and action icons */}
<div className={type === 'query' ? styles.headerQuery : 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 && (
<IconButton
name={isHidden ? 'eye-slash' : 'eye'}
size="sm"
variant="secondary"
tooltip={
isHidden
? t('dashboard-scene.query-transform-card.show-response', 'Show response')
: t('dashboard-scene.query-transform-card.hide-response', 'Hide response')
}
onClick={(e) => handleAction(e, onToggleVisibility)}
className={styles.actionButton}
/>
)}
{type === 'query' && onDuplicate && (
<IconButton
name="copy"
size="sm"
variant="secondary"
tooltip={t('dashboard-scene.query-transform-card.duplicate', 'Duplicate query')}
onClick={(e) => handleAction(e, onDuplicate)}
className={styles.actionButton}
/>
)}
{onRemove && (
<IconButton
name="trash-alt"
size="sm"
variant="secondary"
tooltip={
type === 'query'
? t('dashboard-scene.query-transform-card.remove-query', 'Remove query')
: t('dashboard-scene.query-transform-card.remove-transform', 'Remove transformation')
}
onClick={(e) => handleAction(e, onRemove)}
className={styles.actionButton}
/>
)}
</Stack>
</div>
</div>
{/* Content: Name and datasource */}
<div className={styles.content}>
<div className={styles.name}>{name}</div>
{datasourceName && <div className={styles.datasource}>{datasourceName}</div>}
</div>
</div>
);
}
);
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,
}),
};
};
@@ -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 (
<div className={styles.container}>
<div className={styles.scrollContainer}>
<ScrollContainer>
<div className={styles.content}>
{queries.length > 0 && (
<div className={styles.section}>
{queries.map((item) => (
<QueryTransformCard
key={item.id}
item={item.data}
type="query"
index={item.index}
isSelected={selectedId === item.id}
onClick={() => onSelect(item.id)}
onDuplicate={onDuplicateQuery ? () => onDuplicateQuery(item.index) : undefined}
onRemove={onRemoveQuery ? () => onRemoveQuery(item.index) : undefined}
onToggleVisibility={
onToggleQueryVisibility ? () => onToggleQueryVisibility(item.index) : undefined
}
/>
))}
</div>
)}
{transforms.length > 0 && (
<div className={styles.section}>
{transforms.map((item) => (
<QueryTransformCard
key={item.id}
item={item.data}
type="transform"
index={item.index}
isSelected={selectedId === item.id}
onClick={() => onSelect(item.id)}
onRemove={onRemoveTransform ? () => onRemoveTransform(item.index) : undefined}
/>
))}
</div>
)}
</div>
</ScrollContainer>
</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>
</div>
</div>
);
}
);
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,
}),
};
};
@@ -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<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 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 (
<div className={styles.noEditor}>
<Trans i18nKey="dashboard-scene.scene-query-detail-view.loading">Loading data source...</Trans>
</div>
);
}
const QueryEditor = datasource.components?.QueryEditor;
if (!QueryEditor) {
return (
<div className={styles.noEditor}>
<Trans i18nKey="dashboard-scene.scene-query-detail-view.no-editor">
This data source does not have a query editor
</Trans>
</div>
);
}
return (
<DataSourcePluginContextProvider instanceSettings={dsSettings}>
<ErrorBoundaryAlert boundaryName="query-editor">
<QueryEditor
query={query}
datasource={datasource}
onChange={handleQueryChange}
onRunQuery={handleRunQuery}
data={data}
range={timeRange}
app={CoreApp.PanelEditor}
/>
</ErrorBoundaryAlert>
</DataSourcePluginContextProvider>
);
};
const error = data?.error || data?.errors?.find((e) => e.refId === query.refId);
return (
<div className={styles.container}>
<div className={styles.datasourceSection}>
<DataSourcePicker
current={dsSettings?.uid || query.datasource}
onChange={handleDataSourceChange}
placeholder={t('dashboard-scene.scene-query-detail-view.select-datasource', 'Select data source')}
/>
</div>
<QueryOperationRow
id={`query-${query.refId}`}
index={queryIndex}
draggable={false}
collapsable={false}
isOpen={true}
hideHeader={true}
>
<div className={styles.queryContent}>
{renderQueryEditor()}
{error && <QueryErrorAlert error={error} />}
</div>
</QueryOperationRow>
</div>
);
}
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,
}),
};
};
@@ -227,6 +227,7 @@ export const TransformationOperationRow = ({
draggable
actions={renderActions}
disabled={disabled}
hideHeader={true}
expanderMessages={{
close: 'Collapse transformation row',
open: 'Expand transformation row',
+19 -7
View File
@@ -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</1> ).",
"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",