diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 1cb0b3db98a..caadf39451c 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -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, diff --git a/public/app/core/icons/cached.json b/public/app/core/icons/cached.json index a771248ee9f..0bd02d1838f 100644 --- a/public/app/core/icons/cached.json +++ b/public/app/core/icons/cached.json @@ -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", diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx index d1b2778d987..a52cf84a3d2 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx @@ -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(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 ( + <> + + + + ); + } + if (!selectedItem) { return (
@@ -43,7 +97,7 @@ export const DetailView = memo( const query = selectedItem.data; return ( <> - + @@ -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
{renderContent()}
; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailViewHeader.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailViewHeader.tsx index 9bfd02d0f87..c059c38744d 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailViewHeader.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailViewHeader.tsx @@ -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 ( +
+
+ + + {t('query-library.header.title', 'SAVED QUERIES')} + + + {mode === 'browse' && onSelectQuery && ( + + )} + {mode === 'save' && onSaveQuery && ( + + )} + + {}} + /> + + } + > + + + + +
+
+ ); +} + +// 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(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') && ( {/* Save Query Button (only for queries, not expressions) */} - {selectedItem.type === 'query' && 'refId' in selectedItem.data && ( + {selectedItem.type === 'query' && 'refId' in selectedItem.data && onOpenQueryLibrary && ( @@ -452,27 +518,32 @@ export const DetailViewHeader = ({ )}
- - {/* Saved Queries Drawer */} - {selectedItem.type === 'query' && 'refId' in selectedItem.data && ( - 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 } - } - /> - )} ); +} + +// Main component that delegates to the appropriate sub-component +export const DetailViewHeader = (props: DetailViewHeaderProps) => { + if (props.queryLibraryMode) { + return ( + + ); + } + + return ( + + ); }; 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', }), }; }; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx index 4c21c61f6c2..05c6399123d 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx @@ -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) { const { tabs, panelRef } = model.useState(); const styles = useStyles2(getStyles); const [selectedId, setSelectedId] = useState(null); - const [savedQueriesDrawerState, setSavedQueriesDrawerState] = useState({ - open: false, + const [queryLibraryMode, setQueryLibraryMode] = useState({ + active: false, + mode: 'browse', index: null, }); const [transformDrawerState, setTransformDrawerState] = useState({ @@ -291,14 +291,14 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { [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) { 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) { 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) { tabs={tabs} onRemoveTransform={handleRemoveTransform} onToggleTransformVisibility={handleToggleTransformVisibility} + queryLibraryMode={queryLibraryMode} + onQueryLibrarySelect={handleQueryLibrarySelect} + onQueryLibrarySave={handleQueryLibrarySave} + onQueryLibraryClose={handleQueryLibraryClose} + onOpenQueryLibrary={handleOpenQueryLibrary} /> @@ -479,11 +518,6 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { onTransformationAdd={handleAddTransform} series={series} /> - setSavedQueriesDrawerState({ open: false, index: null })} - onSelectQuery={handleSelectSavedQuery} - /> ); } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryLibraryView.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryLibraryView.tsx new file mode 100644 index 00000000000..3a924453e71 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryLibraryView.tsx @@ -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(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 {datasourceType}; + } + + // Fallback to generic icon + return ; +} + +export const QueryLibraryView = forwardRef(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([]); + 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) => ( + + + + + ); + + // 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 ( + + + {/* Title with icon */} + + + + + + mode === 'save' && setFormTitle(e.currentTarget.value)} + readOnly={mode !== 'save'} + /> + + + + + + {/* Query text */} + + {queryText} + + + + {/* Data source */} + + + + + {/* Author */} + + + + + } + value={author?.displayName || ''} + /> + + + {/* Description */} + + mode === 'save' && setFormDescription(e.currentTarget.value)} + readOnly={mode !== 'save'} + /> + + + {/* Tags */} + + mode === 'save' && setFormTags(tags)} + disabled={mode !== 'save'} + /> + + + {/* Date added */} + + + + + {/* Share checkbox */} + + mode === 'save' && setFormIsVisible(e.currentTarget.checked)} + disabled={mode !== 'save'} + /> + + + + + ); + }; + + return ( + + {/* Content - two column layout */} + {isEmpty ? ( + + {isFiltered ? ( + Try adjusting your search or filter criteria + ) : ( + + Start adding them from Explore or when editing a dashboard + + )} + + ) : ( + + {/* Left column - Query list with search */} + + {/* Search field */} + + } + placeholder={t('query-library.filters.search', 'Search by...')} + value={searchQuery} + onChange={(e) => setSearchQuery(e.currentTarget.value)} + /> + + + + + {/* New query item when in save mode */} + {mode === 'save' && currentQuery && ( + <> + + + + )} + + {/* Existing queries */} + {filteredQueries.map((query, index) => renderQueryItem(query, index))} + + + + + + + {/* Right column - Details form */} + + + + {renderDetailsForm()} + + + + + )} + + ); +}); + +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, + }), +}); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/SavedQueriesDrawer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/SavedQueriesDrawer.tsx deleted file mode 100644 index 624e85a7e4d..00000000000 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/SavedQueriesDrawer.tsx +++ /dev/null @@ -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 ( - -
- {/* Tab-like buttons to switch modes */} - - - - - - {isSaveMode ? ( - /* Save Mode */ -
- -
- - setSaveName(e.currentTarget.value)} - placeholder={t('dashboard-scene.saved-queries-drawer.name-placeholder', 'Enter query name...')} - /> -
-
- - setSaveDescription(e.currentTarget.value)} - placeholder={t( - 'dashboard-scene.saved-queries-drawer.description-placeholder', - 'Enter description...' - )} - /> -
-
- - Query Preview - -
{JSON.stringify(currentQuery, null, 2)}
-
- -
-
- ) : ( - /* Browse Mode */ - <> -
- } - placeholder={t('dashboard-scene.saved-queries-drawer.search-placeholder', 'Search saved queries...')} - value={searchTerm} - onChange={(e) => setSearchTerm(e.currentTarget.value)} - /> -
- -
- {filteredQueries.length === 0 ? ( -
- No queries found -
- ) : ( - filteredQueries.map((savedQuery) => ( -
-
-
{savedQuery.name}
-
{savedQuery.description}
-
- -
- )) - )} -
- -
- - - - This is a stub implementation. Enable the queryLibrary feature toggle for full functionality. - - -
- - )} -
-
- ); -} - -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', - }), -}); diff --git a/public/img/icons/custom/gf-query-library.svg b/public/img/icons/custom/gf-query-library.svg new file mode 100644 index 00000000000..af061d74a3f --- /dev/null +++ b/public/img/icons/custom/gf-query-library.svg @@ -0,0 +1,5 @@ + + + + +