update query library

This commit is contained in:
Develer
2025-12-04 16:54:07 +01:00
parent 1610daeafb
commit 953f0b1a91
8 changed files with 768 additions and 355 deletions
+1
View File
@@ -160,6 +160,7 @@ export const availableIconsIndex = {
'gf-show-context': true,
'gf-pin': true,
'gf-prometheus': true,
'gf-query-library': true,
'gf-traces': true,
globe: true,
grafana: true,
+1
View File
@@ -179,6 +179,7 @@
"custom/gf-logs",
"custom/gf-movepane-left",
"custom/gf-movepane-right",
"custom/gf-query-library",
"custom/gf-traces",
"mono/favorite",
"mono/grafana",
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { memo, useCallback } from 'react';
import { memo, useCallback, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { DataQuery, GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { VizPanel } from '@grafana/scenes';
import { Container, ScrollContainer, useStyles2 } from '@grafana/ui';
@@ -11,22 +11,76 @@ import { DetailViewHeader } from './DetailViewHeader';
import { ExpressionDetailView } from './ExpressionDetailView';
import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab';
import { QueryDetailView } from './QueryDetailView';
import { QueryLibraryView, QueryLibraryViewRef } from './QueryLibraryView';
import { QueryTransformItem } from './QueryTransformList';
import { TabId } from './types';
export interface QueryLibraryMode {
active: boolean;
mode: 'browse' | 'save';
currentQuery?: DataQuery;
}
interface DetailViewProps {
selectedItem: QueryTransformItem | undefined;
panel: VizPanel;
tabs: Array<{ tabId: TabId }>;
onRemoveTransform?: (index: number) => void;
onToggleTransformVisibility?: (index: number) => void;
queryLibraryMode?: QueryLibraryMode;
onQueryLibrarySelect?: (query: DataQuery) => void;
onQueryLibrarySave?: (name: string, description: string) => void;
onQueryLibraryClose?: () => void;
onOpenQueryLibrary?: (mode: 'browse' | 'save', index?: number) => void;
}
export const DetailView = memo(
({ selectedItem, panel, tabs, onRemoveTransform, onToggleTransformVisibility }: DetailViewProps) => {
({
selectedItem,
panel,
tabs,
onRemoveTransform,
onToggleTransformVisibility,
queryLibraryMode,
onQueryLibrarySelect,
onQueryLibrarySave,
onQueryLibraryClose,
onOpenQueryLibrary,
}: DetailViewProps) => {
const styles = useStyles2(getStyles);
const queryLibraryRef = useRef<QueryLibraryViewRef>(null);
const handleSelectQueryFromHeader = useCallback(() => {
queryLibraryRef.current?.selectCurrentQuery();
}, []);
const handleSaveQueryFromHeader = useCallback(() => {
queryLibraryRef.current?.saveQuery();
}, []);
const renderContent = useCallback(() => {
// Show QueryLibraryView when in query library mode
if (queryLibraryMode?.active && onQueryLibraryClose) {
return (
<>
<DetailViewHeader
queryLibraryMode={queryLibraryMode.mode}
onSelectQuery={queryLibraryMode.mode === 'browse' ? handleSelectQueryFromHeader : undefined}
onSaveQuery={queryLibraryMode.mode === 'save' ? handleSaveQueryFromHeader : undefined}
onClose={onQueryLibraryClose}
/>
<QueryLibraryView
ref={queryLibraryRef}
mode={queryLibraryMode.mode}
currentQuery={queryLibraryMode.currentQuery}
onSelectQuery={onQueryLibrarySelect}
onSaveQuery={onQueryLibrarySave}
onClose={onQueryLibraryClose}
/>
</>
);
}
if (!selectedItem) {
return (
<div className={styles.emptyState}>
@@ -43,7 +97,7 @@ export const DetailView = memo(
const query = selectedItem.data;
return (
<>
<DetailViewHeader selectedItem={selectedItem} panel={panel} />
<DetailViewHeader selectedItem={selectedItem} panel={panel} onOpenQueryLibrary={onOpenQueryLibrary} />
<ScrollContainer>
<QueryDetailView panel={panel} query={query} queryIndex={selectedItem.index} />
</ScrollContainer>
@@ -83,7 +137,21 @@ export const DetailView = memo(
}
return null;
}, [selectedItem, panel, tabs, styles.emptyState, onRemoveTransform, onToggleTransformVisibility]);
}, [
selectedItem,
panel,
tabs,
styles.emptyState,
onRemoveTransform,
onToggleTransformVisibility,
queryLibraryMode,
onQueryLibrarySelect,
onQueryLibrarySave,
onQueryLibraryClose,
onOpenQueryLibrary,
handleSelectQueryFromHeader,
handleSaveQueryFromHeader,
]);
return <div className={styles.container}>{renderContent()}</div>;
}
@@ -28,15 +28,34 @@ import { DataSourcePicker } from 'app/features/datasources/components/picker/Dat
import { getQueryRunnerFor } from '../../utils/utils';
import { QueryTransformItem } from './QueryTransformList';
import { SavedQueriesDrawer } from './SavedQueriesDrawer';
interface DetailViewHeaderProps {
// Props for regular item mode
interface ItemModeProps {
selectedItem: QueryTransformItem;
panel: VizPanel;
onRemoveTransform?: (index: number) => void;
onToggleTransformVisibility?: (index: number) => void;
onOpenQueryLibrary?: (mode: 'browse' | 'save') => void;
queryLibraryMode?: never;
onSelectQuery?: never;
onClose?: never;
}
// Props for query library mode
interface QueryLibraryModeProps {
selectedItem?: never;
panel?: never;
onRemoveTransform?: never;
onToggleTransformVisibility?: never;
onOpenQueryLibrary?: never;
queryLibraryMode: 'browse' | 'save';
onSelectQuery?: () => void;
onSaveQuery?: () => void;
onClose: () => void;
}
type DetailViewHeaderProps = ItemModeProps | QueryLibraryModeProps;
const ITEM_CONFIG = (theme: GrafanaTheme2) => ({
query: {
color: theme.colors.primary.main,
@@ -50,21 +69,85 @@ const ITEM_CONFIG = (theme: GrafanaTheme2) => ({
color: theme.visualization.getColorByName('orange'),
icon: 'process' as const,
},
queryLibrary: {
color: theme.visualization.getColorByName('green'),
icon: 'bookmark' as const,
},
});
export const DetailViewHeader = ({
// Separate component for query library header to avoid conditional hooks
function QueryLibraryHeader({
mode,
onSelectQuery,
onSaveQuery,
onClose,
}: {
mode: 'browse' | 'save';
onSelectQuery?: () => void;
onSaveQuery?: () => void;
onClose: () => void;
}) {
const theme = useTheme2();
const config = useMemo(() => ITEM_CONFIG(theme).queryLibrary, [theme]);
const styles = useStyles2(getStyles, config);
return (
<div className={styles.header}>
<div className={styles.headerContent}>
<Stack gap={1} alignItems="center" grow={1} minWidth={0}>
<Icon name="gf-query-library" />
<span className={styles.transformName}>{t('query-library.header.title', 'SAVED QUERIES')}</span>
</Stack>
<Stack gap={0.5} alignItems="center">
{mode === 'browse' && onSelectQuery && (
<Button className={styles.monospace} variant="primary" fill="text" size="sm" onClick={onSelectQuery}>
{t('query-library.header.select-query', 'SELECT QUERY')}
</Button>
)}
{mode === 'save' && onSaveQuery && (
<Button className={styles.monospace} variant="primary" fill="text" size="sm" onClick={onSaveQuery}>
{t('query-library.header.save-query', 'SAVE')}
</Button>
)}
<Dropdown
overlay={
<Menu>
<Menu.Item
label={t('query-library.header.delete-all', 'Delete all')}
icon="trash-alt"
onClick={() => {}}
/>
</Menu>
}
>
<IconButton name="ellipsis-v" variant="secondary" tooltip={t('query-library.header.more', 'More')} />
</Dropdown>
<IconButton
name="times"
variant="secondary"
onClick={onClose}
tooltip={t('query-library.header.close', 'Close')}
/>
</Stack>
</div>
</div>
);
}
// Separate component for item header to keep hooks unconditional
function ItemHeader({
selectedItem,
panel,
onRemoveTransform,
onToggleTransformVisibility,
}: DetailViewHeaderProps) => {
onOpenQueryLibrary,
}: ItemModeProps) {
const theme = useTheme2();
const config = useMemo(() => ITEM_CONFIG(theme)[selectedItem.type], [theme, selectedItem.type]);
const styles = useStyles2(getStyles, config);
const [isEditing, setIsEditing] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [isSavedQueriesDrawerOpen, setIsSavedQueriesDrawerOpen] = useState(false);
// Helper to update queries with consistent pattern
const updateQueries = useCallback(
@@ -267,23 +350,6 @@ export const DetailViewHeader = ({
onToggleTransformVisibility?.(selectedItem.index);
}, [selectedItem, onToggleTransformVisibility]);
// Handler for selecting a query from the library (replaces current query)
const onSelectQueryFromLibrary = useCallback(
(newQuery: DataQuery) => {
if (selectedItem.type !== 'query' || selectedItem.index === undefined) {
return;
}
updateQueries(
(queries) =>
queries.map((q, idx) =>
idx === selectedItem.index ? { ...newQuery, refId: q.refId, datasource: q.datasource } : q
),
true
);
},
[selectedItem, updateQueries]
);
const refId = 'refId' in selectedItem.data ? selectedItem.data.refId : '';
const isHidden =
(selectedItem.type === 'query' || selectedItem.type === 'expression') &&
@@ -362,14 +428,14 @@ export const DetailViewHeader = ({
{(selectedItem.type === 'query' || selectedItem.type === 'expression') && (
<Stack gap={0.5} alignItems="center">
{/* Save Query Button (only for queries, not expressions) */}
{selectedItem.type === 'query' && 'refId' in selectedItem.data && (
{selectedItem.type === 'query' && 'refId' in selectedItem.data && onOpenQueryLibrary && (
<Button
className={styles.monospace}
variant="primary"
fill="text"
size="sm"
icon="bookmark"
onClick={() => setIsSavedQueriesDrawerOpen(true)}
onClick={() => onOpenQueryLibrary('save')}
>
{t('dashboard-scene.detail-view-header.save-query', 'SAVE')}
</Button>
@@ -452,27 +518,32 @@ export const DetailViewHeader = ({
</Stack>
)}
</div>
{/* Saved Queries Drawer */}
{selectedItem.type === 'query' && 'refId' in selectedItem.data && (
<SavedQueriesDrawer
isOpen={isSavedQueriesDrawerOpen}
onClose={() => setIsSavedQueriesDrawerOpen(false)}
onSelectQuery={onSelectQueryFromLibrary}
currentQuery={
'datasource' in selectedItem.data
? {
refId: selectedItem.data.refId,
datasource: datasourceSettings
? { uid: datasourceSettings.uid, type: datasourceSettings.type }
: selectedItem.data.datasource,
}
: { refId: selectedItem.data.refId }
}
/>
)}
</div>
);
}
// Main component that delegates to the appropriate sub-component
export const DetailViewHeader = (props: DetailViewHeaderProps) => {
if (props.queryLibraryMode) {
return (
<QueryLibraryHeader
mode={props.queryLibraryMode}
onSelectQuery={props.onSelectQuery}
onSaveQuery={props.onSaveQuery}
onClose={props.onClose}
/>
);
}
return (
<ItemHeader
selectedItem={props.selectedItem}
panel={props.panel}
onRemoveTransform={props.onRemoveTransform}
onToggleTransformVisibility={props.onToggleTransformVisibility}
onOpenQueryLibrary={props.onOpenQueryLibrary}
/>
);
};
const getStyles = (theme: GrafanaTheme2, config: { color: string }) => {
@@ -546,7 +617,9 @@ const getStyles = (theme: GrafanaTheme2, config: { color: string }) => {
transformName: css({
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.text.primary,
fontSize: theme.typography.body.fontSize,
fontSize: theme.typography.bodySmall.fontSize,
fontFamily: theme.typography.fontFamilyMonospace,
letterSpacing: '0.05em',
}),
};
};
@@ -23,12 +23,11 @@ import { isExpressionQuery } from '../../../expressions/guards';
import { ExpressionQueryType } from '../../../expressions/types';
import { getQueryRunnerFor } from '../../utils/utils';
import { DetailView } from './DetailView';
import { DetailView, QueryLibraryMode } from './DetailView';
import { PanelDataAlertingTab } from './PanelDataAlertingTab';
import { PanelDataQueriesTab } from './PanelDataQueriesTab';
import { PanelDataTransformationsTab } from './PanelDataTransformationsTab';
import { QueryTransformList, QueryTransformItem } from './QueryTransformList';
import { SavedQueriesDrawer } from './SavedQueriesDrawer';
import { TransformationsDrawer } from './TransformationsDrawer';
import { PanelDataPaneTab, TabId } from './types';
import { isDataTransformerConfig, queryItemId, transformItemId } from './utils';
@@ -92,8 +91,9 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
const { tabs, panelRef } = model.useState();
const styles = useStyles2(getStyles);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [savedQueriesDrawerState, setSavedQueriesDrawerState] = useState<DrawerState>({
open: false,
const [queryLibraryMode, setQueryLibraryMode] = useState<QueryLibraryMode & { index: number | null }>({
active: false,
mode: 'browse',
index: null,
});
const [transformDrawerState, setTransformDrawerState] = useState<DrawerState>({
@@ -291,14 +291,14 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
[queryRunner, queries]
);
// This is a stub for the saved queries drawer
const handleSelectSavedQuery = useCallback(
// Handler for selecting a query from the query library
const handleQueryLibrarySelect = useCallback(
(query: DataQuery) => {
if (!queryRunner || !queriesTab) {
return;
}
const selectedIndex = savedQueriesDrawerState.index ?? queries?.length ?? 0;
const selectedIndex = queryLibraryMode.index ?? queries?.length ?? 0;
// Get next available refId
let nextRefId = 'A';
@@ -319,9 +319,43 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
updateQuerySelectionOnStateChange(selectedIndex);
queriesTab.onAddQuery(newQuery);
setSavedQueriesDrawerState({ open: false, index: null });
setQueryLibraryMode({ active: false, mode: 'browse', index: null });
},
[queryRunner, savedQueriesDrawerState.index, queries, updateQuerySelectionOnStateChange, queriesTab]
[queryRunner, queryLibraryMode.index, queries, updateQuerySelectionOnStateChange, queriesTab]
);
// Handler for saving a query to the query library (stub)
const handleQueryLibrarySave = useCallback((_name: string, _description: string) => {
// Stub: In real implementation, this would save to the query library
setQueryLibraryMode({ active: false, mode: 'browse', index: null });
}, []);
// Handler to close the query library view
const handleQueryLibraryClose = useCallback(() => {
setQueryLibraryMode({ active: false, mode: 'browse', index: null });
}, []);
// Handler to open query library in a specific mode
const handleOpenQueryLibrary = useCallback(
(mode: 'browse' | 'save', index?: number) => {
let currentQuery: DataQuery | undefined;
if (
mode === 'save' &&
selectedItem &&
(selectedItem.type === 'query' || selectedItem.type === 'expression') &&
'refId' in selectedItem.data
) {
currentQuery = selectedItem.data;
}
setQueryLibraryMode({
active: true,
mode,
currentQuery,
index: index ?? null,
});
},
[selectedItem]
);
/** TRANSFORMS **/
@@ -428,7 +462,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
selectedId={effectiveSelectedId}
onSelect={handleSelect}
onAddQuery={handleAddQuery}
onAddFromSavedQueries={(index) => setSavedQueriesDrawerState({ open: true, index: index ?? null })}
onAddFromSavedQueries={(index) => handleOpenQueryLibrary('browse', index)}
onAddTransform={(index) => setTransformDrawerState({ open: true, index: index ?? null })}
onAddExpression={handleAddExpression}
onDuplicateQuery={handleDuplicateQuery}
@@ -470,6 +504,11 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
tabs={tabs}
onRemoveTransform={handleRemoveTransform}
onToggleTransformVisibility={handleToggleTransformVisibility}
queryLibraryMode={queryLibraryMode}
onQueryLibrarySelect={handleQueryLibrarySelect}
onQueryLibrarySave={handleQueryLibrarySave}
onQueryLibraryClose={handleQueryLibraryClose}
onOpenQueryLibrary={handleOpenQueryLibrary}
/>
</div>
</div>
@@ -479,11 +518,6 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
onTransformationAdd={handleAddTransform}
series={series}
/>
<SavedQueriesDrawer
isOpen={savedQueriesDrawerState.open}
onClose={() => setSavedQueriesDrawerState({ open: false, index: null })}
onSelectQuery={handleSelectSavedQuery}
/>
</div>
);
}
@@ -0,0 +1,522 @@
import { css, cx } from '@emotion/css';
import { useState, useMemo, useCallback, Fragment, useEffect, forwardRef, useImperativeHandle } from 'react';
import { DataQuery, dateTime, GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import {
Avatar,
Badge,
Box,
Checkbox,
Divider,
EmptyState,
Field,
Icon,
Input,
ScrollContainer,
Stack,
TagsInput,
Text,
useStyles2,
} from '@grafana/ui';
export interface QueryLibraryViewRef {
selectCurrentQuery: () => void;
saveQuery: () => void;
canSave: () => boolean;
}
// Stub saved queries for demonstration
const STUB_SAVED_QUERIES: Array<{
uid: string;
title: string;
description: string;
queryText: string;
datasourceName: string;
datasourceType: string;
datasourceUid: string;
user: { uid: string; displayName: string; avatarUrl?: string };
createdAtTimestamp: number;
tags: string[];
isVisible: boolean;
query: DataQuery;
}> = [
{
uid: '1',
title: 'Rate then sum by(label) then avg',
description: 'Returns CPU usage metrics for all hosts',
queryText: 'rate(node_cpu_seconds_total{mode="user"}[5m])',
datasourceName: 'Prometheus',
datasourceType: 'prometheus',
datasourceUid: 'prometheus',
user: { uid: 'admin', displayName: 'Admin' },
createdAtTimestamp: Date.now() - 86400000,
tags: ['metrics', 'cpu'],
isVisible: true,
query: {
refId: 'A',
datasource: { type: 'prometheus', uid: 'prometheus' },
},
},
{
uid: '2',
title: 'History quantile on rate',
description: 'Returns memory usage metrics',
queryText: 'node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes * 100',
datasourceName: 'Loki',
datasourceType: 'loki',
datasourceUid: 'loki',
user: { uid: 'admin', displayName: 'Admin' },
createdAtTimestamp: Date.now() - 172800000,
tags: ['metrics', 'memory'],
isVisible: true,
query: {
refId: 'A',
datasource: { type: 'loki', uid: 'loki' },
},
},
{
uid: '3',
title: 'Binary Query',
description: 'Returns network traffic in/out bytes',
queryText: 'rate(node_network_receive_bytes_total[5m])',
datasourceName: 'InfluxDB',
datasourceType: 'influxdb',
datasourceUid: 'influxdb',
user: { uid: 'editor', displayName: 'Editor' },
createdAtTimestamp: Date.now() - 259200000,
tags: ['network'],
isVisible: false,
query: {
refId: 'A',
datasource: { type: 'influxdb', uid: 'influxdb' },
},
},
{
uid: '4',
title: 'Service Latency',
description: 'Returns service latency metrics',
queryText: 'histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))',
datasourceName: 'Tempo',
datasourceType: 'tempo',
datasourceUid: 'tempo',
user: { uid: 'admin', displayName: 'Admin' },
createdAtTimestamp: Date.now() - 345600000,
tags: ['latency', 'service'],
isVisible: true,
query: {
refId: 'A',
datasource: { type: 'tempo', uid: 'tempo' },
},
},
{
uid: '5',
title: 'Network Throughput',
description: 'Returns network throughput metrics',
queryText: 'rate(node_network_transmit_bytes_total[5m])',
datasourceName: 'Graphite',
datasourceType: 'graphite',
datasourceUid: 'graphite',
user: { uid: 'admin', displayName: 'Admin' },
createdAtTimestamp: Date.now() - 432000000,
tags: ['network', 'throughput'],
isVisible: true,
query: {
refId: 'A',
datasource: { type: 'graphite', uid: 'graphite' },
},
},
{
uid: '6',
title: 'Log Volume',
description: 'Returns log volume metrics',
queryText: 'sum(rate({job="varlogs"}[5m]))',
datasourceName: 'Loki',
datasourceType: 'loki',
datasourceUid: 'loki',
user: { uid: 'admin', displayName: 'Admin' },
createdAtTimestamp: Date.now() - 518400000,
tags: ['logs', 'volume'],
isVisible: true,
query: {
refId: 'A',
datasource: { type: 'loki', uid: 'loki' },
},
},
];
export interface QueryLibraryViewProps {
mode: 'browse' | 'save';
currentQuery?: DataQuery;
onSelectQuery?: (query: DataQuery) => void;
onSaveQuery?: (name: string, description: string) => void;
onClose: () => void;
}
// Component to render datasource icon
function DatasourceIcon({ datasourceType, className }: { datasourceType: string; className?: string }) {
const [logoUrl, setLogoUrl] = useState<string | null>(null);
useEffect(() => {
const fetchLogo = async () => {
try {
const ds = await getDataSourceSrv().get({ type: datasourceType });
if (ds?.meta?.info?.logos?.small) {
setLogoUrl(ds.meta.info.logos.small);
}
} catch {
// Datasource not found, will use fallback
}
};
fetchLogo();
}, [datasourceType]);
if (logoUrl) {
return <img src={logoUrl} alt={datasourceType} className={className} />;
}
// Fallback to generic icon
return <Icon name="database" />;
}
export const QueryLibraryView = forwardRef<QueryLibraryViewRef, QueryLibraryViewProps>(function QueryLibraryView(
{ mode, currentQuery, onSelectQuery, onSaveQuery, onClose },
ref
) {
const styles = useStyles2(getStyles);
// Filter state
const [searchQuery, setSearchQuery] = useState('');
// Selection state
const [selectedQueryIndex, setSelectedQueryIndex] = useState(0);
// Form state for new/edit query
const [formTitle, setFormTitle] = useState(
mode === 'save' ? t('explore.query-library.default-title', 'New query') : ''
);
const [formDescription, setFormDescription] = useState('');
const [formTags, setFormTags] = useState<string[]>([]);
const [formIsVisible, setFormIsVisible] = useState(true);
// Filter queries
const filteredQueries = useMemo(() => {
return STUB_SAVED_QUERIES.filter((q) => {
const matchesSearch =
!searchQuery ||
q.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.queryText.toLowerCase().includes(searchQuery.toLowerCase());
return matchesSearch;
});
}, [searchQuery]);
// Current selected query or new query
const selectedQuery = mode === 'save' ? null : filteredQueries[selectedQueryIndex];
const handleSelectQuery = useCallback(
(query: DataQuery) => {
onSelectQuery?.(query);
},
[onSelectQuery]
);
const handleSaveQuery = useCallback(() => {
if (formTitle.trim()) {
onSaveQuery?.(formTitle, formDescription);
}
}, [formTitle, formDescription, onSaveQuery]);
// Expose methods via ref
useImperativeHandle(
ref,
() => ({
selectCurrentQuery: () => {
if (selectedQuery) {
handleSelectQuery(selectedQuery.query);
}
},
saveQuery: () => {
handleSaveQuery();
},
canSave: () => {
return formTitle.trim().length > 0;
},
}),
[selectedQuery, handleSelectQuery, handleSaveQuery, formTitle]
);
const isFiltered = Boolean(searchQuery);
const isEmpty = filteredQueries.length === 0 && mode === 'browse';
// Get current datasource type for icons
const getCurrentDatasourceType = () => {
if (currentQuery?.datasource && typeof currentQuery.datasource === 'object' && 'type' in currentQuery.datasource) {
return currentQuery.datasource.type || '';
}
return '';
};
// Render query list item
const renderQueryItem = (query: (typeof STUB_SAVED_QUERIES)[0], index: number) => (
<Fragment key={query.uid}>
<label className={cx(styles.queryItem, selectedQueryIndex === index && styles.selected)}>
<input
type="radio"
name="query-library-list"
className={styles.radioInput}
checked={selectedQueryIndex === index}
onChange={() => setSelectedQueryIndex(index)}
disabled={mode === 'save'}
/>
<Stack alignItems="center" gap={1} minWidth={0}>
<DatasourceIcon datasourceType={query.datasourceType} className={styles.datasourceIcon} />
<Text truncate>{query.title}</Text>
</Stack>
</label>
<Divider spacing={0} />
</Fragment>
);
// Render details form
const renderDetailsForm = () => {
const query = mode === 'save' ? null : selectedQuery;
const queryText = mode === 'save' ? JSON.stringify(currentQuery, null, 2) : query?.queryText;
const getDatasourceName = () => {
if (mode !== 'save') {
return query?.datasourceName || '';
}
if (
currentQuery?.datasource &&
typeof currentQuery.datasource === 'object' &&
'type' in currentQuery.datasource
) {
return currentQuery.datasource.type || 'Unknown';
}
return 'Unknown';
};
const datasourceName = getDatasourceName();
const datasourceType = mode === 'save' ? getCurrentDatasourceType() : query?.datasourceType || '';
const author = mode === 'save' ? { displayName: 'Current User' } : query?.user;
const dateAdded = mode === 'save' ? new Date() : query ? new Date(query.createdAtTimestamp) : new Date();
const formattedDate = dateTime(dateAdded).format('ddd MMM DD YYYY HH:mm [GMT]ZZ');
return (
<Stack direction="column" flex={1} height="100%">
<Box flex={1}>
{/* Title with icon */}
<Box marginBottom={2}>
<Stack gap={1} alignItems="center">
<DatasourceIcon datasourceType={datasourceType} className={styles.datasourceIconLarge} />
<Box flex={1}>
<Field label={t('query-library.query-details.title', 'Title')} noMargin>
<Input
id="query-title"
value={mode === 'save' ? formTitle : query?.title || ''}
onChange={(e) => mode === 'save' && setFormTitle(e.currentTarget.value)}
readOnly={mode !== 'save'}
/>
</Field>
</Box>
</Stack>
</Box>
{/* Query text */}
<Box marginBottom={2}>
<code className={styles.queryCode}>{queryText}</code>
</Box>
<Stack direction="column" gap={2}>
{/* Data source */}
<Field label={t('query-library.query-details.datasource', 'Datasource')} noMargin>
<Input readOnly value={datasourceName} />
</Field>
{/* Author */}
<Field label={t('query-library.query-details.author', 'Author')} noMargin>
<Input
readOnly
prefix={
<Box marginRight={0.5}>
<Avatar width={2} height={2} src="https://secure.gravatar.com/avatar" alt="" />
</Box>
}
value={author?.displayName || ''}
/>
</Field>
{/* Description */}
<Field label={t('query-library.query-details.description', 'Description')} noMargin>
<Input
value={mode === 'save' ? formDescription : query?.description || ''}
onChange={(e) => mode === 'save' && setFormDescription(e.currentTarget.value)}
readOnly={mode !== 'save'}
/>
</Field>
{/* Tags */}
<Field label={t('query-library.query-details.tags', 'Tags')} noMargin>
<TagsInput
tags={mode === 'save' ? formTags : query?.tags || []}
onChange={(tags) => mode === 'save' && setFormTags(tags)}
disabled={mode !== 'save'}
/>
</Field>
{/* Date added */}
<Field label={t('query-library.query-details.date-added', 'Date added')} noMargin>
<Input readOnly value={formattedDate} />
</Field>
{/* Share checkbox */}
<Field noMargin>
<Checkbox
label={t('query-library.query-details.make-query-visible', 'Share query with all users')}
checked={mode === 'save' ? formIsVisible : query?.isVisible || false}
onChange={(e) => mode === 'save' && setFormIsVisible(e.currentTarget.checked)}
disabled={mode !== 'save'}
/>
</Field>
</Stack>
</Box>
</Stack>
);
};
return (
<Stack height="100%" direction="column" gap={0}>
{/* Content - two column layout */}
{isEmpty ? (
<EmptyState
variant={isFiltered ? 'not-found' : 'call-to-action'}
message={
isFiltered
? t('query-library.not-found.title', 'No results found')
: t('query-library.empty-state.title', "You haven't saved any queries yet")
}
>
{isFiltered ? (
<Trans i18nKey="query-library.not-found.message">Try adjusting your search or filter criteria</Trans>
) : (
<Trans i18nKey="query-library.empty-state.message">
Start adding them from Explore or when editing a dashboard
</Trans>
)}
</EmptyState>
) : (
<Stack flex={1} gap={0} minHeight={0}>
{/* Left column - Query list with search */}
<Box display="flex" flex={1} minWidth={0} direction="column">
{/* Search field */}
<Box padding={1} paddingBottom={0}>
<Input
prefix={<Icon name="search" />}
placeholder={t('query-library.filters.search', 'Search by...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
/>
</Box>
<ScrollContainer>
<Stack direction="column" gap={0} flex={1} minWidth={0}>
{/* New query item when in save mode */}
{mode === 'save' && currentQuery && (
<>
<label className={cx(styles.queryItem, styles.selected)}>
<input type="radio" name="query-library-list" className={styles.radioInput} checked readOnly />
<Stack alignItems="center" justifyContent="space-between" width="100%">
<Stack alignItems="center" gap={1} minWidth={0}>
<DatasourceIcon
datasourceType={getCurrentDatasourceType()}
className={styles.datasourceIcon}
/>
<Text truncate>{formTitle || t('explore.query-library.default-title', 'New query')}</Text>
</Stack>
<Badge text={t('query-library.item.new', 'New')} color="orange" />
</Stack>
</label>
<Divider spacing={0} />
</>
)}
{/* Existing queries */}
{filteredQueries.map((query, index) => renderQueryItem(query, index))}
</Stack>
</ScrollContainer>
</Box>
<Divider direction="vertical" spacing={0} />
{/* Right column - Details form */}
<Box display="flex" flex={2} minWidth={0}>
<ScrollContainer>
<Box
direction="column"
display="flex"
flex={1}
paddingBottom={0}
paddingLeft={2}
paddingRight={1}
paddingTop={2}
>
{renderDetailsForm()}
</Box>
</ScrollContainer>
</Box>
</Stack>
)}
</Stack>
);
});
const getStyles = (theme: GrafanaTheme2) => ({
queryItem: css({
display: 'block',
width: '100%',
padding: theme.spacing(1.5, 1.5, 1.5, 1),
position: 'relative',
cursor: 'pointer',
[theme.transitions.handleMotion('no-preference')]: {
transition: theme.transitions.create(['background-color'], {
duration: theme.transitions.duration.short,
}),
},
'&:hover': {
backgroundColor: theme.colors.action.hover,
},
}),
selected: css({
backgroundColor: theme.colors.action.selected,
'&:hover': {
backgroundColor: theme.colors.action.selected,
},
}),
radioInput: css({
position: 'absolute',
opacity: 0,
cursor: 'pointer',
}),
datasourceIcon: css({
width: '16px',
height: '16px',
objectFit: 'contain',
}),
datasourceIconLarge: css({
width: '24px',
height: '24px',
objectFit: 'contain',
}),
queryCode: css({
backgroundColor: theme.colors.action.disabledBackground,
borderRadius: theme.shape.radius.default,
display: 'block',
margin: theme.spacing(0, 0, 2, 0),
overflowWrap: 'break-word',
padding: theme.spacing(1),
whiteSpace: 'pre-wrap',
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.bodySmall.fontSize,
}),
});
@@ -1,291 +0,0 @@
import { css } from '@emotion/css';
import { useState } from 'react';
import { DataQuery, GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Button, Drawer, Icon, Input, Stack, useStyles2 } from '@grafana/ui';
interface SavedQueriesDrawerProps {
isOpen: boolean;
onClose: () => void;
onSelectQuery: (query: DataQuery) => void;
currentQuery?: DataQuery;
}
// Stub saved queries for demonstration
const STUB_SAVED_QUERIES: Array<{ id: string; name: string; description: string; query: DataQuery }> = [
{
id: '1',
name: 'CPU Usage Query',
description: 'Returns CPU usage metrics for all hosts',
query: {
refId: 'A',
datasource: { type: 'prometheus', uid: 'prometheus' },
},
},
{
id: '2',
name: 'Memory Usage Query',
description: 'Returns memory usage metrics',
query: {
refId: 'A',
datasource: { type: 'prometheus', uid: 'prometheus' },
},
},
{
id: '3',
name: 'Network Traffic Query',
description: 'Returns network traffic in/out bytes',
query: {
refId: 'A',
datasource: { type: 'prometheus', uid: 'prometheus' },
},
},
{
id: '4',
name: 'Disk I/O Query',
description: 'Returns disk read/write operations',
query: {
refId: 'A',
datasource: { type: 'prometheus', uid: 'prometheus' },
},
},
];
export function SavedQueriesDrawer({ isOpen, onClose, onSelectQuery, currentQuery }: SavedQueriesDrawerProps) {
const styles = useStyles2(getStyles);
const [searchTerm, setSearchTerm] = useState('');
const [isSaveMode, setIsSaveMode] = useState(false);
const [saveName, setSaveName] = useState('');
const [saveDescription, setSaveDescription] = useState('');
const filteredQueries = STUB_SAVED_QUERIES.filter(
(q) =>
q.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
q.description.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleSelectQuery = (query: DataQuery) => {
onSelectQuery(query);
onClose();
};
const handleSaveQuery = () => {
// Stub: In real implementation, this would save to the query library
// eslint-disable-next-line no-console
console.log('Saving query:', { name: saveName, description: saveDescription, query: currentQuery });
alert(t('dashboard-scene.saved-queries-drawer.save-success', 'Query saved successfully! (stub)'));
onClose();
};
if (!isOpen) {
return null;
}
return (
<Drawer
title={
isSaveMode
? t('dashboard-scene.saved-queries-drawer.save-title', 'Save Query')
: t('dashboard-scene.saved-queries-drawer.title', 'Saved Queries')
}
onClose={onClose}
size="md"
>
<div className={styles.container}>
{/* Tab-like buttons to switch modes */}
<Stack gap={1} direction="row">
<Button
variant={!isSaveMode ? 'primary' : 'secondary'}
fill={!isSaveMode ? 'solid' : 'outline'}
size="sm"
onClick={() => setIsSaveMode(false)}
>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.browse-tab">Browse</Trans>
</Button>
<Button
variant={isSaveMode ? 'primary' : 'secondary'}
fill={isSaveMode ? 'solid' : 'outline'}
size="sm"
onClick={() => setIsSaveMode(true)}
>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.save-tab">Save Current</Trans>
</Button>
</Stack>
{isSaveMode ? (
/* Save Mode */
<div className={styles.saveForm}>
<Stack direction="column" gap={2}>
<div>
<label htmlFor="saved-query-name" className={styles.label}>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.name-label">Name</Trans>
</label>
<Input
id="saved-query-name"
value={saveName}
onChange={(e) => setSaveName(e.currentTarget.value)}
placeholder={t('dashboard-scene.saved-queries-drawer.name-placeholder', 'Enter query name...')}
/>
</div>
<div>
<label htmlFor="saved-query-description" className={styles.label}>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.description-label">Description</Trans>
</label>
<Input
id="saved-query-description"
value={saveDescription}
onChange={(e) => setSaveDescription(e.currentTarget.value)}
placeholder={t(
'dashboard-scene.saved-queries-drawer.description-placeholder',
'Enter description...'
)}
/>
</div>
<div className={styles.queryPreview}>
<span className={styles.label}>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.query-preview">Query Preview</Trans>
</span>
<pre className={styles.queryCode}>{JSON.stringify(currentQuery, null, 2)}</pre>
</div>
<Button onClick={handleSaveQuery} disabled={!saveName.trim()}>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.save-button">Save Query</Trans>
</Button>
</Stack>
</div>
) : (
/* Browse Mode */
<>
<div className={styles.search}>
<Input
prefix={<Icon name="search" />}
placeholder={t('dashboard-scene.saved-queries-drawer.search-placeholder', 'Search saved queries...')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.currentTarget.value)}
/>
</div>
<div className={styles.queryList}>
{filteredQueries.length === 0 ? (
<div className={styles.emptyState}>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.no-results">No queries found</Trans>
</div>
) : (
filteredQueries.map((savedQuery) => (
<div key={savedQuery.id} className={styles.queryItem}>
<div className={styles.queryInfo}>
<div className={styles.queryName}>{savedQuery.name}</div>
<div className={styles.queryDescription}>{savedQuery.description}</div>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleSelectQuery(savedQuery.query)}
icon="arrow-right"
>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.use-query">Use</Trans>
</Button>
</div>
))
)}
</div>
<div className={styles.stubNotice}>
<Icon name="info-circle" />
<span>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.stub-notice">
This is a stub implementation. Enable the queryLibrary feature toggle for full functionality.
</Trans>
</span>
</div>
</>
)}
</div>
</Drawer>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
height: '100%',
}),
search: css({
marginTop: theme.spacing(1),
}),
queryList: css({
flex: 1,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}),
queryItem: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing(1.5),
background: theme.colors.background.secondary,
borderRadius: theme.shape.radius.default,
border: `1px solid ${theme.colors.border.weak}`,
'&:hover': {
background: theme.colors.action.hover,
borderColor: theme.colors.border.medium,
},
}),
queryInfo: css({
flex: 1,
minWidth: 0,
}),
queryName: css({
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.text.primary,
marginBottom: theme.spacing(0.5),
}),
queryDescription: css({
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.secondary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}),
emptyState: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(4),
color: theme.colors.text.secondary,
}),
stubNotice: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
padding: theme.spacing(1),
background: theme.colors.info.transparent,
borderRadius: theme.shape.radius.default,
color: theme.colors.info.text,
fontSize: theme.typography.bodySmall.fontSize,
}),
saveForm: css({
marginTop: theme.spacing(2),
}),
label: css({
display: 'block',
marginBottom: theme.spacing(0.5),
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.text.primary,
}),
queryPreview: css({
marginTop: theme.spacing(1),
}),
queryCode: css({
background: theme.colors.background.secondary,
padding: theme.spacing(1),
borderRadius: theme.shape.radius.default,
fontSize: theme.typography.bodySmall.fontSize,
overflow: 'auto',
maxHeight: '200px',
}),
});
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect width="18" height="18" rx="6" fill="#3D71D9" fill-opacity="0.15"/>
<path d="M2.625 11.25H3.5625V8.625L5.475 11.25H6.375V6.75H5.4375V9.375L3.5625 6.75H2.625V11.25ZM7.125 11.25H10.125V10.3125H8.25V9.4875H10.125V8.55H8.25V7.6875H10.125V6.75H7.125V11.25ZM11.625 11.25H14.625C14.8375 11.25 15.0156 11.1781 15.1594 11.0344C15.3031 10.8906 15.375 10.7125 15.375 10.5V6.75H14.4375V10.125H13.6125V7.5H12.675V10.125H11.8125V6.75H10.875V10.5C10.875 10.7125 10.9469 10.8906 11.0906 11.0344C11.2344 11.1781 11.4125 11.25 11.625 11.25Z" fill="#5794F2"/>
</svg>

After

Width:  |  Height:  |  Size: 654 B