Compare commits

...

74 Commits

Author SHA1 Message Date
Paul Marbach
5977350c51 updates from review 2025-12-05 13:24:20 -05:00
Alex Spencer
d965857ff0 chore: alex - fix debug background 2025-12-05 09:39:18 -08:00
Develer
444eabcabb Merge branch 'data-manipulation-improvements' of github.com:grafana/grafana into data-manipulation-improvements 2025-12-05 18:08:34 +01:00
Develer
f55e17f853 update debug icon 2025-12-05 18:08:14 +01:00
Paul Marbach
18f99ceaa9 Merge branch 'data-manipulation-improvements-resize-issue' into data-manipulation-improvements 2025-12-05 12:01:33 -05:00
Paul Marbach
d2f44c3859 layout update with height issue 2025-12-05 11:59:38 -05:00
Paul Marbach
21e82214a6 good enough 2025-12-05 11:59:12 -05:00
Paul Marbach
b1b8257cfc maybe this will work 2025-12-05 11:18:36 -05:00
Paul Marbach
25a47d6a0f layout update with height issue 2025-12-05 10:59:21 -05:00
Develer
c2b8e7fcd5 cleanup styles 2025-12-05 16:48:15 +01:00
Paul Marbach
e5174895d6 add query inspector, clean up some stuff 2025-12-04 17:29:36 -05:00
Alex Spencer
badacad658 chore: alex - always select preceding node during debug 2025-12-04 13:55:51 -08:00
Alex Spencer
fcebfc952d Merge branch 'data-manipulation-improvements' of https://github.com/grafana/grafana into data-manipulation-improvements 2025-12-04 13:51:20 -08:00
Alex Spencer
6197fe837d chore: alex - fix action icons on hover. 2025-12-04 13:51:18 -08:00
Paul Marbach
fdf40444ab add more queries for Thanos 2025-12-04 16:39:34 -05:00
Alex Spencer
15bf6b45f9 chore: alex - debug mode 2025-12-04 13:38:19 -08:00
Alex Spencer
65efd85d64 chore: remove duplicated query transform list component 2025-12-04 13:23:09 -08:00
Paul Marbach
b8600a43db fix from rebase 2025-12-04 16:08:25 -05:00
Paul Marbach
cee3b34cf7 fix up long sidebar 2025-12-04 15:54:09 -05:00
Develer
8914c2b054 update query library styles 2025-12-04 18:04:25 +01:00
Develer
4532e04474 update query library styles 2025-12-04 17:34:18 +01:00
Develer
29d208c120 fix merge conflicts 2025-12-04 17:02:59 +01:00
Develer
953f0b1a91 update query library 2025-12-04 16:54:07 +01:00
Alex Spencer
c731335e98 chore: alex - transformation ui improvements 2025-12-04 07:28:35 -08:00
Paul Marbach
d185b73e41 nicer hover effects for connection toggle 2025-12-04 10:14:10 -05:00
Paul Marbach
e9f4d90337 expression icons in cards 2025-12-04 09:31:01 -05:00
Alex Spencer
1610daeafb chore: a little bit of cleanup 2025-12-03 21:41:41 -08:00
Paul Marbach
7c04d4123a collapsible sections 2025-12-03 18:29:11 -05:00
Paul Marbach
037acc6fdb add filter for connections 2025-12-03 18:25:15 -05:00
Alex Spencer
8dcba9c5c9 Merge branch 'data-manipulation-improvements' of https://github.com/grafana/grafana into data-manipulation-improvements 2025-12-03 14:11:23 -08:00
Alex Spencer
5cf28fd8d6 chore: alex - ai demo 2025-12-03 14:06:54 -08:00
Paul Marbach
5e69b4dcc2 clean up some state and selection stuff after removing a card 2025-12-03 16:52:06 -05:00
Paul Marbach
33fe608e54 more add button cleanup 2025-12-03 16:38:41 -05:00
Paul Marbach
7055860372 delete console.log 2025-12-03 16:31:22 -05:00
Paul Marbach
9002d98d02 more add button stuff 2025-12-03 16:29:26 -05:00
Paul Marbach
296e17f321 clean up state logic, add the inline add button 2025-12-03 16:02:57 -05:00
Develer
eacd341de7 add save query library functionality stub 2025-12-03 17:57:09 +01:00
Develer
b085f3ccfd add transformation menu 2025-12-03 15:09:06 +01:00
Develer
2ef57dfa4a Merge branch 'data-manipulation-improvements' of github.com:grafana/grafana into data-manipulation-improvements 2025-12-03 14:36:02 +01:00
Develer
f8d451100f fix style spacing 2025-12-03 14:35:42 +01:00
Paul Marbach
b6cd253500 expressions icon const 2025-12-03 08:31:39 -05:00
Paul Marbach
709b0f7ff6 use correct icons for expressions 2025-12-03 08:26:48 -05:00
Alex Spencer
132997e19b chore: cleanup for smoother dnd drops 2025-12-02 19:00:25 -08:00
Alex Spencer
0425e940dd chore: alex - drag n drop, connection line fix 2025-12-02 17:19:40 -08:00
Paul Marbach
65ef473c1f whoops 2025-12-02 18:28:27 -05:00
Paul Marbach
fbf4cc11aa more stuff 2025-12-02 18:27:57 -05:00
Paul Marbach
039292e047 bug fixes 2025-12-02 18:27:55 -05:00
Alex Spencer
ceee8f062c chore: alex - minor padding fix 2025-12-02 15:16:25 -08:00
Alex Spencer
96e0f4718b chore: alex - fix transformation enabled/disabled, sections! 2025-12-02 15:14:25 -08:00
Alex Spencer
2734cb55b6 chore: alex - colors! 2025-12-02 14:39:04 -08:00
Alex Spencer
c50dfb17e3 chore: alex - fix linter, card min and max widths, other small formatting changes 2025-12-02 14:18:22 -08:00
Paul Marbach
bb4a15b31c Merge remote-tracking branch 'origin/main' into data-manipulation-improvements 2025-12-02 15:46:56 -05:00
Paul Marbach
a3a8fb9c91 rework some stuff to make selecting the correct transform easier 2025-12-02 15:42:29 -05:00
Alex Spencer
e60c5a9342 chore: alex - header + footer + splitter padding + various styles updates 2025-12-02 12:28:34 -08:00
Alex Spencer
b489c4360a chore: disabled card state + connection lines ONLY when selected 2025-12-02 11:10:38 -08:00
Paul Marbach
8ffd614937 fix whitepsace wrapping on collapsedText for now 2025-12-02 12:48:30 -05:00
Paul Marbach
ecc37b85a9 remove thin definition, it doesnt do anything 2025-12-02 12:46:02 -05:00
Paul Marbach
70e69a6421 footer layout adjustments 2025-12-02 12:45:58 -05:00
Develer
1aa0a5e7bf update options footer 2025-12-02 18:42:52 +01:00
Paul Marbach
80e4da6a80 subscribe to updates to state to ensure refId updates don't break selection 2025-12-02 11:46:03 -05:00
Paul Marbach
61b5880d95 add options panel 2025-12-02 10:57:39 -05:00
Alex Spencer
dbca357f82 chore: minor cleanup 2025-12-01 21:05:07 -08:00
Alex Spencer
8ae9754dfc chore: build out header a bit 2025-12-01 20:41:05 -08:00
Alex Spencer
91cb43d124 chore: connection lines for fun 2025-12-01 18:32:23 -08:00
Alex Spencer
0a3fc554ee chore: button menu update 2025-12-01 18:32:06 -08:00
Alex Spencer
7fbc8cd0cd chore: init and use CommitMono 2025-12-01 16:49:27 -08:00
Alex Spencer
f9e5e30dc2 chore: implement splitter + minor changes 2025-12-01 15:47:54 -08:00
Paul Marbach
21b799615f fix spread 2025-12-01 17:55:08 -05:00
Paul Marbach
ba49d5a5df cleanups from review 2025-12-01 17:38:50 -05:00
Paul Marbach
c21aae6bd7 wip 2025-12-01 17:29:08 -05:00
Develer
4b9b10d65b move add button 2025-12-01 18:45:14 +01:00
Develer
a2882ebd85 add expressions 2025-12-01 17:30:15 +01:00
Develer
d3e8bdd928 re-org components 2025-12-01 15:34:15 +01:00
Develer
edabcd8b43 init POC commit 2025-12-01 13:43:07 +01:00
48 changed files with 6223 additions and 513 deletions

View File

@@ -1863,11 +1863,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

View File

@@ -54,7 +54,7 @@ export interface ThemeTypographyInput {
}
const defaultFontFamily = "'Inter', 'Helvetica', 'Arial', sans-serif";
const defaultFontFamilyMonospace = "'Roboto Mono', monospace";
const defaultFontFamilyMonospace = "'CommitMono', monospace";
export function createTypography(colors: ThemeColors, typographyInput: ThemeTypographyInput = {}): ThemeTypography {
const {

View File

@@ -41,8 +41,10 @@ export const availableIconsIndex = {
asserts: true,
'expand-arrows': true,
'expand-arrows-alt': true,
'expand-alt': true,
at: true,
ai: true,
'ai-pointer': true,
backward: true,
bars: true,
bell: true,
@@ -76,6 +78,7 @@ export const availableIconsIndex = {
'cloud-info': true,
'cloud-provider': true,
'cloud-upload': true,
code: true,
'code-branch': true,
cog: true,
columns: true,
@@ -85,6 +88,7 @@ export const availableIconsIndex = {
'comments-alt': true,
compass: true,
'compress-arrows': true,
'compress-alt': true,
copy: true,
'corner-up-left': true,
'corner-up-right': true,
@@ -95,6 +99,7 @@ export const availableIconsIndex = {
cube: true,
dashboard: true,
database: true,
'debug-handle': true,
'dice-three': true,
docker: true,
'document-info': true,
@@ -158,6 +163,7 @@ export const availableIconsIndex = {
'gf-show-context': true,
'gf-pin': true,
'gf-prometheus': true,
'gf-query-library': true,
'gf-traces': true,
globe: true,
grafana: true,
@@ -214,6 +220,7 @@ export const availableIconsIndex = {
'pause-circle': true,
pen: true,
percentage: true,
pivot: true,
play: true,
plug: true,
plus: true,

View File

@@ -75,5 +75,24 @@ export function getFontStyles(theme: GrafanaTheme2) {
src: `url('${fontRoot}inter/Inter-MediumItalic.woff2') format('woff2')`,
},
},
{
/* CommitMono - monospace font for code */
'@font-face': {
fontFamily: 'CommitMono',
fontStyle: 'normal',
fontWeight: 400,
fontDisplay: 'swap',
src: `url('${fontRoot}commitmono/CommitMono-Regular.woff2') format('woff2')`,
},
},
{
'@font-face': {
fontFamily: 'CommitMono',
fontStyle: 'italic',
fontWeight: 400,
fontDisplay: 'swap',
src: `url('${fontRoot}commitmono/CommitMono-Italic.woff2') format('woff2')`,
},
},
]);
}

View File

@@ -32,7 +32,7 @@ const theme: GrafanaThemeCommons = {
typography: {
fontFamily: {
sansSerif: '"Inter", "Helvetica", "Arial", sans-serif',
monospace: "'Roboto Mono', monospace",
monospace: "'CommitMono', monospace",
},
size: {
base: '14px',

View File

@@ -1,5 +1,6 @@
import { css } from '@emotion/css';
import { Draggable } from '@hello-pangea/dnd';
import { clsx } from 'clsx';
import { useCallback, useEffect, useState } from 'react';
import * as React from 'react';
import { useUpdateEffect } from 'react-use';
@@ -24,6 +25,8 @@ export interface QueryOperationRowProps {
collapsable?: boolean;
disabled?: boolean;
expanderMessages?: ExpanderMessages;
hideHeader?: boolean;
className?: string;
}
export type QueryOperationRowRenderProp = ((props: QueryOperationRowRenderProps) => React.ReactNode) | React.ReactNode;
@@ -48,9 +51,11 @@ export function QueryOperationRow({
index,
id,
expanderMessages,
hideHeader = false,
className,
}: 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]);
@@ -112,23 +117,25 @@ export function QueryOperationRow({
{(provided) => {
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>
<div ref={provided.innerRef} className={clsx(styles.wrapper, className)} {...provided.draggableProps}>
{!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>
</>
@@ -139,33 +146,35 @@ 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}
/>
<div className={clsx(styles.wrapper, className)}>
{!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),
}),
};
};

View File

@@ -43,6 +43,7 @@
"unicons/circle",
"unicons/clipboard-alt",
"unicons/clock-nine",
"unicons/code",
"unicons/cloud",
"unicons/cloud-database-tree",
"unicons/cloud-download",
@@ -55,6 +56,7 @@
"unicons/comment-alt-share",
"unicons/comments-alt",
"unicons/compass",
"unicons/compress-alt",
"unicons/copy",
"unicons/corner-down-right-alt",
"unicons/corner-up-left",
@@ -62,6 +64,7 @@
"unicons/cube",
"unicons/dashboard",
"unicons/database",
"unicons/debug-handle",
"unicons/document-info",
"unicons/download-alt",
"unicons/draggabledots",
@@ -72,6 +75,7 @@
"unicons/exchange-alt",
"unicons/exclamation-circle",
"unicons/exclamation-triangle",
"unicons/expand-alt",
"unicons/external-link-alt",
"unicons/eye",
"unicons/eye-slash",
@@ -158,6 +162,7 @@
"unicons/message",
"unicons/palette",
"unicons/percentage",
"unicons/pivot",
"unicons/shield-exclamation",
"unicons/plus-square",
"unicons/x",
@@ -177,6 +182,7 @@
"custom/gf-logs",
"custom/gf-movepane-left",
"custom/gf-movepane-right",
"custom/gf-query-library",
"custom/gf-traces",
"mono/favorite",
"mono/grafana",
@@ -187,6 +193,7 @@
"unicons/record-audio",
"unicons/scim",
"solid/bookmark",
"unicons/ai-pointer",
"unicons/ai-sparkle",
"unicons/dollar-alt",
"unicons/window-grid",

View File

@@ -17,7 +17,12 @@ export function queryIsEmpty(query: DataQuery): boolean {
return true;
}
export function addQuery(queries: DataQuery[], query?: Partial<DataQuery>, datasource?: DataSourceRef): DataQuery[] {
export function addQuery(
queries: DataQuery[],
query?: Partial<DataQuery>,
datasource?: DataSourceRef,
index = queries.length
): DataQuery[] {
const q: DataQuery = {
...query,
refId: getNextRefId(queries),
@@ -28,7 +33,9 @@ export function addQuery(queries: DataQuery[], query?: Partial<DataQuery>, datas
q.datasource = datasource;
}
return [...queries, q];
queries.splice(index, 0, q);
return [...queries];
}
export function isDataQuery(url: string): boolean {

View File

@@ -0,0 +1,151 @@
import { css } from '@emotion/css';
import { ComponentProps, memo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Button, Dropdown, Icon, IconButton, Menu, useStyles2 } from '@grafana/ui';
import { ExpressionQueryType, getExpressionIcon } from 'app/features/expressions/types';
interface AddDataItemMenuProps {
onAddQuery: (index?: number) => void;
onAddFromSavedQueries: (index?: number) => void;
onAddTransform: (index?: number) => void;
onAddExpression: (type: ExpressionQueryType, index?: number) => void;
index?: number;
allowedTypes?: Array<'query' | 'transform' | 'expression'>;
show?: boolean;
text?: string;
}
export const AddDataItemMenu = memo(
({
onAddQuery,
onAddTransform,
onAddExpression,
onAddFromSavedQueries,
index,
text,
allowedTypes = ['query', 'expression', 'transform'],
show = true,
}: AddDataItemMenuProps) => {
const styles = useStyles2(getStyles);
const [menuShown, setMenuShown] = useState(false);
if (!show && !menuShown) {
return;
}
const renderButton = (onClick?: ComponentProps<typeof Button>['onClick']) => {
return text ? (
<Button onClick={onClick} className={styles.textButton} size="md" variant="primary" icon="plus" fill="text">
{text}
</Button>
) : (
<IconButton
onClick={onClick}
name="plus"
size="xs"
variant="primary"
tooltip={t('dashboard-scene.add-data-item-menu.add-button', 'Add')}
/>
);
};
if (allowedTypes.length === 1 && allowedTypes[0] === 'transform') {
return renderButton(() => onAddTransform(index));
}
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 expressionSubItems = expressionTypes.map(({ type, label }) => (
<Menu.Item key={type} label={label} icon={getExpressionIcon(type)} onClick={() => onAddExpression(type, index)} />
));
const menu = (
<Menu>
{allowedTypes.includes('query') && (
<Menu.Item
label={t('dashboard-scene.add-data-item-menu.add-query', 'Query')}
icon="database"
onClick={() => onAddQuery(index)}
/>
)}
{allowedTypes.includes('query') && (
<Menu.Item
label={t('dashboard-scene.add-data-item-menu.add-from-saved-queries', 'From saved queries')}
icon="bookmark"
onClick={() => onAddFromSavedQueries(index)}
component={() => (
<span className={styles.badge}>
<Icon name="gf-query-library" />
</span>
)}
className={styles.queryLibraryMenuItem}
/>
)}
{allowedTypes.includes('transform') && (
<Menu.Item
label={t('dashboard-scene.add-data-item-menu.add-transformation', 'Transformation')}
icon="process"
onClick={() => onAddTransform(index)}
/>
)}
{allowedTypes.includes('expression') && (
<Menu.Item
label={t('dashboard-scene.add-data-item-menu.expressions-group', 'Expression')}
icon="calculator-alt"
childItems={expressionSubItems}
/>
)}
</Menu>
);
return (
<Dropdown overlay={menu} placement="top-end" onVisibleChange={(shown) => setMenuShown(shown)}>
{renderButton()}
</Dropdown>
);
}
);
const getStyles = (theme: GrafanaTheme2) => ({
textButton: css({
paddingLeft: theme.spacing(1),
fontFamily: theme.typography.fontFamilyMonospace,
}),
queryLibraryMenuItem: css({
flexDirection: 'row',
alignItems: 'center',
}),
badge: css({
display: 'flex',
alignItems: 'center',
}),
});
AddDataItemMenu.displayName = 'AddDataItemMenu';

View File

@@ -0,0 +1,362 @@
import { css } from '@emotion/css';
import { FormEvent, useEffect, useRef, useState } from 'react';
import { GrafanaTheme2, IconName } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Icon, IconButton, Stack, useStyles2 } from '@grafana/ui';
import { usePanelDataPaneColors } from './theme';
interface ContextPill {
id: string;
label: string;
type: 'query' | 'transform' | 'expression';
icon: IconName;
}
interface AiModeCardProps {
selectedContexts: ContextPill[];
onRemoveContext: (id: string) => void;
onSubmit: (prompt: string) => void;
onDemoWorkflow?: {
availableCardIds: string[];
onSelectContext: (id: string) => void;
onAddOrganizeFieldsTransformation: () => void;
onCloseAiMode: () => void;
};
}
export const AiModeCard = ({ selectedContexts, onRemoveContext, onSubmit, onDemoWorkflow }: AiModeCardProps) => {
const colors = usePanelDataPaneColors();
const styles = useStyles2(getStyles, colors);
const [prompt, setPrompt] = useState('');
const editorRef = useRef<HTMLDivElement>(null);
const [isRunningDemo, setIsRunningDemo] = useState(false);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (prompt.trim()) {
onSubmit(prompt);
setPrompt('');
if (editorRef.current) {
editorRef.current.textContent = '';
}
}
};
const handleInput = () => {
if (editorRef.current) {
const content = editorRef.current.textContent || '';
setPrompt(content);
// Clear the innerHTML if there's no actual text content to show the placeholder
if (!content.trim()) {
editorRef.current.innerHTML = '';
}
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (prompt.trim()) {
handleSubmit(e);
}
}
};
// Demo workflow effect - only runs if URL has ?aiDemo=true
useEffect(() => {
if (!onDemoWorkflow || isRunningDemo || onDemoWorkflow.availableCardIds.length === 0) {
return;
}
// Check for demo URL parameter
const urlParams = new URLSearchParams(window.location.search);
const isDemoEnabled = urlParams.get('aiDemo') === 'true';
if (!isDemoEnabled) {
return;
}
setIsRunningDemo(true);
const runDemoWorkflow = async () => {
// Wait a bit before starting
await new Promise((resolve) => setTimeout(resolve, 500));
// Step 1: Select random cards (1-3)
const numCards = Math.min(
Math.floor(Math.random() * 3) + 1, // Random 1-3
onDemoWorkflow.availableCardIds.length
);
const shuffled = [...onDemoWorkflow.availableCardIds].sort(() => Math.random() - 0.5);
const cardsToSelect = shuffled.slice(0, numCards);
for (const cardId of cardsToSelect) {
onDemoWorkflow.onSelectContext(cardId);
await new Promise((resolve) => setTimeout(resolve, 300));
}
// Wait a bit after selecting cards
await new Promise((resolve) => setTimeout(resolve, 800));
// Step 2: Type the message character by character
const message = "I'd like to auto-organize my fields by name in ascending order!";
for (let i = 0; i <= message.length; i++) {
const partial = message.slice(0, i);
setPrompt(partial);
if (editorRef.current) {
editorRef.current.textContent = partial;
}
await new Promise((resolve) => setTimeout(resolve, 30 + Math.random() * 40)); // Vary speed
}
// Wait a bit before submitting
await new Promise((resolve) => setTimeout(resolve, 1000));
// Step 3: Submit and clear input
if (editorRef.current) {
editorRef.current.textContent = '';
}
setPrompt('');
// Wait a moment
await new Promise((resolve) => setTimeout(resolve, 500));
// Find the transformations section or last transform card to scroll to first
const transformCards = document.querySelectorAll('[data-card-id^="transform-"]');
const transformSection = document.querySelector('[data-testid="query-transform-list-content"]');
if (transformCards.length > 0) {
// Scroll to the last transform card
const lastTransform = transformCards[transformCards.length - 1];
lastTransform.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (transformSection) {
// If no transforms yet, scroll to bottom of the section
transformSection.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
// Wait for scroll to complete, then add the transformation
await new Promise((resolve) => setTimeout(resolve, 800));
onDemoWorkflow.onAddOrganizeFieldsTransformation();
// Wait a moment to show the new transformation, then close AI mode
await new Promise((resolve) => setTimeout(resolve, 1000));
onDemoWorkflow.onCloseAiMode();
};
runDemoWorkflow();
}, [onDemoWorkflow, isRunningDemo]);
const hasContext = selectedContexts.length > 0;
return (
<div className={styles.container}>
{/* Context Pills Section */}
{hasContext && (
<div className={styles.contextSection}>
<Stack direction="row" gap={1} wrap="wrap">
{selectedContexts.map((context) => (
<div key={context.id} className={styles.badge} style={styles.getBadgeStyle(context.type)}>
<Icon name={context.icon} size="sm" style={styles.getBadgeColor(context.type)} />
<span className={styles.badgeLabel} style={styles.getBadgeColor(context.type)}>
{context.label}
</span>
<button
className={styles.badgeRemove}
onClick={() => onRemoveContext(context.id)}
aria-label={t('dashboard-scene.ai-mode-card.remove-context', 'Remove context')}
>
×
</button>
</div>
))}
</Stack>
</div>
)}
{/* Prompt Input Section */}
<form onSubmit={handleSubmit} className={styles.form}>
<div
ref={editorRef}
className={styles.input}
contentEditable
suppressContentEditableWarning
onInput={handleInput}
onKeyDown={handleKeyDown}
data-placeholder={t('dashboard-scene.ai-mode-card.prompt-placeholder', 'Describe what you want to do...')}
role="textbox"
aria-multiline="true"
aria-label={t('dashboard-scene.ai-mode-card.aria-label', 'AI prompt input')}
tabIndex={0}
/>
<div className={styles.bottomRow}>
<div className={styles.leftContent}>
{!hasContext && (
<Stack direction="row" gap={0.5}>
<Icon name="ai-pointer" size="lg" />
<div className={styles.emptyState}>
{t('dashboard-scene.ai-mode-card.click-nodes-to-add-context', 'Click nodes to add context')}
</div>
</Stack>
)}
</div>
<IconButton
name="message"
type="submit"
disabled={!prompt.trim()}
tooltip={t('dashboard-scene.ai-mode-card.submit', 'Send message')}
className={styles.submitButton}
/>
</div>
</form>
</div>
);
};
const getStyles = (theme: GrafanaTheme2, colors: ReturnType<typeof usePanelDataPaneColors>) => {
const getColorForType = (type: 'query' | 'transform' | 'expression') => {
switch (type) {
case 'query':
return colors.query.accent;
case 'expression':
return colors.expression.accent;
case 'transform':
return colors.transform.accent;
}
};
return {
container: css({
background: theme.colors.background.primary,
border: 'none',
borderRadius: 'unset',
padding: `0 0 ${theme.spacing(1)} 0`,
width: '100%',
maxWidth: '100%',
overflow: 'hidden',
boxSizing: 'border-box',
}),
contextSection: css({
marginBottom: theme.spacing(2),
padding: `0 ${theme.spacing(1.5)}`,
}),
badge: css({
display: 'inline-flex',
alignItems: 'center',
gap: theme.spacing(0.5),
border: 'none',
borderRadius: theme.shape.radius.default,
padding: theme.spacing(0.5, 1.5),
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightMedium,
}),
getBadgeStyle: (type: 'query' | 'transform' | 'expression') => ({
background: `${getColorForType(type)}26`, // 26 in hex = ~15% opacity
}),
badgeLabel: css({}),
getBadgeColor: (type: 'query' | 'transform' | 'expression') => ({
color: getColorForType(type),
}),
badgeRemove: css({
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
marginLeft: theme.spacing(0.5),
fontSize: '16px',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: theme.colors.text.secondary,
opacity: 0.7,
'&:hover': {
opacity: 1,
color: theme.colors.text.primary,
},
}),
form: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1.5),
width: '100%',
minWidth: 0,
}),
input: css({
width: '100%',
maxWidth: '100%',
minWidth: 0,
boxSizing: 'border-box',
maxHeight: theme.spacing(38),
minHeight: theme.spacing(4),
height: 'auto',
overflow: 'auto',
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
border: 'none',
background: 'transparent',
whiteSpace: 'pre-wrap',
outline: 'none',
wordBreak: 'break-word',
fontSize: '14px',
lineHeight: 1.5,
fontFamily: theme.typography.fontFamily,
color: theme.colors.text.primary,
'&::-webkit-scrollbar': {
width: '4px',
height: '4px',
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: theme.colors.border.weak,
borderRadius: theme.shape.radius.default,
'&:hover': {
background: theme.colors.border.strong,
},
},
'&::-webkit-scrollbar-corner': {
background: 'transparent',
},
scrollbarWidth: 'thin',
scrollbarColor: `${theme.colors.border.weak} transparent`,
'&:empty:before': {
content: 'attr(data-placeholder)',
color: theme.colors.text.disabled,
pointerEvents: 'none',
display: 'block',
},
'&:focus:empty:before': {
color: theme.colors.text.secondary,
},
}),
bottomRow: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: theme.spacing(1),
padding: `0 ${theme.spacing(0.5)}`,
}),
leftContent: css({
flex: 1,
}),
emptyState: css({
fontSize: theme.typography.fontSize - 2,
color: theme.colors.text.secondary,
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
fontFamily: theme.typography.fontFamilyMonospace,
}),
submitButton: css({
flexShrink: 0,
}),
};
};

View File

@@ -0,0 +1,197 @@
import { css } from '@emotion/css';
import { useLayoutEffect, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
interface Connection {
from: string;
to: string;
}
interface ConnectionLinesProps {
connections: Connection[];
isDragging?: boolean;
onClick?: () => void;
selected?: boolean;
}
export function ConnectionLines({ connections, isDragging = false, selected, onClick }: ConnectionLinesProps) {
const styles = useStyles2(getStyles, selected);
const [positions, setPositions] = useState<Map<string, DOMRect>>(new Map());
const containerRef = useRef<SVGSVGElement>(null);
useLayoutEffect(() => {
let rafId: number | null = null;
let isUpdating = false;
const updatePositions = () => {
if (isUpdating) {
return;
}
isUpdating = true;
rafId = requestAnimationFrame(() => {
const newPositions = new Map<string, DOMRect>();
const cards = document.querySelectorAll('[data-card-id]');
cards.forEach((element) => {
const cardId = element.getAttribute('data-card-id');
if (cardId) {
newPositions.set(cardId, element.getBoundingClientRect());
}
});
setPositions(newPositions);
isUpdating = false;
});
};
// Initial update and update when drag ends
if (!isDragging) {
updatePositions();
}
const container = containerRef.current?.parentElement;
if (container) {
// Only observe mutations when not dragging (for card add/remove/reorder)
let mutationTimeout: number | null = null;
const observer = new MutationObserver(() => {
if (isDragging) {
return;
}
if (mutationTimeout) {
clearTimeout(mutationTimeout);
}
mutationTimeout = window.setTimeout(updatePositions, 50);
});
observer.observe(container, { childList: true, subtree: true });
// Track container resize (from splitter drag)
const resizeObserver = new ResizeObserver(updatePositions);
resizeObserver.observe(container);
return () => {
if (rafId) {
cancelAnimationFrame(rafId);
}
if (mutationTimeout) {
clearTimeout(mutationTimeout);
}
observer.disconnect();
resizeObserver.disconnect();
};
}
return () => {
if (rafId) {
cancelAnimationFrame(rafId);
}
};
}, [connections, isDragging]);
// Get container rect from the SVG's parent (contentWrapper)
const containerRect = containerRef.current?.parentElement?.getBoundingClientRect();
if (!containerRect || connections.length === 0) {
return <svg ref={containerRef} className={styles.svg} />;
}
// Helper to find card rect by refId (tries query-X and expression-X formats)
const findCardRect = (refId: string): DOMRect | undefined => {
return positions.get(`query-${refId}`) || positions.get(`expression-${refId}`);
};
const refIds = new Set<string>();
connections.forEach(({ from, to }) => {
refIds.add(from);
refIds.add(to);
});
const swimlaneX = containerRect.width - 24; // Adjusted for increased right padding (48px)
const cardPositions: number[] = [];
refIds.forEach((refId) => {
const cardRect = findCardRect(refId);
if (cardRect) {
cardPositions.push(cardRect.top + cardRect.height / 2 - containerRect.top);
}
});
if (cardPositions.length === 0) {
return <svg ref={containerRef} className={styles.svg} />;
}
const minY = Math.min(...cardPositions);
const maxY = Math.max(...cardPositions);
return (
<svg ref={containerRef} className={styles.svg}>
<g className={styles.connectionGroup} onClick={onClick}>
<line x1={swimlaneX} y1={minY} x2={swimlaneX} y2={maxY} className={styles.swimlane} />
{Array.from(refIds).map((refId) => {
const cardRect = findCardRect(refId);
if (!cardRect) {
return null;
}
const pointY = cardRect.top + cardRect.height / 2 - containerRect.top;
return <circle key={refId} cx={swimlaneX} cy={pointY} className={styles.point} />;
})}
</g>
</svg>
);
}
const getStyles = (theme: GrafanaTheme2, selected?: boolean) => ({
svg: css({
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 10,
overflow: 'visible',
pointerEvents: 'none',
width: '100%',
}),
connectionGroup: css({
pointerEvents: 'auto',
cursor: 'pointer',
opacity: 0.7,
'&:hover': {
opacity: 1,
},
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
animation: 'fadeIn 0.2s ease-in-out',
'@keyframes fadeIn': {
from: {
opacity: 0,
},
to: {
opacity: 1,
},
},
transition: theme.transitions.create(['opacity']),
},
}),
swimlane: css({
stroke: selected ? theme.colors.primary.border : theme.colors.text.maxContrast,
strokeWidth: 2,
'g:hover &': {
strokeWidth: 3,
},
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: theme.transitions.create(['strokeWidth']),
},
}),
point: css({
fill: selected ? theme.colors.primary.border : theme.colors.text.maxContrast,
r: 4,
'g:hover &': {
r: 6,
},
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: theme.transitions.create(['r']),
},
}),
});

View File

@@ -16,6 +16,7 @@ interface EmptyTransformationsProps {
onShowPicker: () => void;
onGoToQueries?: () => void;
onAddTransformation?: (transformationId: string) => void;
showIllustrations?: boolean;
}
const TRANSFORMATION_IDS = [
@@ -60,6 +61,7 @@ export function LegacyEmptyTransformationsMessage({ onShowPicker }: { onShowPick
export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps) {
const hasGoToQueries = props.onGoToQueries != null;
const hasAddTransformation = props.onAddTransformation != null;
const showIllustrations = props.showIllustrations ?? true;
// Get transformations from registry
const transformations = useMemo(() => {
@@ -118,7 +120,7 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps)
key={transform.id}
transform={transform}
onClick={handleTransformationClick}
showIllustrations={true}
showIllustrations={showIllustrations}
showPluginState={false}
showTags={false}
/>

View File

@@ -0,0 +1,144 @@
import { css } from '@emotion/css';
import { useCallback, useMemo } from 'react';
import { useAsync } from 'react-use';
import {
CoreApp,
DataSourceApi,
DataSourceJsonData,
DataSourcePluginContextProvider,
GrafanaTheme2,
} 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 queryRunner = getQueryRunnerFor(panel);
const queryRunnerState = queryRunner?.useState();
const allQueries = queryRunnerState?.queries || [];
const dsSettings = useMemo(() => getDataSourceSrv().getInstanceSettings(ExpressionDatasourceUID), []);
// Load expression datasource
// FIXME: handle loading and error cases
const { value: datasource } = useAsync(
async (): Promise<DataSourceApi<ExpressionQuery, DataSourceJsonData, {}>> =>
// NOTE: getDataSourceSrv().get() does not correctly support generics.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(await getDataSourceSrv().get(ExpressionDatasourceUID)) as unknown as DataSourceApi<
ExpressionQuery,
DataSourceJsonData,
{}
>,
[]
);
// Subscribe to panel data
const data = useMemo(() => {
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),
};
return 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}
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),
width: '100%',
}),
expressionContent: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}),
loading: css({
padding: theme.spacing(2),
textAlign: 'center',
color: theme.colors.text.secondary,
}),
};
};

