diff --git a/public/app/features/alerting/unified/components/expressions/Expression.tsx b/public/app/features/alerting/unified/components/expressions/Expression.tsx index 173bca42a4c..79f805d52ad 100644 --- a/public/app/features/alerting/unified/components/expressions/Expression.tsx +++ b/public/app/features/alerting/unified/components/expressions/Expression.tsx @@ -18,7 +18,7 @@ import { ClassicConditions } from 'app/features/expressions/components/ClassicCo import { Math } from 'app/features/expressions/components/Math'; import { Reduce } from 'app/features/expressions/components/Reduce'; import { Resample } from 'app/features/expressions/components/Resample'; -import { SqlExpr } from 'app/features/expressions/components/SqlExpr'; +import { SqlExpr } from 'app/features/expressions/components/SqlExpressions/SqlExpr'; import { Threshold } from 'app/features/expressions/components/Threshold'; import { ExpressionQuery, diff --git a/public/app/features/expressions/ExpressionQueryEditor.tsx b/public/app/features/expressions/ExpressionQueryEditor.tsx index 538aa2e52f6..e08cdd5d94c 100644 --- a/public/app/features/expressions/ExpressionQueryEditor.tsx +++ b/public/app/features/expressions/ExpressionQueryEditor.tsx @@ -11,7 +11,7 @@ import { ExpressionTypeDropdown } from './components/ExpressionTypeDropdown'; import { Math } from './components/Math'; import { Reduce } from './components/Reduce'; import { Resample } from './components/Resample'; -import { SqlExpr } from './components/SqlExpr'; +import { SqlExpr } from './components/SqlExpressions/SqlExpr'; import { Threshold } from './components/Threshold'; import { ExpressionQuery, ExpressionQueryType, expressionTypes } from './types'; import { getDefaults } from './utils/expressionTypes'; diff --git a/public/app/features/expressions/components/sqlCompletionProvider.ts b/public/app/features/expressions/components/SqlExpressions/CompletionProvider/sqlCompletionProvider.ts similarity index 95% rename from public/app/features/expressions/components/sqlCompletionProvider.ts rename to public/app/features/expressions/components/SqlExpressions/CompletionProvider/sqlCompletionProvider.ts index 013088aa770..f94b08992bd 100644 --- a/public/app/features/expressions/components/sqlCompletionProvider.ts +++ b/public/app/features/expressions/components/SqlExpressions/CompletionProvider/sqlCompletionProvider.ts @@ -2,7 +2,7 @@ import { SelectableValue } from '@grafana/data'; import { ColumnDefinition, LanguageCompletionProvider, TableDefinition, TableIdentifier } from '@grafana/plugin-ui'; import { config } from '@grafana/runtime'; -import { ALLOWED_FUNCTIONS } from '../utils/metaSqlExpr'; +import { ALLOWED_FUNCTIONS } from '../../../utils/metaSqlExpr'; interface CompletionProviderGetterArgs { getFields: (t: TableIdentifier) => Promise; diff --git a/public/app/features/expressions/components/GenAI/GenAIExplanationDrawer.tsx b/public/app/features/expressions/components/SqlExpressions/GenAI/GenAIExplanationDrawer.tsx similarity index 100% rename from public/app/features/expressions/components/GenAI/GenAIExplanationDrawer.tsx rename to public/app/features/expressions/components/SqlExpressions/GenAI/GenAIExplanationDrawer.tsx diff --git a/public/app/features/expressions/components/GenAI/GenAISQLExplainButton.tsx b/public/app/features/expressions/components/SqlExpressions/GenAI/GenAISQLExplainButton.tsx similarity index 92% rename from public/app/features/expressions/components/GenAI/GenAISQLExplainButton.tsx rename to public/app/features/expressions/components/SqlExpressions/GenAI/GenAISQLExplainButton.tsx index 86c5953025a..cd9a1071c5f 100644 --- a/public/app/features/expressions/components/GenAI/GenAISQLExplainButton.tsx +++ b/public/app/features/expressions/components/SqlExpressions/GenAI/GenAISQLExplainButton.tsx @@ -2,9 +2,9 @@ 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 { 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'; diff --git a/public/app/features/expressions/components/GenAI/GenAISQLSuggestionsButton.tsx b/public/app/features/expressions/components/SqlExpressions/GenAI/GenAISQLSuggestionsButton.tsx similarity index 94% rename from public/app/features/expressions/components/GenAI/GenAISQLSuggestionsButton.tsx rename to public/app/features/expressions/components/SqlExpressions/GenAI/GenAISQLSuggestionsButton.tsx index 3b386879354..bdc839739c3 100644 --- a/public/app/features/expressions/components/GenAI/GenAISQLSuggestionsButton.tsx +++ b/public/app/features/expressions/components/SqlExpressions/GenAI/GenAISQLSuggestionsButton.tsx @@ -2,9 +2,9 @@ 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 { 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'; diff --git a/public/app/features/expressions/components/GenAI/GenAISuggestionsDrawer.tsx b/public/app/features/expressions/components/SqlExpressions/GenAI/GenAISuggestionsDrawer.tsx similarity index 100% rename from public/app/features/expressions/components/GenAI/GenAISuggestionsDrawer.tsx rename to public/app/features/expressions/components/SqlExpressions/GenAI/GenAISuggestionsDrawer.tsx diff --git a/public/app/features/expressions/components/GenAI/SuggestionsDrawerButton.tsx b/public/app/features/expressions/components/SqlExpressions/GenAI/SuggestionsDrawerButton.tsx similarity index 100% rename from public/app/features/expressions/components/GenAI/SuggestionsDrawerButton.tsx rename to public/app/features/expressions/components/SqlExpressions/GenAI/SuggestionsDrawerButton.tsx diff --git a/public/app/features/expressions/components/GenAI/hooks/useSQLExplanations.ts b/public/app/features/expressions/components/SqlExpressions/GenAI/hooks/useSQLExplanations.ts similarity index 100% rename from public/app/features/expressions/components/GenAI/hooks/useSQLExplanations.ts rename to public/app/features/expressions/components/SqlExpressions/GenAI/hooks/useSQLExplanations.ts diff --git a/public/app/features/expressions/components/GenAI/hooks/useSQLSuggestions.ts b/public/app/features/expressions/components/SqlExpressions/GenAI/hooks/useSQLSuggestions.ts similarity index 100% rename from public/app/features/expressions/components/GenAI/hooks/useSQLSuggestions.ts rename to public/app/features/expressions/components/SqlExpressions/GenAI/hooks/useSQLSuggestions.ts diff --git a/public/app/features/expressions/components/GenAI/sqlPromptConfig.ts b/public/app/features/expressions/components/SqlExpressions/GenAI/sqlPromptConfig.ts similarity index 100% rename from public/app/features/expressions/components/GenAI/sqlPromptConfig.ts rename to public/app/features/expressions/components/SqlExpressions/GenAI/sqlPromptConfig.ts diff --git a/public/app/features/expressions/components/GenAI/utils.ts b/public/app/features/expressions/components/SqlExpressions/GenAI/utils.ts similarity index 100% rename from public/app/features/expressions/components/GenAI/utils.ts rename to public/app/features/expressions/components/SqlExpressions/GenAI/utils.ts diff --git a/public/app/features/expressions/components/SqlExpressions/SchemaInspector/SchemaInspectorPanel.tsx b/public/app/features/expressions/components/SqlExpressions/SchemaInspector/SchemaInspectorPanel.tsx new file mode 100644 index 00000000000..c84bf0d4223 --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/SchemaInspector/SchemaInspectorPanel.tsx @@ -0,0 +1,264 @@ +import { css } from '@emotion/css'; +import { useState, useMemo } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; +import { + Stack, + Tab, + TabsBar, + TabContent, + Icon, + Badge, + Text, + useStyles2, + InteractiveTable, + ScrollContainer, + Alert, + Spinner, + IconButton, +} from '@grafana/ui'; + +import { SQLSchemas, SQLSchemaField, SQLSchemaData } from '../hooks/useSQLSchemas'; + +import { getFieldTypeIcon } from './utils'; + +type SchemaField = SQLSchemaField; +type SampleValue = string | number | boolean; +type SampleRow = SampleValue[]; +type SampleRows = SampleRow[]; +type SchemaData = SQLSchemaData; + +interface SchemaInspectorPanelProps { + schemas: SQLSchemas | null; + loading: boolean; + error: Error | null; + onClose: () => void; +} + +export const SchemaInspectorPanel = ({ schemas, loading, error, onClose }: SchemaInspectorPanelProps) => { + const styles = useStyles2(getStyles); + + const schemaResponse: SQLSchemas = schemas ?? {}; + const refIds = Object.keys(schemaResponse); + + const [selectedTab, setSelectedTab] = useState(''); + const activeSchemaTab = refIds.includes(selectedTab) ? selectedTab : refIds[0] || ''; + const activeSchemaData = schemaResponse[activeSchemaTab]; + + const columns = useMemo( + () => [ + { + id: 'field', + header: 'Field', + accessorKey: 'name', + cell: ({ row }: { row: { original: SchemaField } }) => ( + + +
{row.original.name}
+
+ ), + }, + { + id: 'type', + header: 'Type', + accessorKey: 'mysqlType', + cell: ({ row }: { row: { original: SchemaField } }) => , + }, + { + id: 'nullable', + header: 'Nullable', + accessorKey: 'nullable', + cell: ({ row }: { row: { original: SchemaField } }) => ( + + ), + }, + { + id: 'sample', + header: 'Sample values', + accessorKey: 'sample', + cell: ({ + row, + }: { + row: { + original: SchemaField & { + fieldIndex: number; + sampleRows: SchemaData['sampleRows']; + }; + }; + }) => { + const { fieldIndex, sampleRows } = row.original; + + // Extract sample values for this field (column index) + const sampleValues = sampleRows?.map((sampleRow) => sampleRow[fieldIndex]) ?? []; + + // Format as a proper array string with quoted strings + const arrayString = JSON.stringify(sampleValues); + + return
{arrayString}
; + }, + }, + ], + [styles] + ); + + const renderSchemaFields = (fields: SchemaField[], sampleRows: SampleRows | null) => { + // Enhance fields with fieldIndex and sampleRows for the Sample column + const enhancedFields = fields.map((field, index) => ({ + ...field, + fieldIndex: index, + sampleRows, + })); + + return ( +
+ name} + pageSize={0} // No pagination + /> +
+ ); + }; + + const renderSchemaTabContent = ({ columns, error, sampleRows }: SchemaData) => { + if (error) { + return ( +
+ + {error} + +
+ ); + } + + if (!columns || columns.length === 0) { + return ( +
+ + This query returned no schema information. + +
+ ); + } + + return renderSchemaFields(columns, sampleRows); + }; + + const renderContent = () => { + if (error) { + return ( +
+ + {error.message} + +
+ ); + } + + if (loading) { + return ( +
+ + + + Loading schema information... + + +
+ ); + } + + if (!activeSchemaData || refIds.length === 0) { + return ( +
+ +
+ ); + } + + return ( + + {renderSchemaTabContent(activeSchemaData)} + + ); + }; + + return ( +
+
+ {refIds.length > 0 && ( + + {refIds.map((refId) => ( + setSelectedTab(refId)} + /> + ))} + + )} + +
+ {renderContent()} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + schemaInfoContainer: css({ + padding: theme.spacing(1), + }), + schemaInspector: css({ + height: '100%', + display: 'flex', + flexDirection: 'column', + }), + // Unfortunate hack to get the close button to align with the tabs since we need to + // override the default styles of the TabsBar component. + tabsBarWrapper: css({ + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + padding: `0 ${theme.spacing(1)}`, + }), + tableCell: css({ + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightMedium, + fontFamily: theme.typography.fontFamilyMonospace, + }), + tableContainer: css({ + margin: theme.spacing(1), + flex: 1, + overflowY: 'auto', + overflowX: 'auto', + minHeight: 0, // Allow flex child to shrink + border: `1px solid ${theme.colors.border.medium}`, + borderRadius: theme.shape.radius.default, + backgroundColor: theme.colors.background.primary, + }), + nullableIcon: css({ + color: theme.colors.success.text, + }), + requiredIcon: css({ + color: theme.colors.warning.text, + }), +}); diff --git a/public/app/features/expressions/components/SqlExpressions/SchemaInspector/utils.ts b/public/app/features/expressions/components/SqlExpressions/SchemaInspector/utils.ts new file mode 100644 index 00000000000..7412274b3e1 --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/SchemaInspector/utils.ts @@ -0,0 +1,18 @@ +export const getFieldTypeIcon = (mysqlType: string) => { + switch (mysqlType.toLowerCase()) { + case 'text': + return 'font'; + case 'double': + case 'float': + case 'int': + case 'bigint': + return 'calculator-alt'; + case 'datetime': + case 'timestamp': + return 'clock-nine'; + case 'boolean': + return 'toggle-on'; + default: + return 'question-circle'; + } +}; diff --git a/public/app/features/expressions/components/SqlExpr.test.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx similarity index 68% rename from public/app/features/expressions/components/SqlExpr.test.tsx rename to public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx index 53931e785db..b7acc586447 100644 --- a/public/app/features/expressions/components/SqlExpr.test.tsx +++ b/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx @@ -1,6 +1,6 @@ -import { render, waitFor, fireEvent, act } from 'test/test-utils'; +import { render, waitFor, fireEvent, act, testWithFeatureToggles } from 'test/test-utils'; -import { ExpressionQuery, ExpressionQueryType } from '../types'; +import { ExpressionQuery, ExpressionQueryType } from '../../types'; import { SqlExpr, SqlExprProps } from './SqlExpr'; @@ -50,6 +50,20 @@ jest.mock('./GenAI/hooks/useSQLExplanations', () => ({ })), })); +// Mock the backend API +const mockBackendSrv = { + post: jest.fn().mockResolvedValue({ + kind: 'SQLSchemaResponse', + apiVersion: 'query.grafana.app/v0alpha1', + sqlSchemas: {}, + }), +}; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getBackendSrv: () => mockBackendSrv, +})); + // Note: Add more mocks if needed for other lazy components describe('SqlExpr', () => { @@ -204,3 +218,90 @@ describe('SqlExpr with GenAI features', () => { expect(await findByTestId('explanation-drawer')).toBeInTheDocument(); }); }); + +describe('Schema Inspector feature toggle', () => { + const defaultProps: SqlExprProps = { + onChange: jest.fn(), + refIds: [{ value: 'A' }], + query: { refId: 'expression_1', type: ExpressionQueryType.sql, expression: `SELECT * FROM A LIMIT 10` }, + queries: [], + }; + + describe('when feature enabled', () => { + testWithFeatureToggles({ enable: ['queryService', 'grafanaAPIServerWithExperimentalAPIs'] }); + + afterEach(() => { + mockBackendSrv.post.mockResolvedValue({ + kind: 'SQLSchemaResponse', + apiVersion: 'query.grafana.app/v0alpha1', + sqlSchemas: {}, + }); + }); + + it('renders panel open by default', () => { + const { getByText } = render(); + + expect(getByText('No schema information available')).toBeInTheDocument(); + }); + + it('closes panel and shows reopen button when close button clicked', async () => { + const { queryByText, getByLabelText, findByText } = render(); + + expect(queryByText('No schema information available')).toBeInTheDocument(); + + const closeButton = getByLabelText('Close schema inspector'); + await act(async () => fireEvent.click(closeButton)); + + expect(queryByText('No schema information available')).not.toBeInTheDocument(); + expect(await findByText('Inspect schema')).toBeInTheDocument(); + }); + + it('reopens panel when inspect schema button clicked after closing', async () => { + const { queryByText, getByLabelText, getByText } = render(); + + const closeButton = getByLabelText('Close schema inspector'); + await act(async () => fireEvent.click(closeButton)); + + expect(queryByText('No schema information available')).not.toBeInTheDocument(); + + const reopenButton = getByText('Inspect schema'); + await act(async () => fireEvent.click(reopenButton)); + + expect(queryByText('No schema information available')).toBeInTheDocument(); + }); + + it('renders tabs for multiple query refIds', async () => { + mockBackendSrv.post.mockResolvedValue({ + kind: 'SQLSchemaResponse', + apiVersion: 'query.grafana.app/v0alpha1', + sqlSchemas: { + A: { columns: [], sampleRows: [] }, + B: { columns: [], sampleRows: [] }, + C: { columns: [], sampleRows: [] }, + }, + }); + + const propsWithQueries = { + ...defaultProps, + queries: [{ refId: 'A' }, { refId: 'B' }, { refId: 'C' }], + }; + + const { findByRole } = render(); + + expect(await findByRole('tab', { name: 'A' })).toBeInTheDocument(); + expect(await findByRole('tab', { name: 'B' })).toBeInTheDocument(); + expect(await findByRole('tab', { name: 'C' })).toBeInTheDocument(); + }); + }); + + describe('when feature disabled', () => { + testWithFeatureToggles({ enable: [] }); + + it('does not render panel or button', () => { + const { queryByText } = render(); + + expect(queryByText('Inspect schema')).not.toBeInTheDocument(); + expect(queryByText('No schema information available')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/expressions/components/SqlExpr.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExpr.tsx similarity index 73% rename from public/app/features/expressions/components/SqlExpr.tsx rename to public/app/features/expressions/components/SqlExpressions/SqlExpr.tsx index 8ae4d704962..361d748b716 100644 --- a/public/app/features/expressions/components/SqlExpr.tsx +++ b/public/app/features/expressions/components/SqlExpressions/SqlExpr.tsx @@ -1,4 +1,4 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { useMemo, useRef, useEffect, useState, lazy, Suspense, useCallback } from 'react'; import { useMeasure } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; @@ -11,14 +11,16 @@ import { DataQuery } from '@grafana/schema/dist/esm/index'; import { formatSQL } from '@grafana/sql'; import { useStyles2, Stack, Button, Modal } from '@grafana/ui'; -import { ExpressionQueryEditorProps } from '../ExpressionQueryEditor'; -import { SqlExpressionQuery } from '../types'; -import { fetchSQLFields } from '../utils/metaSqlExpr'; +import { ExpressionQueryEditorProps } from '../../ExpressionQueryEditor'; +import { SqlExpressionQuery } from '../../types'; +import { fetchSQLFields } from '../../utils/metaSqlExpr'; +import { QueryToolbox } from '../QueryToolbox'; +import { getSqlCompletionProvider } from './CompletionProvider/sqlCompletionProvider'; import { useSQLExplanations } from './GenAI/hooks/useSQLExplanations'; import { useSQLSuggestions } from './GenAI/hooks/useSQLSuggestions'; -import { QueryToolbox } from './QueryToolbox'; -import { getSqlCompletionProvider } from './sqlCompletionProvider'; +import { SchemaInspectorPanel } from './SchemaInspector/SchemaInspectorPanel'; +import { useSQLSchemas } from './hooks/useSQLSchemas'; // Lazy load the GenAI components to avoid circular dependencies const GenAISQLSuggestionsButton = lazy(() => @@ -53,6 +55,7 @@ const GenAIExplanationDrawer = lazy(() => // Account for Monaco editor's border to prevent clipping const EDITOR_BORDER_ADJUSTMENT = 2; // 1px border on top and bottom +const EDITOR_HEIGHT = 300; export interface SqlExprProps { refIds: Array>; @@ -90,11 +93,12 @@ FROM LIMIT 10`; - const styles = useStyles2(getStyles); - const containerRef = useRef(null); const [dimensions, setDimensions] = useState({ height: 0 }); + const styles = useStyles2((theme) => getStyles(theme, dimensions.height || EDITOR_HEIGHT)); + const containerRef = useRef(null); const [toolboxRef, toolboxMeasure] = useMeasure(); const [isExpanded, setIsExpanded] = useState(false); + const [isSchemaInspectorOpen, setIsSchemaInspectorOpen] = useState(true); const { handleApplySuggestion, handleHistoryUpdate, handleCloseDrawer, handleOpenDrawer, isDrawerOpen, suggestions } = useSQLSuggestions(); @@ -109,6 +113,18 @@ LIMIT updatePrevExpression, } = useSQLExplanations(query.expression || ''); + const { + schemas, + loading: schemasLoading, + error: schemasError, + isFeatureEnabled: isSchemasFeatureEnabled, + refetch: refetchSchemas, + } = useSQLSchemas({ + queries, + enabled: isSchemaInspectorOpen, + timeRange: metadata?.range, + }); + const queryContext = useMemo( () => ({ alerting, @@ -172,7 +188,12 @@ LIMIT onRunQuery(); } - }, [onRunQuery]); + + // Refetch schemas when query is run (only if inspector is open) + if (isSchemaInspectorOpen) { + refetchSchemas(); + } + }, [onRunQuery, refetchSchemas, isSchemaInspectorOpen]); // Set up resize observer to handle container resizing useEffect(() => { @@ -224,6 +245,17 @@ LIMIT const renderSQLButtons = () => (
+ {isSchemasFeatureEnabled && !isSchemaInspectorOpen && ( + + )} @@ -273,16 +305,32 @@ LIMIT <>
{renderSQLButtons()} -
- - {({ formatQuery }) => renderToolbox(formatQuery)} - +
+
+ + {({ formatQuery }) => renderToolbox(formatQuery)} + +
+ {isSchemaInspectorOpen && isSchemasFeatureEnabled && ( +
+ setIsSchemaInspectorOpen(false)} + /> +
+ )}
@@ -335,19 +383,35 @@ LIMIT ); }; -const getStyles = (theme: GrafanaTheme2) => ({ +const getStyles = (theme: GrafanaTheme2, editorHeight: number) => ({ sqlContainer: css({ display: 'grid', + gap: theme.spacing(1), gridTemplateRows: 'auto 1fr', gridTemplateAreas: ` "buttons" - "editor" + "content" `, - gap: theme.spacing(0.5), + }), + + contentContainer: css({ + gridArea: 'content', + display: 'grid', + gap: theme.spacing(1), + gridTemplateColumns: '1fr 0fr', + gridTemplateAreas: '"editor schema"', + [theme.transitions.handleMotion('no-preference')]: { + transition: theme.transitions.create(['grid-template-columns'], { + duration: theme.transitions.duration.standard, + }), + }, + }), + contentContainerWithSchema: css({ + gridTemplateColumns: '1fr 1fr', }), editorContainer: css({ gridArea: 'editor', - height: '240px', + height: editorHeight, // Use dynamic height from ResizeObserver resize: 'vertical', overflow: 'auto', minHeight: '100px', @@ -374,6 +438,37 @@ const getStyles = (theme: GrafanaTheme2) => ({ alignItems: 'center', gap: theme.spacing(1), }), + schemaInspector: css({ + gridArea: 'schema', + height: editorHeight, + overflow: 'hidden', + minWidth: 0, + }), + schemaInspectorOpen: css({ + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + }), + schemaFields: css({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(1), + padding: theme.spacing(1), + maxHeight: '120px', + overflowY: 'auto', + }), + fieldItem: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + padding: theme.spacing(1), + backgroundColor: theme.colors.background.secondary, + borderRadius: theme.shape.radius.default, + border: `1px solid ${theme.colors.border.weak}`, + fontSize: theme.typography.bodySmall.fontSize, + }), + responseContainer: css({ + padding: theme.spacing(2), + }), }); async function fetchFields(identifier: TableIdentifier, queries: DataQuery[]) { diff --git a/public/app/features/expressions/components/SqlExpressions/hooks/useSQLSchemas.ts b/public/app/features/expressions/components/SqlExpressions/hooks/useSQLSchemas.ts new file mode 100644 index 00000000000..fd9f5f41a1e --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/hooks/useSQLSchemas.ts @@ -0,0 +1,95 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; + +import { getAPINamespace } from '@grafana/api-clients'; +import { getDefaultTimeRange, TimeRange } from '@grafana/data'; +import { config, getBackendSrv } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; + +export interface SQLSchemaField { + name: string; + mysqlType: string; + dataFrameFieldType: string; + nullable: boolean; +} + +export interface SQLSchemaData { + columns: SQLSchemaField[] | null; + sampleRows: Array> | null; + error?: string; +} + +export type SQLSchemas = Record; + +export interface SQLSchemasResponse { + kind: string; + apiVersion: string; + sqlSchemas: SQLSchemas; +} + +interface UseSQLSchemasOptions { + queries?: DataQuery[]; + enabled: boolean; + timeRange?: TimeRange; +} + +export function useSQLSchemas({ queries, enabled, timeRange }: UseSQLSchemasOptions) { + const isFeatureEnabled = useMemo( + () => config.featureToggles.queryService || config.featureToggles.grafanaAPIServerWithExperimentalAPIs || false, + [] + ); + + // Start with loading=true if we're going to fetch on mount + const [schemas, setSchemas] = useState(null); + const [loading, setLoading] = useState(enabled && isFeatureEnabled && Boolean(queries)); + const [error, setError] = useState(null); + + // Store queries in ref so we can access current value without triggering effect + const queriesRef = useRef(queries); + queriesRef.current = queries; + + const fetchSchemas = useCallback(async () => { + if (!enabled || !isFeatureEnabled) { + return; + } + + const currentQueries = queriesRef.current; + if (!currentQueries) { + return; + } + + setLoading(true); + setError(null); + + try { + if (currentQueries.length === 0) { + setSchemas({ kind: 'SQLSchemaResponse', apiVersion: 'query.grafana.app/v0alpha1', sqlSchemas: {} }); + setLoading(false); + return; + } + + const namespace = getAPINamespace(); + const currentTimeRange = timeRange || getDefaultTimeRange(); + + const response = await getBackendSrv().post( + `/apis/query.grafana.app/v0alpha1/namespaces/${namespace}/sqlschemas/name`, + { + queries: currentQueries, + from: currentTimeRange.from.toISOString(), + to: currentTimeRange.to.toISOString(), + } + ); + + setSchemas(response); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to fetch SQL schemas')); + } finally { + setLoading(false); + } + }, [enabled, isFeatureEnabled, timeRange]); + + useEffect(() => { + fetchSchemas(); + }, [fetchSchemas]); + + return { schemas, loading, error, isFeatureEnabled, refetch: fetchSchemas }; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 874be92a233..1da6f55fa78 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7730,11 +7730,24 @@ "label-upsample": "Upsample", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, + "schema-inspector-panel": { + "aria-label-close-schema-inspector": "Close schema inspector" + }, "sql-expr": { "button-run-query": "Run query", "modal-title": "SQL Editor", "tooltip-experimental": "SQL Expressions LLM integration is experimental. Please report any issues to the Grafana team." }, + "sql-schema": { + "close-schema-inspector": "Close schema inspector", + "error-title": "Error", + "inspect-button": "Inspect schema", + "loading": "Loading schema information...", + "no-data-title": "No schema information available", + "no-fields-desc": "This query returned no schema information.", + "no-fields-title": "No schema information", + "query-error-title": "Query error" + }, "threshold": { "label-input": "Input" }