diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 576b5215d2e..51ffb9dd8ce 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -356,6 +356,7 @@ lerna.json @grafana/frontend-ops /public/app/features/datasources/ @grafana/user-essentials /public/app/features/dimensions/ @grafana/dataviz-squad /public/app/features/dataframe-import/ @grafana/grafana-bi-squad +/public/app/features/datasource-drawer/ @grafana/grafana-bi-squad /public/app/features/explore/ @grafana/explore-squad /public/app/features/expressions/ @grafana/observability-metrics /public/app/features/folders/ @grafana/user-essentials diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 9c44bf9632f..d9bcf1ebb02 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -92,6 +92,7 @@ Alpha features might be changed or removed without prior notice. | `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | | `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | | `individualCookiePreferences` | Support overriding cookie preferences per user | +| `drawerDataSourcePicker` | Changes the user experience for data source selection to a drawer. | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 882192e7665..b3ff652c50b 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -82,4 +82,5 @@ export interface FeatureToggles { logsContextDatasourceUi?: boolean; lokiQuerySplitting?: boolean; individualCookiePreferences?: boolean; + drawerDataSourcePicker?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 6fe8d0cd349..b1c885fd54a 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -372,5 +372,11 @@ var ( Description: "Support overriding cookie preferences per user", State: FeatureStateAlpha, }, + { + Name: "drawerDataSourcePicker", + Description: "Changes the user experience for data source selection to a drawer.", + State: FeatureStateAlpha, + FrontendOnly: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 2e72cce6bef..8f7b2b13c31 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -270,4 +270,8 @@ const ( // FlagIndividualCookiePreferences // Support overriding cookie preferences per user FlagIndividualCookiePreferences = "individualCookiePreferences" + + // FlagDrawerDataSourcePicker + // Changes the user experience for data source selection to a drawer. + FlagDrawerDataSourcePicker = "drawerDataSourcePicker" ) diff --git a/public/app/features/datasource-drawer/DataSourceDrawer.test.ts b/public/app/features/datasource-drawer/DataSourceDrawer.test.ts new file mode 100644 index 00000000000..918ea38ac8e --- /dev/null +++ b/public/app/features/datasource-drawer/DataSourceDrawer.test.ts @@ -0,0 +1,37 @@ +import { DataSourceInstanceSettings } from '@grafana/data'; +import { DataSourceJsonData, DataSourceRef } from '@grafana/schema'; + +import { isDataSourceMatch } from './DataSourceDrawer'; + +describe('DataSourceDrawer', () => { + describe('isDataSourceMatch', () => { + const dataSourceInstanceSettings = { uid: 'a' } as DataSourceInstanceSettings; + + it('matches a string with the uid', () => { + expect(isDataSourceMatch(dataSourceInstanceSettings, 'a')).toBeTruthy(); + }); + it('matches a datasource with a datasource by the uid', () => { + expect( + isDataSourceMatch(dataSourceInstanceSettings, { uid: 'a' } as DataSourceInstanceSettings) + ).toBeTruthy(); + }); + it('matches a datasource ref with a datasource by the uid', () => { + expect(isDataSourceMatch(dataSourceInstanceSettings, { uid: 'a' } as DataSourceRef)).toBeTruthy(); + }); + + it('doesnt match with null', () => { + expect(isDataSourceMatch(dataSourceInstanceSettings, null)).toBeFalsy(); + }); + it('doesnt match a datasource to a non matching string', () => { + expect(isDataSourceMatch(dataSourceInstanceSettings, 'b')).toBeFalsy(); + }); + it('doesnt match a datasource with a different datasource uid', () => { + expect( + isDataSourceMatch(dataSourceInstanceSettings, { uid: 'b' } as DataSourceInstanceSettings) + ).toBeFalsy(); + }); + it('doesnt match a datasource with a datasource ref with a different uid', () => { + expect(isDataSourceMatch(dataSourceInstanceSettings, { uid: 'b' } as DataSourceRef)).toBeFalsy(); + }); + }); +}); diff --git a/public/app/features/datasource-drawer/DataSourceDrawer.tsx b/public/app/features/datasource-drawer/DataSourceDrawer.tsx new file mode 100644 index 00000000000..4e7936bce86 --- /dev/null +++ b/public/app/features/datasource-drawer/DataSourceDrawer.tsx @@ -0,0 +1,161 @@ +import { css } from '@emotion/css'; +import React, { useCallback, useState } from 'react'; + +import { DataSourceInstanceSettings, DataSourceJsonData, DataSourceRef, GrafanaTheme2 } from '@grafana/data'; +import { + Button, + CustomScrollbar, + Drawer, + FileDropzone, + FileDropzoneDefaultChildren, + Input, + ModalsController, + useStyles2, +} from '@grafana/ui'; + +import { DataSourceCard } from './components/DataSourceCard'; +import { DataSourceDisplay } from './components/DataSourceDisplay'; +import { PickerContentProps, DataSourceDrawerProps } from './types'; + +export function DataSourceDrawer(props: DataSourceDrawerProps) { + const { current, onChange } = props; + const styles = useStyles2(getStyles); + + return ( + + {({ showModal, hideModal }) => ( + + )} + + ); +} + +function PickerContent(props: PickerContentProps) { + const { datasources, enableFileUpload, recentlyUsed = [], onChange, fileUploadOptions, onDismiss, current } = props; + const changeCallback = useCallback( + (ds: string) => { + onChange(ds); + }, + [onChange] + ); + + const [filterTerm, onFilterChange] = useState(''); + const styles = useStyles2(getStyles); + + const filteredDataSources = datasources.filter((ds) => { + return ds?.name.toLocaleLowerCase().indexOf(filterTerm.toLocaleLowerCase()) !== -1; + }); + + return ( + +
+
+ { + onFilterChange(e.currentTarget.value); + }} + value={filterTerm} + > +
+
+ + {recentlyUsed + .map((uid) => filteredDataSources.find((ds) => ds.uid === uid)) + .map((ds) => { + if (!ds) { + return null; + } + return ( + + ); + })} + {recentlyUsed && recentlyUsed.length > 0 &&
} + {filteredDataSources.map((ds) => ( + + ))} +
+
+ {enableFileUpload && ( +
+ undefined} + options={{ + ...fileUploadOptions, + onDrop: (...args) => { + onDismiss(); + fileUploadOptions?.onDrop?.(...args); + }, + }} + > + + +
+ )} +
+
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + drawerContent: css` + display: flex; + flex-direction: column; + height: 100%; + `, + picker: css` + background: ${theme.colors.background.secondary}; + `, + filterContainer: css` + padding-bottom: ${theme.spacing(1)}; + `, + dataSourceList: css` + height: 50px; + flex-grow: 1; + `, + additionalContent: css` + padding-top: ${theme.spacing(1)}; + `, + }; +} + +export function isDataSourceMatch( + ds: DataSourceInstanceSettings | undefined, + current: string | DataSourceInstanceSettings | DataSourceRef | null | undefined +): boolean | undefined { + if (!ds) { + return false; + } + if (!current) { + return false; + } + if (typeof current === 'string') { + return ds.uid === current; + } + return ds.uid === current.uid; +} diff --git a/public/app/features/datasource-drawer/DataSourcePicker.tsx b/public/app/features/datasource-drawer/DataSourcePicker.tsx new file mode 100644 index 00000000000..c9b84753a22 --- /dev/null +++ b/public/app/features/datasource-drawer/DataSourcePicker.tsx @@ -0,0 +1,98 @@ +import React, { PureComponent } from 'react'; + +// Components + +import { DataSourceInstanceSettings, DataSourceRef, getDataSourceUID } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { DataSourceJsonData } from '@grafana/schema'; + +import { DataSourceDrawer } from './DataSourceDrawer'; +import { DataSourcePickerProps } from './types'; + +/** + * Component state description for the {@link DataSourcePicker} + * + * @internal + */ +export interface DataSourcePickerState { + error?: string; +} + +/** + * Component to be able to select a datasource from the list of installed and enabled + * datasources in the current Grafana instance. + * + * @internal + */ +export class DataSourcePicker extends PureComponent { + dataSourceSrv = getDataSourceSrv(); + + state: DataSourcePickerState = {}; + + componentDidMount() { + const { current } = this.props; + const dsSettings = this.dataSourceSrv.getInstanceSettings(current); + if (!dsSettings) { + this.setState({ error: 'Could not find data source ' + current }); + } + } + + onChange = (ds?: string) => { + const dsSettings = this.dataSourceSrv.getInstanceSettings(ds); + + if (dsSettings) { + this.props.onChange(dsSettings); + this.setState({ error: undefined }); + } + }; + + private getCurrentDs(): DataSourceInstanceSettings | string | DataSourceRef | null | undefined { + const { current, noDefault } = this.props; + if (!current && noDefault) { + return; + } + + const ds = this.dataSourceSrv.getInstanceSettings(current); + if (ds) { + return ds; + } + + return getDataSourceUID(current); + } + + getDatasources() { + const { alerting, tracing, metrics, mixed, dashboard, variables, annotations, pluginId, type, filter, logs } = + this.props; + + return this.dataSourceSrv.getList({ + alerting, + tracing, + metrics, + logs, + dashboard, + mixed, + variables, + annotations, + pluginId, + filter, + type, + }); + } + + render() { + const { recentlyUsed, fileUploadOptions, enableFileUpload } = this.props; + + return ( +
+ +
+ ); + } +} diff --git a/public/app/features/datasource-drawer/DataSourcePickerWithHistory.test.ts b/public/app/features/datasource-drawer/DataSourcePickerWithHistory.test.ts new file mode 100644 index 00000000000..752deb96927 --- /dev/null +++ b/public/app/features/datasource-drawer/DataSourcePickerWithHistory.test.ts @@ -0,0 +1,27 @@ +import { updateHistory } from './DataSourcePickerWithHistory'; + +describe('DataSourcePickerWithHistory', () => { + describe('updateHistory', () => { + const early = { uid: 'b', lastUse: '2023-02-27T13:39:08.318Z' }; + const later = { uid: 'a', lastUse: '2023-02-28T13:39:08.318Z' }; + + it('should add an item to the history', () => { + expect(updateHistory([], early)).toEqual([early]); + }); + + it('should sort later entries first', () => { + expect(updateHistory([early], later)).toEqual([later, early]); + }); + + it('should update an already existing history item with the new lastUsed date', () => { + const laterB = { uid: early.uid, lastUse: later.lastUse }; + expect(updateHistory([early], laterB)).toEqual([laterB]); + }); + + it('should keep the three latest items in history', () => { + const evenLater = { uid: 'c', lastUse: '2023-03-01T13:39:08.318Z' }; + const latest = { uid: 'd', lastUse: '2023-03-02T13:39:08.318Z' }; + expect(updateHistory([early, later, evenLater], latest)).toEqual([latest, evenLater, later]); + }); + }); +}); diff --git a/public/app/features/datasource-drawer/DataSourcePickerWithHistory.tsx b/public/app/features/datasource-drawer/DataSourcePickerWithHistory.tsx new file mode 100644 index 00000000000..1f0f8272413 --- /dev/null +++ b/public/app/features/datasource-drawer/DataSourcePickerWithHistory.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import { dateTime } from '@grafana/data'; +import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider'; + +import { DataSourcePicker } from './DataSourcePicker'; +import { DataSourcePickerHistoryItem, DataSourcePickerWithHistoryProps } from './types'; + +const DS_PICKER_STORAGE_KEY = 'DATASOURCE_PICKER'; + +export const DataSourcePickerWithHistory = (props: DataSourcePickerWithHistoryProps) => { + return ( + + defaultValue={[]} + storageKey={props.localStorageKey ?? DS_PICKER_STORAGE_KEY} + > + {(rawValues, onSaveToStore) => { + return ( + dsi.uid)} //Filter recently to have a time cutoff + onChange={(ds) => { + onSaveToStore(updateHistory(rawValues, { uid: ds.uid, lastUse: dateTime(new Date()).toISOString() })); + props.onChange(ds); + }} + > + ); + }} + + ); +}; + +export function updateHistory(values: DataSourcePickerHistoryItem[], newValue: DataSourcePickerHistoryItem) { + const newHistory = values; + const existingIndex = newHistory.findIndex((dpi) => dpi.uid === newValue.uid); + if (existingIndex !== -1) { + newHistory[existingIndex] = newValue; + } else { + newHistory.push(newValue); + } + + newHistory.sort((a, b) => { + const al = dateTime(a.lastUse); + const bl = dateTime(b.lastUse); + if (al.isBefore(bl)) { + return 1; + } else if (bl.isBefore(al)) { + return -1; + } else { + return 0; + } + }); + + return newHistory.slice(0, 3); +} diff --git a/public/app/features/datasource-drawer/components/DataSourceCard.tsx b/public/app/features/datasource-drawer/components/DataSourceCard.tsx new file mode 100644 index 00000000000..7856f570cb2 --- /dev/null +++ b/public/app/features/datasource-drawer/components/DataSourceCard.tsx @@ -0,0 +1,38 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { DataSourceInstanceSettings, DataSourceJsonData, GrafanaTheme2 } from '@grafana/data'; +import { Card, PluginSignatureBadge, Tag, useStyles2 } from '@grafana/ui'; + +export interface DataSourceCardProps { + onChange: (uid: string) => void; + selected?: boolean; + ds: DataSourceInstanceSettings; +} + +export function DataSourceCard(props: DataSourceCardProps) { + const { selected, ds, onChange } = props; + const styles = useStyles2(getStyles); + return ( + onChange(ds.uid)}> + + {`${ds.meta.name} + + + {[ds.meta.name, ds.url, ds.isDefault && ]} + + + + + {ds.name} + + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + selectedDataSource: css` + background-color: ${theme.colors.emphasize(theme.colors.background.secondary, 0.1)}; + `, + }; +} diff --git a/public/app/features/datasource-drawer/components/DataSourceDisplay.tsx b/public/app/features/datasource-drawer/components/DataSourceDisplay.tsx new file mode 100644 index 00000000000..45e46c6414a --- /dev/null +++ b/public/app/features/datasource-drawer/components/DataSourceDisplay.tsx @@ -0,0 +1,48 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { DataSourceInstanceSettings, DataSourceJsonData, GrafanaTheme2 } from '@grafana/data'; +import { DataSourceRef } from '@grafana/schema'; +import { useStyles2 } from '@grafana/ui'; + +export interface DataSourceDisplayProps { + dataSource: DataSourceInstanceSettings | string | DataSourceRef | null | undefined; +} + +export function DataSourceDisplay(props: DataSourceDisplayProps) { + const { dataSource } = props; + const styles = useStyles2(getStyles); + + if (!dataSource) { + return Unknown; + } + + if (typeof dataSource === 'string') { + return ${dataSource} - not found; + } + + if ('name' in dataSource) { + return ( + <> + {`${dataSource.meta.name} + {dataSource.name} + + ); + } + + return {dataSource.uid} - not found; +} + +function getStyles(theme: GrafanaTheme2) { + return { + pickerDSLogo: css` + height: 20px; + width: 20px; + margin-right: ${theme.spacing(1)}; + `, + }; +} diff --git a/public/app/features/datasource-drawer/types.ts b/public/app/features/datasource-drawer/types.ts new file mode 100644 index 00000000000..c7589ab7110 --- /dev/null +++ b/public/app/features/datasource-drawer/types.ts @@ -0,0 +1,52 @@ +import { DropzoneOptions } from 'react-dropzone'; + +import { DataSourceInstanceSettings } from '@grafana/data'; +import { DataSourceJsonData, DataSourceRef } from '@grafana/schema'; + +export interface DataSourceDrawerProps { + datasources: Array>; + onFileDrop?: () => void; + onChange: (ds: string) => void; + current: DataSourceInstanceSettings | string | DataSourceRef | null | undefined; + enableFileUpload?: boolean; + fileUploadOptions?: DropzoneOptions; + recentlyUsed?: string[]; +} + +export interface PickerContentProps extends DataSourceDrawerProps { + onDismiss: () => void; +} + +export interface DataSourcePickerProps { + onChange: (ds: DataSourceInstanceSettings) => void; + current: DataSourceRef | string | null; // uid + tracing?: boolean; + recentlyUsed?: string[]; + mixed?: boolean; + dashboard?: boolean; + metrics?: boolean; + type?: string | string[]; + annotations?: boolean; + variables?: boolean; + alerting?: boolean; + pluginId?: string; + /** If true,we show only DSs with logs; and if true, pluginId shouldnt be passed in */ + logs?: boolean; + // Does not set the default data source if there is no value. + noDefault?: boolean; + inputId?: string; + filter?: (dataSource: DataSourceInstanceSettings) => boolean; + onClear?: () => void; + disabled?: boolean; + enableFileUpload?: boolean; + fileUploadOptions?: DropzoneOptions; +} + +export interface DataSourcePickerWithHistoryProps extends Omit { + localStorageKey?: string; +} + +export interface DataSourcePickerHistoryItem { + lastUse: string; + uid: string; +} diff --git a/public/app/features/query/components/QueryGroup.tsx b/public/app/features/query/components/QueryGroup.tsx index e1f3d08df61..fff8e9c66ed 100644 --- a/public/app/features/query/components/QueryGroup.tsx +++ b/public/app/features/query/components/QueryGroup.tsx @@ -1,9 +1,12 @@ import { css } from '@emotion/css'; import React, { PureComponent } from 'react'; +import { DropEvent, FileRejection } from 'react-dropzone'; import { Unsubscribable } from 'rxjs'; import { CoreApp, + DataFrameJSON, + dataFrameToJSON, DataQuery, DataSourceApi, DataSourceInstanceSettings, @@ -18,8 +21,11 @@ import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp'; import config from 'app/core/config'; import { backendSrv } from 'app/core/services/backend_srv'; import { addQuery, queryIsEmpty } from 'app/core/utils/query'; +import * as DFImport from 'app/features/dataframe-import'; +import { DataSourcePickerWithHistory } from 'app/features/datasource-drawer/DataSourcePickerWithHistory'; import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource'; import { DashboardQueryEditor, isSharedDashboardQuery } from 'app/plugins/datasource/dashboard'; +import { GrafanaQuery, GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; import { QueryGroupDataSource, QueryGroupOptions } from 'app/types'; import { isQueryWithMixedDatasource } from '../../query-library/api/SavedQueriesApi'; @@ -274,14 +280,32 @@ export class QueryGroup extends PureComponent { Data source
- + {config.featureToggles.drawerDataSourcePicker ? ( + + ) : ( + + )}
{dataSource && ( <> @@ -369,7 +393,32 @@ export class QueryGroup extends PureComponent { this.onScrollBottom(); }; - onQueriesChange = (queries: DataQuery[]) => { + onFileDrop = (acceptedFiles: File[], fileRejections: FileRejection[], event: DropEvent) => { + DFImport.filesToDataframes(acceptedFiles).subscribe(async (next) => { + const snapshot: DataFrameJSON[] = []; + next.dataFrames.forEach((df) => { + const dataframeJson = dataFrameToJSON(df); + snapshot.push(dataframeJson); + }); + const ds = getDataSourceSrv().getInstanceSettings('-- Grafana --'); + await this.onChangeDataSource(ds!); + this.onQueriesChange([ + { + refId: 'A', + datasource: { + type: 'grafana', + uid: 'grafana', + }, + queryType: GrafanaQueryType.Snapshot, + snapshot: snapshot, + file: next.file, + }, + ]); + this.props.onRunQueries(); + }); + }; + + onQueriesChange = (queries: DataQuery[] | GrafanaQuery[]) => { this.onChange({ queries }); this.setState({ queries }); };