View File

@@ -1,9 +1,9 @@
import { css } from '@emotion/css';
import { useCallback, useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { DataTransformerConfig, SelectableValue } from '@grafana/data';
import {
SceneComponentProps,
SceneDataQuery,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
@@ -11,42 +11,63 @@ import {
SceneObjectUrlValues,
VizPanel,
} from '@grafana/scenes';
import { Container, ScrollContainer, TabContent, TabsBar, 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 { ExpressionQueryType } from 'app/features/expressions/types';
import { getQueryRunnerFor } from '../../utils/utils';
import { PanelDataAlertingTab } from './PanelDataAlertingTab';
import { PanelDataQueriesTab } from './PanelDataQueriesTab';
import { PanelDataTransformationsTab } from './PanelDataTransformationsTab';
import { QueryTransformDetailView, QueryLibraryMode } from './QueryTransformDetailView';
import { useQueryTransformItems } from './hooks';
import { PanelDataPaneTab, TabId } from './types';
import { isDataTransformerConfig, queryItemId, transformItemId } from './utils';
export interface PanelDataPaneState extends SceneObjectState {
tabs: PanelDataPaneTab[];
tab: TabId;
selectedQueryTransform: string | null;
panelRef: SceneObjectRef<VizPanel>;
transformPickerIndex?: number | null;
queryLibraryMode: QueryLibraryMode & { index: number | null };
isDebugMode?: boolean;
debugPosition?: number;
}
export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> {
static Component = PanelDataPaneRendered;
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['tab'] });
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['tab', 'selectedQueryTransform'] });
public static createFor(panel: VizPanel) {
const panelRef = panel.getRef();
const tabs: PanelDataPaneTab[] = [
new PanelDataQueriesTab({ panelRef }),
new PanelDataTransformationsTab({ panelRef }),
new PanelDataQueriesTab({ panelRef: panel.getRef() }),
new PanelDataTransformationsTab({ panelRef: panel.getRef() }),
];
if (shouldShowAlertingTab(panel.state.pluginId)) {
tabs.push(new PanelDataAlertingTab({ panelRef }));
tabs.push(new PanelDataAlertingTab({ panelRef: panel.getRef() }));
}
const tab = tabs[0]?.tabId ?? TabId.Queries;
return new PanelDataPane({
selectedQueryTransform: null,
panelRef,
tabs,
tab: TabId.Queries,
tab,
queryLibraryMode: {
active: false,
mode: 'browse',
index: null,
},
isDebugMode: false,
debugPosition: 0,
});
}
@@ -54,6 +75,22 @@ export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> {
this.setState({ tab: tab.tabId });
};
public onChangeSelected = (selectedId: string | null) => {
this.setState({ selectedQueryTransform: selectedId });
};
public onTransformPicker = (index?: number | null) => {
this.setState({ transformPickerIndex: index });
};
public setQueryLibraryMode = (mode: QueryLibraryMode & { index: number | null }) => {
this.setState({ queryLibraryMode: mode });
};
public setDebugState = (isDebugMode: boolean, debugPosition: number) => {
this.setState({ isDebugMode, debugPosition });
};
public getUrlState() {
return { tab: this.state.tab };
}
@@ -63,34 +100,239 @@ 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 styles = useStyles2(getStyles);
const {
tabs,
selectedQueryTransform,
panelRef,
transformPickerIndex,
queryLibraryMode,
isDebugMode = false,
debugPosition = 0,
} = model.useState();
if (!tabs || !tabs.length) {
return;
}
// Subscribe to query runner and tab state changes
const panel = panelRef.resolve();
const queryRunner = getQueryRunnerFor(panel);
const queryRunnerState = queryRunner?.useState();
const queriesTab = tabs.find((t): t is PanelDataQueriesTab => t.tabId === TabId.Queries);
const transformsTab = tabs.find((t): t is PanelDataTransformationsTab => t.tabId === TabId.Transformations);
const transformer = transformsTab?.getDataTransformer();
const transformerState = transformer?.useState();
const queries = queryRunnerState?.queries;
const transformations = transformerState?.transformations?.filter(isDataTransformerConfig);
const currentTab = tabs.find((t) => t.tabId === tab);
const { allItems } = useQueryTransformItems(queries, transformations);
const handleSelect = useCallback(
(id: string | null) => {
model.setState({ selectedQueryTransform: id });
model.onTransformPicker(null);
},
[model]
);
// Auto-select first item if nothing is selected
const selectedId = useMemo(() => {
if (transformPickerIndex != null) {
return null;
}
if (selectedQueryTransform === null && allItems.length > 0) {
return allItems[0].id;
}
return selectedQueryTransform;
}, [transformPickerIndex, selectedQueryTransform, allItems]);
const selectedItem = useMemo(() => allItems.find((item) => item.id === selectedId), [allItems, selectedId]);
const updateQuerySelectionOnStateChange = useCallback(
(index: number) => {
if (queryRunner) {
const unsub = queryRunner.subscribeToState((newState) => {
const newQueries = newState.queries;
if (newQueries.length > 0) {
const selected = newQueries[index] ?? newQueries[0];
handleSelect(queryItemId(selected));
}
unsub.unsubscribe();
});
}
},
[queryRunner, handleSelect]
);
/** QUERIES AND EXPRESSIONS **/
// Handler for selecting a query from the query library
const handleQueryLibrarySelect = useCallback(
(query: SceneDataQuery) => {
if (!queryRunner || !queriesTab) {
return;
}
const selectedIndex = queryLibraryMode.index ?? queries?.length ?? 0;
// Get next available refId
let nextRefId = 'A';
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
for (let i = 0; i < alphabet.length; i++) {
if (!queries?.some((q) => q.refId === alphabet[i])) {
nextRefId = alphabet[i];
break;
}
}
// Create new query with the selected refId
const newQuery: SceneDataQuery = {
...query,
refId: nextRefId,
};
updateQuerySelectionOnStateChange(selectedIndex);
queriesTab.onAddQuery(newQuery);
model.setQueryLibraryMode({ active: false, mode: 'browse', index: null });
},
[queryRunner, queryLibraryMode.index, queries, updateQuerySelectionOnStateChange, queriesTab, model]
);
// Handler for saving a query to the query library (stub)
const handleQueryLibrarySave = useCallback(
(_name: string, _description: string) => {
// Stub: In real implementation, this would save to the query library
model.setQueryLibraryMode({ active: false, mode: 'browse', index: null });
},
[model]
);
// Handler to close the query library view
const handleQueryLibraryClose = useCallback(() => {
model.setQueryLibraryMode({ active: false, mode: 'browse', index: null });
}, [model]);
// Handler to open query library in a specific mode
const handleOpenQueryLibrary = useCallback(
(mode: QueryLibraryMode['mode'], index?: number) => {
let currentQuery: SceneDataQuery | undefined;
if (mode === 'save' && (selectedItem?.type === 'query' || selectedItem?.type === 'expression')) {
currentQuery = selectedItem.data;
}
model.setQueryLibraryMode({
active: true,
mode,
currentQuery,
index: index ?? null,
});
},
[selectedItem, model]
);
const handleGoToQueries = useCallback(() => {
// Close the transformation picker
model.onTransformPicker(null);
// Add a SQL expression
if (queriesTab) {
updateQuerySelectionOnStateChange(queries?.length ?? 0);
queriesTab.onAddExpressionOfType(ExpressionQueryType.sql);
}
}, [queriesTab, updateQuerySelectionOnStateChange, queries, model]);
/** TRANSFORMS **/
const handleAddTransform = useCallback(
(selected: SelectableValue<string>, customOptions?: Record<string, unknown>) => {
if (!selected.value) {
return;
}
if (transformsTab && transformer) {
const selectedIndex = transformPickerIndex ?? transformations?.length ?? 0;
const newTransformation: DataTransformerConfig = {
id: selected.value,
options: customOptions ?? {},
};
const unsub = transformer.subscribeToState((newState) => {
const newTransform = newState.transformations[selectedIndex];
model.onChangeSelected(!!newTransform ? transformItemId(selectedIndex) : null);
model.onTransformPicker(null);
unsub.unsubscribe();
});
const newTransformations = [...(transformations ?? [])];
newTransformations.splice(selectedIndex, 0, newTransformation);
transformsTab.onChangeTransformations(newTransformations);
}
},
[transformsTab, transformer, transformPickerIndex, transformations, model]
);
const handleRemoveTransform = useCallback(
(index: number) => {
if (transformsTab) {
const newTransformations = transformations?.filter((_, i) => i !== index) ?? [];
transformsTab.onChangeTransformations(newTransformations);
// Clear selection if removing the selected transformation
if (selectedId === transformItemId(index)) {
const prevTransform = newTransformations[index - 1];
handleSelect(prevTransform ? transformItemId(index - 1) : null);
}
}
},
[transformations, transformsTab, selectedId, handleSelect]
);
const handleToggleTransformVisibility = useCallback(
(index: number) => {
if (transformsTab) {
const newTransformations =
transformations?.map((t, i) => (i === index ? { ...t, disabled: t.disabled ? undefined : true } : t)) ?? [];
transformsTab.onChangeTransformations(newTransformations);
}
},
[transformations, transformsTab]
);
const handleOpenQueryInspector = useCallback(() => {
queriesTab?.onOpenInspector();
}, [queriesTab]);
// 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>
</div>
<QueryTransformDetailView
selectedItem={selectedItem}
panel={panel}
tabs={tabs}
onRemoveTransform={handleRemoveTransform}
onToggleTransformVisibility={handleToggleTransformVisibility}
isAddingTransform={transformPickerIndex != null}
onAddTransformation={handleAddTransform}
onCancelAddTransform={() => model.onTransformPicker(null)}
transformationData={series}
onGoToQueries={handleGoToQueries}
queryLibraryMode={queryLibraryMode}
onQueryLibrarySelect={handleQueryLibrarySelect}
onQueryLibrarySave={handleQueryLibrarySave}
onQueryLibraryClose={handleQueryLibraryClose}
onOpenQueryLibrary={handleOpenQueryLibrary}
onOpenQueryInspector={handleOpenQueryInspector}
isDebugMode={isDebugMode}
debugPosition={debugPosition}
/>
);
}
@@ -107,33 +349,3 @@ export function shouldShowAlertingTab(pluginId: string) {
return isGraph || isTimeseries;
}
function getStyles(theme: GrafanaTheme2) {
return {
dataPane: css({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
minHeight: 0,
height: '100%',
width: '100%',
}),
tabBorder: css({
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),
height: '100%',
}),
tabsBar: css({
flexShrink: 0,
paddingLeft: theme.spacing(2),
}),
};
}

View File

