update query library
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
+117
-44
@@ -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 |
Reference in New Issue
Block a user