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:
Alex Spencer
2025-11-24 08:05:50 -08:00
committed by GitHub
parent 6a6c5e8ae0
commit 3d95d2aa79
18 changed files with 620 additions and 34 deletions
@@ -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';
@@ -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[]>;
@@ -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';
@@ -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';
@@ -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';
}
};
@@ -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();
});
});
});
@@ -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 };
}
+13
View File
@@ -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"
}