@@ -837,7 +837,7 @@ async function setupScene(panelId: string) {
deactivators.push(dashboard.activate());
deactivators.push(panelEditor.activate());
const queriesTab = panelEditor.state.dataPane!.state.tabs[0] as PanelDataQueriesTab;
const queriesTab = panelEditor.state.dataPane!.state.tabs[0];
deactivators.push(queriesTab.activate());
await Promise.resolve();

View File

@@ -273,17 +273,25 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
};
}
public addQueryClick = () => {
public addQueryClick = (index?: number) => {
const queries = this.getQueries();
this.onQueriesChange(addQuery(queries, this.newQuery()));
const dsSettings = this.state.dsSettings;
this.onQueriesChange(
addQuery(
queries,
this.newQuery(),
dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined },
index
)
);
};
public onAddQuery = (query: Partial<DataQuery>) => {
public onAddQuery = (query: Partial<DataQuery>, index?: number) => {
const queries = this.getQueries();
const dsSettings = this.state.dsSettings;
this.onQueriesChange(
addQuery(queries, query, dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined })
addQuery(queries, query, dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined }, index)
);
};
@@ -291,7 +299,7 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
return (dsSettings.meta.backend || dsSettings.meta.alerting || dsSettings.meta.mixed) === true;
}
public onAddExpressionOfType = (type: ExpressionQueryType) => {
public onAddExpressionOfType = (type: ExpressionQueryType, index?: number) => {
const queries = this.getQueries();
// Create base expression query with the specified type
const baseQuery = expressionDatasource.newQuery();
@@ -299,7 +307,7 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
// Apply defaults specific to the expression type
const queryWithDefaults = getDefaults(queryWithType);
this.onQueriesChange(addQuery(queries, queryWithDefaults));
this.onQueriesChange(addQuery(queries, queryWithDefaults, undefined, index));
};
public renderExtraActions() {
@@ -411,7 +419,7 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<Panel
<>
<Button
icon="plus"
onClick={model.addQueryClick}
onClick={(ev) => model.addQueryClick()}
variant="secondary"
data-testid={selectors.components.QueryTab.addQuery}
>

View File

@@ -0,0 +1,412 @@
import { css } from '@emotion/css';
import { Dispatch, SetStateAction, useCallback, useEffect, useMemo } from 'react';
import { DataTransformerConfig, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { t } from '@grafana/i18n';
import { SceneComponentProps, SceneDataQuery } from '@grafana/scenes';
import { Button, 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 { ExpressionQueryType } from '../../../expressions/types';
import { getQueryRunnerFor } from '../../utils/utils';
import { PanelDataPane } from './PanelDataPane';
import { PanelDataQueriesTab } from './PanelDataQueriesTab';
import { PanelDataTransformationsTab } from './PanelDataTransformationsTab';
import { QueryLibraryMode } from './QueryTransformDetailView';
import { QueryTransformList } from './QueryTransformList';
import { useQueryTransformItems } from './hooks';
import { TabId } from './types';
import { isDataTransformerConfig, queryItemId, transformItemId } from './utils';
export enum SidebarSize {
Mini = 'mini',
Full = 'full',
}
export interface SidebarState {
size: SidebarSize;
collapsed: boolean;
}
export function PanelDataSidebar({
model,
sidebarState,
setSidebarState,
}: SceneComponentProps<PanelDataPane> & {
sidebarState: SidebarState;
setSidebarState: Dispatch<SetStateAction<SidebarState>>;
}) {
const { panelRef, tabs, selectedQueryTransform, transformPickerIndex } = model.useState();
const styles = useStyles2(getStyles, sidebarState);
const panel = panelRef.resolve();
// Subscribe to query runner and tab state changes
const queryRunner = getQueryRunnerFor(panel);
const queryRunnerState = queryRunner?.useState();
const queriesTab = tabs.find((t): t is PanelDataQueriesTab => t.tabId === TabId.Queries);
const transformsTab = tabs.find((t): t is PanelDataTransformationsTab => t.tabId === TabId.Transformations);
const transformer = transformsTab?.getDataTransformer();
const transformerState = transformer?.useState();
const queries = queryRunnerState?.queries;
const transformations = transformerState?.transformations?.filter(isDataTransformerConfig);
// the selectedId is based on the refId of the query. refId is a user-editable property, so it can change,
// which will break the selectId and result in the UI going into a deselected state. to avoid this,
// we can subscribe to changes and detect if a single refId just changed, and then assume that change
// is a rename of the currently selected query.
useEffect(() => {
queryRunner?.subscribeToState((newState, prevState) => {
// loop over the new queries and confirm that the refIds are the same. if not, then a mutation
// occurred, but we need to figure out if it was a rename or a reorder
const oldOrderedRefIds = prevState.queries.map(({ refId }) => refId);
if (newState.queries.length !== oldOrderedRefIds.length) {
return; // add, remove, something else.
}
let refIdChanges = 0;
let updatedQuery: SceneDataQuery | undefined = undefined;
for (let i = 0; i < newState.queries.length; i++) {
const newQuery = newState.queries[i];
const oldRefId = oldOrderedRefIds[i];
if (newQuery.refId !== oldRefId) {
if (++refIdChanges < 2) {
updatedQuery = newQuery;
} else {
return; // more than 2 refId changes, so it's a reorder or something else.
}
}
}
if (updatedQuery) {
model.onChangeSelected(queryItemId(updatedQuery));
}
});
}, [queryRunner, model]);
// Build separate lists for queries/expressions and transformations
const { queryExpressionItems, transformItems, allItems } = useQueryTransformItems(queries, transformations);
// Auto-select first item if nothing is selected
const selectedId = useMemo(() => {
if (transformPickerIndex != null) {
return null;
}
if (selectedQueryTransform === null && allItems.length > 0) {
return allItems[0].id;
}
return selectedQueryTransform;
}, [transformPickerIndex, selectedQueryTransform, allItems]);
const selectedItem = useMemo(() => allItems.find((item) => item.id === selectedId), [allItems, selectedId]);
const shouldShowAlerting = useMemo(() => tabs.some((tab) => tab.tabId === TabId.Alert), [tabs]);
const updateQuerySelectionOnStateChange = useCallback(
(index: number) => {
if (queryRunner) {
const unsub = queryRunner.subscribeToState((newState) => {
const newQueries = newState.queries;
if (newQueries.length > 0) {
const selected = newQueries[index] ?? newQueries[0];
model.onChangeSelected(queryItemId(selected));
}
unsub.unsubscribe();
});
}
},
[queryRunner, model]
);
/** QUERIES AND EXPRESSIONS **/
const handleAddQuery = useCallback(
(index?: number) => {
if (queriesTab) {
updateQuerySelectionOnStateChange(index ?? queries?.length ?? 0);
queriesTab.addQueryClick(index);
}
},
[queries, queriesTab, updateQuerySelectionOnStateChange]
);
const handleAddExpression = useCallback(
(type: ExpressionQueryType, index?: number) => {
if (queriesTab) {
updateQuerySelectionOnStateChange(index ?? queries?.length ?? 0);
queriesTab.onAddExpressionOfType(type, index);
}
},
[queriesTab, updateQuerySelectionOnStateChange, queries]
);
const handleDuplicateQuery = useCallback(
(index: number) => {
if (queryRunner && queriesTab) {
const queryToDuplicate = queries?.[index];
if (queryToDuplicate) {
// Create a copy with a new refId
let newRefId = queryToDuplicate.refId;
let counter = 1;
while (queries.some((q) => q.refId === newRefId)) {
newRefId = `${queryToDuplicate.refId}_${counter}`;
counter++;
}
const duplicatedQuery = {
...queryToDuplicate,
refId: newRefId,
};
updateQuerySelectionOnStateChange(index + 1);
queriesTab.onAddQuery(duplicatedQuery, index + 1);
}
}
},
[queryRunner, queriesTab, queries, updateQuerySelectionOnStateChange]
);
const handleRemoveQuery = useCallback(
(index: number) => {
if (queryRunner) {
const deletedQuery = queries?.[index];
const newQueries = queries?.filter((_, i) => i !== index);
queryRunner.setState({ queries: newQueries });
queryRunner.runQueries();
// Clear selection if removing the selected query
if (deletedQuery && selectedId === queryItemId(deletedQuery)) {
const prevQuery = newQueries?.[index - 1];
model.onChangeSelected(prevQuery ? queryItemId(prevQuery) : null);
}
}
},
[queryRunner, selectedId, queries, model]
);
const handleToggleQueryVisibility = useCallback(
(index: number) => {
if (queryRunner) {
const newQueries = queries?.map((q, i) => (i === index ? { ...q, hide: !q.hide } : q));
queryRunner.setState({ queries: newQueries });
queryRunner.runQueries();
}
},
[queryRunner, queries]
);
const handleOpenQueryLibrary = useCallback(
(mode: QueryLibraryMode['mode'], index?: number) => {
let currentQuery: SceneDataQuery | undefined;
if (mode === 'save' && (selectedItem?.type === 'query' || selectedItem?.type === 'expression')) {
currentQuery = selectedItem.data;
}
model.setQueryLibraryMode({
active: true,
mode,
currentQuery,
index: index ?? null,
});
},
[selectedItem, model]
);
/** TRANSFORMS **/
const handleAddTransform = useCallback(
(selected: SelectableValue<string>, customOptions?: Record<string, unknown>) => {
if (!selected.value) {
return;
}
if (transformsTab && transformer) {
const selectedIndex = transformPickerIndex ?? transformations?.length ?? 0;
const newTransformation: DataTransformerConfig = {
id: selected.value,
options: customOptions ?? {},
};
const unsub = transformer.subscribeToState((newState) => {
const newTransform = newState.transformations[selectedIndex];
model.onChangeSelected(!!newTransform ? transformItemId(selectedIndex) : null);
model.onTransformPicker(null);
unsub.unsubscribe();
});
const newTransformations = [...(transformations ?? [])];
newTransformations.splice(selectedIndex, 0, newTransformation);
transformsTab.onChangeTransformations(newTransformations);
}
},
[transformsTab, transformer, transformPickerIndex, transformations, model]
);
const handleRemoveTransform = useCallback(
(index: number) => {
if (transformsTab) {
const newTransformations = transformations?.filter((_, i) => i !== index) ?? [];
transformsTab.onChangeTransformations(newTransformations);
// Clear selection if removing the selected transformation
if (selectedId === transformItemId(index)) {
const prevTransform = newTransformations[index - 1];
model.onChangeSelected(prevTransform ? transformItemId(index - 1) : null);
}
}
},
[transformations, transformsTab, selectedId, model]
);
const handleToggleTransformVisibility = useCallback(
(index: number) => {
if (transformsTab) {
const newTransformations =
transformations?.map((t, i) => (i === index ? { ...t, disabled: t.disabled ? undefined : true } : t)) ?? [];
transformsTab.onChangeTransformations(newTransformations);
}
},
[transformations, transformsTab]
);
const handleReorderDataSources = useCallback(
(startIndex: number, endIndex: number) => {
if (queryRunner) {
const queries = queryRunner.state.queries || [];
const newQueries = Array.from(queries);
const [removed] = newQueries.splice(startIndex, 1);
newQueries.splice(endIndex, 0, removed);
queryRunner.setState({ queries: newQueries });
}
},
[queryRunner]
);
const handleReorderTransforms = useCallback(
(startIndex: number, endIndex: number) => {
if (transformsTab) {
const newTransformations = [...(transformations ?? [])];
const [removed] = newTransformations.splice(startIndex, 1);
newTransformations.splice(endIndex, 0, removed);
transformsTab.onChangeTransformations(newTransformations);
}
},
[transformations, transformsTab]
);
const handleDebugStateChange = useCallback(
(isDebugMode: boolean, debugPosition: number) => {
model.setDebugState(isDebugMode, debugPosition);
},
[model]
);
// Get data for transformations drawer
// Note: series data is not currently used in the sidebar but may be needed for future features
// const sourceData = queryRunner?.useState();
// const series = sourceData?.data?.series || [];
if (sidebarState.collapsed) {
return (
<div className={styles.sidebarPane}>
<Button
icon="angle-right"
variant="secondary"
onClick={() => setSidebarState((prevState) => ({ ...prevState, collapsed: true }))}
tooltip={t('app.features.dashboardScene.panelEdit.panelDataPane.expandSidebar', 'Expand sidebar')}
/>
</div>
);
}
return (
<div className={styles.sidebarPane}>
<QueryTransformList
allItems={allItems}
dataSourceItems={queryExpressionItems}
transformItems={transformItems}
selectedId={selectedId}
sidebarSize={sidebarState.size}
hasAlerting={shouldShowAlerting}
onResizeSidebar={(size: SidebarSize) => setSidebarState((prevState) => ({ ...prevState, size }))}
onCollapseSidebar={() => setSidebarState((prevState) => ({ ...prevState, collapsed: true }))}
onSelect={(id) => {
model.onChangeSelected(id);
model.onTransformPicker(null);
}}
onAddQuery={handleAddQuery}
onAddFromSavedQueries={(index) => handleOpenQueryLibrary('browse', index)}
onAddTransform={(index) => {
model.onChangeSelected(null);
model.onTransformPicker(index);
}}
onAddExpression={handleAddExpression}
onDuplicateQuery={handleDuplicateQuery}
onRemoveQuery={handleRemoveQuery}
onToggleQueryVisibility={handleToggleQueryVisibility}
onRemoveTransform={handleRemoveTransform}
onToggleTransformVisibility={handleToggleTransformVisibility}
onReorderDataSources={handleReorderDataSources}
onReorderTransforms={handleReorderTransforms}
onDebugStateChange={handleDebugStateChange}
onAddOrganizeFieldsTransform={() =>
handleAddTransform(
{ value: 'organize' },
{
excludeByName: {},
indexByName: {},
renameByName: {},
includeByName: {},
orderByMode: 'auto',
orderBy: [
{
type: 'name',
desc: false,
},
],
}
)
}
/>
</div>
);
}
export function shouldShowAlertingTab(pluginId: string) {
const { unifiedAlertingEnabled = false } = getConfig();
const hasRuleReadPermissions = contextSrv.hasPermission(getRulesPermissions(GRAFANA_RULES_SOURCE_NAME).read);
const isAlertingAvailable = unifiedAlertingEnabled && hasRuleReadPermissions;
if (!isAlertingAvailable) {
return false;
}
const isGraph = pluginId === 'graph';
const isTimeseries = pluginId === 'timeseries';
return isGraph || isTimeseries;
}
function getStyles(theme: GrafanaTheme2, sidebarState: SidebarState) {
return {
sidebarPane: css({
overflow: 'hidden',
height: '100%',
width: sidebarState.collapsed ? 49 : '100%',
padding: sidebarState.collapsed ? theme.spacing(1) : 'unset',
borderTopRightRadius: theme.shape.radius.md,
border: `1px solid ${theme.colors.border.weak}`,
borderBottom: 'none',
background: theme.colors.background.primary,
...(sidebarState.size === SidebarSize.Mini
? {
borderTopLeftRadius: theme.shape.radius.md,
}
: {
borderLeft: 'none',
}),
}),
};
}

View File

@@ -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';
@@ -25,7 +24,7 @@ import { PanelDataPane } from './PanelDataPane';
import { PanelDataQueriesTab } from './PanelDataQueriesTab';
import { TransformationsDrawer } from './TransformationsDrawer';
import { PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types';
import { findSqlExpression, scrollToQueryRow } from './utils';
import { findSqlExpression, isDataTransformerConfig, scrollToQueryRow } from './utils';
const SET_TIMEOUT = 750;
@@ -68,23 +67,19 @@ export class PanelDataTransformationsTab
}
}
export function PanelDataTransformationsTabRendered({ model }: SceneComponentProps<PanelDataTransformationsTab>) {
const styles = useStyles2(getStyles);
export function PanelDataTransformationsTabRendered({
model,
selectedIdx,
}: SceneComponentProps<PanelDataTransformationsTab> & { selectedIdx?: number }) {
const sourceData = model.getQueryRunner().useState();
const { data, transformations: transformsWrongType } = model.getDataTransformer().useState();
const { data, transformations: rawTransformations } = model.getDataTransformer().useState();
// Type guard to ensure transformations are DataTransformerConfig[]
const transformations = useMemo<DataTransformerConfig[]>(() => {
return Array.isArray(transformsWrongType)
? transformsWrongType.filter(
(t): t is DataTransformerConfig =>
t !== null && typeof t === 'object' && 'id' in t && typeof t.id === 'string'
)
: [];
}, [transformsWrongType]);
return Array.isArray(rawTransformations) ? rawTransformations.filter(isDataTransformerConfig) : [];
}, [rawTransformations]);
const [drawerOpen, setDrawerOpen] = useState<boolean>(false);
const [confirmModalOpen, setConfirmModalOpen] = useState<boolean>(false);
const openDrawer = () => setDrawerOpen(true);
const closeDrawer = () => setDrawerOpen(false);
@@ -162,46 +157,11 @@ 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)}
<TransformationsEditor
data={sourceData.data}
transformations={transformations}
model={model}
selectedIdx={selectedIdx}
/>
{transformationsDrawer}
</>
@@ -212,10 +172,12 @@ interface TransformationEditorProps {
transformations: DataTransformerConfig[];
model: PanelDataTransformationsTab;
data: PanelData;
selectedIdx?: number;
}
function TransformationsEditor({ transformations, model, data }: TransformationEditorProps) {
function TransformationsEditor({ transformations, model, data, selectedIdx }: 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 +196,40 @@ 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}
selectedIdx={selectedIdx}
></TransformationOperationRows>
{provided.placeholder}
</div>
);
}}
</Droppable>
</DragDropContext>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
removeAll: css({
marginLeft: theme.spacing(2),
container: css({
padding: theme.spacing(2),
}),
});

View File

