mirror of
https://github.com/grafana/grafana.git
synced 2025-12-20 16:54:59 +08:00
Compare commits
66 Commits
titolins/a
...
data-manip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1b8257cfc | ||
|
|
25a47d6a0f | ||
|
|
e5174895d6 | ||
|
|
badacad658 | ||
|
|
fcebfc952d | ||
|
|
6197fe837d | ||
|
|
fdf40444ab | ||
|
|
15bf6b45f9 | ||
|
|
65efd85d64 | ||
|
|
b8600a43db | ||
|
|
cee3b34cf7 | ||
|
|
8914c2b054 | ||
|
|
4532e04474 | ||
|
|
29d208c120 | ||
|
|
953f0b1a91 | ||
|
|
c731335e98 | ||
|
|
d185b73e41 | ||
|
|
e9f4d90337 | ||
|
|
1610daeafb | ||
|
|
7c04d4123a | ||
|
|
037acc6fdb | ||
|
|
8dcba9c5c9 | ||
|
|
5cf28fd8d6 | ||
|
|
5e69b4dcc2 | ||
|
|
33fe608e54 | ||
|
|
7055860372 | ||
|
|
9002d98d02 | ||
|
|
296e17f321 | ||
|
|
eacd341de7 | ||
|
|
b085f3ccfd | ||
|
|
2ef57dfa4a | ||
|
|
f8d451100f | ||
|
|
b6cd253500 | ||
|
|
709b0f7ff6 | ||
|
|
132997e19b | ||
|
|
0425e940dd | ||
|
|
65ef473c1f | ||
|
|
fbf4cc11aa | ||
|
|
039292e047 | ||
|
|
ceee8f062c | ||
|
|
96e0f4718b | ||
|
|
2734cb55b6 | ||
|
|
c50dfb17e3 | ||
|
|
bb4a15b31c | ||
|
|
a3a8fb9c91 | ||
|
|
e60c5a9342 | ||
|
|
b489c4360a | ||
|
|
8ffd614937 | ||
|
|
ecc37b85a9 | ||
|
|
70e69a6421 | ||
|
|
1aa0a5e7bf | ||
|
|
80e4da6a80 | ||
|
|
61b5880d95 | ||
|
|
dbca357f82 | ||
|
|
8ae9754dfc | ||
|
|
91cb43d124 | ||
|
|
0a3fc554ee | ||
|
|
7fbc8cd0cd | ||
|
|
f9e5e30dc2 | ||
|
|
21b799615f | ||
|
|
ba49d5a5df | ||
|
|
c21aae6bd7 | ||
|
|
4b9b10d65b | ||
|
|
a2882ebd85 | ||
|
|
d3e8bdd928 | ||
|
|
edabcd8b43 |
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -43,6 +43,7 @@ export const availableIconsIndex = {
|
||||
'expand-arrows-alt': true,
|
||||
at: true,
|
||||
ai: true,
|
||||
'ai-pointer': true,
|
||||
backward: true,
|
||||
bars: true,
|
||||
bell: true,
|
||||
@@ -76,6 +77,7 @@ export const availableIconsIndex = {
|
||||
'cloud-info': true,
|
||||
'cloud-provider': true,
|
||||
'cloud-upload': true,
|
||||
code: true,
|
||||
'code-branch': true,
|
||||
cog: true,
|
||||
columns: true,
|
||||
@@ -158,6 +160,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 +217,7 @@ export const availableIconsIndex = {
|
||||
'pause-circle': true,
|
||||
pen: true,
|
||||
percentage: true,
|
||||
pivot: true,
|
||||
play: true,
|
||||
plug: true,
|
||||
plus: true,
|
||||
|
||||
@@ -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')`,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"unicons/circle",
|
||||
"unicons/clipboard-alt",
|
||||
"unicons/clock-nine",
|
||||
"unicons/code",
|
||||
"unicons/cloud",
|
||||
"unicons/cloud-database-tree",
|
||||
"unicons/cloud-download",
|
||||
@@ -158,6 +159,7 @@
|
||||
"unicons/message",
|
||||
"unicons/palette",
|
||||
"unicons/percentage",
|
||||
"unicons/pivot",
|
||||
"unicons/shield-exclamation",
|
||||
"unicons/plus-square",
|
||||
"unicons/x",
|
||||
@@ -177,6 +179,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 +190,7 @@
|
||||
"unicons/record-audio",
|
||||
"unicons/scim",
|
||||
"solid/bookmark",
|
||||
"unicons/ai-pointer",
|
||||
"unicons/ai-sparkle",
|
||||
"unicons/dollar-alt",
|
||||
"unicons/window-grid",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: 0,
|
||||
fontFamily: theme.typography.fontFamilyMonospace,
|
||||
}),
|
||||
queryLibraryMenuItem: css({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
badge: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
});
|
||||
|
||||
AddDataItemMenu.displayName = 'AddDataItemMenu';
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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']),
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
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 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}
|
||||
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',
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
@@ -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(1.5),
|
||||
}),
|
||||
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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
<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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,7 +23,7 @@ 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 isScrollingLayout = useScrollReflowLimit();
|
||||
@@ -85,134 +87,92 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
|
||||
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(`${(containerHeight - vizHeight) + 40}px`);
|
||||
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 {
|
||||
gridTemplateAreas: '\n' + grid.map((row) => `"${row.join(' ')}"`).join('\n'),
|
||||
gridTemplateRows: rows.map((r) => r).join(' '),
|
||||
};
|
||||
}, [controls, dataPane, sidebarState.size, vizHeight]);
|
||||
|
||||
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 ref={vizRef} style={{ height: 550 }} className={cx(styles.viz, isScrollingLayout && styles.fixedSizeViz)}>
|
||||
{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 +194,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 +202,58 @@ 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`,
|
||||
height: '100%',
|
||||
minHeight: '100%',
|
||||
maxHeight: '100%',
|
||||
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: 200,
|
||||
maxHeight: 700, // FIXME: needs a dynamic height
|
||||
...(sidebarState.size === SidebarSize.Mini && {
|
||||
paddingLeft: theme.spacing(2),
|
||||
}),
|
||||
}),
|
||||
dataPane: css({
|
||||
gridArea: 'data-pane',
|
||||
}),
|
||||
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 +262,6 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
rotate: '-90deg',
|
||||
},
|
||||
}),
|
||||
vizWrapper: css({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
paddingLeft: theme.spacing(2),
|
||||
}),
|
||||
fixedSizeViz: css({
|
||||
height: '100vh',
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -227,6 +227,7 @@ export const TransformationOperationRow = ({
|
||||
draggable
|
||||
actions={renderActions}
|
||||
disabled={disabled}
|
||||
hideHeader={true}
|
||||
expanderMessages={{
|
||||
close: 'Collapse transformation row',
|
||||
open: 'Expand transformation row',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
BIN
public/fonts/commitmono/CommitMono-Italic.woff2
Normal file
BIN
public/fonts/commitmono/CommitMono-Italic.woff2
Normal file
Binary file not shown.
BIN
public/fonts/commitmono/CommitMono-Regular.woff2
Normal file
BIN
public/fonts/commitmono/CommitMono-Regular.woff2
Normal file
Binary file not shown.
5
public/img/icons/custom/gf-query-library.svg
Normal file
5
public/img/icons/custom/gf-query-library.svg
Normal 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 |
15
public/img/icons/unicons/ai-pointer.svg
Normal file
15
public/img/icons/unicons/ai-pointer.svg
Normal 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 |
3
public/img/icons/unicons/code.svg
Normal file
3
public/img/icons/unicons/code.svg
Normal 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 |
3
public/img/icons/unicons/pivot.svg
Normal file
3
public/img/icons/unicons/pivot.svg
Normal 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 |
@@ -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,25 @@
|
||||
"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",
|
||||
"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 +6037,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 +6232,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 +6245,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 +6290,46 @@
|
||||
"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 flow",
|
||||
"nodes": "nodes",
|
||||
"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 +6501,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"
|
||||
@@ -7358,6 +7451,9 @@
|
||||
"pane": {
|
||||
"loading-placeholder": "Loading..."
|
||||
},
|
||||
"query-library": {
|
||||
"default-title": "New query"
|
||||
},
|
||||
"queryless-apps-extensions": {
|
||||
"aria-label-go-queryless": "Go queryless"
|
||||
},
|
||||
@@ -12305,10 +12401,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 +12418,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 +12437,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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user