SQLExpressions: Add new schema inspector panel (#113545)
* chore: initial approach * chore: keep editor and inspector height in tandem * chore: improve drawer animation * feat: hook it up for real * chore: i18n * chore: consistent errors/warnings * chore: remove unused className * chore: oops fix button name! * chore: set up refetch. call on apply query * chore: use getAPINamespace instead of "default" * chore: use dashboard timerange for api call * fix: shadow variable update * fix: add styles back to dependency array. it's weird but oh well * chore: reorder some code for readability * chore: default inspector open * chore: pr feedback - border radius * chore: pr feedback - don't filter queries * chore: pr feedback - button updates * refactor: improve schema inspector logic and loading state management * chore: refactor some logic + tests * chore: i18n
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
+1
-1
@@ -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<ColumnDefinition[]>;
|
||||
+3
-3
@@ -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';
|
||||
|
||||
+3
-3
@@ -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';
|
||||
|
||||
+264
@@ -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<string>('');
|
||||
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 } }) => (
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
<Icon name={getFieldTypeIcon(row.original.mysqlType)} />
|
||||
<div className={styles.tableCell}>{row.original.name}</div>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
header: 'Type',
|
||||
accessorKey: 'mysqlType',
|
||||
cell: ({ row }: { row: { original: SchemaField } }) => <Badge text={row.original.mysqlType} color="blue" />,
|
||||
},
|
||||
{
|
||||
id: 'nullable',
|
||||
header: 'Nullable',
|
||||
accessorKey: 'nullable',
|
||||
cell: ({ row }: { row: { original: SchemaField } }) => (
|
||||
<Icon
|
||||
name={row.original.nullable ? 'check' : 'times'}
|
||||
className={row.original.nullable ? styles.nullableIcon : styles.requiredIcon}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 <div className={styles.tableCell}>{arrayString}</div>;
|
||||
},
|
||||
},
|
||||
],
|
||||
[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 (
|
||||
<div className={styles.tableContainer}>
|
||||
<InteractiveTable
|
||||
columns={columns}
|
||||
data={enhancedFields}
|
||||
getRowId={({ name }) => name}
|
||||
pageSize={0} // No pagination
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSchemaTabContent = ({ columns, error, sampleRows }: SchemaData) => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.schemaInfoContainer}>
|
||||
<Alert title={t('expressions.sql-schema.query-error-title', 'Query error')} severity="error">
|
||||
{error}
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!columns || columns.length === 0) {
|
||||
return (
|
||||
<div className={styles.schemaInfoContainer}>
|
||||
<Alert severity="warning" title={t('expressions.sql-schema.no-fields-title', 'No schema information')}>
|
||||
<Trans i18nKey="expressions.sql-schema.no-fields-desc">This query returned no schema information.</Trans>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return renderSchemaFields(columns, sampleRows);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.schemaInfoContainer}>
|
||||
<Alert title={t('expressions.sql-schema.error-title', 'Error')} severity="error">
|
||||
{error.message}
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.schemaInfoContainer}>
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
<Spinner />
|
||||
<Text variant="code" color="secondary">
|
||||
<Trans i18nKey="expressions.sql-schema.loading">Loading schema information...</Trans>
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeSchemaData || refIds.length === 0) {
|
||||
return (
|
||||
<div className={styles.schemaInfoContainer}>
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={t('expressions.sql-schema.no-data-title', 'No schema information available')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollContainer backgroundColor="primary">
|
||||
<TabContent>{renderSchemaTabContent(activeSchemaData)}</TabContent>
|
||||
</ScrollContainer>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.schemaInspector}>
|
||||
<div className={styles.tabsBarWrapper}>
|
||||
{refIds.length > 0 && (
|
||||
<TabsBar>
|
||||
{refIds.map((refId) => (
|
||||
<Tab
|
||||
key={refId}
|
||||
label={refId}
|
||||
active={activeSchemaTab === refId}
|
||||
onChangeTab={() => setSelectedTab(refId)}
|
||||
/>
|
||||
))}
|
||||
</TabsBar>
|
||||
)}
|
||||
<IconButton
|
||||
name="times"
|
||||
onClick={onClose}
|
||||
tooltip={t('expressions.sql-schema.close-schema-inspector', 'Close schema inspector')}
|
||||
aria-label={t(
|
||||
'expressions.schema-inspector-panel.aria-label-close-schema-inspector',
|
||||
'Close schema inspector'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
};
|
||||
+103
-2
@@ -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(<SqlExpr {...defaultProps} />);
|
||||
|
||||
expect(getByText('No schema information available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes panel and shows reopen button when close button clicked', async () => {
|
||||
const { queryByText, getByLabelText, findByText } = render(<SqlExpr {...defaultProps} />);
|
||||
|
||||
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(<SqlExpr {...defaultProps} />);
|
||||
|
||||
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(<SqlExpr {...propsWithQueries} />);
|
||||
|
||||
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(<SqlExpr {...defaultProps} />);
|
||||
|
||||
expect(queryByText('Inspect schema')).not.toBeInTheDocument();
|
||||
expect(queryByText('No schema information available')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
+118
-23
@@ -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<SelectableValue<string>>;
|
||||
@@ -90,11 +93,12 @@ FROM
|
||||
LIMIT
|
||||
10`;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ height: 0 });
|
||||
const styles = useStyles2((theme) => getStyles(theme, dimensions.height || EDITOR_HEIGHT));
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [toolboxRef, toolboxMeasure] = useMeasure<HTMLDivElement>();
|
||||
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 = () => (
|
||||
<div className={styles.sqlButtons}>
|
||||
<Stack direction="row" gap={1} alignItems="center" justifyContent="end">
|
||||
{isSchemasFeatureEnabled && !isSchemaInspectorOpen && (
|
||||
<Button
|
||||
icon="table-expand-all"
|
||||
onClick={() => setIsSchemaInspectorOpen(true)}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
fill="outline"
|
||||
>
|
||||
<Trans i18nKey="expressions.sql-schema.inspect-button">Inspect schema</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button icon="play" onClick={executeQuery} size="sm">
|
||||
{t('expressions.sql-expr.button-run-query', 'Run query')}
|
||||
</Button>
|
||||
@@ -273,16 +305,32 @@ LIMIT
|
||||
<>
|
||||
<div className={styles.sqlContainer}>
|
||||
{renderSQLButtons()}
|
||||
<div ref={containerRef} className={styles.editorContainer}>
|
||||
<SQLEditor
|
||||
query={query.expression || initialQuery}
|
||||
onChange={onEditorChange}
|
||||
width={width}
|
||||
height={height ?? dimensions.height - EDITOR_BORDER_ADJUSTMENT - toolboxMeasure.height}
|
||||
language={EDITOR_LANGUAGE_DEFINITION}
|
||||
>
|
||||
{({ formatQuery }) => renderToolbox(formatQuery)}
|
||||
</SQLEditor>
|
||||
<div
|
||||
className={cx(styles.contentContainer, {
|
||||
[styles.contentContainerWithSchema]: isSchemaInspectorOpen && isSchemasFeatureEnabled,
|
||||
})}
|
||||
>
|
||||
<div ref={containerRef} className={styles.editorContainer}>
|
||||
<SQLEditor
|
||||
query={query.expression || initialQuery}
|
||||
onChange={onEditorChange}
|
||||
width={width}
|
||||
height={height ?? dimensions.height - EDITOR_BORDER_ADJUSTMENT - toolboxMeasure.height}
|
||||
language={EDITOR_LANGUAGE_DEFINITION}
|
||||
>
|
||||
{({ formatQuery }) => renderToolbox(formatQuery)}
|
||||
</SQLEditor>
|
||||
</div>
|
||||
{isSchemaInspectorOpen && isSchemasFeatureEnabled && (
|
||||
<div className={`${styles.schemaInspector} ${isSchemaInspectorOpen ? styles.schemaInspectorOpen : ''}`}>
|
||||
<SchemaInspectorPanel
|
||||
schemas={schemas?.sqlSchemas ?? null}
|
||||
loading={schemasLoading}
|
||||
error={schemasError}
|
||||
onClose={() => setIsSchemaInspectorOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
@@ -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[]) {
|
||||
@@ -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<Array<string | number | boolean>> | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type SQLSchemas = Record<string, SQLSchemaData>;
|
||||
|
||||
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<SQLSchemasResponse | null>(null);
|
||||
const [loading, setLoading] = useState(enabled && isFeatureEnabled && Boolean(queries));
|
||||
const [error, setError] = useState<Error | null>(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<SQLSchemasResponse>(
|
||||
`/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 };
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user