@@ -0,0 +1,466 @@
import { css } from '@emotion/css';
import { useCallback, useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import {
CoreApp,
DataQuery,
DataSourcePluginContextProvider,
GrafanaTheme2,
TimeRange,
getDataSourceRef,
} from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { SceneDataQuery, VizPanel, sceneGraph, SceneQueryRunner } from '@grafana/scenes';
import { Button, ErrorBoundaryAlert, Stack, useStyles2 } from '@grafana/ui';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { QueryErrorAlert } from 'app/features/query/components/QueryErrorAlert';
import { QueryGroupOptionsEditor } from 'app/features/query/components/QueryGroupOptions';
import { QueryGroupOptions } from 'app/types/query';
import { PanelTimeRange } from '../../scene/panel-timerange/PanelTimeRange';
import { getQueryRunnerFor } from '../../utils/utils';
import { getUpdatedHoverHeader } from '../getPanelFrameOptions';
interface QueryDetailViewProps {
panel: VizPanel;
query: SceneDataQuery;
queryIndex: number;
}
export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewProps) {
const [showOptions, setShowOptions] = useState(false);
const styles = useStyles2(getStyles, showOptions);
const dsSettings = useMemo(() => {
try {
return getDataSourceSrv().getInstanceSettings(query.datasource);
} catch {
return getDataSourceSrv().getInstanceSettings(null);
}
}, [query.datasource]);
const queryRunner = getQueryRunnerFor(panel);
const queryRunnerState = queryRunner?.useState();
const timeRange: TimeRange | undefined = queryRunner?.state.$timeRange?.state.value;
// Load datasource
// FIXME: handle loading and error cases
const { value: datasource } = useAsync(async () => {
try {
return await getDataSourceSrv().get(query.datasource);
} catch {
return await getDataSourceSrv().get();
}
}, [query.datasource]);
// Subscribe to panel data
const data = useMemo(() => {
if (!queryRunnerState?.data) {
return;
}
// Filter data for this specific query
const panelData = queryRunnerState.data;
const filteredSeries = panelData.series.filter((s) => s.refId === query.refId);
return {
...panelData,
series: filteredSeries,
error: panelData.errors?.find((e) => e.refId === query.refId),
};
}, [queryRunnerState?.data, query.refId]);
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]);
// FIXME this should be a memo or some other structure, not a callback
const buildQueryOptions = useCallback((): QueryGroupOptions => {
if (!queryRunner) {
return {
queries: [],
dataSource: dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined },
};
}
const timeRangeObj = sceneGraph.getTimeRange(panel);
let timeRangeOpts: QueryGroupOptions['timeRange'] = {
from: undefined,
shift: undefined,
hide: undefined,
};
if (timeRangeObj instanceof PanelTimeRange) {
timeRangeOpts = {
from: timeRangeObj.state.timeFrom,
shift: timeRangeObj.state.timeShift,
hide: timeRangeObj.state.hideTimeOverride,
};
}
return {
cacheTimeout: dsSettings?.meta.queryOptions?.cacheTimeout ? queryRunner.state.cacheTimeout : undefined,
queryCachingTTL: dsSettings?.cachingConfig?.enabled ? queryRunner.state.queryCachingTTL : undefined,
dataSource: {
default: dsSettings?.isDefault,
...(dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined }),
},
queries: queryRunner.state.queries,
maxDataPoints: queryRunner.state.maxDataPoints,
minInterval: queryRunner.state.minInterval,
timeRange: timeRangeOpts,
};
}, [queryRunner, panel, dsSettings]);
const handleQueryOptionsChange = useCallback(
(options: QueryGroupOptions) => {
if (!queryRunner) {
return;
}
const dataObjStateUpdate: Partial<SceneQueryRunner['state']> = {};
const panelStateUpdate: Partial<VizPanel['state']> = {};
if (options.maxDataPoints !== queryRunner.state.maxDataPoints) {
dataObjStateUpdate.maxDataPoints = options.maxDataPoints ?? undefined;
}
if (options.minInterval !== queryRunner.state.minInterval) {
dataObjStateUpdate.minInterval = options.minInterval ?? undefined;
}
const timeFrom = options.timeRange?.from ?? undefined;
const timeShift = options.timeRange?.shift ?? undefined;
const hideTimeOverride = options.timeRange?.hide;
if (timeFrom !== undefined || timeShift !== undefined) {
panelStateUpdate.$timeRange = new PanelTimeRange({ timeFrom, timeShift, hideTimeOverride });
panelStateUpdate.hoverHeader = getUpdatedHoverHeader(panel.state.title, panelStateUpdate.$timeRange);
} else {
panelStateUpdate.$timeRange = undefined;
panelStateUpdate.hoverHeader = getUpdatedHoverHeader(panel.state.title, undefined);
}
if (options.cacheTimeout !== queryRunner.state.cacheTimeout) {
dataObjStateUpdate.cacheTimeout = options.cacheTimeout;
}
if (options.queryCachingTTL !== queryRunner.state.queryCachingTTL) {
dataObjStateUpdate.queryCachingTTL = options.queryCachingTTL;
}
panel.setState(panelStateUpdate);
queryRunner.setState(dataObjStateUpdate);
queryRunner.runQueries();
},
[queryRunner, panel]
);
const renderQueryEditor = () => {
if (!datasource || !dsSettings) {
return (
<div className={styles.noEditor}>
<Trans i18nKey="dashboard-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.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);
const queryOptions = buildQueryOptions();
const panelData = queryRunnerState?.data;
const renderCollapsedText = (): React.ReactNode | undefined => {
if (!panelData) {
return undefined;
}
const optionItems: React.ReactNode[] = [];
// Helper to render value with appropriate styling
const renderValue = (value: string | number, isCustom: boolean) => (
<span className={isCustom ? styles.collapsedTextValueCustom : styles.collapsedTextValue}>{value}</span>
);
// Helper to render the custom indicator (green circle)
const renderCustomIndicator = (isCustom: boolean) =>
isCustom ? <span className={styles.customIndicator} /> : null;
// Max data points - custom if explicitly set
const hasCustomMaxDataPoints = queryOptions.maxDataPoints !== undefined && queryOptions.maxDataPoints !== null;
const mdDesc = queryOptions.maxDataPoints ?? panelData.request?.maxDataPoints ?? '-';
optionItems.push(
<span key="md" className={styles.collapsedText}>
{renderCustomIndicator(hasCustomMaxDataPoints)}
<span className={styles.collapsedTextLabel}>
<Trans i18nKey="query.query-group-options-editor.collapsed-max-data-points-label">Max data points</Trans>
</span>
{' = '}
{renderValue(mdDesc, hasCustomMaxDataPoints)}
</span>
);
// Min interval - custom if explicitly set (not falling back to datasource default)
const hasCustomMinInterval = !!queryOptions.minInterval;
const minIntervalDesc = queryOptions.minInterval ?? datasource?.interval ?? 'No limit';
optionItems.push(
<span key="min-interval" className={styles.collapsedText}>
{renderCustomIndicator(hasCustomMinInterval)}
<span className={styles.collapsedTextLabel}>
<Trans i18nKey="query.query-group-options-editor.collapsed-min-interval-label">Min interval</Trans>
</span>
{' = '}
{renderValue(minIntervalDesc, hasCustomMinInterval)}
</span>
);
// Interval - read-only, never custom (computed value)
const intervalDesc = panelData.request?.interval ?? '-';
optionItems.push(
<span key="interval" className={styles.collapsedText}>
{renderCustomIndicator(false)}
<span className={styles.collapsedTextLabel}>
<Trans i18nKey="query.query-group-options-editor.collapsed-interval-label">Interval</Trans>
</span>
{' = '}
{renderValue(intervalDesc, false)}
</span>
);
// Cache timeout - custom if explicitly set (not using default "60")
if (dsSettings?.meta.queryOptions?.cacheTimeout) {
const hasCustomCacheTimeout = !!queryOptions.cacheTimeout;
const cacheTimeoutDesc = queryOptions.cacheTimeout ?? '60';
optionItems.push(
<span key="cache-timeout" className={styles.collapsedText}>
{renderCustomIndicator(hasCustomCacheTimeout)}
<span className={styles.collapsedTextLabel}>
<Trans i18nKey="query.query-group-options-editor.collapsed-cache-timeout-label">Cache timeout</Trans>
</span>
{' = '}
{renderValue(cacheTimeoutDesc, hasCustomCacheTimeout)}
</span>
);
}
// Cache TTL - custom if explicitly set (not using datasource default)
if (datasource?.cachingConfig?.enabled) {
const hasCustomCacheTTL = queryOptions.queryCachingTTL !== undefined && queryOptions.queryCachingTTL !== null;
const cacheTTLDesc = queryOptions.queryCachingTTL ?? datasource.cachingConfig.TTLMs ?? '-';
optionItems.push(
<span key="cache-ttl" className={styles.collapsedText}>
{renderCustomIndicator(hasCustomCacheTTL)}
<span className={styles.collapsedTextLabel}>
<Trans i18nKey="query.query-group-options-editor.collapsed-cache-ttl-label">Cache TTL</Trans>
</span>
{' = '}
{renderValue(cacheTTLDesc, hasCustomCacheTTL)}
</span>
);
}
// Relative time - custom if explicitly set
const hasCustomRelativeTime = !!queryOptions.timeRange?.from;
const relativeTime = queryOptions.timeRange?.from ?? '1h';
optionItems.push(
<span key="relative-time" className={styles.collapsedText}>
{renderCustomIndicator(hasCustomRelativeTime)}
<span className={styles.collapsedTextLabel}>
<Trans i18nKey="query.query-group-options-editor.collapsed-relative-time-label">Relative time</Trans>
</span>
{' = '}
{renderValue(relativeTime, hasCustomRelativeTime)}
</span>
);
// Time shift - custom if explicitly set
const hasCustomTimeShift = !!queryOptions.timeRange?.shift;
const timeShift = queryOptions.timeRange?.shift ?? '1h';
optionItems.push(
<span key="time-shift" className={styles.collapsedText}>
{renderCustomIndicator(hasCustomTimeShift)}
<span className={styles.collapsedTextLabel}>
<Trans i18nKey="query.query-group-options-editor.collapsed-time-shift-label">Time shift</Trans>
</span>
{' = '}
{renderValue(timeShift, hasCustomTimeShift)}
</span>
);
return <>{optionItems}</>;
};
return (
<div className={styles.container}>
<QueryOperationRow
id={`query-${query.refId}`}
index={queryIndex}
draggable={false}
collapsable={false}
isOpen={true}
hideHeader={true}
className={styles.queryOperationRow}
>
{error && <QueryErrorAlert error={error} />}
{renderQueryEditor()}
</QueryOperationRow>
{datasource &&
panelData &&
(showOptions ? (
<div className={styles.optionsColumn}>
<Stack gap={1} direction="column">
<Button
size="md"
icon="angle-right"
fill="text"
variant="secondary"
onClick={() => setShowOptions(!showOptions)}
className={styles.optionsButton}
>
<Trans i18nKey="dashboard-scene.query-detail-view.query-options">Query Options</Trans>
</Button>
{datasource && panelData && (
<QueryGroupOptionsEditor
options={queryOptions}
dataSource={datasource}
data={panelData}
onChange={handleQueryOptionsChange}
/>
)}
</Stack>
</div>
) : (
<div className={styles.optionsFooter}>
{renderCollapsedText()}
<Button
size="sm"
icon="angle-left"
fill="text"
onClick={() => setShowOptions(!showOptions)}
className={styles.optionsButton}
>
<Trans i18nKey="dashboard-scene.query-detail-view.options">Options</Trans>
</Button>
</div>
))}
</div>
);
}
const getStyles = (theme: GrafanaTheme2, showOptions: boolean) => {
return {
container: css({
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
display: 'flex',
flexDirection: showOptions ? 'row' : 'column',
}),
queryOperationRow: css({
marginBottom: '0 !important', // need to beat specificty in the underling component
height: showOptions ? '100%' : 'calc(100% - 32px)', // 32px for the footer
flexGrow: 1,
gap: theme.spacing(1),
overflowY: 'auto',
padding: theme.spacing(2),
}),
optionsFooter: css({
height: '32px',
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
borderTop: `1px solid ${theme.colors.border.weak}`,
position: 'sticky',
bottom: 0,
zIndex: theme.zIndex.navbarFixed,
padding: theme.spacing(0.5, 2),
background: theme.colors.background.secondary,
}),
optionsColumn: css({
width: '300px',
display: 'flex',
flexDirection: 'column',
borderLeft: `1px solid ${theme.colors.border.weak}`,
background: theme.colors.background.secondary,
padding: theme.spacing(2),
}),
optionsButton: css({
paddingLeft: 0,
fontFamily: theme.typography.fontFamilyMonospace,
textTransform: 'uppercase',
}),
noEditor: css({
padding: theme.spacing(2),
textAlign: 'center',
color: theme.colors.text.secondary,
}),
collapsedText: css({
marginInline: theme.spacing(1),
fontSize: theme.typography.size.sm,
color: theme.colors.text.secondary,
display: 'inline-flex',
alignItems: 'center',
gap: theme.spacing(0.5),
whiteSpace: 'nowrap',
}),
collapsedTextLabel: css({
color: theme.colors.text.primary,
}),
collapsedTextValue: css({
color: theme.colors.text.secondary,
}),
collapsedTextValueCustom: css({
color: theme.visualization.getColorByName('green'),
}),
customIndicator: css({
width: 6,
height: 6,
borderRadius: theme.shape.radius.circle,
backgroundColor: theme.visualization.getColorByName('green'),
flexShrink: 0,
}),
};
};

View File

@@ -0,0 +1,572 @@
import { css, cx } from '@emotion/css';
import { useState, useMemo, useCallback, Fragment, useEffect, forwardRef, useImperativeHandle } from 'react';
import { DataQuery, dateTime, GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import {
Avatar,
Badge,
Box,
Checkbox,
Divider,
EmptyState,
Field,
Icon,
Input,
ScrollContainer,
Stack,
TagsInput,
Text,
useStyles2,
} from '@grafana/ui';
export interface QueryLibraryViewRef {
selectCurrentQuery: () => void;
saveQuery: () => void;
canSave: () => boolean;
}
// Stub saved queries for demonstration
const STUB_SAVED_QUERIES: Array<{
uid: string;
title: string;
description: string;
queryText: string;
datasourceName: string;
datasourceType: string;
datasourceUid: string;
user: { uid: string; displayName: string; avatarUrl?: string };
createdAtTimestamp: number;
tags: string[];
isVisible: boolean;
query: DataQuery;
}> = [
{
uid: '1',
title: 'Rate then sum by(label) then avg',
description: 'Returns CPU usage metrics for all hosts',
queryText: 'rate(node_cpu_seconds_total{mode="user"}[5m])',
datasourceName: 'Prometheus',
datasourceType: 'prometheus',
datasourceUid: 'prometheus',
user: { uid: 'admin', displayName: 'Admin' },
createdAtTimestamp: Date.now() - 86400000,
tags: ['metrics', 'cpu'],
isVisible: true,
query: {
refId: 'A',
datasource: { type: 'prometheus', uid: 'prometheus' },
},
},
{
uid: '7',
title: '5xx Error Rate',
description: 'Returns the rate of 5xx HTTP errors',
queryText: 'sum by(code) (rate(prometheus_http_requests_total{ code=~"5.." }[5m]))',
datasourceName: 'Prometheus',
datasourceType: 'prometheus',
datasourceUid: 'prometheus',
user: { uid: 'admin', displayName: 'Admin' },
createdAtTimestamp: Date.now() - 604800000,
tags: ['errors', 'http'],
isVisible: true,
query: {
refId: 'B',
datasource: { type: 'prometheus', uid: 'prometheus' },
},
},
{
uid: '8',
title: '95th Percentile Latency',
description: 'Returns the 95th percentile latency for HTTP requests',
queryText: `histogram_quantile(
0.95,
sum by(le)(
rate(prometheus_http_request_duration_seconds_bucket[5m])
)
)`,
datasourceName: 'Prometheus',
datasourceType: 'prometheus',
datasourceUid: 'prometheus',
user: { uid: 'admin', displayName: 'Admin' },
createdAtTimestamp: Date.now() - 691200000,
tags: ['latency', 'http'],
isVisible: false,
query: {
refId: 'C',
datasource: { type: 'prometheus', uid: 'prometheus' },
},
},
{
uid: '2',
title: 'History quantile on rate',
description: 'Returns memory usage metrics',
queryText: 'node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes * 100',
datasourceName: 'Loki',
datasourceType: 'loki',
datasourceUid: 'loki',
user: { uid: 'admin', displayName: 'Admin' },
createdAtTimestamp: Date.now() - 172800000,
tags: ['metrics', 'memory'],
isVisible: true,
query: {
refId: 'A',
datasource: { type: 'loki', uid: 'loki' },
},
},
{
uid: '3',
title: 'Binary Query',
description: 'Returns network traffic in/out bytes',
queryText: 'rate(node_network_receive_bytes_total[5m])',
datasourceName: 'InfluxDB',
datasourceType: 'influxdb',
datasourceUid: 'influxdb',
user: { uid: 'editor', displayName: 'Editor' },
createdAtTimestamp: Date.now() - 259200000,
tags: ['network'],
isVisible: false,
query: {
refId: 'A',
datasource: { type: 'influxdb', uid: 'influxdb' },
},
},
{
uid: '4',
title: 'Service Latency',
description: 'Returns service latency metrics',
queryText: 'histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))',
datasourceName: 'Tempo',
datasourceType: 'tempo',
datasourceUid: 'tempo',
user: { uid: 'admin', displayName: 'Admin' },
createdAtTimestamp: Date.now() - 345600000,
tags: ['latency', 'service'],
isVisible: true,
query: {
refId: 'A',
datasource: { type: 'tempo', uid: 'tempo' },
},
},
{
uid: '5',
title: 'Network Throughput',
description: 'Returns network throughput metrics',
queryText: 'rate(node_network_transmit_bytes_total[5m])',
datasourceName: 'Graphite',
datasourceType: 'graphite',
datasourceUid: 'graphite',
user: { uid: 'admin', displayName: 'Admin' },
createdAtTimestamp: Date.now() - 432000000,
tags: ['network', 'throughput'],
isVisible: true,
query: {
refId: 'A',
datasource: { type: 'graphite', uid: 'graphite' },
},
},
{
uid: '6',
title: 'Log Volume',
description: 'Returns log volume metrics',
queryText: 'sum(rate({job="varlogs"}[5m]))',
datasourceName: 'Loki',
datasourceType: 'loki',
datasourceUid: 'loki',
user: { uid: 'admin', displayName: 'Admin' },
createdAtTimestamp: Date.now() - 518400000,
tags: ['logs', 'volume'],
isVisible: true,
query: {
refId: 'A',
datasource: { type: 'loki', uid: 'loki' },
},
},
];
export interface QueryLibraryViewProps {
mode: 'browse' | 'save';
currentQuery?: DataQuery;
onSelectQuery?: (query: DataQuery) => void;
onSaveQuery?: (name: string, description: string) => void;
onClose: () => void;
}
// Component to render datasource icon
function DatasourceIcon({ datasourceType, className }: { datasourceType: string; className?: string }) {
const [logoUrl, setLogoUrl] = useState<string | null>(null);
useEffect(() => {
const fetchLogo = async () => {
try {
const ds = await getDataSourceSrv().get({ type: datasourceType });
if (ds?.meta?.info?.logos?.small) {
setLogoUrl(ds.meta.info.logos.small);
}
} catch {
// Datasource not found, will use fallback
}
};
fetchLogo();
}, [datasourceType]);
if (logoUrl) {
return <img src={logoUrl} alt={datasourceType} className={className} />;
}
// Fallback to generic icon
return <Icon name="database" />;
}
export const QueryLibraryView = forwardRef<QueryLibraryViewRef, QueryLibraryViewProps>(function QueryLibraryView(
{ mode, currentQuery, onSelectQuery, onSaveQuery, onClose },
ref
) {
const styles = useStyles2(getStyles);
// Filter state
const [searchQuery, setSearchQuery] = useState('');
// Selection state
const [selectedQueryIndex, setSelectedQueryIndex] = useState(0);
// Form state for new/edit query
const [formTitle, setFormTitle] = useState(
mode === 'save' ? t('explore.query-library.default-title', 'New query') : ''
);
const [formDescription, setFormDescription] = useState('');
const [formTags, setFormTags] = useState<string[]>([]);
const [formIsVisible, setFormIsVisible] = useState(true);
// Filter queries
const filteredQueries = useMemo(() => {
return STUB_SAVED_QUERIES.filter((q) => {
const matchesSearch =
!searchQuery ||
q.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.queryText.toLowerCase().includes(searchQuery.toLowerCase());
return matchesSearch;
});
}, [searchQuery]);
// Current selected query or new query
const selectedQuery = mode === 'save' ? null : filteredQueries[selectedQueryIndex];
const handleSelectQuery = useCallback(
(query: DataQuery) => {
onSelectQuery?.(query);
},
[onSelectQuery]
);
const handleSaveQuery = useCallback(() => {
if (formTitle.trim()) {
onSaveQuery?.(formTitle, formDescription);
}
}, [formTitle, formDescription, onSaveQuery]);
// Expose methods via ref
useImperativeHandle(
ref,
() => ({
selectCurrentQuery: () => {
if (selectedQuery) {
handleSelectQuery(selectedQuery.query);
}
},
saveQuery: () => {
handleSaveQuery();
},
canSave: () => {
return formTitle.trim().length > 0;
},
}),
[selectedQuery, handleSelectQuery, handleSaveQuery, formTitle]
);
const isFiltered = Boolean(searchQuery);
const isEmpty = filteredQueries.length === 0 && mode === 'browse';
// Get current datasource type for icons
const getCurrentDatasourceType = () => {
if (currentQuery?.datasource && typeof currentQuery.datasource === 'object' && 'type' in currentQuery.datasource) {
return currentQuery.datasource.type || '';
}
return '';
};
// Render query list item
const renderQueryItem = (query: (typeof STUB_SAVED_QUERIES)[0], index: number) => (
<Fragment key={query.uid}>
<label
className={cx(
styles.queryItem,
mode === 'browse' && selectedQueryIndex === index && styles.selected,
mode === 'save' && styles.disabled
)}
>
<input
type="radio"
name="query-library-list"
className={styles.radioInput}
checked={mode === 'browse' && selectedQueryIndex === index}
onChange={() => setSelectedQueryIndex(index)}
disabled={mode === 'save'}
/>
<Stack alignItems="center" gap={1} minWidth={0}>
<DatasourceIcon datasourceType={query.datasourceType} className={styles.datasourceIcon} />
<Text truncate>{query.title}</Text>
</Stack>
</label>
<Divider spacing={0} />
</Fragment>
);
// Render details form
const renderDetailsForm = () => {
const query = mode === 'save' ? null : selectedQuery;
const queryText = mode === 'save' ? JSON.stringify(currentQuery, null, 2) : query?.queryText;
const getDatasourceName = () => {
if (mode !== 'save') {
return query?.datasourceName || '';
}
if (
currentQuery?.datasource &&
typeof currentQuery.datasource === 'object' &&
'type' in currentQuery.datasource
) {
return currentQuery.datasource.type || 'Unknown';
}
return 'Unknown';
};
const datasourceName = getDatasourceName();
const datasourceType = mode === 'save' ? getCurrentDatasourceType() : query?.datasourceType || '';
const author = mode === 'save' ? { displayName: 'Current User' } : query?.user;
const dateAdded = mode === 'save' ? new Date() : query ? new Date(query.createdAtTimestamp) : new Date();
const formattedDate = dateTime(dateAdded).format('ddd MMM DD YYYY HH:mm [GMT]ZZ');
return (
<Stack direction="column" flex={1} height="100%">
<Box flex={1}>
{/* Title with icon */}
<Box marginBottom={2}>
<Stack gap={1} alignItems="center">
<DatasourceIcon datasourceType={datasourceType} className={styles.datasourceIconLarge} />
<Box flex={1}>
<Input
id="query-title"
value={mode === 'save' ? formTitle : query?.title || ''}
onChange={(e) => mode === 'save' && setFormTitle(e.currentTarget.value)}
readOnly={mode !== 'save'}
/>
</Box>
</Stack>
</Box>
{/* Query text */}
<Box marginBottom={2}>
<code className={styles.queryCode}>{queryText}</code>
</Box>
<Stack direction="column" gap={2}>
{/* Data source */}
<Field label={t('query-library.query-details.datasource', 'Datasource')} noMargin>
<Input readOnly value={datasourceName} />
</Field>
{/* Author */}
<Field label={t('query-library.query-details.author', 'Author')} noMargin>
<Input
readOnly
prefix={
<Box marginRight={0.5}>
<Avatar width={2} height={2} src="https://secure.gravatar.com/avatar" alt="" />
</Box>
}
value={author?.displayName || ''}
/>
</Field>
{/* Description */}
<Field label={t('query-library.query-details.description', 'Description')} noMargin>
<Input
value={mode === 'save' ? formDescription : query?.description || ''}
onChange={(e) => mode === 'save' && setFormDescription(e.currentTarget.value)}
readOnly={mode !== 'save'}
/>
</Field>
{/* Tags */}
<Field label={t('query-library.query-details.tags', 'Tags')} noMargin>
<TagsInput
tags={mode === 'save' ? formTags : query?.tags || []}
onChange={(tags) => mode === 'save' && setFormTags(tags)}
disabled={mode !== 'save'}
/>
</Field>
{/* Date added */}
<Field label={t('query-library.query-details.date-added', 'Date added')} noMargin>
<Input readOnly value={formattedDate} />
</Field>
{/* Share checkbox */}
<Field noMargin>
<Checkbox
label={t('query-library.query-details.make-query-visible', 'Share query with all users')}
checked={mode === 'save' ? formIsVisible : query?.isVisible || false}
onChange={(e) => mode === 'save' && setFormIsVisible(e.currentTarget.checked)}
disabled={mode !== 'save'}
/>
</Field>
</Stack>
</Box>
</Stack>
);
};
return (
<Stack height="100%" direction="column" gap={0}>
{/* Content - two column layout */}
{isEmpty ? (
<EmptyState
variant={isFiltered ? 'not-found' : 'call-to-action'}
message={
isFiltered
? t('query-library.not-found.title', 'No results found')
: t('query-library.empty-state.title', "You haven't saved any queries yet")
}
>
{isFiltered ? (
<Trans i18nKey="query-library.not-found.message">Try adjusting your search or filter criteria</Trans>
) : (
<Trans i18nKey="query-library.empty-state.message">
Start adding them from Explore or when editing a dashboard
</Trans>
)}
</EmptyState>
) : (
<Stack flex={1} gap={0} minHeight={0}>
{/* Left column - Query list with search */}
<Box display="flex" flex={1} minWidth={0} direction="column">
{/* Search field */}
<Box padding={2}>
<Input
prefix={<Icon name="search" />}
placeholder={t('query-library.filters.search', 'Search by...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
/>
</Box>
<ScrollContainer>
<Stack direction="column" gap={0} flex={1} minWidth={0}>
{/* New query item when in save mode */}
{mode === 'save' && currentQuery && (
<>
<label className={cx(styles.queryItem, styles.selected)}>
<input type="radio" name="query-library-list" className={styles.radioInput} checked readOnly />
<Stack alignItems="center" justifyContent="space-between" width="100%">
<Stack alignItems="center" gap={1} minWidth={0}>
<DatasourceIcon
datasourceType={getCurrentDatasourceType()}
className={styles.datasourceIcon}
/>
<Text truncate>{formTitle || t('explore.query-library.default-title', 'New query')}</Text>
</Stack>
<Badge text={t('query-library.item.new', 'New')} color="orange" />
</Stack>
</label>
<Divider spacing={0} />
</>
)}
{/* Existing queries */}
{filteredQueries.map((query, index) => renderQueryItem(query, index))}
</Stack>
</ScrollContainer>
</Box>
<Divider direction="vertical" spacing={0} />
{/* Right column - Details form */}
<Box display="flex" flex={2} minWidth={0}>
<ScrollContainer>
<Box
direction="column"
display="flex"
flex={1}
paddingBottom={2}
paddingLeft={2}
paddingRight={2}
paddingTop={2}
>
{renderDetailsForm()}
</Box>
</ScrollContainer>
</Box>
</Stack>
)}
</Stack>
);
});
const getStyles = (theme: GrafanaTheme2) => ({
queryItem: css({
display: 'block',
width: '100%',
padding: theme.spacing(2),
position: 'relative',
cursor: 'pointer',
[theme.transitions.handleMotion('no-preference')]: {
transition: theme.transitions.create(['background-color'], {
duration: theme.transitions.duration.short,
}),
},
'&:hover': {
backgroundColor: theme.colors.action.hover,
},
}),
selected: css({
backgroundColor: theme.colors.action.selected,
'&:hover': {
backgroundColor: theme.colors.action.selected,
},
}),
disabled: css({
opacity: 0.5,
cursor: 'default',
'&:hover': {
backgroundColor: 'transparent',
},
}),
radioInput: css({
position: 'absolute',
opacity: 0,
cursor: 'pointer',
}),
datasourceIcon: css({
width: '16px',
height: '16px',
objectFit: 'contain',
}),
datasourceIconLarge: css({
width: '24px',
height: '24px',
objectFit: 'contain',
}),
queryCode: css({
backgroundColor: theme.colors.action.disabledBackground,
borderRadius: theme.shape.radius.default,
display: 'block',
margin: theme.spacing(0, 0, 2, 0),
overflowWrap: 'break-word',
padding: theme.spacing(1),
whiteSpace: 'pre-wrap',
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.bodySmall.fontSize,
}),
});

View File

@@ -0,0 +1,332 @@
import { css } from '@emotion/css';
import clsx from 'clsx';
import { memo, useMemo } from 'react';
import { GrafanaTheme2, IconName } from '@grafana/data';
import { t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { Icon, IconButton, Stack, useStyles2 } from '@grafana/ui';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { getExpressionIcon } from 'app/features/expressions/types';
import { usePanelDataPaneColors } from './theme';
import { QueryTransformItem } from './types';
interface QueryTransformCardProps {
item: QueryTransformItem;
isSelected: boolean;
onClick: () => void;
onDuplicate?: () => void;
onRemove?: () => void;
onToggleVisibility?: () => void;
debugHiddenOverride?: boolean | null;
}
export const QueryTransformCard = memo(
({
item: { data, type, id: itemId, index },
isSelected,
onClick,
onDuplicate,
onRemove,
onToggleVisibility,
debugHiddenOverride,
}: QueryTransformCardProps) => {
const colors = usePanelDataPaneColors();
const styles = useStyles2(getStyles, colors);
const datasourceIcon = useMemo(() => {
if (type === 'query' && 'datasource' in data && data.datasource) {
try {
const dsSettings = getDataSourceSrv().getInstanceSettings(data.datasource);
return dsSettings?.meta.info.logos.small;
} catch {
return undefined;
}
}
return undefined;
}, [type, data]);
// Compute effective visibility: use debug override if present, otherwise use actual state
const actualHidden =
((type === 'query' || type === 'expression') && 'hide' in data && data.hide) ||
(type === 'transform' && 'disabled' in data && data.disabled);
const isHidden =
debugHiddenOverride !== null && debugHiddenOverride !== undefined ? debugHiddenOverride : actualHidden;
const typeLabel = useMemo(() => {
switch (type) {
case 'query':
return t('dashboard-scene.query-transform-card.query.label', 'Query');
case 'expression':
return t('dashboard-scene.query-transform-card.expression.label', 'Expression');
case 'transform':
return t('dashboard-scene.query-transform-card.transform.label', 'Transform');
default:
throw new Error('unreachable');
}
}, [type]);
const name = useMemo(() => {
switch (type) {
case 'query':
case 'expression': {
// FIXME untranslated string
return data.refId || `${type === 'expression' ? 'Expression' : 'Query'} ${index + 1}`;
}
case 'transform':
return data.id.replace(/-/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase());
default:
throw new Error('unreachable');
}
}, [type, data, index]);
const icon = useMemo((): IconName => {
switch (type) {
case 'query':
return 'database';
case 'expression': {
const type = isExpressionQuery(data) ? data.type : undefined;
return getExpressionIcon(type);
}
case 'transform':
return 'pivot';
default:
throw new Error('unreachable');
}
}, [data, type]);
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={clsx(styles.card, { [styles.cardSelected]: isSelected, [styles.cardHidden]: isHidden })}
onClick={onClick}
onKeyDown={handleKeyDown}
data-testid={`${type}-card-${index}`}
data-card-id={itemId}
>
{/* Header with type and action icons */}
<div
className={
type === 'query'
? styles.headerQuery
: type === 'expression'
? styles.headerExpression
: styles.headerTransform
}
>
<div className={styles.headerLeft}>
<Icon name={icon} className={styles.headerIcon} />
<span className={styles.typeLabel}>{typeLabel}</span>
</div>
<div className={styles.headerActions}>
<div className={clsx(styles.quickActions, styles.quickActionsClass)}>
{(type === 'query' || type === 'expression') && 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}
/>
)}
</div>
{onToggleVisibility && (
<div
className={clsx(styles.eyeIconWrapper, !isHidden && [styles.eyeIconHidden, styles.eyeIconHiddenClass])}
>
<IconButton
name={isHidden ? 'eye-slash' : 'eye'}
size="sm"
variant="secondary"
tooltip={
isHidden
? type === 'transform'
? t('dashboard-scene.query-transform-card.enable-transform', 'Enable transformation')
: t('dashboard-scene.query-transform-card.show-response', 'Show response')
: type === 'transform'
? t('dashboard-scene.query-transform-card.disable-transform', 'Disable transformation')
: t('dashboard-scene.query-transform-card.hide-response', 'Hide response')
}
onClick={(e) => handleAction(e, onToggleVisibility)}
className={styles.actionButton}
/>
</div>
)}
</div>
</div>
{/* Content: Name */}
<div className={styles.content}>
<Stack direction="row" alignItems="center" gap={1}>
{type === 'query' && datasourceIcon && (
<img src={datasourceIcon} alt="" className={styles.datasourceIcon} />
)}
<div className={styles.name}>{name}</div>
</Stack>
</div>
</div>
);
}
);
QueryTransformCard.displayName = 'QueryTransformCard';
const getStyles = (theme: GrafanaTheme2, colors: ReturnType<typeof usePanelDataPaneColors>) => {
const selectedClass = 'card-selected';
const hiddenClass = 'card-hidden';
const hoverOnlyClass = 'hover-only';
return {
card: css({
position: 'relative',
cursor: 'pointer',
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
overflow: 'hidden',
background: theme.colors.background.secondary,
width: '100%',
minWidth: 180,
maxWidth: 300,
'&:hover': {
borderColor: theme.colors.border.strong,
[`.${hoverOnlyClass}`]: {
opacity: 1,
},
},
[`&.${selectedClass}`]: {
borderColor: theme.colors.primary.border,
boxShadow: `0 0 0 1px ${theme.colors.primary.border}`,
},
[`&.${selectedClass}:hover`]: {
borderColor: theme.colors.primary.border,
boxShadow: `0 0 0 1px ${theme.colors.primary.border}`,
},
[`&.${hiddenClass}`]: {
opacity: 0.5,
},
}),
cardSelected: selectedClass,
cardHidden: hiddenClass,
headerQuery: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing(0.5),
background: theme.colors.background.canvas,
borderBottom: `1px solid ${theme.colors.border.weak}`,
color: colors.query.accent,
}),
headerTransform: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing(0.5),
background: theme.colors.background.canvas,
borderBottom: `1px solid ${theme.colors.border.weak}`,
color: colors.transform.accent,
}),
headerExpression: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing(0.5),
background: theme.colors.background.canvas,
borderBottom: `1px solid ${theme.colors.border.weak}`,
color: colors.expression.accent,
}),
headerLeft: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
flex: 1,
minWidth: 0,
}),
headerIcon: css({
color: 'inherit',
flexShrink: 0,
}),
typeLabel: css({
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.bodySmall.fontSize,
color: 'inherit',
textTransform: 'uppercase',
}),
headerActions: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.25),
}),
quickActions: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.25),
opacity: 0,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'opacity 0.2s ease',
},
}),
quickActionsClass: hoverOnlyClass,
eyeIconWrapper: css({
display: 'flex',
alignItems: 'center',
}),
eyeIconHidden: css({
opacity: 0,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'opacity 0.2s ease',
},
}),
eyeIconHiddenClass: hoverOnlyClass,
actionButton: css({
'&:hover': {
background: theme.colors.action.hover,
},
}),
content: css({
padding: theme.spacing(0.75) + ' ' + theme.spacing(1),
}),
datasourceIcon: css({
width: '16px',
height: '16px',
flexShrink: 0,
}),
name: css({
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.maxContrast,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}),
};
};

