SQL Expressions: LLM plugin integration - SQL suggestions and SQL explanations (#107545)

* init: first pass at AI generated SQL expressions

* chore: fixes to tests

* i18n

* chore: small GenAIButton style update

* lazy load the genAI button - circular dependency issue in our test mocks...?

* chore: polish

* fix: i18n

* feat: make it a whole lot more capable

* chore: extract prompt logic to common file

* chore: consolidate state management to custom hooks

* chore: clean up, update GenAIButton API, etc.

* chore: major sql prompt improvement + plan for future + genAIButton api update

* chore: polish for now

* chore: clean up folder structure

* chore: conditionally use hooks + improve prompt

* chore: betterer....

* chore: polish

* feat: testing 🚀

* chore: polish polish polish

* chore: remove startWithPrompt

* chore: timeout 30

* chore: add experimental badge

* i18n + 60 sec timeout

* chore: MOAR POLISH

* chore: clean up explanation drawer

* chore: update tests

* chore: update FF checks

* chore: get the rendering w/FF correct

* chore: fix tests

* chore: re-work ai tooltips

* chore: cleanup

* chore: handle sql button styling differently

* chore: fix styling last time
This commit is contained in:
Alex Spencer 2025-08-13 09:24:04 -07:00 committed by GitHub
parent 50a3aa3137
commit 31fc7d5d7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1329 additions and 47 deletions

View File

@ -4,10 +4,10 @@ import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { llm } from '@grafana/llm';
import { Button, Spinner, useStyles2, Tooltip, Toggletip, Text } from '@grafana/ui';
import { Button, Spinner, useStyles2, Tooltip, Toggletip, Text, Stack } from '@grafana/ui';
import { GenAIHistory } from './GenAIHistory';
import { StreamStatus, useLLMStream } from './hooks';
import { StreamStatus, TIMEOUT, useLLMStream } from './hooks';
import { AutoGenerateItem, EventTrackingSrc, reportAutoGenerateInteraction } from './tracking';
import { DEFAULT_LLM_MODEL, Message, sanitizeReply } from './utils';
@ -36,6 +36,10 @@ export interface GenAIButtonProps {
toggletip will be enabled.
*/
tooltip?: string;
// Optional callback to receive history updates
onHistoryChange?: (history: string[]) => void;
// Optional timeout for the LLM stream. Default is 10 seconds
timeout?: number;
}
export const STOP_GENERATION_TEXT = 'Stop generating';
@ -50,13 +54,22 @@ export const GenAIButton = ({
eventTrackingSrc,
disabled,
tooltip,
onHistoryChange,
timeout = TIMEOUT,
}: GenAIButtonProps) => {
const styles = useStyles2(getStyles);
const [history, setHistory] = useState<string[]>([]);
const unshiftHistoryEntry = useCallback((historyEntry: string) => {
setHistory((h) => [historyEntry, ...h]);
}, []);
const unshiftHistoryEntry = useCallback(
(historyEntry: string) => {
setHistory((h) => {
const newHistory = [historyEntry, ...h];
return newHistory;
});
onHistoryChange?.([historyEntry, ...history]);
},
[onHistoryChange, history]
);
const onResponse = useCallback(
(reply: string) => {
@ -71,6 +84,7 @@ export const GenAIButton = ({
model,
temperature,
onResponse,
timeout,
});
const [showHistory, setShowHistory] = useState(false);
@ -181,6 +195,7 @@ export const GenAIButton = ({
onApplySuggestion={onApplySuggestion}
updateHistory={unshiftHistoryEntry}
eventTrackingSrc={eventTrackingSrc}
timeout={timeout}
/>
}
placement="left-start"
@ -198,7 +213,7 @@ export const GenAIButton = ({
};
return (
<div className={styles.wrapper}>
<Stack direction="row" gap={0.5} alignItems="center">
{isGenerating && <Spinner size="sm" className={styles.spinner} />}
{isFirstHistoryEntry ? (
<Tooltip show={showTooltip} interactive content={tooltipContent}>
@ -207,14 +222,11 @@ export const GenAIButton = ({
) : (
renderButtonWithToggletip()
)}
</div>
</Stack>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
display: 'flex',
}),
spinner: css({
color: theme.colors.text.link,
}),

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
@ -18,6 +18,7 @@ export interface GenAIHistoryProps {
onApplySuggestion: (suggestion: string) => void;
updateHistory: (historyEntry: string) => void;
eventTrackingSrc: EventTrackingSrc;
timeout?: number;
}
const temperature = 0.5;
@ -28,12 +29,17 @@ export const GenAIHistory = ({
messages,
onApplySuggestion,
updateHistory,
timeout,
}: GenAIHistoryProps) => {
const styles = useStyles2(getStyles);
const [currentIndex, setCurrentIndex] = useState(1);
const [customFeedback, setCustomPrompt] = useState('');
// Keep ref in sync with messages prop to avoid stale closure issues
const messagesRef = useRef(messages);
messagesRef.current = messages;
const onResponse = useCallback(
(response: string) => {
updateHistory(sanitizeReply(response));
@ -45,6 +51,7 @@ export const GenAIHistory = ({
model: DEFAULT_LLM_MODEL,
temperature,
onResponse,
timeout,
});
const reportInteraction = (item: AutoGenerateItem, otherMetadata?: object) =>
@ -70,7 +77,7 @@ export const GenAIHistory = ({
};
const onGenerateWithFeedback = (suggestion: string) => {
setMessages((messages) => [...messages, ...getFeedbackMessage(history[currentIndex - 1], suggestion)]);
setMessages(() => [...messagesRef.current, ...getFeedbackMessage(history[currentIndex - 1], suggestion)]);
if (suggestion in QuickFeedbackType) {
reportInteraction(AutoGenerateItem.quickFeedback, { quickFeedbackItem: suggestion });
@ -104,7 +111,6 @@ export const GenAIHistory = ({
)}
<GenerationHistoryCarousel history={history} index={currentIndex} onNavigate={onNavigate} />
<div className={styles.actionButtons}>
<QuickFeedback onSuggestionClick={onGenerateWithFeedback} isGenerating={isStreamGenerating} />
</div>

View File

@ -20,17 +20,19 @@ export enum StreamStatus {
COMPLETED = 'completed',
}
export const TIMEOUT = 10000;
export const TIMEOUT = 10000; // 10 seconds
interface Options {
model: string;
temperature: number;
onResponse?: (response: string) => void;
timeout?: number;
}
const defaultOptions = {
model: DEFAULT_LLM_MODEL,
temperature: 1,
timeout: TIMEOUT,
};
interface UseLLMStreamResponse {
@ -47,7 +49,8 @@ interface UseLLMStreamResponse {
}
// TODO: Add tests
export function useLLMStream({ model, temperature, onResponse }: Options = defaultOptions): UseLLMStreamResponse {
export function useLLMStream(options: Options = defaultOptions): UseLLMStreamResponse {
const { model, temperature, onResponse, timeout } = { ...defaultOptions, ...options };
// The messages array to send to the LLM, updated when the button is clicked.
const [messages, setMessages] = useState<Message[]>([]);
@ -145,17 +148,17 @@ export function useLLMStream({ model, temperature, onResponse }: Options = defau
// If the stream is generating and we haven't received a reply, it times out.
useEffect(() => {
let timeout: NodeJS.Timeout | undefined;
let timeoutId: NodeJS.Timeout | undefined;
if (streamStatus === StreamStatus.GENERATING && reply === '') {
timeout = setTimeout(() => {
onError(new Error(`LLM stream timed out after ${TIMEOUT}ms`));
}, TIMEOUT);
timeoutId = setTimeout(() => {
onError(new Error(`LLM stream timed out after ${timeout}ms`));
}, timeout);
}
return () => {
clearTimeout(timeout);
clearTimeout(timeoutId);
};
}, [streamStatus, reply, onError]);
}, [streamStatus, reply, onError, timeout]);
if (asyncError || enabledError) {
setError(asyncError || enabledError);

View File

@ -9,6 +9,7 @@ export enum EventTrackingSrc {
dashboardChanges = 'dashboard-changes',
dashboardTitle = 'dashboard-title',
dashboardDescription = 'dashboard-description',
sqlExpressions = 'sql-expressions',
unknown = 'unknown',
}

View File

@ -15,7 +15,7 @@ import { Threshold } from './components/Threshold';
import { ExpressionQuery, ExpressionQueryType, expressionTypes } from './types';
import { getDefaults } from './utils/expressionTypes';
type Props = QueryEditorProps<DataSourceApi<ExpressionQuery>, ExpressionQuery>;
export type ExpressionQueryEditorProps = QueryEditorProps<DataSourceApi<ExpressionQuery>, ExpressionQuery>;
const labelWidth = 15;
@ -78,7 +78,7 @@ function useExpressionsCache() {
return { getCachedExpression, setCachedExpression };
}
export function ExpressionQueryEditor(props: Props) {
export function ExpressionQueryEditor(props: ExpressionQueryEditorProps) {
const { query, queries, onRunQuery, onChange, app } = props;
const { getCachedExpression, setCachedExpression } = useExpressionsCache();
@ -118,7 +118,7 @@ export function ExpressionQueryEditor(props: Props) {
return <Threshold onChange={onChange} query={query} labelWidth={labelWidth} refIds={refIds} />;
case ExpressionQueryType.sql:
return <SqlExpr onChange={onChange} query={query} refIds={refIds} queries={queries} />;
return <SqlExpr onChange={onChange} query={query} refIds={refIds} queries={queries} metadata={props} />;
}
};

View File

@ -0,0 +1,29 @@
import { renderMarkdown } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { Drawer, Stack, Card } from '@grafana/ui';
interface AIExplanationDrawerProps {
isOpen: boolean;
onClose: () => void;
explanation: string;
}
export const GenAIExplanationDrawer = ({ isOpen, onClose, explanation }: AIExplanationDrawerProps) => {
if (!isOpen) {
return null;
}
return (
<Drawer
onClose={onClose}
size="md"
title={<Trans i18nKey="sql-expressions.explanation-modal-title">SQL Query Explanation</Trans>}
>
<Stack direction="column" data-testid="explanation-drawer">
<Card noMargin>
<div className="markdown-html" dangerouslySetInnerHTML={{ __html: renderMarkdown(explanation) }} />
</Card>
</Stack>
</Drawer>
);
};

View File

@ -0,0 +1,100 @@
import { useCallback } from 'react';
import { t } from '@grafana/i18n';
import { GenAIButton } from '../../../dashboard/components/GenAI/GenAIButton';
import { EventTrackingSrc } from '../../../dashboard/components/GenAI/tracking';
import { Message, Role } from '../../../dashboard/components/GenAI/utils';
import { getSQLExplanationSystemPrompt, QueryUsageContext } from './sqlPromptConfig';
interface GenAISQLExplainButtonProps {
currentQuery: string;
onExplain: (explanation: string) => void;
refIds: string[];
schemas?: unknown; // Reserved for future schema implementation
queryContext?: QueryUsageContext;
}
// AI prompt for explaining SQL expressions
const getExplanationPrompt = (currentQuery: string): string => {
if (!currentQuery || currentQuery.trim() === '') {
return 'There is no SQL query to explain. Please enter a SQL expression first.';
}
return `${currentQuery}
Explain what this query does in simple terms.`;
};
/**
* Creates messages for the LLM to explain SQL queries
*
* @param refIds - The list of RefIDs available in the current context
* @param currentQuery - The current SQL query to explain
* @param schemas - Optional schema information (planned for future implementation)
* @param queryContext - Optional query usage context
* @returns A list of messages to be sent to the LLM for explaining the SQL query
*/
const getSQLExplanationMessages = (
refIds: string[],
currentQuery: string,
schemas?: unknown,
queryContext?: QueryUsageContext
): Message[] => {
const systemPrompt = getSQLExplanationSystemPrompt({
refIds: refIds.length > 0 ? refIds.join(', ') : 'A',
currentQuery: currentQuery.trim() || 'No current query provided',
schemas, // Will be utilized once schema extraction is implemented
queryContext,
});
const userPrompt = getExplanationPrompt(currentQuery);
return [
{
role: Role.system,
content: systemPrompt,
},
{
role: Role.user,
content: userPrompt,
},
];
};
export const GenAISQLExplainButton = ({
currentQuery,
onExplain,
queryContext,
refIds,
schemas, // Future implementation will use this for enhanced context
}: GenAISQLExplainButtonProps) => {
const messages = useCallback(() => {
return getSQLExplanationMessages(refIds, currentQuery, schemas, queryContext);
}, [refIds, currentQuery, schemas, queryContext]);
const hasQuery = currentQuery && currentQuery.trim() !== '';
return (
<GenAIButton
disabled={!hasQuery}
eventTrackingSrc={EventTrackingSrc.sqlExpressions}
messages={messages}
onGenerate={onExplain}
temperature={0.3}
text={t('sql-expressions.explain-query', 'Explain query')}
timeout={60000} // 60 seconds
toggleTipTitle={t('sql-expressions.ai-explain-title', 'AI-powered SQL expression explanation')}
tooltip={
!hasQuery
? t('sql-expressions.explain-empty-query-tooltip', 'Enter a SQL expression to get an explanation')
: t(
'expressions.sql-expr.tooltip-experimental',
'SQL Expressions LLM integration is experimental. Please report any issues to the Grafana team.'
)
}
/>
);
};

View File

@ -0,0 +1,122 @@
import { useCallback } from 'react';
import { t } from '@grafana/i18n';
import { GenAIButton } from '../../../dashboard/components/GenAI/GenAIButton';
import { EventTrackingSrc } from '../../../dashboard/components/GenAI/tracking';
import { Message, Role } from '../../../dashboard/components/GenAI/utils';
import { getSQLSuggestionSystemPrompt, QueryUsageContext } from './sqlPromptConfig';
interface GenAISQLSuggestionsButtonProps {
currentQuery: string;
onGenerate: (suggestion: string) => void;
onHistoryUpdate?: (history: string[]) => void;
refIds: string[];
initialQuery: string;
schemas?: unknown; // Reserved for future schema implementation
errorContext?: string[];
queryContext?: QueryUsageContext;
}
// AI prompts for different SQL use cases
const getContextualPrompts = (refIds: string[], currentQuery: string): string[] => {
const trimmedQuery = currentQuery.trim();
// If there's a current query, focus more on fixing/improving it
if (trimmedQuery) {
return [`Improve, fix syntax errors, or optimize this SQL query: ${trimmedQuery}`];
}
// If no current query, focus on suggestions
return [
`Join, aggregate, filter, calculate percentiles, create time-based
window functions, or generally just make common SQL pattern queries for data from ${refIds.join(', ')}`,
];
};
/**
* Creates messages for the LLM to generate SQL suggestions
*
* @param refIds - The list of RefIDs available in the current context
* @param currentQuery - The current SQL query being edited
* @param schemas - Optional schema information (planned for future implementation)
* @param errorContext - Optional error context for targeted fixes (planned for future implementation)
* @param queryContext - Optional query usage context
* @returns A list of messages to be sent to the LLM for generating SQL suggestions
*/
const getSQLSuggestionMessages = (
refIds: string[],
currentQuery: string,
schemas?: unknown,
errorContext?: string[],
queryContext?: QueryUsageContext
): Message[] => {
const trimmedQuery = currentQuery.trim();
const queryInstruction = trimmedQuery
? 'Focus on fixing, improving, or enhancing the current query provided above.'
: 'Generate a new SQL query based on the available RefIDs and common use cases.';
const systemPrompt = getSQLSuggestionSystemPrompt({
refIds: refIds.length > 0 ? refIds.join(', ') : 'A',
currentQuery: trimmedQuery || 'No current query provided',
queryInstruction: queryInstruction,
schemas, // Will be utilized once schema extraction is implemented
errorContext,
queryContext,
});
const contextualPrompts = getContextualPrompts(refIds, currentQuery);
const selectedPrompt = contextualPrompts[Math.floor(Math.random() * contextualPrompts.length)];
return [
{
role: Role.system,
content: systemPrompt,
},
{
role: Role.user,
content: selectedPrompt,
},
];
};
export const GenAISQLSuggestionsButton = ({
currentQuery,
onGenerate,
onHistoryUpdate,
refIds,
initialQuery,
schemas, // Future implementation will use this for enhanced context
errorContext,
queryContext,
}: GenAISQLSuggestionsButtonProps) => {
const messages = useCallback(() => {
return getSQLSuggestionMessages(refIds, currentQuery, schemas, errorContext, queryContext);
}, [refIds, currentQuery, schemas, errorContext, queryContext]);
const text = !currentQuery || currentQuery === initialQuery ? 'Generate suggestion' : 'Improve query';
return (
<GenAIButton
disabled={refIds.length === 0}
eventTrackingSrc={EventTrackingSrc.sqlExpressions}
messages={messages}
onGenerate={onGenerate}
onHistoryChange={onHistoryUpdate}
temperature={0.3}
text={t('sql-expressions.sql-ai-interaction', `{{text}}`, { text })}
timeout={60000} // 60 seconds
toggleTipTitle={t('sql-expressions.ai-suggestions-title', 'AI-powered SQL expression suggestions')}
tooltip={
refIds.length === 0
? t('sql-expressions.add-query-tooltip', 'Add at least one data query to generate SQL suggestions')
: t(
'expressions.sql-expr.tooltip-experimental',
'SQL Expressions LLM integration is experimental. Please report any issues to the Grafana team.'
)
}
/>
);
};

View File

@ -0,0 +1,237 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, renderMarkdown } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { CodeEditor, Drawer, useStyles2, Stack, Button, Card, Text, ClipboardButton } from '@grafana/ui';
import { parseSuggestion } from './utils';
interface AISuggestionsDrawerProps {
isOpen: boolean;
onApplySuggestion: (suggestion: string) => void;
onClose: () => void;
suggestions: string[];
}
export const GenAISuggestionsDrawer = ({
isOpen,
onApplySuggestion,
onClose,
suggestions,
}: AISuggestionsDrawerProps) => {
const styles = useStyles2(getStyles);
if (!isOpen) {
return null;
}
return (
<Drawer
onClose={onClose}
size="lg"
title={<Trans i18nKey="sql-expressions.sql-suggestion-history">SQL Suggestion History</Trans>}
>
<div className={styles.content} data-testid="suggestions-drawer">
<Stack direction="column" gap={3}>
<div className={styles.timelineContainer}>
{/* Vertical timeline line */}
<div className={styles.timelineLine} />
<div className={styles.suggestionsList}>
{suggestions.map((suggestion, index) => {
const parsedSuggestion = parseSuggestion(suggestion);
const isLatest = index === 0;
return (
<div key={index} className={styles.timelineItem}>
{/* Timeline node */}
<div
className={`${styles.timelineNode} ${isLatest ? styles.timelineNodeActive : styles.timelineNodeInactive}`}
/>
<Card noMargin key={index} className={isLatest ? styles.latestSuggestion : ''}>
<div className={styles.suggestionContent}>
{parsedSuggestion.map(({ type, content, language }, partIndex) => (
<div key={partIndex} className={styles.suggestionPart}>
{type === 'code' ? (
<div className={styles.codeBlock}>
<div className={styles.codeHeader}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Text variant="bodySmall" weight="bold">
<Trans
i18nKey="sql-expressions.code-label"
values={{ language: language?.toUpperCase() || 'CODE' }}
>
{'{{ language }}'}
</Trans>
</Text>
<Stack direction="row" gap={1}>
<ClipboardButton
size="sm"
icon="copy"
variant="secondary"
getText={() => content}
>
<Trans i18nKey="sql-expressions.copy">Copy</Trans>
</ClipboardButton>
<Button
size="sm"
variant="primary"
icon="ai-sparkle"
onClick={() => onApplySuggestion(content)}
>
<Trans i18nKey="sql-expressions.apply">Apply</Trans>
</Button>
</Stack>
</Stack>
</div>
<CodeEditor
value={content}
language={language === 'sql' || language === 'mysql' ? 'mysql' : 'sql'}
width="100%"
height={Math.max(80, Math.min(300, (content.split('\n').length + 1) * 20))}
readOnly={true}
showMiniMap={false}
showLineNumbers={true}
monacoOptions={{
lineNumbers: 'on',
folding: false,
minimap: { enabled: false },
scrollBeyondLastLine: false,
renderLineHighlight: 'none',
wordWrap: 'on',
readOnly: true,
contextmenu: false,
padding: { top: 8, bottom: 8 },
automaticLayout: true,
fontSize: 13,
lineHeight: 20,
}}
/>
</div>
) : (
<div
className="markdown-html"
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
)}
</div>
))}
</div>
</Card>
</div>
);
})}
</div>
</div>
</Stack>
</div>
</Drawer>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
content: css({
height: '100%',
display: 'flex',
flexDirection: 'column',
}),
emptyState: css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
textAlign: 'center',
gap: theme.spacing(2),
}),
timelineContainer: css({
position: 'relative',
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
flex: 1,
paddingLeft: theme.spacing(4.5), // Space for timeline line and nodes
}),
timelineLine: css({
position: 'absolute',
// Offset the 2px width of the timeline line
left: `calc(${theme.spacing(1)} + 2px)`,
top: theme.spacing(1),
bottom: 0,
width: '2px',
backgroundColor: theme.colors.border.strong,
zIndex: 1,
}),
suggestionsList: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
position: 'relative',
}),
timelineItem: css({
position: 'relative',
display: 'flex',
alignItems: 'flex-start',
gap: theme.spacing(2),
}),
timelineNode: css({
position: 'absolute',
left: theme.spacing(-4.5), // Position on the timeline line
top: theme.spacing(1), // Align with card content
width: theme.spacing(3),
height: theme.spacing(3),
borderRadius: theme.shape.radius.pill,
border: `2px solid ${theme.colors.primary.main}`,
backgroundColor: theme.colors.background.primary,
zIndex: 2,
flexShrink: 0,
}),
timelineNodeActive: css({
backgroundColor: theme.colors.primary.main, // Filled circle for current/latest
boxShadow: `0 0 0 4px ${theme.colors.background.primary}`, // White ring around filled circle
}),
timelineNodeInactive: css({
backgroundColor: theme.colors.background.primary, // Empty circle for others
boxShadow: `0 0 0 4px ${theme.colors.background.primary}`, // White ring around filled circle
}),
latestSuggestion: css({
border: `2px solid ${theme.colors.primary.main}`,
position: 'relative',
'&::before': {
content: '"Latest"',
position: 'absolute',
top: theme.spacing(-0.5),
right: theme.spacing(1),
backgroundColor: theme.colors.primary.main,
color: theme.colors.primary.contrastText,
padding: theme.spacing(0.25, 1),
borderRadius: theme.shape.radius.default,
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightMedium,
},
}),
suggestionContent: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1.5),
width: '100%',
overflowX: 'auto',
}),
suggestionPart: css({
display: 'block',
width: '100%',
}),
codeBlock: css({
border: `1px solid ${theme.colors.border.medium}`,
borderRadius: theme.shape.radius.default,
overflow: 'hidden',
marginBottom: theme.spacing(1),
width: '100%',
minWidth: '600px',
}),
codeHeader: css({
backgroundColor: theme.colors.background.secondary,
padding: theme.spacing(1, 1.5),
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
});

View File

@ -0,0 +1,40 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { Button, Stack, Text, useStyles2 } from '@grafana/ui';
interface SuggestionsBadgeProps {
suggestions: string[];
handleOpenDrawer: () => void;
}
export const SuggestionsDrawerButton = ({ suggestions, handleOpenDrawer }: SuggestionsBadgeProps) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.buttonWrapper} data-testid="suggestions-badge">
<Button variant="secondary" fill="outline" size="sm" onClick={handleOpenDrawer} icon="list-ol">
<Stack direction="row" gap={1} alignItems="center">
<Trans i18nKey="sql-expressions.suggestions">Suggestions</Trans>
<span className={styles.countBadge}>
<Text variant="bodySmall" weight="bold">
{suggestions.length}
</Text>
</span>
</Stack>
</Button>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
countBadge: css({
color: theme.colors.primary.text,
fontWeight: 'bold',
}),
buttonWrapper: css({
position: 'relative',
display: 'inline-block',
}),
});

View File

@ -0,0 +1,60 @@
import { useState, useMemo } from 'react';
export const useSQLExplanations = (currentExpression: string) => {
const [explanation, setExplanation] = useState<string>('');
const [isExplanationOpen, setIsExplanationOpen] = useState(false);
const [prevExpression, setPrevExpression] = useState<string>(currentExpression);
/**
* Handle new explanation from the AI system
* Sets the explanation and opens the modal
*/
const handleExplain = (newExplanation: string) => {
setExplanation(newExplanation);
setIsExplanationOpen(true);
};
const handleOpenExplanation = () => setIsExplanationOpen(true);
const handleCloseExplanation = () => setIsExplanationOpen(false);
/**
* Update the previous expression tracker
* Should be called when the query expression changes
*/
const updatePrevExpression = (newExpression: string) => {
setPrevExpression(newExpression);
// Clear explanation when expression changes
setExplanation('');
};
const clearExplanation = () => {
setExplanation('');
setIsExplanationOpen(false);
};
/**
* Determine if we should show "View explanation" button vs "Explain query" button
* Returns true if there's an existing explanation OR if the expression has changed
*/
const shouldShowViewExplanation = useMemo(() => {
return Boolean(explanation) || prevExpression !== currentExpression;
}, [explanation, prevExpression, currentExpression]);
return {
// State
explanation,
isExplanationOpen,
prevExpression,
// Computed state
shouldShowViewExplanation,
// Actions
handleExplain,
handleOpenExplanation,
handleCloseExplanation,
updatePrevExpression,
clearExplanation,
};
};

View File

@ -0,0 +1,55 @@
import { useState, useRef } from 'react';
export const useSQLSuggestions = () => {
const [suggestions, setSuggestions] = useState<string[]>([]);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const prevSuggestionsLengthRef = useRef(0);
/**
* Handle new suggestions from the AI system
* Updates the suggestions list and manages unseen suggestions state
*/
const handleHistoryUpdate = (history: string[]) => {
setSuggestions(history);
// Auto-open drawer when new suggestions are received during feedback loop
if (history.length > prevSuggestionsLengthRef.current) {
setIsDrawerOpen(true);
}
// Update the reference to track suggestion count for next time
prevSuggestionsLengthRef.current = history.length;
};
/**
* Handle applying a suggestion to the editor
* Returns the suggestion for the parent component to handle
* i.e., the parent component can then use the suggestions for the editor drawer
*/
const handleApplySuggestion = (suggestion: string) => {
setIsDrawerOpen(false);
return suggestion;
};
const handleOpenDrawer = () => setIsDrawerOpen(true);
const handleCloseDrawer = () => setIsDrawerOpen(false);
const clearSuggestions = () => {
setSuggestions([]);
setIsDrawerOpen(false);
prevSuggestionsLengthRef.current = 0;
};
return {
// state
suggestions,
isDrawerOpen,
// actions
handleHistoryUpdate,
handleApplySuggestion,
handleOpenDrawer,
handleCloseDrawer,
clearSuggestions,
};
};

View File

@ -0,0 +1,209 @@
/**
* Configuration file for SQL AI prompts used across expression components
* NOTE: Schema and error context information integration is planned for future implementation
*/
import { DataQuery } from '@grafana/schema';
// Common SQL context information shared across all prompts
const COMMON_SQL_CONTEXT = {
engineInfo: 'MySQL dialectic based on dolthub go-mysql-server. The tables are all in memory',
refIdExplanation: 'RefIDs (A, B, C, etc.) represent data from other queries',
columnInfo: 'value should always be represented as __value__',
} as const;
// Template placeholders used in prompts
const TEMPLATE_PLACEHOLDERS = {
refIds: '{refIds}',
currentQuery: '{currentQuery}',
queryInstruction: '{queryInstruction}',
schemaInfo: '{schemaInfo}', // Note: Schema information will be implemented in future updates
errorContext: '{errorContext}', // Note: Error context will be implemented in future updates
queryContext: '{queryContext}',
} as const;
export interface QueryUsageContext {
panelId?: string;
alerting?: boolean;
queries?: DataQuery[];
dashboardContext?: {
dashboardTitle?: string;
panelName?: string;
};
datasources?: string[];
totalRows?: number;
requestTime?: number;
numberOfQueries?: number;
seriesData?: unknown;
}
/**
* System prompt for SQL suggestion generation with enhanced context
*/
const SQL_SUGGESTION_SYSTEM_PROMPT = `You are a SQL expert for Grafana expressions specializing in time series data analysis.
IMPORTANT - Current SQL Errors (if any): ${TEMPLATE_PLACEHOLDERS.errorContext}
SQL dialect required by Grafana expressions: ${COMMON_SQL_CONTEXT.engineInfo}
RefIDs context: ${COMMON_SQL_CONTEXT.refIdExplanation}
Grafana specific context: ${COMMON_SQL_CONTEXT.columnInfo}
Available RefIDs to use in composable queries: ${TEMPLATE_PLACEHOLDERS.refIds}
Current query to be improved: ${TEMPLATE_PLACEHOLDERS.currentQuery}
Schema information to use in composable queries: ${TEMPLATE_PLACEHOLDERS.schemaInfo}
${TEMPLATE_PLACEHOLDERS.queryContext}
Query instruction: ${TEMPLATE_PLACEHOLDERS.queryInstruction}
You may be able to derive schema information from the series data in queryContext.
Given the above data, help users with their SQL query by:
- **PRIORITY: If there are errors listed above, focus on fixing them first**
- Fixing syntax errors using available field and data type information
- Suggesting optimal queries based on actual data schema and patterns.
- Look at query context stats: totalRows, requestTime, numberOfQueries, and if it looks like performance should be part of the conversation, suggest optimizing for performance. Note indexing is not supported in Grafana expressions.
- Leveraging time series patterns and Grafana-specific use cases
Guidelines:
- Use proper field names and types based on schema information
- Include LIMIT clauses for performance unless aggregating
- Consider time-based filtering and grouping for time series data
- Suggest meaningful aggregations for metric data
- Use appropriate JOIN conditions when correlating multiple RefIDs
`;
/**
* System prompt for SQL explanation generation with enhanced context
*/
const SQL_EXPLANATION_SYSTEM_PROMPT = `You are an expert in SQL and Grafana SQL expressions with deep knowledge of time series data.
SQL dialect: ${COMMON_SQL_CONTEXT.engineInfo}
RefIDs: ${COMMON_SQL_CONTEXT.refIdExplanation}
Grafana specific context: ${COMMON_SQL_CONTEXT.columnInfo}
Available RefIDs: ${TEMPLATE_PLACEHOLDERS.refIds}
Schema: ${TEMPLATE_PLACEHOLDERS.schemaInfo}
${TEMPLATE_PLACEHOLDERS.queryContext}
Explain SQL queries clearly and concisely, focusing on:
- What data is being selected and from which RefIDs
- How the data is being transformed or aggregated
- The purpose and business meaning of the query using dashboard and panel name from query context if relevant
- Performance implications and optimization opportunities. Database columns can not be indexed in context of Grafana sql expressions. Don't focus on
performance unless the query context has a requestTime or totalRows that looks like it could benefit from it.
- Time series specific patterns and their significance
Provide a clear explanation of what this SQL query does:`;
/**
* Generate query context text for prompts
*/
const generateQueryContext = (queryContext?: QueryUsageContext): string => {
if (!queryContext) {
return '';
}
const contextParts = [];
if (queryContext.panelId) {
contextParts.push(
`Panel Type: ${queryContext.panelId}. Please use this to generate suggestions that are relevant to the panel type.`
);
}
if (queryContext.alerting) {
contextParts.push(
'Context: Alerting rule (focus on boolean/threshold results). Please use this to generate suggestions that are relevant to the alerting rule.'
);
}
if (queryContext.queries) {
const queriesText = Array.isArray(queryContext.queries)
? JSON.stringify(queryContext.queries, null, 2)
: String(queryContext.queries);
contextParts.push(`Queries available to use in the SQL Expression: ${queriesText}`);
}
if (queryContext.dashboardContext) {
const dashboardText =
typeof queryContext.dashboardContext === 'object'
? JSON.stringify(queryContext.dashboardContext, null, 2)
: String(queryContext.dashboardContext);
contextParts.push(`Dashboard context (dashboard title and panel name): ${dashboardText}`);
}
if (queryContext.datasources) {
const datasourcesText = Array.isArray(queryContext.datasources)
? JSON.stringify(queryContext.datasources, null, 2)
: String(queryContext.datasources);
contextParts.push(`Datasources available to use in the SQL Expression: ${datasourcesText}`);
}
if (queryContext.totalRows) {
contextParts.push(`Total rows in the query: ${queryContext.totalRows}`);
}
if (queryContext.requestTime) {
contextParts.push(`Request time: ${queryContext.requestTime}`);
}
if (queryContext.numberOfQueries) {
contextParts.push(`Number of queries: ${queryContext.numberOfQueries}`);
}
if (queryContext.seriesData) {
const seriesDataText =
typeof queryContext.seriesData === 'object'
? JSON.stringify(queryContext.seriesData, null, 2)
: String(queryContext.seriesData);
contextParts.push(`Series data: ${seriesDataText}`);
}
return contextParts.length
? `Query Context:
${contextParts.join('\n')}`
: '';
};
/**
* Enhanced interface for prompt generation variables
*/
export interface SQLPromptVariables {
refIds: string;
currentQuery: string;
queryInstruction: string;
schemas?: unknown; // Reserved for future schema implementation
errorContext?: string[];
queryContext?: QueryUsageContext;
}
/**
* Generate the complete system prompt for SQL suggestions with enhanced context
*
* Note: Schema information integration is planned for future implementation
*/
export const getSQLSuggestionSystemPrompt = (variables: SQLPromptVariables): string => {
const queryContext = generateQueryContext(variables.queryContext);
const schemaInfo = ''; // Placeholder for future schema information
const errorContext = variables.errorContext?.length
? variables.errorContext.join('\n')
: 'No current errors detected.';
return SQL_SUGGESTION_SYSTEM_PROMPT.replaceAll(TEMPLATE_PLACEHOLDERS.refIds, variables.refIds)
.replaceAll(TEMPLATE_PLACEHOLDERS.currentQuery, variables.currentQuery)
.replaceAll(TEMPLATE_PLACEHOLDERS.queryInstruction, variables.queryInstruction)
.replaceAll(TEMPLATE_PLACEHOLDERS.schemaInfo, schemaInfo)
.replaceAll(TEMPLATE_PLACEHOLDERS.errorContext, errorContext)
.replaceAll(TEMPLATE_PLACEHOLDERS.queryContext, queryContext);
};
/**
* Generate the complete system prompt for SQL explanations with enhanced context
*/
export const getSQLExplanationSystemPrompt = (variables: Omit<SQLPromptVariables, 'queryInstruction'>): string => {
const queryContext = generateQueryContext(variables.queryContext);
const schemaInfo = ''; // Placeholder for future schema information
return SQL_EXPLANATION_SYSTEM_PROMPT.replaceAll(TEMPLATE_PLACEHOLDERS.refIds, variables.refIds)
.replaceAll(TEMPLATE_PLACEHOLDERS.schemaInfo, schemaInfo)
.replaceAll(TEMPLATE_PLACEHOLDERS.queryContext, queryContext);
};

View File

@ -0,0 +1,63 @@
/**
* Parses AI-generated suggestions that contain mixed content (text and code blocks).
*
* AI responses often come in markdown format with explanatory text and code blocks
* delimited by triple backticks (```). This function separates and structures that
* content for proper rendering in the UI.
*
* @param suggestion - Raw AI suggestion string potentially containing markdown code blocks
* @returns Array of parsed parts, each with:
* - type: 'text' for explanatory content, 'code' for SQL queries
* - content: The actual text or code content
* - language: For code blocks, the detected language (defaults to 'sql', normalized to 'mysql')
*
* Example input: "Here's a query:\n```sql\nSELECT * FROM A\n```\nThis joins data."
* Example output: [
* { type: 'text', content: "Here's a query:" },
* { type: 'code', content: 'SELECT * FROM A', language: 'mysql' },
* { type: 'text', content: 'This joins data.' }
* ]
*/
export const parseSuggestion = (suggestion: string) => {
if (!suggestion) {
return [];
}
const parts: Array<{ type: 'text' | 'code'; content: string; language?: string }> = [];
// Split by triple backticks to find code blocks
const segments = suggestion.split(/```/);
segments.forEach((segment, index) => {
if (index % 2 === 0) {
// Even indices are text (outside code blocks)
if (segment.trim()) {
parts.push({ type: 'text', content: segment.trim() });
}
} else {
// Odd indices are code blocks
const lines = segment.split('\n');
let language = 'sql'; // default language
let codeContent = segment;
// Check if first line specifies a language
if (lines[0] && lines[0].trim() && !lines[0].includes(' ')) {
language = lines[0].trim().toLowerCase();
codeContent = lines.slice(1).join('\n');
}
// Remove trailing newlines
codeContent = codeContent.replace(/\n+$/, '');
if (codeContent.trim()) {
const finalLanguage = language === 'mysql' ? 'mysql' : language === 'sql' ? 'mysql' : language;
parts.push({
type: 'code',
content: codeContent.trim(),
language: finalLanguage,
});
}
}
});
return parts;
};

View File

@ -1,8 +1,8 @@
import { render } from '@testing-library/react';
import { render, waitFor, fireEvent, act } from 'test/test-utils';
import { ExpressionQuery } from '../types';
import { ExpressionQuery, ExpressionQueryType } from '../types';
import { SqlExpr } from './SqlExpr';
import { SqlExpr, SqlExprProps } from './SqlExpr';
jest.mock('@grafana/ui', () => ({
...jest.requireActual('@grafana/ui'),
@ -13,13 +13,54 @@ jest.mock('@grafana/plugin-ui', () => ({
SQLEditor: () => <div data-testid="sql-editor">SQL Editor Mock</div>,
}));
// Mock lazy loaded GenAI components
jest.mock('./GenAI/GenAISQLSuggestionsButton', () => ({
GenAISQLSuggestionsButton: ({ currentQuery, initialQuery }: { currentQuery: string; initialQuery: string }) => {
const text = !currentQuery || currentQuery === initialQuery ? 'Generate suggestion' : 'Improve query';
return <div data-testid="suggestions-button">{text}</div>;
},
}));
jest.mock('./GenAI/GenAISQLExplainButton', () => ({
GenAISQLExplainButton: () => <div data-testid="explain-button">Explain query</div>,
}));
// Mock custom hooks for GenAI features
jest.mock('./GenAI/hooks/useSQLSuggestions', () => ({
useSQLSuggestions: jest.fn(() => ({
handleApplySuggestion: jest.fn(),
handleHistoryUpdate: jest.fn(),
handleCloseDrawer: jest.fn(),
handleOpenDrawer: jest.fn(),
isDrawerOpen: false,
suggestions: [],
})),
}));
jest.mock('./GenAI/hooks/useSQLExplanations', () => ({
useSQLExplanations: jest.fn((currentExpression: string) => ({
explanation: '',
handleCloseExplanation: jest.fn(),
handleOpenExplanation: jest.fn(),
handleExplain: jest.fn(),
isExplanationOpen: false,
shouldShowViewExplanation: false,
updatePrevExpression: jest.fn(),
prevExpression: currentExpression,
})),
}));
// Note: Add more mocks if needed for other lazy components
describe('SqlExpr', () => {
it('initializes new expressions with default query', () => {
it('initializes new expressions with default query', async () => {
const onChange = jest.fn();
const refIds = [{ value: 'A' }];
const query = { refId: 'expr1', type: 'sql', expression: '' } as ExpressionQuery;
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
await act(async () => {
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
});
// Verify onChange was called
expect(onChange).toHaveBeenCalled();
@ -29,13 +70,15 @@ describe('SqlExpr', () => {
expect(updatedQuery.expression.toUpperCase()).toContain('SELECT');
});
it('preserves existing expressions when mounted', () => {
it('preserves existing expressions when mounted', async () => {
const onChange = jest.fn();
const refIds = [{ value: 'A' }];
const existingExpression = 'SELECT 1 AS foo';
const query = { refId: 'expr1', type: 'sql', expression: existingExpression } as ExpressionQuery;
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
await act(async () => {
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
});
// Check if onChange was called
if (onChange.mock.calls.length > 0) {
@ -48,14 +91,116 @@ describe('SqlExpr', () => {
expect(query.expression).toBe(existingExpression);
});
it('adds alerting format when alerting prop is true', () => {
it('adds alerting format when alerting prop is true', async () => {
const onChange = jest.fn();
const refIds = [{ value: 'A' }];
const query = { refId: 'expr1', type: 'sql' } as ExpressionQuery;
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} alerting queries={[]} />);
await act(async () => {
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} alerting queries={[]} />);
});
const updatedQuery = onChange.mock.calls[0][0];
expect(updatedQuery.format).toBe('alerting');
});
});
describe('SqlExpr with GenAI features', () => {
const defaultProps: SqlExprProps = {
onChange: jest.fn(),
refIds: [{ value: 'A' }],
query: { refId: 'expression_1', type: ExpressionQueryType.sql, expression: `SELECT * FROM A LIMIT 10` },
queries: [],
};
it('renders GenAI buttons with empty expression', async () => {
const customProps = { ...defaultProps, query: { ...defaultProps.query, expression: '' } };
const { findByText } = render(<SqlExpr {...customProps} />);
expect(await findByText('Generate suggestion')).toBeInTheDocument();
expect(await findByText('Explain query')).toBeInTheDocument();
});
it('renders GenAI buttons with non-empty expression', async () => {
const { findByText } = render(<SqlExpr {...defaultProps} />);
expect(await findByText('Improve query')).toBeInTheDocument();
expect(await findByText('Explain query')).toBeInTheDocument();
});
it('renders "Improve query" when currentQuery differs from initialQuery', async () => {
const customProps = {
...defaultProps,
query: { ...defaultProps.query, expression: 'SELECT * FROM A WHERE value > 10' },
};
const { findByText } = render(<SqlExpr {...customProps} />);
expect(await findByText('Improve query')).toBeInTheDocument();
});
it('renders View explanation button when shouldShowViewExplanation is true', async () => {
const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations');
useSQLExplanations.mockImplementation((currentExpression: string) => ({
shouldShowViewExplanation: true,
}));
const { findByText } = render(<SqlExpr {...defaultProps} />);
expect(await findByText('View explanation')).toBeInTheDocument();
});
it('renders Explain query button when shouldShowViewExplanation is false', async () => {
const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations');
useSQLExplanations.mockImplementation((currentExpression: string) => ({
shouldShowViewExplanation: false,
}));
const { findByText } = render(<SqlExpr {...defaultProps} />);
expect(await findByText('Explain query')).toBeInTheDocument();
});
it('renders SuggestionsDrawerButton when there are suggestions', async () => {
const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions');
useSQLSuggestions.mockImplementation(() => ({ suggestions: ['suggestion1', 'suggestion2'] }));
const { findByTestId } = render(<SqlExpr {...defaultProps} />);
expect(await findByTestId('suggestions-badge')).toBeInTheDocument();
});
it('does not render SuggestionsDrawerButton when there are no suggestions', async () => {
const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions');
useSQLSuggestions.mockImplementation(() => ({ suggestions: [] }));
const { queryByTestId } = render(<SqlExpr {...defaultProps} />);
expect(await waitFor(() => queryByTestId('suggestions-badge'))).not.toBeInTheDocument();
});
it('calls handleOpenExplanation when View explanation is clicked', async () => {
const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations');
const mockHandleOpen = jest.fn();
useSQLExplanations.mockImplementation(() => ({
shouldShowViewExplanation: true,
handleOpenExplanation: mockHandleOpen,
}));
const { findByText } = render(<SqlExpr {...defaultProps} />);
const button = await findByText('View explanation');
fireEvent.click(button);
expect(mockHandleOpen).toHaveBeenCalled();
});
it('renders suggestions drawer when isDrawerOpen is true', async () => {
const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions');
useSQLSuggestions.mockImplementation(() => ({
isDrawerOpen: true,
suggestions: ['suggestion1', 'suggestion2'],
}));
const { findByTestId } = render(<SqlExpr {...defaultProps} />);
expect(await findByTestId('suggestions-drawer')).toBeInTheDocument();
});
it('renders explanation drawer when isExplanationOpen is true', async () => {
const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations');
useSQLExplanations.mockImplementation(() => ({ isExplanationOpen: true }));
const { findByTestId } = render(<SqlExpr {...defaultProps} />);
expect(await findByTestId('explanation-drawer')).toBeInTheDocument();
});
});

View File

@ -1,29 +1,65 @@
import { css } from '@emotion/css';
import { useMemo, useRef, useEffect, useState } from 'react';
import { useMemo, useRef, useEffect, useState, lazy, Suspense } from 'react';
import { SelectableValue } from '@grafana/data';
import { SelectableValue, GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { SQLEditor, CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/plugin-ui';
import { DataQuery } from '@grafana/schema/dist/esm/index';
import { useStyles2 } from '@grafana/ui';
import { useStyles2, Stack, Button } from '@grafana/ui';
import { ExpressionQueryEditorProps } from '../ExpressionQueryEditor';
import { SqlExpressionQuery } from '../types';
import { fetchSQLFields } from '../utils/metaSqlExpr';
import { useSQLExplanations } from './GenAI/hooks/useSQLExplanations';
import { useSQLSuggestions } from './GenAI/hooks/useSQLSuggestions';
import { getSqlCompletionProvider } from './sqlCompletionProvider';
// Lazy load the GenAI components to avoid circular dependencies
const GenAISQLSuggestionsButton = lazy(() =>
import('./GenAI/GenAISQLSuggestionsButton').then((module) => ({
default: module.GenAISQLSuggestionsButton,
}))
);
const GenAISQLExplainButton = lazy(() =>
import('./GenAI/GenAISQLExplainButton').then((module) => ({
default: module.GenAISQLExplainButton,
}))
);
const SuggestionsDrawerButton = lazy(() =>
import('./GenAI/SuggestionsDrawerButton').then((module) => ({
default: module.SuggestionsDrawerButton,
}))
);
const GenAISuggestionsDrawer = lazy(() =>
import('./GenAI/GenAISuggestionsDrawer').then((module) => ({
default: module.GenAISuggestionsDrawer,
}))
);
const GenAIExplanationDrawer = lazy(() =>
import('./GenAI/GenAIExplanationDrawer').then((module) => ({
default: module.GenAIExplanationDrawer,
}))
);
// Account for Monaco editor's border to prevent clipping
const EDITOR_BORDER_ADJUSTMENT = 2; // 1px border on top and bottom
interface Props {
export interface SqlExprProps {
refIds: Array<SelectableValue<string>>;
query: SqlExpressionQuery;
queries: DataQuery[] | undefined;
onChange: (query: SqlExpressionQuery) => void;
/** Should the `format` property be set to `alerting`? */
alerting?: boolean;
metadata?: ExpressionQueryEditorProps;
}
export const SqlExpr = ({ onChange, refIds, query, alerting = false, queries }: Props) => {
export const SqlExpr = ({ onChange, refIds, query, alerting = false, queries, metadata }: SqlExprProps) => {
const vars = useMemo(() => refIds.map((v) => v.value!), [refIds]);
const completionProvider = useMemo(
() =>
@ -48,12 +84,70 @@ export const SqlExpr = ({ onChange, refIds, query, alerting = false, queries }:
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ height: 0 });
const { handleApplySuggestion, handleHistoryUpdate, handleCloseDrawer, handleOpenDrawer, isDrawerOpen, suggestions } =
useSQLSuggestions();
const {
explanation,
handleCloseExplanation,
handleOpenExplanation,
handleExplain,
isExplanationOpen,
shouldShowViewExplanation,
updatePrevExpression,
} = useSQLExplanations(query.expression || '');
const queryContext = useMemo(
() => ({
alerting,
panelId: metadata?.data?.request?.panelPluginId,
queries: metadata?.queries,
dashboardContext: {
dashboardTitle: metadata?.data?.request?.dashboardTitle ?? '',
panelName: metadata?.data?.request?.panelName ?? '',
},
datasources: metadata?.queries?.map((query) => query.datasource?.type ?? '') ?? [],
totalRows: metadata?.data?.series.reduce((sum, frame) => sum + frame.length, 0),
requestTime: metadata?.data?.request?.endTime
? metadata?.data?.request?.endTime - metadata?.data?.request?.startTime
: -1,
numberOfQueries: metadata?.data?.request?.targets?.length ?? 0,
seriesData: metadata?.data?.series,
}),
[alerting, metadata]
);
const errorContext = useMemo(() => {
if (!metadata?.data) {
return [];
}
const errors: string[] = [];
// Handle multiple errors (preferred)
if (metadata.data.errors?.length) {
errors.push(...metadata.data.errors.map((err) => err.message).filter((msg): msg is string => Boolean(msg)));
}
// Handle legacy single error
else if (metadata.data.error?.message) {
errors.push(metadata.data.error.message);
}
return errors;
}, [metadata?.data]);
const onEditorChange = (expression: string) => {
onChange({
...query,
expression,
format: alerting ? 'alerting' : undefined,
});
updatePrevExpression(expression);
};
const onApplySuggestion = (suggestion: string) => {
onEditorChange(suggestion);
handleApplySuggestion(suggestion);
};
// Set up resize observer to handle container resizing
@ -81,24 +175,112 @@ export const SqlExpr = ({ onChange, refIds, query, alerting = false, queries }:
}, []);
return (
<div ref={containerRef} className={styles.editorContainer}>
<SQLEditor
query={query.expression || initialQuery}
onChange={onEditorChange}
height={dimensions.height - EDITOR_BORDER_ADJUSTMENT}
language={EDITOR_LANGUAGE_DEFINITION}
/>
</div>
<>
<div className={styles.sqlContainer}>
<div className={styles.sqlButtons}>
<Stack direction="row" gap={1} alignItems="center" justifyContent="end">
<Suspense fallback={null}>
{shouldShowViewExplanation ? (
<Button
fill="outline"
icon="gf-movepane-right"
onClick={handleOpenExplanation}
size="sm"
variant="secondary"
>
<Trans i18nKey="sql-expressions.view-explanation">View explanation</Trans>
</Button>
) : (
<GenAISQLExplainButton
currentQuery={query.expression || ''}
onExplain={handleExplain}
queryContext={queryContext}
refIds={vars}
// schemas={schemas} // Will be added when schema extraction is implemented
/>
)}
</Suspense>
<Suspense fallback={null}>
<GenAISQLSuggestionsButton
currentQuery={query.expression || ''}
initialQuery={initialQuery}
onGenerate={() => {}} // Noop - history is managed via onHistoryUpdate
onHistoryUpdate={handleHistoryUpdate}
queryContext={queryContext}
refIds={vars}
errorContext={errorContext} // Will be added when error tracking is implemented
// schemas={schemas} // Will be added when schema extraction is implemented
/>
</Suspense>
</Stack>
{suggestions.length > 0 && (
<Suspense fallback={null}>
<SuggestionsDrawerButton handleOpenDrawer={handleOpenDrawer} suggestions={suggestions} />
</Suspense>
)}
</div>
<div ref={containerRef} className={styles.editorContainer}>
<SQLEditor
query={query.expression || initialQuery}
onChange={onEditorChange}
height={dimensions.height - EDITOR_BORDER_ADJUSTMENT}
language={EDITOR_LANGUAGE_DEFINITION}
/>
</div>
</div>
<>
<Suspense fallback={null}>
<GenAISuggestionsDrawer
isOpen={isDrawerOpen}
onApplySuggestion={onApplySuggestion}
onClose={handleCloseDrawer}
suggestions={suggestions}
/>
</Suspense>
<Suspense fallback={null}>
<GenAIExplanationDrawer
isOpen={isExplanationOpen}
onClose={handleCloseExplanation}
explanation={explanation}
/>
</Suspense>
</>
</>
);
};
const getStyles = () => ({
const getStyles = (theme: GrafanaTheme2) => ({
sqlContainer: css({
display: 'grid',
gridTemplateRows: 'auto 1fr',
gridTemplateAreas: `
"buttons"
"editor"
`,
gap: theme.spacing(0.5),
}),
editorContainer: css({
gridArea: 'editor',
height: '240px',
resize: 'vertical',
overflow: 'auto',
minHeight: '100px',
}),
// This is NOT ideal. The alternative is to expose SQL buttons as a separate component,
// Then consume them in ExpressionQueryEditor. This requires a lot of refactoring and
// can be prioritized later.
sqlButtons: css({
gridArea: 'buttons',
justifySelf: 'end',
transform: `translateY(${theme.spacing(-4)})`,
marginBottom: theme.spacing(-4), // Prevent affecting editor position
zIndex: 10, // Ensure buttons appear above other elements
position: 'relative', // Required for z-index to work
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
});
async function fetchFields(identifier: TableIdentifier, queries: DataQuery[]) {

View File

@ -7490,6 +7490,9 @@
"label-upsample": "Upsample",
"tooltip-s-m-h": "10s, 1m, 30m, 1h"
},
"sql-expr": {
"tooltip-experimental": "SQL Expressions LLM integration is experimental. Please report any issues to the Grafana team."
},
"threshold": {
"label-input": "Input"
}
@ -12495,6 +12498,21 @@
"sort-picker": {
"select-aria-label": "Sort"
},
"sql-expressions": {
"add-query-tooltip": "Add at least one data query to generate SQL suggestions",
"ai-explain-title": "AI-powered SQL expression explanation",
"ai-suggestions-title": "AI-powered SQL expression suggestions",
"apply": "Apply",
"code-label": "{{ language }}",
"copy": "Copy",
"explain-empty-query-tooltip": "Enter a SQL expression to get an explanation",
"explain-query": "Explain query",
"explanation-modal-title": "SQL Query Explanation",
"sql-ai-interaction": "{{text}}",
"sql-suggestion-history": "SQL Suggestion History",
"suggestions": "Suggestions",
"view-explanation": "View explanation"
},
"stat": {
"add-orientation-option": {
"description-orientation": "Layout orientation",