View File

@@ -0,0 +1,209 @@
import { css } from '@emotion/css';
import { memo, useCallback, useRef } from 'react';
import { DataFrame, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans } from '@grafana/i18n';
import { SceneDataQuery, VizPanel } from '@grafana/scenes';
import { Container, ScrollContainer, useStyles2 } from '@grafana/ui';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { ExpressionDetailView } from './ExpressionDetailView';
import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab';
import { QueryDetailView } from './QueryDetailView';
import { QueryLibraryView, QueryLibraryViewRef } from './QueryLibraryView';
import { QueryTransformDetailViewHeader } from './QueryTransformDetailViewHeader';
import { TransformationPickerView } from './TransformationPickerView';
import { TabId, QueryTransformItem } from './types';
export interface QueryLibraryMode {
active: boolean;
mode: 'browse' | 'save';
currentQuery?: SceneDataQuery;
}
interface QueryTransformDetailViewProps {
selectedItem: QueryTransformItem | undefined;
panel: VizPanel;
tabs: Array<{ tabId: TabId }>;
onRemoveTransform?: (index: number) => void;
onToggleTransformVisibility?: (index: number) => void;
isAddingTransform?: boolean;
onAddTransformation?: (selectedItem: SelectableValue<string>, customOptions?: Record<string, unknown>) => void;
onCancelAddTransform?: () => void;
transformationData?: DataFrame[];
onGoToQueries?: () => void;
queryLibraryMode?: QueryLibraryMode;
onQueryLibrarySelect?: (query: SceneDataQuery) => void;
onQueryLibrarySave?: (name: string, description: string) => void;
onQueryLibraryClose?: () => void;
onOpenQueryLibrary?: (mode: 'browse' | 'save', index?: number) => void;
onOpenQueryInspector?: () => void;
isDebugMode?: boolean;
debugPosition?: number;
}
const QueryTransformDetailViewContent = ({
selectedItem,
panel,
tabs,
onRemoveTransform,
onToggleTransformVisibility,
isAddingTransform,
onAddTransformation,
onCancelAddTransform,
transformationData,
onGoToQueries,
queryLibraryMode,
onQueryLibrarySelect,
onQueryLibrarySave,
onQueryLibraryClose,
onOpenQueryLibrary,
onOpenQueryInspector,
isDebugMode,
debugPosition,
styles,
}: QueryTransformDetailViewProps & { styles: ReturnType<typeof getStyles> }) => {
const queryLibraryRef = useRef<QueryLibraryViewRef>(null);
const handleSelectQueryFromHeader = useCallback(() => {
queryLibraryRef.current?.selectCurrentQuery();
}, []);
const handleSaveQueryFromHeader = useCallback(() => {
queryLibraryRef.current?.saveQuery();
}, []);
// Show transformation picker when in add mode
if (isAddingTransform && onAddTransformation && onCancelAddTransform) {
return (
<TransformationPickerView
data={transformationData || []}
onAddTransformation={onAddTransformation}
onCancel={onCancelAddTransform}
onGoToQueries={onGoToQueries}
/>
);
}
// Show QueryLibraryView when in query library mode
if (queryLibraryMode?.active && onQueryLibraryClose) {
return (
<>
<QueryTransformDetailViewHeader
queryLibraryMode={queryLibraryMode.mode}
onSelectQuery={queryLibraryMode.mode === 'browse' ? handleSelectQueryFromHeader : undefined}
onSaveQuery={queryLibraryMode.mode === 'save' ? handleSaveQueryFromHeader : undefined}
onClose={onQueryLibraryClose}
/>
<QueryLibraryView
ref={queryLibraryRef}
mode={queryLibraryMode.mode}
currentQuery={queryLibraryMode.currentQuery}
onSelectQuery={onQueryLibrarySelect}
onSaveQuery={onQueryLibrarySave}
onClose={onQueryLibraryClose}
/>
</>
);
}
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') {
const query = selectedItem.data;
return (
<>
<QueryTransformDetailViewHeader
selectedItem={selectedItem}
panel={panel}
onOpenQueryLibrary={onOpenQueryLibrary}
onOpenQueryInspector={onOpenQueryInspector}
/>
<ScrollContainer>
<QueryDetailView panel={panel} query={query} queryIndex={selectedItem.index} />
</ScrollContainer>
</>
);
} else if (selectedItem.type === 'expression') {
const data = selectedItem.data;
if (isExpressionQuery(data)) {
return (
<>
<QueryTransformDetailViewHeader
selectedItem={selectedItem}
panel={panel}
onOpenQueryInspector={onOpenQueryInspector}
/>
<ScrollContainer>
<ExpressionDetailView panel={panel} expression={data} expressionIndex={selectedItem.index} />
</ScrollContainer>
</>
);
}
} else {
const transformsTab = tabs.find((t): t is PanelDataTransformationsTab => t.tabId === TabId.Transformations);
if (transformsTab) {
return (
<>
<QueryTransformDetailViewHeader
selectedItem={selectedItem}
panel={panel}
onRemoveTransform={onRemoveTransform}
onToggleTransformVisibility={onToggleTransformVisibility}
isDebugMode={isDebugMode}
debugPosition={debugPosition}
/>
<ScrollContainer>
<Container>
<PanelDataTransformationsTabRendered model={transformsTab} selectedIdx={selectedItem.index} />
</Container>
</ScrollContainer>
</>
);
}
}
return null;
};
export const QueryTransformDetailView = memo((props: QueryTransformDetailViewProps) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.container} data-testid={selectors.components.PanelEditor.DataPane.content}>
<QueryTransformDetailViewContent {...props} styles={styles} />
</div>
);
});
QueryTransformDetailView.displayName = 'QueryTransformDetailView';
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
background: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
borderTopLeftRadius: theme.shape.radius.md,
borderTopRightRadius: theme.shape.radius.md,
overflow: 'hidden',
}),
emptyState: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: theme.colors.text.secondary,
fontSize: theme.typography.h5.fontSize,
}),
});

View File

@@ -0,0 +1,829 @@
import { css, cx } from '@emotion/css';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { mergeMap } from 'rxjs/operators';
import {
CoreApp,
DataFrame,
DataQuery,
DataSourceInstanceSettings,
DataTransformerConfig,
DataTransformContext,
GrafanaTheme2,
standardTransformersRegistry,
transformDataFrame,
} from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { getDataSourceSrv, getTemplateSrv } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import {
Button,
Drawer,
Dropdown,
FieldValidationMessage,
Icon,
IconButton,
Input,
JSONFormatter,
Menu,
Stack,
useStyles2,
useTheme2,
} from '@grafana/ui';
import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { FALLBACK_DOCS_LINK } from 'app/features/transformers/docs/constants';
import { getQueryRunnerFor } from '../../utils/utils';
import { QueryTransformItem } from './types';
// Props for regular item mode
interface ItemModeProps {
selectedItem: QueryTransformItem;
panel: VizPanel;
onRemoveTransform?: (index: number) => void;
onToggleTransformVisibility?: (index: number) => void;
onOpenQueryLibrary?: (mode: 'browse' | 'save') => void;
onOpenQueryInspector?: () => void;
isDebugMode?: boolean;
debugPosition?: number;
queryLibraryMode?: never;
onSelectQuery?: never;
onClose?: never;
}
// Props for query library mode
interface QueryLibraryModeProps {
selectedItem?: never;
panel?: never;
onRemoveTransform?: never;
onToggleTransformVisibility?: never;
onOpenQueryLibrary?: never;
onOpenQueryInspector?: never;
queryLibraryMode: 'browse' | 'save';
onSelectQuery?: () => void;
onSaveQuery?: () => void;
onClose: () => void;
}
type QueryTransformDetailViewHeaderProps = ItemModeProps | QueryLibraryModeProps;
const ITEM_CONFIG = (theme: GrafanaTheme2) => ({
query: {
color: theme.colors.primary.main,
icon: 'database' as const,
},
expression: {
color: theme.visualization.getColorByName('purple'),
icon: 'calculator-alt' as const,
},
transform: {
color: theme.visualization.getColorByName('orange'),
icon: 'process' as const,
},
queryLibrary: {
color: theme.visualization.getColorByName('green'),
icon: 'bookmark' as const,
},
});
// Separate component for query library header to avoid conditional hooks
function QueryLibraryHeader({
mode,
onSelectQuery,
onSaveQuery,
onClose,
}: {
mode: 'browse' | 'save';
onSelectQuery?: () => void;
onSaveQuery?: () => void;
onClose: () => void;
}) {
const theme = useTheme2();
const config = useMemo(() => ITEM_CONFIG(theme).queryLibrary, [theme]);
const styles = useStyles2(getStyles, config);
return (
<div className={styles.header}>
<div className={styles.headerContent}>
<Stack gap={1} alignItems="center" grow={1} minWidth={0}>
<Icon name="gf-query-library" />
<span className={styles.transformName}>{t('query-library.header.title', 'SAVED QUERIES')}</span>
</Stack>
<Stack gap={0.5} alignItems="center">
{mode === 'browse' && onSelectQuery && (
<Button className={styles.monospace} variant="primary" fill="text" size="sm" onClick={onSelectQuery}>
{t('query-library.header.select-query', 'SELECT QUERY')}
</Button>
)}
{mode === 'save' && onSaveQuery && (
<Button className={styles.monospace} variant="primary" fill="text" size="sm" onClick={onSaveQuery}>
{t('query-library.header.save-query', 'SAVE')}
</Button>
)}
<Dropdown
overlay={
<Menu>
<Menu.Item
label={t('query-library.header.delete-all', 'Delete all')}
icon="trash-alt"
onClick={() => {}}
/>
</Menu>
}
>
<IconButton name="ellipsis-v" variant="secondary" tooltip={t('query-library.header.more', 'More')} />
</Dropdown>
<IconButton
name="times"
variant="secondary"
onClick={onClose}
tooltip={t('query-library.header.close', 'Close')}
/>
</Stack>
</div>
</div>
);
}
// Separate component for item header to keep hooks unconditional
function ItemHeader({
selectedItem,
panel,
onRemoveTransform,
onToggleTransformVisibility,
onOpenQueryLibrary,
onOpenQueryInspector,
isDebugMode,
debugPosition,
}: ItemModeProps) {
const theme = useTheme2();
const config = useMemo(() => ITEM_CONFIG(theme)[selectedItem.type], [theme, selectedItem.type]);
const styles = useStyles2(getStyles, config);
const [isEditing, setIsEditing] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [showHelp, setShowHelp] = useState(false);
const [showDebug, setShowDebug] = useState(false);
const [transformInput, setTransformInput] = useState<DataFrame[]>([]);
const [transformOutput, setTransformOutput] = useState<DataFrame[]>([]);
// Helper to update queries with consistent pattern
const updateQueries = useCallback(
(updater: (queries: DataQuery[]) => DataQuery[], runAfter = false) => {
const queryRunner = getQueryRunnerFor(panel);
if (!queryRunner) {
return;
}
const queries = queryRunner.state.queries || [];
const newQueries = updater(queries);
queryRunner.setState({ queries: newQueries });
if (runAfter) {
queryRunner.runQueries();
}
},
[panel]
);
// Get datasource settings for queries
const datasourceSettings = useMemo(() => {
if (selectedItem.type === 'query') {
try {
// If the query has a datasource, use it; otherwise get the default datasource
const datasource = 'datasource' in selectedItem.data ? selectedItem.data.datasource : null;
return getDataSourceSrv().getInstanceSettings(datasource);
} catch {
return getDataSourceSrv().getInstanceSettings(null);
}
}
return undefined;
}, [selectedItem]);
const queryRunner = getQueryRunnerFor(panel);
// Handle datasource change for queries
const handleDataSourceChange = useCallback(
async (newDsSettings: DataSourceInstanceSettings) => {
if (selectedItem.type !== 'query' || selectedItem.index === undefined) {
return;
}
try {
// Load the new datasource to get its default query
const newDatasource = await getDataSourceSrv().get(newDsSettings.uid);
const defaultQuery = newDatasource.getDefaultQuery?.(CoreApp.PanelEditor) || {};
const queryRunner = getQueryRunnerFor(panel);
if (!queryRunner) {
return;
}
const queries = queryRunner.state.queries || [];
const newQueries = queries.map((q, idx) => {
if (idx === selectedItem.index) {
// Merge default query with existing query to preserve properties
return {
...defaultQuery,
...q,
datasource: { uid: newDsSettings.uid, type: newDsSettings.type },
refId: q.refId,
};
}
return q;
});
// Update queries, datasource, and clear cached data
queryRunner.setState({
datasource: { uid: newDsSettings.uid, type: newDsSettings.type },
queries: newQueries,
data: undefined,
});
// Run the query with the new datasource
queryRunner.runQueries();
} catch (error) {
console.error('Error changing datasource:', error);
}
},
[selectedItem, panel]
);
// Handle query name editing
const onEditQueryName = useCallback(() => {
setIsEditing(true);
}, []);
const onEndEditName = useCallback(
(newName: string) => {
setIsEditing(false);
// Ignore change if invalid
if (validationError) {
setValidationError(null);
return;
}
if (!('refId' in selectedItem.data) || selectedItem.data.refId === newName || selectedItem.index === undefined) {
return;
}
// Update the query with the new refId - just update state, don't run queries
updateQueries((queries) => queries.map((q, idx) => (idx === selectedItem.index ? { ...q, refId: newName } : q)));
},
[selectedItem, validationError, updateQueries]
);
const onInputChange = useCallback(
(event: React.SyntheticEvent<HTMLInputElement>) => {
const newName = event.currentTarget.value.trim();
if (newName.length === 0) {
setValidationError('An empty query name is not allowed');
return;
}
for (const otherQuery of queryRunner?.state.queries || []) {
if (otherQuery !== selectedItem.data && newName === otherQuery.refId) {
setValidationError('Query name already exists');
return;
}
}
if (validationError) {
setValidationError(null);
}
},
[queryRunner, selectedItem.data, validationError]
);
const onEditQueryBlur = useCallback(
(event: React.SyntheticEvent<HTMLInputElement>) => {
onEndEditName(event.currentTarget.value.trim());
},
[onEndEditName]
);
const onKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
onEndEditName(event.currentTarget.value);
}
},
[onEndEditName]
);
const onFocus = useCallback((event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
}, []);
// Action handlers
const onCopyQuery = useCallback(() => {
if (selectedItem.index === undefined) {
return;
}
updateQueries((queries) => {
const queryToCopy = queries[selectedItem.index];
return queryToCopy ? [...queries, { ...queryToCopy }] : queries;
});
}, [selectedItem, updateQueries]);
const onRemoveQuery = useCallback(() => {
if (selectedItem.index === undefined) {
return;
}
updateQueries((queries) => queries.filter((_, idx) => idx !== selectedItem.index));
}, [selectedItem, updateQueries]);
const onToggleHideQuery = useCallback(() => {
if ((selectedItem.type !== 'query' && selectedItem.type !== 'expression') || selectedItem.index === undefined) {
return;
}
updateQueries(
(queries) => queries.map((q, idx) => (idx === selectedItem.index ? { ...q, hide: !q.hide } : q)),
true // Run queries after update
);
}, [selectedItem, updateQueries]);
const onRunQuery = useCallback(() => {
const queryRunner = getQueryRunnerFor(panel);
queryRunner?.runQueries();
}, [panel]);
// Transformation action handlers
const onRemoveTransformation = useCallback(() => {
if (selectedItem.type !== 'transform' || selectedItem.index === undefined) {
return;
}
onRemoveTransform?.(selectedItem.index);
}, [selectedItem, onRemoveTransform]);
const onToggleTransformationVisibility = useCallback(() => {
if (selectedItem.type !== 'transform' || selectedItem.index === undefined) {
return;
}
onToggleTransformVisibility?.(selectedItem.index);
}, [selectedItem, onToggleTransformVisibility]);
const refId = 'refId' in selectedItem.data ? selectedItem.data.refId : '';
const isHidden =
(selectedItem.type === 'query' || selectedItem.type === 'expression') &&
'hide' in selectedItem.data &&
selectedItem.data.hide;
const isTransformDisabled =
selectedItem.type === 'transform' && 'disabled' in selectedItem.data && selectedItem.data.disabled;
// Get transformation display name and transformer info
const transformerInfo = useMemo(() => {
if (selectedItem.type === 'transform' && 'id' in selectedItem.data) {
const transformId = selectedItem.data.id;
const transformer = standardTransformersRegistry.get(transformId);
return transformer;
}
return undefined;
}, [selectedItem]);
const transformationName = transformerInfo?.name || '';
// Calculate transformation input/output for debug mode
useEffect(() => {
if (selectedItem.type !== 'transform' || !showDebug || !('disabled' in selectedItem.data)) {
return;
}
// Get the query runner for source data
const queryRunner = getQueryRunnerFor(panel);
if (!queryRunner) {
return;
}
// Get the source data (before any transformations)
const sourceData = queryRunner.state.data;
if (!sourceData?.series || sourceData.series.length === 0) {
setTransformInput([]);
setTransformOutput([]);
return;
}
// Get all transformations from the panel's data transformer
const $data = panel.state.$data;
if (!$data || !('state' in $data) || !('transformations' in $data.state)) {
return;
}
const transformations = $data.state.transformations;
if (!Array.isArray(transformations)) {
return;
}
const allTransformations: DataTransformerConfig[] = transformations;
// In debug mode, use debugPosition to determine which transformations to apply
// debugPosition is relative to all items (queries + transforms)
// We need to calculate the transformation index from it
let currentIndex = selectedItem.index;
if (isDebugMode && debugPosition !== undefined) {
// debugPosition is the number of enabled items
// To get transformation index: debugPosition - number_of_queries
// Number of queries = debugPosition - allTransformations.length (approximately, but we need to get it properly)
// Actually, let's get the number of queries from queryRunner
const numQueries = queryRunner.state.queries?.length || 0;
const maxTransformIndex = Math.max(0, (debugPosition || 0) - numQueries);
// Use the minimum of the calculated index and the current selected index
// This ensures we don't go beyond what's enabled in debug mode
currentIndex = Math.min(selectedItem.index, maxTransformIndex - 1);
}
// Get transformations before and including current one
const inputTransforms = allTransformations.slice(0, Math.max(0, currentIndex));
const outputTransforms = allTransformations.slice(Math.max(0, currentIndex), Math.max(0, currentIndex) + 1);
const ctx: DataTransformContext = {
interpolate: (v: string) => getTemplateSrv().replace(v),
};
// Input: Apply all transformations before this one to the source data
const inputSubscription = transformDataFrame(inputTransforms, sourceData.series, ctx).subscribe((frames) => {
setTransformInput(frames);
});
// Output: Apply input transforms, then apply the current transform to get the output
const outputSubscription = transformDataFrame(inputTransforms, sourceData.series, ctx)
.pipe(mergeMap((before) => transformDataFrame(outputTransforms, before, ctx)))
.subscribe((frames) => {
setTransformOutput(frames);
});
return () => {
inputSubscription.unsubscribe();
outputSubscription.unsubscribe();
};
}, [selectedItem, showDebug, panel, isDebugMode, debugPosition]);
return (
<div className={styles.header}>
<div className={styles.headerContent}>
{/* Left side: Icon, Datasource, Name */}
<Stack gap={1} alignItems="center" grow={1} minWidth={0}>
<Icon name={config.icon} className={styles.icon} />
{/* Datasource picker for queries */}
{selectedItem.type === 'query' && datasourceSettings && (
<DataSourcePicker
dashboard={true}
variables={true}
current={datasourceSettings.name}
onChange={handleDataSourceChange}
/>
)}
{/* Transformation name */}
{selectedItem.type === 'transform' && transformationName && (
<span className={styles.transformName}>{transformationName}</span>
)}
{/* Editable query/expression name */}
{(selectedItem.type === 'query' || selectedItem.type === 'expression') && refId && (
<>
{!isEditing ? (
<button
className={cx(styles.queryNameWrapper, styles.monospace)}
title={t('dashboard-scene.detail-view-header.edit-query-name', 'Edit query name')}
onClick={onEditQueryName}
type="button"
>
<span className={styles.queryName}>{refId}</span>
<Icon name="pen" className={styles.queryEditIcon} size="sm" />
</button>
) : (
<>
<Input
type="text"
defaultValue={refId}
onBlur={onEditQueryBlur}
autoFocus
onKeyDown={onKeyDown}
onFocus={onFocus}
invalid={validationError !== null}
onChange={onInputChange}
className={styles.queryNameInput}
/>
{validationError && <FieldValidationMessage horizontal>{validationError}</FieldValidationMessage>}
</>
)}
</>
)}
</Stack>
{/* Right side: Run Query + Actions Menu for queries/expressions */}
{(selectedItem.type === 'query' || selectedItem.type === 'expression') && (
<Stack gap={0.5} alignItems="center">
{/* Save Query Button (only for queries, not expressions) */}
{selectedItem.type === 'query' && 'refId' in selectedItem.data && onOpenQueryLibrary && (
<Button
className={styles.monospace}
variant="primary"
fill="text"
size="sm"
icon="bookmark"
onClick={() => onOpenQueryLibrary('save')}
>
{t('dashboard-scene.detail-view-header.save-query', 'SAVE')}
</Button>
)}
<Button
className={styles.monospace}
variant="primary"
fill="text"
size="sm"
onClick={onRunQuery}
icon="play"
>
{t('dashboard-scene.detail-view-header.run-query', 'RUN QUERY')}
</Button>
<Dropdown
overlay={
<Menu>
<Menu.Item
label={t('dashboard-scene.detail-view-header.duplicate-query', 'Duplicate')}
icon="copy"
onClick={onCopyQuery}
/>
<Menu.Item
label={
isHidden
? t('dashboard-scene.detail-view-header.show-response', 'Show response')
: t('dashboard-scene.detail-view-header.hide-response', 'Hide response')
}
icon={isHidden ? 'eye-slash' : 'eye'}
onClick={onToggleHideQuery}
/>
<Menu.Divider />
<Menu.Item
label={t('dashboard-scene.detail-view-header.remove-query', 'Remove')}
icon="trash-alt"
onClick={onRemoveQuery}
/>
<Menu.Item
label={t('dashboard-scene.detail-view-header.query-inspector', 'Query inspector')}
icon="wrench"
onClick={onOpenQueryInspector}
/>
</Menu>
}
>
<IconButton
name="ellipsis-v"
variant="secondary"
tooltip={t('dashboard-scene.detail-view-header.actions', 'Actions')}
/>
</Dropdown>
</Stack>
)}
{/* Right side: Actions Menu for transformations */}
{selectedItem.type === 'transform' && (
<Stack gap={0.5} alignItems="center">
<IconButton
name="question-circle"
variant="secondary"
tooltip={t('dashboard-scene.detail-view-header.show-documentation', 'Show documentation')}
onClick={() => setShowHelp(true)}
/>
<IconButton
name="bug"
variant="secondary"
tooltip={t('dashboard-scene.detail-view-header.debug', 'Debug transformation')}
onClick={() => setShowDebug(!showDebug)}
/>
<Dropdown
overlay={
<Menu>
<Menu.Item
label={
isTransformDisabled
? t('dashboard-scene.detail-view-header.enable-transform', 'Enable')
: t('dashboard-scene.detail-view-header.disable-transform', 'Disable')
}
icon={isTransformDisabled ? 'eye' : 'eye-slash'}
onClick={onToggleTransformationVisibility}
/>
<Menu.Divider />
<Menu.Item
label={t('dashboard-scene.detail-view-header.remove-transform', 'Remove')}
icon="trash-alt"
onClick={onRemoveTransformation}
/>
</Menu>
}
>
<IconButton
name="ellipsis-v"
variant="secondary"
tooltip={t('dashboard-scene.detail-view-header.actions', 'Actions')}
/>
</Dropdown>
</Stack>
)}
</div>
{/* Transformation Help Drawer */}
{selectedItem.type === 'transform' && transformerInfo && showHelp && (
<Drawer
title={transformerInfo.name}
subtitle={t('dashboard-scene.detail-view-header.transformation-help', 'Transformation help')}
onClose={() => setShowHelp(false)}
>
<OperationRowHelp
markdown={transformerInfo.help || FALLBACK_DOCS_LINK}
styleOverrides={{ borderTop: '2px solid' }}
/>
</Drawer>
)}
{/* Transformation Debug Drawer */}
{selectedItem.type === 'transform' && showDebug && (
<Drawer
title={t('dashboard-scene.detail-view-header.debug-transformation', 'Debug transformation')}
subtitle={transformationName}
onClose={() => setShowDebug(false)}
>
<div className={styles.debugWrapper}>
<div className={styles.debug}>
<div className={styles.debugTitle}>
<Trans i18nKey="dashboard-scene.detail-view-header.input-data">Input data</Trans>
</div>
<div className={styles.debugJson}>
<JSONFormatter json={transformInput} />
</div>
</div>
<div className={styles.debugSeparator}>
<Icon name="arrow-right" />
</div>
<div className={styles.debug}>
<div className={styles.debugTitle}>
<Trans i18nKey="dashboard-scene.detail-view-header.output-data">Output data</Trans>
</div>
<div className={styles.debugJson}>
<JSONFormatter json={transformOutput} />
</div>
</div>
</div>
</Drawer>
)}
</div>
);
}
// Main component that delegates to the appropriate sub-component
export const QueryTransformDetailViewHeader = (props: QueryTransformDetailViewHeaderProps) => {
if (props.queryLibraryMode) {
return (
<QueryLibraryHeader
mode={props.queryLibraryMode}
onSelectQuery={props.onSelectQuery}
onSaveQuery={props.onSaveQuery}
onClose={props.onClose}
/>
);
}
return (
<ItemHeader
selectedItem={props.selectedItem}
panel={props.panel}
onRemoveTransform={props.onRemoveTransform}
onToggleTransformVisibility={props.onToggleTransformVisibility}
onOpenQueryLibrary={props.onOpenQueryLibrary}
onOpenQueryInspector={props.onOpenQueryInspector}
isDebugMode={props.isDebugMode}
debugPosition={props.debugPosition}
/>
);
};
const getStyles = (theme: GrafanaTheme2, config: { color: string }) => {
return {
monospace: css({
fontFamily: theme.typography.fontFamilyMonospace,
}),
header: css({
padding: theme.spacing(0.5),
borderLeft: `4px solid ${config.color}`,
borderBottom: `1px solid ${theme.colors.border.weak}`,
background: theme.colors.background.secondary,
minHeight: theme.spacing(6),
}),
headerContent: css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: theme.spacing(2),
height: '100%',
paddingLeft: theme.spacing(1),
}),
icon: css({
color: theme.colors.text.secondary,
fontSize: theme.typography.h5.fontSize,
lineHeight: 1,
}),
queryNameWrapper: css({
display: 'flex',
cursor: 'pointer',
border: '1px solid transparent',
borderRadius: theme.shape.radius.default,
alignItems: 'center',
padding: theme.spacing(0.5, 1),
margin: 0,
background: 'transparent',
overflow: 'hidden',
'&:hover': {
background: theme.colors.action.hover,
border: `1px dashed ${theme.colors.border.strong}`,
},
'&:focus': {
border: `2px solid ${theme.colors.primary.border}`,
},
'&:hover, &:focus': {
'.query-name-edit-icon': {
visibility: 'visible',
},
},
}),
queryName: css({
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.primary.text,
cursor: 'pointer',
overflow: 'hidden',
marginLeft: theme.spacing(0.5),
}),
queryEditIcon: cx(
css({
marginLeft: theme.spacing(1),
visibility: 'hidden',
}),
'query-name-edit-icon'
),
queryNameInput: css({
maxWidth: '300px',
margin: '-4px 0',
}),
transformName: css({
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.text.primary,
fontSize: theme.typography.bodySmall.fontSize,
fontFamily: theme.typography.fontFamilyMonospace,
letterSpacing: '0.05em',
}),
debugWrapper: css({
display: 'flex',
flexDirection: 'row',
}),
debugSeparator: css({
width: '48px',
minHeight: '300px',
display: 'flex',
alignItems: 'center',
alignSelf: 'stretch',
justifyContent: 'center',
margin: `0 ${theme.spacing(0.5)}`,
color: theme.colors.primary.text,
}),
debugTitle: css({
padding: `${theme.spacing(1)} ${theme.spacing(0.25)}`,
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.primary,
borderBottom: `1px solid ${theme.colors.border.weak}`,
flexGrow: 0,
flexShrink: 1,
}),
debug: css({
marginTop: theme.spacing(1),
padding: `0 ${theme.spacing(1, 1, 1)}`,
border: `1px solid ${theme.colors.border.weak}`,
background: `${theme.isLight ? theme.v1.palette.white : theme.v1.palette.gray05}`,
borderRadius: theme.shape.radius.default,
width: '100%',
minHeight: '300px',
display: 'flex',
flexDirection: 'column',
alignSelf: 'stretch',
}),
debugJson: css({
flexGrow: 1,
height: '100%',
overflow: 'hidden',
padding: theme.spacing(0.5),
}),
};
};

View File

@@ -0,0 +1,231 @@
import { css } from '@emotion/css';
import { FormEvent, useMemo, useState } from 'react';
import { DataFrame, GrafanaTheme2, SelectableValue, standardTransformersRegistry } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Box, FilterPill, Grid, IconButton, Input, ScrollContainer, Stack, Switch, useStyles2 } from '@grafana/ui';
import { getCategoriesLabels } from 'app/features/transformers/utils';
import { TransformationCard } from '../../../dashboard/components/TransformationsEditor/TransformationCard';
import { FilterCategory } from '../../../dashboard/components/TransformationsEditor/TransformationsEditor';
import { NewEmptyTransformationsMessage } from './EmptyTransformationsMessage';
const VIEW_ALL_VALUE = 'viewAll';
interface TransformationPickerViewProps {
data: DataFrame[];
onAddTransformation: (selectedItem: SelectableValue<string>, customOptions?: Record<string, unknown>) => void;
onCancel: () => void;
onGoToQueries?: () => void;
}
export function TransformationPickerView({
data,
onAddTransformation,
onCancel,
onGoToQueries,
}: TransformationPickerViewProps) {
const styles = useStyles2(getStyles);
const [search, setSearch] = useState('');
const [showAll, setShowAll] = useState(false);
const [showIllustrations, setShowIllustrations] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<FilterCategory>(VIEW_ALL_VALUE);
const allTransformations = useMemo(
() => standardTransformersRegistry.list().sort((a, b) => (a.name > b.name ? 1 : b.name > a.name ? -1 : 0)),
[]
);
const filterCategoriesLabels: Array<[FilterCategory, string]> = useMemo(
() => [
[VIEW_ALL_VALUE, t('dashboard.transformation-picker-ng.view-all', 'View all')],
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
...(Object.entries(getCategoriesLabels()) as Array<[FilterCategory, string]>),
],
[]
);
const transformations = allTransformations.filter((t) => {
// Filter by category
if (selectedFilter && selectedFilter !== VIEW_ALL_VALUE && !t.categories?.has(selectedFilter)) {
return false;
}
// Filter by search
const searchLower = search.toLocaleLowerCase();
const textMatch =
t.name.toLocaleLowerCase().includes(searchLower) || t.description?.toLocaleLowerCase().includes(searchLower);
const tagMatch = t.tags?.size
? Array.from(t.tags).some((tag) => tag.toLocaleLowerCase().includes(searchLower))
: false;
return textMatch || tagMatch;
});
const onSearchChange = (e: FormEvent<HTMLInputElement>) => setSearch(e.currentTarget.value);
const handleAddTransformation = (transformationId: string) => {
reportInteraction('grafana_panel_transformations_clicked', {
type: transformationId,
context: 'transformation_picker_view',
});
onAddTransformation({ value: transformationId });
};
const searchBoxSuffix = search ? (
<>
{transformations.length} / {allTransformations.length} &nbsp;&nbsp;
<IconButton
name="times"
onClick={() => setSearch('')}
tooltip={t('dashboard-scene.transformation-picker-view.clear-search', 'Clear search')}
/>
</>
) : undefined;
return (
<div className={styles.container}>
<div className={styles.header}>
<Stack direction="row" gap={1} alignItems="center" justifyContent="space-between">
<h3 className={styles.title}>
<Trans i18nKey="dashboard-scene.transformation-picker-view.title">Add transformation</Trans>
</h3>
<IconButton
name="times"
onClick={onCancel}
tooltip={t('dashboard-scene.transformation-picker-view.close', 'Close')}
size="lg"
/>
</Stack>
</div>
{showAll && (
<>
<div className={styles.searchContainer}>
<div className={styles.searchWrapper}>
<Input
value={search}
onChange={onSearchChange}
placeholder={t(
'dashboard-scene.transformation-picker-view.search-placeholder',
'Search for transformation'
)}
suffix={search ? searchBoxSuffix : undefined}
autoFocus
data-testid={selectors.components.Transforms.searchInput}
className={styles.searchInput}
/>
<Stack direction="row" alignItems="center" gap={0.5}>
<span className={styles.switchLabel}>
<Trans i18nKey="dashboard.transformation-picker-ng.show-images">Show images</Trans>
</span>
<Switch value={showIllustrations} onChange={() => setShowIllustrations(!showIllustrations)} />
</Stack>
</div>
<Stack direction="row" wrap="wrap" rowGap={1} columnGap={0.5}>
{filterCategoriesLabels.map(([slug, label]) => (
<FilterPill
key={slug}
onClick={() => setSelectedFilter(slug)}
label={label}
selected={selectedFilter === slug}
/>
))}
</Stack>
</div>
</>
)}
<ScrollContainer>
<Box padding={2}>
{showAll ? (
// Show all transformations when "show more" clicked
transformations.length === 0 ? (
<div className={styles.noResults}>
<p>
<Trans i18nKey="dashboard-scene.transformation-picker-view.no-results">
No transformations found
</Trans>
</p>
</div>
) : (
<Grid columns={3} gap={1}>
{transformations.map((transform) => (
<TransformationCard
key={transform.id}
transform={transform}
showIllustrations={showIllustrations}
showPluginState={false}
showTags={true}
onClick={handleAddTransformation}
data={data}
/>
))}
</Grid>
)
) : (
// Show empty state with featured transformations when not searching
<NewEmptyTransformationsMessage
onShowPicker={() => setShowAll(true)}
onAddTransformation={handleAddTransformation}
onGoToQueries={onGoToQueries}
showIllustrations={true}
/>
)}
</Box>
</ScrollContainer>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
background: theme.colors.background.primary,
}),
header: css({
padding: theme.spacing(2, 2, 0, 2),
borderBottom: `1px solid ${theme.colors.border.weak}`,
paddingBottom: theme.spacing(2),
}),
title: css({
margin: 0,
fontSize: theme.typography.h4.fontSize,
fontWeight: theme.typography.h4.fontWeight,
}),
searchContainer: css({
padding: theme.spacing(2),
borderBottom: `1px solid ${theme.colors.border.weak}`,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
}),
searchWrapper: css({
display: 'flex',
flexWrap: 'wrap',
columnGap: theme.spacing(2),
rowGap: theme.spacing(1),
width: '100%',
paddingBottom: theme.spacing(1),
}),
searchInput: css({
flexGrow: 1,
width: 'initial',
}),
switchLabel: css({
whiteSpace: 'nowrap',
}),
noResults: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '200px',
color: theme.colors.text.secondary,
fontSize: theme.typography.h5.fontSize,
}),
};
};

View File

@@ -0,0 +1,109 @@
/**
* Helper utilities for debug mode functionality in the panel data pane.
*
* Debug mode allows users to interactively enable/disable queries and transformations
* by dragging a visual line through the pipeline. This module provides utilities to:
* - Save original item states
* - Sync items to debug-computed states
* - Restore items to their original states
*/
import { QueryTransformItem } from './types';
/**
* Get the current visibility/disabled state of an item.
*
* @param item - The query, expression, or transform item
* @returns true if the item is currently hidden/disabled, false otherwise
*/
export function getItemHiddenState(item: QueryTransformItem): boolean {
if (item.type === 'query' || item.type === 'expression') {
return (item.data && 'hide' in item.data && item.data.hide) || false;
}
if (item.type === 'transform') {
return (item.data && 'disabled' in item.data && item.data.disabled) || false;
}
return false;
}
/**
* Get the appropriate toggle function for an item based on its type.
*
* @param item - The item to get the toggle function for
* @param onToggleQuery - Toggle function for queries/expressions
* @param onToggleTransform - Toggle function for transformations
* @returns The appropriate toggle function or undefined
*/
function getToggleFunction(
item: QueryTransformItem,
onToggleQuery?: (index: number) => void,
onToggleTransform?: (index: number) => void
): ((index: number) => void) | undefined {
if (item.type === 'query' || item.type === 'expression') {
return onToggleQuery;
}
if (item.type === 'transform') {
return onToggleTransform;
}
return undefined;
}
/**
* Sync all items to match their debug-computed states.
* This toggles item visibility/disabled states to match what the debug line position dictates.
*
* @param items - All query, expression, and transform items
* @param isItemHiddenByDebug - Function that computes if an item should be hidden in debug mode
* @param onToggleQuery - Callback to toggle query/expression visibility
* @param onToggleTransform - Callback to toggle transformation disabled state
*/
export function syncItemsToDebugState(
items: QueryTransformItem[],
isItemHiddenByDebug: (itemId: string) => boolean | null,
onToggleQuery?: (index: number) => void,
onToggleTransform?: (index: number) => void
): void {
items.forEach((item) => {
const debugHidden = isItemHiddenByDebug(item.id);
if (debugHidden === null) {
return;
}
const actuallyHidden = getItemHiddenState(item);
if (debugHidden !== actuallyHidden) {
const toggleFn = getToggleFunction(item, onToggleQuery, onToggleTransform);
toggleFn?.(item.index);
}
});
}
/**
* Restore all items to their original states.
* This should be called when exiting debug mode to restore user settings.
*
* @param items - All query, expression, and transform items
* @param originalStates - Map of item IDs to their original hidden/disabled states
* @param onToggleQuery - Callback to toggle query/expression visibility
* @param onToggleTransform - Callback to toggle transformation disabled state
*/
export function restoreItemStates(
items: QueryTransformItem[],
originalStates: Map<string, boolean>,
onToggleQuery?: (index: number) => void,
onToggleTransform?: (index: number) => void
): void {
items.forEach((item) => {
const originalState = originalStates.get(item.id);
if (originalState === undefined) {
return;
}
const currentState = getItemHiddenState(item);
if (originalState !== currentState) {
const toggleFn = getToggleFunction(item, onToggleQuery, onToggleTransform);
toggleFn?.(item.index);
}
});
}

View File

@@ -0,0 +1,47 @@
import { useMemo } from 'react';
import { DataTransformerConfig } from '@grafana/data';
import { SceneDataQuery } from '@grafana/scenes';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { QueryItem, TransformItem } from './types';
import { isDataTransformerConfig, queryItemId, transformItemId } from './utils';
export function useQueryTransformItems(queries?: SceneDataQuery[], transformations?: DataTransformerConfig[]) {
const result = useMemo(() => {
const queryExpressionItems: QueryItem[] = [];
const transformItems: TransformItem[] = [];
// Add queries and expressions
for (let i = 0; i < (queries?.length ?? 0); i++) {
const query = queries![i];
queryExpressionItems.push({
id: queryItemId(query),
type: isExpressionQuery(query) ? 'expression' : 'query',
data: query,
index: i, // Store actual index in queries array
});
}
// Add transformations
for (let i = 0; i < (transformations?.length ?? 0); i++) {
const transform = transformations![i];
if (isDataTransformerConfig(transform)) {
transformItems.push({
id: transformItemId(i),
type: 'transform',
data: transform,
index: i,
});
}
}
return {
queryExpressionItems,
transformItems,
allItems: [...queryExpressionItems, ...transformItems],
};
}, [queries, transformations]);
return result;
}

View File

@@ -0,0 +1,19 @@
/**
* Custom accent colors for the Panel Data Pane card types.
*/
export const PANEL_DATA_PANE_COLORS = {
query: '#FF8904', // Orange
expression: '#C27AFF', // Purple
transform: '#00D492', // Green
} as const;
/**
* Get panel data pane colors with proper structure for components
*/
export function usePanelDataPaneColors() {
return {
query: { accent: PANEL_DATA_PANE_COLORS.query },
expression: { accent: PANEL_DATA_PANE_COLORS.expression },
transform: { accent: PANEL_DATA_PANE_COLORS.transform },
};
}

View File

@@ -1,4 +1,5 @@
import { SceneObject } from '@grafana/scenes';
import { DataTransformerConfig } from '@grafana/data';
import { SceneDataQuery, SceneObject } from '@grafana/scenes';
export enum TabId {
Queries = 'queries',
@@ -16,3 +17,20 @@ export interface PanelDataPaneTab extends SceneObject {
getTabLabel(): string;
tabId: TabId;
}
interface QueryTransformItemBase {
id: string;
index: number;
}
export interface QueryItem extends QueryTransformItemBase {
type: 'query' | 'expression';
data: SceneDataQuery;
}
export interface TransformItem extends QueryTransformItemBase {
type: 'transform';
data: DataTransformerConfig;
}
export type QueryTransformItem = QueryItem | TransformItem;

View File

@@ -0,0 +1,138 @@
/**
* Custom hook for managing debug mode UI state in the panel data pane.
*
* This hook ONLY manages UI state (position, dragging, etc.).
* The parent component is responsible for saving/syncing/restoring actual item states.
* This keeps the hook pure and avoids side effects.
*/
import { useCallback, useEffect, useState } from 'react';
import { QueryTransformItem } from './types';
// Approximate height of card + gap for position calculations
const CARD_WITH_GAP = 80;
export interface UseDebugModeResult {
// State
isDebugMode: boolean;
debugPosition: number;
isDraggingDebugLine: boolean;
dragOffset: number;
// Actions
toggleDebugMode: () => void;
setDebugPosition: (position: number) => void;
handleDebugLineMouseDown: (e: React.MouseEvent) => void;
isItemHiddenByDebug: (itemId: string) => boolean | null;
}
export function useDebugMode(
allItems: QueryTransformItem[],
onPositionChange?: (newPosition: number) => void
): UseDebugModeResult {
const [isDebugMode, setIsDebugMode] = useState(false);
const [debugPosition, setDebugPosition] = useState(allItems.length);
const [isDraggingDebugLine, setIsDraggingDebugLine] = useState(false);
const [dragStartY, setDragStartY] = useState(0);
const [dragStartPosition, setDragStartPosition] = useState(0);
const [dragOffset, setDragOffset] = useState(0);
// Compute whether an item should be hidden by debug mode
const isItemHiddenByDebug = useCallback(
(itemId: string): boolean | null => {
if (!isDebugMode) {
return null; // Not in debug mode, use actual item state
}
const globalIndex = allItems.findIndex((i) => i.id === itemId);
if (globalIndex === -1) {
return null;
}
// Items at or after debugPosition are hidden
return globalIndex >= debugPosition;
},
[isDebugMode, debugPosition, allItems]
);
const toggleDebugMode = useCallback(() => {
if (!isDebugMode) {
// When enabling debug mode, start with all items enabled
setDebugPosition(allItems.length);
}
setIsDebugMode(!isDebugMode);
}, [isDebugMode, allItems.length]);
const handleDebugLineDrag = useCallback(
(e: MouseEvent) => {
if (!isDraggingDebugLine) {
return;
}
// Calculate how far we've moved from the start
const deltaY = e.clientY - dragStartY;
setDragOffset(deltaY);
},
[isDraggingDebugLine, dragStartY]
);
const handleDebugLineMouseDown = useCallback(
(e: React.MouseEvent) => {
setIsDraggingDebugLine(true);
setDragStartY(e.clientY);
setDragStartPosition(debugPosition);
setDragOffset(0);
},
[debugPosition]
);
const handleDebugLineMouseUp = useCallback(() => {
setIsDraggingDebugLine(false);
// Snap to nearest card position
const cardsMoved = Math.round(dragOffset / CARD_WITH_GAP);
let newPosition = dragStartPosition + cardsMoved;
// Clamp between 1 and allItems.length
newPosition = Math.max(1, Math.min(allItems.length, newPosition));
setDebugPosition(newPosition);
setDragOffset(0);
// Notify parent of position change so it can select the appropriate card
onPositionChange?.(newPosition);
}, [dragOffset, dragStartPosition, allItems.length, onPositionChange]);
// Add global mouse event listeners for dragging
useEffect(() => {
if (isDraggingDebugLine) {
document.addEventListener('mousemove', handleDebugLineDrag);
document.addEventListener('mouseup', handleDebugLineMouseUp);
return () => {
document.removeEventListener('mousemove', handleDebugLineDrag);
document.removeEventListener('mouseup', handleDebugLineMouseUp);
};
}
return undefined;
}, [isDraggingDebugLine, handleDebugLineDrag, handleDebugLineMouseUp]);
// Cleanup drag state when debug mode is disabled
useEffect(() => {
if (!isDebugMode && isDraggingDebugLine) {
setIsDraggingDebugLine(false);
setDragOffset(0);
}
}, [isDebugMode, isDraggingDebugLine]);
return {
isDebugMode,
debugPosition,
isDraggingDebugLine,
dragOffset,
toggleDebugMode,
setDebugPosition,
handleDebugLineMouseDown,
isItemHiddenByDebug,
};
}

View File

@@ -1,4 +1,6 @@
import { DataQuery } from '@grafana/schema';
import { CustomTransformerDefinition, SceneDataQuery } from '@grafana/scenes';
import { DataQuery, DataTransformerConfig } from '@grafana/schema';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { ExpressionQueryType } from 'app/features/expressions/types';
export function findSqlExpression(queries: DataQuery[]) {
@@ -22,3 +24,11 @@ export function scrollToQueryRow(refId: string) {
}
}
}
export const queryItemId = (query: SceneDataQuery) =>
isExpressionQuery(query) ? `expression-${query.refId}` : `query-${query.refId}`;
export const transformItemId = (index: number) => `transform-${index}`;
export const isDataTransformerConfig = (
t: DataTransformerConfig | CustomTransformerDefinition
): t is DataTransformerConfig => t !== null && typeof t === 'object' && 'id' in t && typeof t.id === 'string';

View File

@@ -1,11 +1,12 @@
import { css, cx } from '@emotion/css';
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useMeasure } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { SceneComponentProps, VizPanel } from '@grafana/scenes';
import { Button, Spinner, ToolbarButton, useStyles2, useTheme2 } from '@grafana/ui';
import { SceneComponentProps } from '@grafana/scenes';
import { Spinner, ToolbarButton, useStyles2, useTheme2 } from '@grafana/ui';
import { MIN_SUGGESTIONS_PANE_WIDTH } from 'app/features/panel/suggestions/constants';
import { useEditPaneCollapsed } from '../edit-pane/shared';
@@ -13,6 +14,7 @@ import { NavToolbarActions } from '../scene/NavToolbarActions';
import { UnlinkModal } from '../scene/UnlinkModal';
import { getDashboardSceneFor, getLibraryPanelBehavior } from '../utils/utils';
import { PanelDataSidebar, SidebarSize, SidebarState } from './PanelDataPane/PanelDataSidebar';
import { PanelEditor } from './PanelEditor';
import { SaveLibraryVizPanelModal } from './SaveLibraryVizPanelModal';
import { useSnappingSplitter } from './splitter/useSnappingSplitter';
@@ -21,8 +23,13 @@ import { scrollReflowMediaCondition, useScrollReflowLimit } from './useScrollRef
export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>) {
const dashboard = getDashboardSceneFor(model);
const { optionsPane } = model.useState();
const styles = useStyles2(getStyles);
const styles = useStyles2(getWrapperStyles);
const [isInitiallyCollapsed, setIsCollapsed] = useEditPaneCollapsed();
const [containerRef, { height: containerHeight }] = useMeasure<HTMLDivElement>();
useEffect(() => {
console.log('PanelEditorRenderer containerHeight', containerHeight);
}, [containerHeight]);
const isScrollingLayout = useScrollReflowLimit();
@@ -44,7 +51,7 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
}, [splitterState.collapsed, setIsCollapsed]);
return (
<>
<div style={{ height: '100%' }} ref={containerRef}>
<NavToolbarActions dashboard={dashboard} />
<div
{...containerProps}
@@ -52,7 +59,7 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
data-testid={selectors.components.PanelEditor.General.content}
>
<div {...primaryProps} className={cx(primaryProps.className, styles.body)}>
<VizAndDataPane model={model} />
<VizAndDataPane model={model} containerHeight={Math.max(containerHeight, 500)} />
</div>
<div {...splitterProps} />
<div {...secondaryProps} className={cx(secondaryProps.className, styles.optionsPane)}>
@@ -75,144 +82,114 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
{!splitterState.collapsed && !optionsPane && <Spinner />}
</div>
</div>
</>
</div>
);
}
function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
function VizAndDataPane({
model,
containerHeight = 800,
}: SceneComponentProps<PanelEditor> & { containerHeight?: number }) {
const dashboard = getDashboardSceneFor(model);
const { dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal, tableView } = model.useState();
const panel = model.getPanel();
const libraryPanel = getLibraryPanelBehavior(panel);
const { controls } = dashboard.useState();
const styles = useStyles2(getStyles);
const [sidebarState, setSidebarState] = useState<SidebarState>({ size: SidebarSize.Mini, collapsed: false });
const [vizRef, { height: vizHeight }] = useMeasure<HTMLDivElement>();
const styles = useStyles2(getStyles, sidebarState);
const isScrollingLayout = useScrollReflowLimit();
const { containerProps, primaryProps, secondaryProps, splitterProps, splitterState, onToggleCollapse } =
useSnappingSplitter({
direction: 'column',
dragPosition: 'start',
initialSize: 0.5,
collapseBelowPixels: 150,
disabled: isScrollingLayout,
});
const gridStyles = useMemo(() => {
const rows = [];
const grid = [];
containerProps.className = cx(containerProps.className, styles.container);
if (controls) {
rows.push('32px');
grid.push(['controls', 'controls']);
}
if (!dataPane && !isScrollingLayout) {
primaryProps.style.flexGrow = 1;
grid.push(['viz', 'viz']);
rows.push(`${vizHeight}px`);
if (dataPane) {
rows.push('auto');
grid.push(['sidebar', 'data-pane']);
if (sidebarState.size === SidebarSize.Full) {
for (let i = 0; i < grid.length; i++) {
grid[i][0] = 'sidebar';
}
}
}
return {
height: containerHeight,
maxHeight: containerHeight,
gridTemplateAreas: '\n' + grid.map((row) => `"${row.join(' ')}"`).join('\n'),
gridTemplateRows: rows.map((r) => r).join(' '),
};
}, [controls, dataPane, sidebarState.size, vizHeight, containerHeight]);
if (!containerHeight) {
return null;
}
return (
<div className={cx(styles.pageContainer, controls && styles.pageContainerWithControls)}>
<div className={styles.pageContainer} style={gridStyles}>
{controls && (
<div className={styles.controlsWrapper}>
<controls.Component model={controls} />
</div>
)}
<div {...containerProps}>
<div {...primaryProps} className={cx(primaryProps.className, isScrollingLayout && styles.fixedSizeViz)}>
<VizWrapper panel={panel} tableView={tableView} />
</div>
{showLibraryPanelSaveModal && libraryPanel && (
<SaveLibraryVizPanelModal
libraryPanel={libraryPanel}
onDismiss={model.onDismissLibraryPanelSaveModal}
onConfirm={model.onConfirmSaveLibraryPanel}
onDiscard={model.onDiscard}
></SaveLibraryVizPanelModal>
)}
{showLibraryPanelUnlinkModal && libraryPanel && (
<UnlinkModal
onDismiss={model.onDismissUnlinkLibraryPanelModal}
onConfirm={model.onConfirmUnlinkLibraryPanel}
isOpen
/>
)}
{dataPane && (
<>
<div {...splitterProps} />
<div
{...secondaryProps}
className={cx(secondaryProps.className, isScrollingLayout && styles.fullSizeEditor)}
>
{splitterState.collapsed && (
<div className={styles.expandDataPane}>
<Button
tooltip={t('dashboard-scene.viz-and-data-pane.tooltip-open-query-pane', 'Open query pane')}
icon={'arrow-to-right'}
onClick={onToggleCollapse}
variant="secondary"
size="sm"
className={styles.openDataPaneButton}
aria-label={t('dashboard-scene.viz-and-data-pane.aria-label-open-query-pane', 'Open query pane')}
/>
</div>
)}
{!splitterState.collapsed && <dataPane.Component model={dataPane} />}
</div>
</>
)}
<div
className={cx(styles.viz, isScrollingLayout && styles.fixedSizeViz)}
ref={vizRef}
style={{ height: containerHeight / 2, maxHeight: containerHeight - 80 }}
>
{tableView ? <tableView.Component model={tableView} /> : <panel.Component model={panel} />}
</div>
{dataPane && (
<>
<div className={cx(styles.dataPane, isScrollingLayout && styles.fullSizeEditor)}>
<dataPane.Component model={dataPane} />
</div>
<div className={styles.sidebar}>
<PanelDataSidebar model={dataPane} sidebarState={sidebarState} setSidebarState={setSidebarState} />
</div>
</>
)}
{showLibraryPanelSaveModal && libraryPanel && (
<SaveLibraryVizPanelModal
libraryPanel={libraryPanel}
onDismiss={model.onDismissLibraryPanelSaveModal}
onConfirm={model.onConfirmSaveLibraryPanel}
onDiscard={model.onDiscard}
></SaveLibraryVizPanelModal>
)}
{showLibraryPanelUnlinkModal && libraryPanel && (
<UnlinkModal
onDismiss={model.onDismissUnlinkLibraryPanelModal}
onConfirm={model.onConfirmUnlinkLibraryPanel}
isOpen
/>
)}
</div>
);
}
interface VizWrapperProps {
panel: VizPanel;
tableView?: VizPanel;
}
function VizWrapper({ panel, tableView }: VizWrapperProps) {
const styles = useStyles2(getStyles);
const panelToShow = tableView ?? panel;
return (
<div className={styles.vizWrapper}>
<panelToShow.Component model={panelToShow} />
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
function getWrapperStyles(theme: GrafanaTheme2) {
const scrollReflowMediaQuery = '@media ' + scrollReflowMediaCondition;
return {
pageContainer: css({
display: 'grid',
gridTemplateAreas: `
"panels"`,
gridTemplateColumns: `1fr`,
gridTemplateRows: '1fr',
height: '100%',
[scrollReflowMediaQuery]: {
gridTemplateColumns: `100%`,
},
}),
pageContainerWithControls: css({
gridTemplateAreas: `
"controls"
"panels"`,
gridTemplateRows: 'auto 1fr',
}),
container: css({
gridArea: 'panels',
height: '100%',
}),
canvasContent: css({
label: 'canvas-content',
display: 'flex',
flexDirection: 'column',
flexBasis: '100%',
flexGrow: 1,
minHeight: 0,
width: '100%',
}),
content: css({
position: 'absolute',
width: '100%',
height: '100%',
overflow: 'unset',
paddingTop: theme.spacing(2),
[scrollReflowMediaQuery]: {
height: 'auto',
display: 'grid',
@@ -234,7 +211,6 @@ function getStyles(theme: GrafanaTheme2) {
flexDirection: 'column',
borderLeft: `1px solid ${theme.colors.border.weak}`,
background: theme.colors.background.primary,
marginTop: theme.spacing(2),
borderTop: `1px solid ${theme.colors.border.weak}`,
borderTopLeftRadius: theme.shape.radius.default,
}),
@@ -243,24 +219,55 @@ function getStyles(theme: GrafanaTheme2) {
flexDirection: 'column',
padding: theme.spacing(2, 1),
}),
expandDataPane: css({
display: 'flex',
flexDirection: 'row',
padding: theme.spacing(1),
borderTop: `1px solid ${theme.colors.border.weak}`,
borderRight: `1px solid ${theme.colors.border.weak}`,
background: theme.colors.background.primary,
flexGrow: 1,
justifyContent: 'space-around',
}),
rotate180: css({
rotate: '180deg',
}),
};
}
function getStyles(theme: GrafanaTheme2, sidebarState: SidebarState) {
const scrollReflowMediaQuery = '@media ' + scrollReflowMediaCondition;
return {
pageContainer: css({
display: 'grid',
gap: theme.spacing(2),
gridTemplateColumns: `auto 1fr`,
overflow: 'hidden',
[scrollReflowMediaQuery]: {
gridTemplateColumns: `100%`,
},
}),
sidebar: css({
gridArea: 'sidebar',
overflow: 'auto',
resize: 'horizontal',
minWidth: 285,
maxWidth: 400,
...(sidebarState.size === SidebarSize.Mini && {
paddingLeft: theme.spacing(2),
}),
}),
viz: css({
gridArea: 'viz',
overflow: 'auto',
resize: 'vertical',
height: '100%',
minHeight: 100,
...(sidebarState.size === SidebarSize.Mini && {
paddingLeft: theme.spacing(2),
}),
}),
dataPane: css({
gridArea: 'data-pane',
overflow: 'hidden',
}),
controlsWrapper: css({
gridArea: 'controls',
display: 'flex',
flexDirection: 'column',
flexGrow: 0,
gridArea: 'controls',
...(sidebarState.size === SidebarSize.Mini && {
paddingLeft: theme.spacing(2),
}),
}),
openDataPaneButton: css({
width: theme.spacing(8),
@@ -269,11 +276,6 @@ function getStyles(theme: GrafanaTheme2) {
rotate: '-90deg',
},
}),
vizWrapper: css({
height: '100%',
width: '100%',
paddingLeft: theme.spacing(2),
}),
fixedSizeViz: css({
height: '100vh',
}),

View File

@@ -158,7 +158,7 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPa
}, [pluginId]);
return (
<>
<div className={styles.wrapper}>
{!isVizPickerOpen && (
<>
<div className={styles.top}>
@@ -235,12 +235,19 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPa
showBackButton={config.featureToggles.newVizSuggestions ? hasPickedViz || !isNewPanel : true}
/>
)}
</>
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
display: 'contents',
'& h6': {
fontFamily: theme.typography.fontFamilyMonospace,
textTransform: 'uppercase',
},
}),
top: css({
display: 'flex',
flexDirection: 'row',

View File

@@ -250,12 +250,12 @@ function getStyles(theme: GrafanaTheme2) {
return {
controls: css({
gap: theme.spacing(1),
padding: theme.spacing(2, 2, 1, 2),
flexDirection: 'row',
flexWrap: 'nowrap',
position: 'relative',
width: '100%',
marginLeft: 'auto',
marginBottom: theme.spacing(-1),
[theme.breakpoints.down('sm')]: {
flexDirection: 'column-reverse',
alignItems: 'stretch',

View File

@@ -227,6 +227,7 @@ export const TransformationOperationRow = ({
draggable
actions={renderActions}
disabled={disabled}
hideHeader={true}
expanderMessages={{
close: 'Collapse transformation row',
open: 'Expand transformation row',

View File

@@ -1,3 +1,5 @@
import { memo } from 'react';
import { DataTransformerConfig, standardTransformersRegistry } from '@grafana/data';
import { TransformationOperationRow } from './TransformationOperationRow';
@@ -9,36 +11,56 @@ interface TransformationOperationRowsProps {
configs: TransformationsEditorTransformation[];
onRemove: (index: number) => void;
onChange: (index: number, config: DataTransformerConfig) => void;
selectedIdx?: number;
}
export const TransformationOperationRows = ({
data,
onChange,
onRemove,
configs,
}: TransformationOperationRowsProps) => {
return (
<>
{configs.map((t, i) => {
const uiConfig = standardTransformersRegistry.getIfExists(t.transformation.id);
export const TransformationOperationRows = memo(
({ data, onChange, onRemove, configs, selectedIdx }: TransformationOperationRowsProps) => {
if (selectedIdx != null) {
const t = configs[selectedIdx];
if (!t) {
return null;
}
if (!uiConfig) {
return null;
}
const uiConfig = standardTransformersRegistry.getIfExists(t.transformation.id);
if (!uiConfig) {
return null;
}
return (
<TransformationOperationRow
index={i}
id={`${t.id}`}
key={`${t.id}`}
data={data}
configs={configs}
uiConfig={uiConfig}
onRemove={onRemove}
onChange={onChange}
/>
);
})}
</>
);
};
return (
<TransformationOperationRow
index={selectedIdx}
id={`${t.id}`}
key={`${t.id}`}
data={data}
configs={configs}
uiConfig={uiConfig}
onRemove={onRemove}
onChange={onChange}
/>
);
}
return configs.map((t, i) => {
const uiConfig = standardTransformersRegistry.getIfExists(t.transformation.id);
if (!uiConfig) {
return null;
}
return (
<TransformationOperationRow
index={i}
id={`${t.id}`}
key={`${t.id}`}
data={data}
configs={configs}
uiConfig={uiConfig}
onRemove={onRemove}
onChange={onChange}
/>
);
});
}
);
TransformationOperationRows.displayName = 'TransformationOperationRows';

View File

@@ -5,14 +5,7 @@ import { FeatureState, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Dropdown, FeatureBadge, Icon, Menu, Tooltip, useStyles2 } from '@grafana/ui';
import { ExpressionQueryType, expressionTypes } from 'app/features/expressions/types';
const EXPRESSION_ICON_MAP = {
[ExpressionQueryType.math]: 'calculator-alt',
[ExpressionQueryType.reduce]: 'compress-arrows',
[ExpressionQueryType.resample]: 'sync',
[ExpressionQueryType.classic]: 'cog',
[ExpressionQueryType.threshold]: 'sliders-v-alt',
[ExpressionQueryType.sql]: 'database',
} as const satisfies Record<ExpressionQueryType, string>;
import { getExpressionIcon } from '../types';
interface ExpressionTypeDropdownProps {
children: ReactElement;
@@ -35,7 +28,7 @@ const ExpressionMenuItem = memo<ExpressionMenuItemProps>(({ item, onSelect }) =>
component={() => (
<div className={styles.expressionTypeItem} role="menuitem">
<div className={styles.expressionTypeItemContent} data-testid={`expression-type-${value}`}>
<Icon className={styles.icon} name={EXPRESSION_ICON_MAP[value!]} aria-hidden="true" />
<Icon className={styles.icon} name={getExpressionIcon(value!)} aria-hidden="true" />
{label}
{value === ExpressionQueryType.sql && <FeatureBadge featureState={FeatureState.preview} />}
</div>

View File

@@ -1,4 +1,4 @@
import { DataQuery, ReducerID, SelectableValue } from '@grafana/data';
import { DataQuery, IconName, ReducerID, SelectableValue } from '@grafana/data';
import { config } from 'app/core/config';
import { EvalFunction } from '../alerting/state/alertDef';
@@ -17,6 +17,7 @@ export enum ExpressionQueryType {
sql = 'sql',
}
// FIXME: should be translated
export const getExpressionLabel = (type: ExpressionQueryType) => {
switch (type) {
case ExpressionQueryType.math:
@@ -34,6 +35,19 @@ export const getExpressionLabel = (type: ExpressionQueryType) => {
}
};
export const EXPRESSION_ICON_MAP = {
[ExpressionQueryType.math]: 'calculator-alt',
[ExpressionQueryType.reduce]: 'compress-arrows',
[ExpressionQueryType.resample]: 'sync',
[ExpressionQueryType.classic]: 'cog',
[ExpressionQueryType.threshold]: 'sliders-v-alt',
[ExpressionQueryType.sql]: 'database',
} as const satisfies Record<ExpressionQueryType, string>;
export const getExpressionIcon = (type?: ExpressionQueryType): IconName => {
return type && type in EXPRESSION_ICON_MAP ? EXPRESSION_ICON_MAP[type] : 'calculator-alt';
};
export const expressionTypes: Array<SelectableValue<ExpressionQueryType>> = [
{
value: ExpressionQueryType.math,

View File

@@ -1,10 +1,9 @@
import { css, cx } from '@emotion/css';
import { css } from '@emotion/css';
import React, { useState, ChangeEvent, FocusEvent, useCallback } from 'react';
import { rangeUtil, PanelData, DataSourceApi, GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Trans } from '@grafana/i18n';
import { Input, InlineSwitch, useStyles2, InlineLabel } from '@grafana/ui';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { QueryGroupOptions } from 'app/types/query';
interface Props {
@@ -18,7 +17,6 @@ export const QueryGroupOptionsEditor = React.memo(({ options, dataSource, data,
const [timeRangeFrom, setTimeRangeFrom] = useState(options.timeRange?.from || '');
const [timeRangeShift, setTimeRangeShift] = useState(options.timeRange?.shift || '');
const [timeRangeHide, setTimeRangeHide] = useState(options.timeRange?.hide ?? false);
const [isOpen, setIsOpen] = useState(false);
const [relativeTimeIsValid, setRelativeTimeIsValid] = useState(true);
const [timeShiftIsValid, setTimeShiftIsValid] = useState(true);
@@ -141,14 +139,6 @@ export const QueryGroupOptionsEditor = React.memo(({ options, dataSource, data,
[onChange, options]
);
const onOpenOptions = useCallback(() => {
setIsOpen(true);
}, []);
const onCloseOptions = useCallback(() => {
setIsOpen(false);
}, []);
const renderCacheTimeoutOption = () => {
const tooltip = `If your time series store has a query cache this option can override the default cache timeout. Specify a
numeric value in seconds.`;
@@ -204,7 +194,7 @@ export const QueryGroupOptionsEditor = React.memo(({ options, dataSource, data,
const renderMaxDataPointsOption = () => {
const realMd = data.request?.maxDataPoints;
const value = options.maxDataPoints ?? '';
const isAuto = value === '';
// const isAuto = value === '';
return (
<>
@@ -230,7 +220,7 @@ export const QueryGroupOptionsEditor = React.memo(({ options, dataSource, data,
onBlur={onMaxDataPointsBlur}
defaultValue={value}
/>
{isAuto && (
{/* {isAuto && (
<>
<span className={cx(styles.noSquish, styles.operator)}>=</span>
<span className={cx(styles.noSquish, styles.left)}>
@@ -239,7 +229,7 @@ export const QueryGroupOptionsEditor = React.memo(({ options, dataSource, data,
</Trans>
</span>
</>
)}
)} */}
</>
);
};
@@ -284,127 +274,89 @@ export const QueryGroupOptionsEditor = React.memo(({ options, dataSource, data,
<Trans i18nKey="query.query-group-options-editor.render-interval-option.interval">Interval</Trans>
</InlineLabel>
<span className={styles.noSquish}>{realInterval}</span>
<span className={cx(styles.noSquish, styles.operator)}>=</span>
{/* <span className={cx(styles.noSquish, styles.operator)}>=</span>
<span className={cx(styles.noSquish, styles.left)}>
<Trans i18nKey="query.query-group-options-editor.render-interval-option.time-range-max-data-points">
Time range / max data points
</Trans>
</span>
</>
);
};
const renderCollapsedText = (): React.ReactNode | undefined => {
if (isOpen) {
return undefined;
}
let mdDesc = options.maxDataPoints ?? '';
if (mdDesc === '' && data.request) {
mdDesc = `auto = ${data.request.maxDataPoints}`;
}
const intervalDesc = data.request?.interval ?? options.minInterval;
return (
<>
{
<span className={styles.collapsedText}>
<Trans i18nKey="query.query-group-options-editor.collapsed-max-data-points">MD = {{ mdDesc }}</Trans>
</span>
}
{
<span className={styles.collapsedText}>
<Trans i18nKey="query.query-group-options-editor.collapsed-interval">Interval = {{ intervalDesc }}</Trans>
</span>
}
</span> */}
</>
);
};
return (
<QueryOperationRow
id="Query options"
index={0}
title={t('query.query-group-options-editor.Query options-title-query-options', 'Query options')}
headerElement={renderCollapsedText()}
isOpen={isOpen}
onOpen={onOpenOptions}
onClose={onCloseOptions}
>
<div className={styles.grid}>
{renderMaxDataPointsOption()}
{renderIntervalOption()}
{renderCacheTimeoutOption()}
{renderQueryCachingTTLOption()}
<div className={styles.grid}>
{renderMaxDataPointsOption()}
{renderIntervalOption()}
{renderCacheTimeoutOption()}
{renderQueryCachingTTLOption()}
<InlineLabel
htmlFor="relative-time-input"
tooltip={
<Trans
i18nKey="query.query-group-options-editor.relative-time-tooltip"
values={{ relativeFrom: 'now-5m', relativeTo: '5m', variable: '$_relativeTime' }}
>
Overrides the relative time range for individual panels, which causes them to be different than what is
selected in the dashboard time picker in the top-right corner of the dashboard. For example to configure
the Last 5 minutes the Relative time should be <code>{'{{relativeFrom}}'}</code> and{' '}
<code>{'{{relativeTo}}'}</code>, or variables like <code>{'{{variable}}'}</code>.
</Trans>
}
>
<Trans i18nKey="query.query-group-options-editor.relative-time">Relative time</Trans>
</InlineLabel>
<Input
id="relative-time-input"
type="text"
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="1h"
onChange={onRelativeTimeChange}
onBlur={onOverrideTime}
invalid={!relativeTimeIsValid}
value={timeRangeFrom}
/>
<InlineLabel
htmlFor="time-shift-input"
className={styles.firstColumn}
tooltip={
<Trans
i18nKey="query.query-group-options-editor.time-shift-tooltip"
values={{ relativeFrom: 'now-1h', relativeTo: '1h', variable: '$_timeShift' }}
>
Overrides the time range for individual panels by shifting its start and end relative to the time picker.
For example to configure the Last 1h the Time shift should be <code>{'{{relativeFrom}}'}</code> and{' '}
<code>{'{{relativeTo}}'}</code>, or variables like <code>{'{{variable}}'}</code>.
</Trans>
}
>
<Trans i18nKey="query.query-group-options-editor.time-shift">Time shift</Trans>
</InlineLabel>
<Input
id="time-shift-input"
type="text"
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="1h"
onChange={onTimeShiftChange}
onBlur={onTimeShift}
invalid={!timeShiftIsValid}
value={timeRangeShift}
/>
{(timeRangeShift || timeRangeFrom) && (
<>
<InlineLabel htmlFor="hide-time-info-switch" className={styles.firstColumn}>
<Trans i18nKey="query.query-group-options-editor.hide-time-info">Hide time info</Trans>
</InlineLabel>
<InlineSwitch
id="hide-time-info-switch"
className={styles.left}
value={timeRangeHide}
onChange={onToggleTimeOverride}
/>
</>
)}
</div>
</QueryOperationRow>
<InlineLabel
htmlFor="relative-time-input"
tooltip={
<Trans
i18nKey="query.query-group-options-editor.relative-time-tooltip"
values={{ relativeFrom: 'now-5m', relativeTo: '5m', variable: '$_relativeTime' }}
>
Overrides the relative time range for individual panels, which causes them to be different than what is
selected in the dashboard time picker in the top-right corner of the dashboard. For example to configure the
Last 5 minutes the Relative time should be <code>{'{{relativeFrom}}'}</code> and{' '}
<code>{'{{relativeTo}}'}</code>, or variables like <code>{'{{variable}}'}</code>.
</Trans>
}
>
<Trans i18nKey="query.query-group-options-editor.relative-time">Relative time</Trans>
</InlineLabel>
<Input
id="relative-time-input"
type="text"
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="1h"
onChange={onRelativeTimeChange}
onBlur={onOverrideTime}
invalid={!relativeTimeIsValid}
value={timeRangeFrom}
/>
<InlineLabel
htmlFor="time-shift-input"
className={styles.firstColumn}
tooltip={
<Trans
i18nKey="query.query-group-options-editor.time-shift-tooltip"
values={{ relativeFrom: 'now-1h', relativeTo: '1h', variable: '$_timeShift' }}
>
Overrides the time range for individual panels by shifting its start and end relative to the time picker.
For example to configure the Last 1h the Time shift should be <code>{'{{relativeFrom}}'}</code> and{' '}
<code>{'{{relativeTo}}'}</code>, or variables like <code>{'{{variable}}'}</code>.
</Trans>
}
>
<Trans i18nKey="query.query-group-options-editor.time-shift">Time shift</Trans>
</InlineLabel>
<Input
id="time-shift-input"
type="text"
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="1h"
onChange={onTimeShiftChange}
onBlur={onTimeShift}
invalid={!timeShiftIsValid}
value={timeRangeShift}
/>
{(timeRangeShift || timeRangeFrom) && (
<>
<InlineLabel htmlFor="hide-time-info-switch" className={styles.firstColumn}>
<Trans i18nKey="query.query-group-options-editor.hide-time-info">Hide time info</Trans>
</InlineLabel>
<InlineSwitch
id="hide-time-info-switch"
className={styles.left}
value={timeRangeHide}
onChange={onToggleTimeOverride}
/>
</>
)}
</div>
);
});
@@ -422,10 +374,11 @@ function getStyles(theme: GrafanaTheme2) {
return {
grid: css({
display: 'grid',
gridTemplateColumns: `auto minmax(5em, 1fr) auto 1fr`,
gridTemplateColumns: `auto 8em`,
gap: theme.spacing(0.5),
gridAutoRows: theme.spacing(4),
whiteSpace: 'nowrap',
minWidth: '100%',
}),
firstColumn: css({
gridColumn: 1,

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect width="18" height="18" rx="6" fill="#3D71D9" fill-opacity="0.15"/>
<path d="M2.625 11.25H3.5625V8.625L5.475 11.25H6.375V6.75H5.4375V9.375L3.5625 6.75H2.625V11.25ZM7.125 11.25H10.125V10.3125H8.25V9.4875H10.125V8.55H8.25V7.6875H10.125V6.75H7.125V11.25ZM11.625 11.25H14.625C14.8375 11.25 15.0156 11.1781 15.1594 11.0344C15.3031 10.8906 15.375 10.7125 15.375 10.5V6.75H14.4375V10.125H13.6125V7.5H12.675V10.125H11.8125V6.75H10.875V10.5C10.875 10.7125 10.9469 10.8906 11.0906 11.0344C11.2344 11.1781 11.4125 11.25 11.625 11.25Z" fill="#5794F2"/>
</svg>

After

Width:  |  Height:  |  Size: 654 B

View File

@@ -0,0 +1,15 @@
<svg viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2221_8678)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.709 16.2723L9.0591 11.3225L9.05674 11.3202C8.99857 11.1443 8.99043 10.9556 9.03322 10.7753C9.07602 10.595 9.16806 10.4302 9.29909 10.2992C9.43011 10.1681 9.59496 10.0761 9.77525 10.0333C9.95554 9.99051 10.1442 9.99865 10.3201 10.0568L15.2699 11.7067C15.4175 11.7573 15.552 11.8402 15.6635 11.9495C15.7813 12.0674 15.8678 12.2128 15.9151 12.3726C15.9624 12.5324 15.969 12.7014 15.9343 12.8644C15.8997 13.0274 15.8248 13.1792 15.7166 13.3059C15.6084 13.4326 15.4702 13.5303 15.3146 13.59L13.4832 14.29C13.4398 14.3078 13.4003 14.334 13.3671 14.3672C13.334 14.4004 13.3077 14.4399 13.29 14.4833L12.5899 16.3147C12.5158 16.5066 12.3843 16.671 12.2133 16.7854C12.0423 16.8998 11.8402 16.9587 11.6346 16.9541C11.4289 16.9494 11.2297 16.8815 11.064 16.7595C10.8984 16.6375 10.7744 16.4673 10.709 16.2723ZM15.0601 12.3408L10.1103 10.6909C10.0519 10.672 9.98949 10.6697 9.92985 10.6841C9.87021 10.6985 9.8157 10.729 9.77232 10.7724C9.72894 10.8158 9.69838 10.8703 9.68399 10.9299C9.6696 10.9896 9.67195 11.052 9.69078 11.1104L11.3407 16.0602C11.3632 16.1243 11.4048 16.18 11.4599 16.2198C11.515 16.2597 11.5809 16.2817 11.6489 16.283C11.7169 16.2843 11.7836 16.2647 11.8402 16.227C11.8967 16.1893 11.9404 16.1352 11.9653 16.0719L12.6677 14.2429C12.6773 14.2187 12.6883 14.1951 12.7007 14.1722C12.7304 14.1171 12.8939 13.8942 12.8939 13.8942C12.8939 13.8942 13.0955 13.7422 13.1721 13.7008C13.195 13.6884 13.2186 13.6774 13.2428 13.6678L15.0719 12.9654C15.1351 12.9405 15.1892 12.8968 15.2269 12.8402C15.2646 12.7837 15.2842 12.717 15.2829 12.649C15.2816 12.581 15.2596 12.5151 15.2198 12.46C15.1799 12.4049 15.1242 12.3633 15.0601 12.3408Z" fill="currentColor"/>
</g>
<path d="M9.70686 3.04459C9.80477 2.67921 10.165 2.45827 10.5115 2.55111C10.858 2.64395 11.0595 3.01541 10.9616 3.38079L10.2525 6.02711C10.1546 6.39249 9.79434 6.61343 9.44787 6.5206C9.10139 6.42776 8.89988 6.0563 8.99778 5.69092L9.70686 3.04459Z" fill="currentColor"/>
<path d="M14.4934 7.08119C14.8424 6.9789 15.197 7.19013 15.2855 7.55297C15.3739 7.91582 15.1627 8.29289 14.8137 8.39518L12.286 9.13603C11.937 9.23832 11.5823 9.0271 11.4939 8.66425C11.4054 8.3014 11.6166 7.92433 11.9657 7.82204L14.4934 7.08119Z" fill="currentColor"/>
<path d="M7.59344 15.9508C7.49553 16.3162 7.13529 16.5371 6.78882 16.4443C6.44234 16.3515 6.24083 15.98 6.33873 15.6146L7.04781 12.9683C7.14572 12.6029 7.50596 12.382 7.85244 12.4748C8.19891 12.5677 8.40042 12.9391 8.30252 13.3045L7.59344 15.9508Z" fill="currentColor"/>
<path d="M2.8069 11.9142C2.45789 12.0165 2.10326 11.8053 2.01481 11.4424C1.92636 11.0796 2.13758 10.7025 2.48659 10.6002L5.01434 9.85938C5.36335 9.75709 5.71798 9.96832 5.80643 10.3312C5.89488 10.694 5.68366 11.0711 5.33465 11.1734L2.8069 11.9142Z" fill="currentColor"/>
<path d="M3.86361 5.46111C3.61251 5.19802 3.61812 4.76586 3.87614 4.49585C4.13417 4.22584 4.5469 4.22023 4.79801 4.48332L6.61668 6.38879C6.86778 6.65188 6.86217 7.08404 6.60415 7.35405C6.34612 7.62406 5.93339 7.62967 5.68228 7.36658L3.86361 5.46111Z" fill="currentColor"/>
<defs>
<clipPath id="clip0_2221_8678">
<rect width="8" height="8" fill="white" transform="translate(11.6567 18.3135) rotate(-135)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.81875 4.55625L7.94375 2.68125C7.83125 2.56875 7.675 2.5 7.5 2.5C7.15625 2.5 6.875 2.78125 6.875 3.125C6.875 3.3 6.94375 3.45625 7.05625 3.56875L8.49375 5L7.0625 6.43125C6.94375 6.54375 6.875 6.7 6.875 6.875C6.875 7.21875 7.15625 7.5 7.5 7.5C7.675 7.5 7.83125 7.43125 7.94375 7.31875L9.81875 5.44375C9.93125 5.33125 10 5.175 10 5C10 4.825 9.93125 4.66875 9.81875 4.55625ZM3.125 3.125C3.125 2.78125 2.84375 2.5 2.5 2.5C2.325 2.5 2.16875 2.56875 2.05625 2.68125L0.18125 4.55625C0.06875 4.66875 0 4.825 0 5C0 5.175 0.06875 5.33125 0.18125 5.44375L2.05625 7.31875C2.16875 7.43125 2.325 7.5 2.5 7.5C2.84375 7.5 3.125 7.21875 3.125 6.875C3.125 6.7 3.05625 6.54375 2.94375 6.43125L1.50625 5L2.9375 3.56875C3.05625 3.45625 3.125 3.3 3.125 3.125ZM5.625 1.25C5.325 1.25 5.08125 1.46875 5.025 1.75625L3.775 8.00625C3.76875 8.04375 3.75 8.08125 3.75 8.125C3.75 8.46875 4.03125 8.75 4.375 8.75C4.675 8.75 4.91875 8.53125 4.975 8.24375L6.225 1.99375C6.23125 1.95625 6.25 1.91875 6.25 1.875C6.25 1.53125 5.96875 1.25 5.625 1.25Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 16 5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.8 1.66667H15.2C15.4122 1.66667 15.6157 1.57887 15.7657 1.42259C15.9157 1.26631 16 1.05435 16 0.833333C16 0.61232 15.9157 0.400358 15.7657 0.244078C15.6157 0.0877975 15.4122 0 15.2 0H0.8C0.587827 0 0.384344 0.0877975 0.234315 0.244078C0.0842854 0.400358 0 0.61232 0 0.833333C0 1.05435 0.0842854 1.26631 0.234315 1.42259C0.384344 1.57887 0.587827 1.66667 0.8 1.66667ZM15.2 3.33333H0.8C0.587827 3.33333 0.384344 3.42113 0.234315 3.57741C0.0842854 3.73369 0 3.94565 0 4.16667C0 4.38768 0.0842854 4.59964 0.234315 4.75592C0.384344 4.9122 0.587827 5 0.8 5H15.2C15.4122 5 15.6157 4.9122 15.7657 4.75592C15.9157 4.59964 16 4.38768 16 4.16667C16 3.94565 15.9157 3.73369 15.7657 3.57741C15.6157 3.42113 15.4122 3.33333 15.2 3.33333Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 837 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.85625 4.3875L0.1875 7.05625C0.06875 7.16875 0 7.325 0 7.5C0 7.84375 0.28125 8.125 0.625 8.125C0.8 8.125 0.95625 8.05625 1.06875 7.94375L3.7375 5.275C3.375 5.05625 3.06875 4.75 2.85625 4.3875ZM9.375 5C9.03125 5 8.75 5.28125 8.75 5.625V5.99375L7.14375 4.3875C6.93125 4.75 6.625 5.05625 6.2625 5.26875L7.86875 6.875H7.5C7.15625 6.875 6.875 7.15625 6.875 7.5C6.875 7.84375 7.15625 8.125 7.5 8.125H9.375C9.71875 8.125 10 7.84375 10 7.5V5.625C10 5.28125 9.71875 5 9.375 5ZM6.875 3.125C6.875 2.0875 6.0375 1.25 5 1.25C3.9625 1.25 3.125 2.0875 3.125 3.125C3.125 4.1625 3.9625 5 5 5C6.0375 5 6.875 4.1625 6.875 3.125ZM5 3.75C4.65625 3.75 4.375 3.46875 4.375 3.125C4.375 2.78125 4.65625 2.5 5 2.5C5.34375 2.5 5.625 2.78125 5.625 3.125C5.625 3.46875 5.34375 3.75 5 3.75Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 915 B

View File

@@ -3233,6 +3233,17 @@
"title-query-result": "Query result"
}
},
"app": {
"features": {
"dashboardScene": {
"panelEdit": {
"panelDataPane": {
"expandSidebar": "Expand sidebar"
}
}
}
}
},
"app-chrome": {
"skip-content-button": "Skip to main content",
"top-bar": {
@@ -5797,6 +5808,19 @@
"label-use-static-key-dimensions": "Use static key dimensions",
"name-allow-custom-values": "Allow custom values"
},
"add-data-item-menu": {
"add-button": "Add",
"add-from-saved-queries": "From saved queries",
"add-query": "Query",
"add-transformation": "Transformation",
"expression-classic": "Classic condition",
"expression-math": "Math",
"expression-reduce": "Reduce",
"expression-resample": "Resample",
"expression-sql": "SQL",
"expression-threshold": "Threshold",
"expressions-group": "Expression"
},
"add-to-dashboard": {
"message": {
"could-navigate-selected-dashboard-please-again": "Could not navigate to the selected dashboard. Please try again.",
@@ -5825,6 +5849,13 @@
"new-panel": "New panel"
}
},
"ai-mode-card": {
"aria-label": "AI prompt input",
"click-nodes-to-add-context": "Click nodes to add context",
"prompt-placeholder": "Describe what you want to do...",
"remove-context": "Remove context",
"submit": "Send message"
},
"annotation-settings-edit": {
"back-to-list": "Back to list",
"delete": "Delete",
@@ -5963,6 +5994,26 @@
"description-label": {
"description": "Description"
},
"detail-view-header": {
"actions": "Actions",
"debug": "Debug transformation",
"debug-transformation": "Debug transformation",
"disable-transform": "Disable",
"duplicate-query": "Duplicate",
"edit-query-name": "Edit query name",
"enable-transform": "Enable",
"hide-response": "Hide response",
"input-data": "Input data",
"output-data": "Output data",
"query-inspector": "Query inspector",
"remove-query": "Remove",
"remove-transform": "Remove",
"run-query": "RUN QUERY",
"save-query": "SAVE",
"show-documentation": "Show documentation",
"show-response": "Show response",
"transformation-help": "Transformation help"
},
"edit-link-view": {
"edit-link-page-nav": {
"text": {
@@ -5987,6 +6038,9 @@
"sql-name": "Transform with SQL",
"sql-transformation-description": "Manipulate your data using MySQL-like syntax"
},
"expression-detail-view": {
"loading": "Loading expression editor..."
},
"general-settings-edit-view": {
"editable_options": {
"label": {
@@ -6179,6 +6233,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"
},
@@ -6189,13 +6246,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"
@@ -6241,9 +6291,47 @@
"see-docs": "See <2>documentation</2> for more information about provisioning.",
"title-cannot-delete-provisioned-dashboard": "Cannot delete provisioned dashboard"
},
"query-detail-view": {
"loading": "Loading data source...",
"no-editor": "This data source does not have a query editor",
"options": "Options",
"query-options": "Query Options"
},
"query-editor": {
"query": "Query"
},
"query-transform-card": {
"disable-transform": "Disable transformation",
"duplicate": "Duplicate query",
"enable-transform": "Enable transformation",
"expression": {
"label": "Expression"
},
"hide-response": "Hide response",
"query": {
"label": "Query"
},
"remove-query": "Remove query",
"remove-transform": "Remove transformation",
"show-response": "Show response",
"transform": {
"label": "Transform"
}
},
"query-transform-list": {
"add": "Add",
"ai-mode": "AI",
"ai-mode-enter": "Supercharge your pipeline with AI",
"ai-mode-exit": "Exit AI mode",
"debug-mode-enter": "Step through your pipeline",
"debug-mode-exit": "Exit debug mode",
"debug-position": "Debug position",
"header": "Pipeline",
"nodes": "nodes",
"notifications-tooltip": "Alerts (coming soon)",
"queries-expressions": "Queries & Expressions",
"transformations": "Transformations"
},
"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.",
@@ -6415,6 +6503,13 @@
}
}
},
"transformation-picker-view": {
"clear-search": "Clear search",
"close": "Close",
"no-results": "No transformations found",
"search-placeholder": "Search for transformation",
"title": "Add transformation"
},
"transformations-drawer": {
"search-box-suffix": {
"tooltip-clear-search": "Clear search"
@@ -6538,10 +6633,6 @@
"aria-label-change-visualization": "Change visualization",
"text": "Change"
},
"viz-and-data-pane": {
"aria-label-open-query-pane": "Open query pane",
"tooltip-open-query-pane": "Open query pane"
},
"viz-panel-links-renderer": {
"aria-label-panel-links": "Panel links"
}
@@ -7358,6 +7449,9 @@
"pane": {
"loading-placeholder": "Loading..."
},
"query-library": {
"default-title": "New query"
},
"queryless-apps-extensions": {
"aria-label-go-queryless": "Go queryless"
},
@@ -12305,10 +12399,14 @@
"title-data-source-help": "Data source help"
},
"query-group-options-editor": {
"collapsed-interval": "Interval = {{intervalDesc}}",
"collapsed-max-data-points": "MD = {{mdDesc}}",
"collapsed-cache-timeout-label": "Cache timeout",
"collapsed-cache-ttl-label": "Cache TTL",
"collapsed-interval-label": "Interval",
"collapsed-max-data-points-label": "Max data points",
"collapsed-min-interval-label": "Min interval",
"collapsed-relative-time-label": "Relative time",
"collapsed-time-shift-label": "Time shift",
"hide-time-info": "Hide time info",
"Query options-title-query-options": "Query options",
"relative-time": "Relative time",
"relative-time-tooltip": "Overrides the relative time range for individual panels, which causes them to be different than what is selected in the dashboard time picker in the top-right corner of the dashboard. For example to configure the Last 5 minutes the Relative time should be <1>{{relativeFrom}}</1> and <4>{{relativeTo}}</4>, or variables like <6>{{variable}}</6>.",
"render-cache-timeout-option": {
@@ -12318,13 +12416,11 @@
"interval": "Interval",
"interval-tooltip": "The evaluated interval that is sent to data source and is used in <1>$__interval</1> and <4>$__interval_ms</4>. This value is not exactly equal to <6>Time range / max data points</6>, it will approximate a series of magic number.",
"min-interval": "Min interval",
"min-interval-tooltip": "A lower limit for the interval. Recommended to be set to write frequency, for example <1>1m</1> if your data is written every minute. Default value can be set in data source settings for most data sources.",
"time-range-max-data-points": "Time range / max data points"
"min-interval-tooltip": "A lower limit for the interval. Recommended to be set to write frequency, for example <1>1m</1> if your data is written every minute. Default value can be set in data source settings for most data sources."
},
"render-max-data-points-option": {
"max-data-points": "Max data points",
"max-data-points-tooltip": "The maximum data points per series. Used directly by some data sources and used in calculation of auto interval. With streaming data this value is used for the rolling buffer.",
"width-of-panel": "Width of panel"
"max-data-points-tooltip": "The maximum data points per series. Used directly by some data sources and used in calculation of auto interval. With streaming data this value is used for the rolling buffer."
},
"render-query-caching-ttloption": {
"cache-ttl": "Cache TTL"
@@ -12339,6 +12435,38 @@
"title-data-source-help": "Data source help"
}
},
"query-library": {
"empty-state": {
"message": "Start adding them from Explore or when editing a dashboard",
"title": "You haven't saved any queries yet"
},
"filters": {
"search": "Search by..."
},
"header": {
"close": "Close",
"delete-all": "Delete all",
"more": "More",
"save-query": "SAVE",
"select-query": "SELECT QUERY",
"title": "SAVED QUERIES"
},
"item": {
"new": "New"
},
"not-found": {
"message": "Try adjusting your search or filter criteria",
"title": "No results found"
},
"query-details": {
"author": "Author",
"datasource": "Datasource",
"date-added": "Date added",
"description": "Description",
"make-query-visible": "Share query with all users",
"tags": "Tags"
}
},
"query-operation": {
"header": {
"collapse-row": "Collapse query row",

View File

@@ -101,7 +101,7 @@ $height-lg: 48;
/* stylelint-disable-next-line string-quotes */
$font-family-sans-serif: 'Inter', 'Helvetica', 'Arial', sans-serif;
/* stylelint-disable-next-line string-quotes */
$font-family-monospace: 'Roboto Mono', monospace;
$font-family-monospace: 'CommitMono', monospace;
$font-file-path: '../fonts' !default;