mirror of
https://github.com/grafana/grafana.git
synced 2025-08-01 23:43:26 +08:00

* QueryLibrary: AddfromIcon init work * Removed console log * updated i18n * Added unit tests for button * Fixed linting * Updates per feedback - new replace method * Remove run query call when replace * lint fixes * Feedback + method renaming to 'replace' * Fix await
579 lines
17 KiB
TypeScript
579 lines
17 KiB
TypeScript
import classNames from 'classnames';
|
|
import { cloneDeep, filter, uniqBy, uniqueId } from 'lodash';
|
|
import pluralize from 'pluralize';
|
|
import { PureComponent, ReactNode } from 'react';
|
|
|
|
import {
|
|
CoreApp,
|
|
DataSourceApi,
|
|
DataSourceInstanceSettings,
|
|
DataSourcePluginContextProvider,
|
|
PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context,
|
|
EventBusExtended,
|
|
HistoryItem,
|
|
LoadingState,
|
|
PanelData,
|
|
QueryResultMetaNotice,
|
|
TimeRange,
|
|
getDataSourceRef,
|
|
PluginExtensionPoints,
|
|
} from '@grafana/data';
|
|
import { selectors } from '@grafana/e2e-selectors';
|
|
import { getDataSourceSrv, renderLimitedComponents, reportInteraction, usePluginComponents } from '@grafana/runtime';
|
|
import { DataQuery } from '@grafana/schema';
|
|
import { Badge, ErrorBoundaryAlert, List } from '@grafana/ui';
|
|
import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp';
|
|
import {
|
|
QueryOperationAction,
|
|
QueryOperationToggleAction,
|
|
} from 'app/core/components/QueryOperationRow/QueryOperationAction';
|
|
import {
|
|
QueryOperationRow,
|
|
QueryOperationRowRenderProps,
|
|
} from 'app/core/components/QueryOperationRow/QueryOperationRow';
|
|
import { Trans, t } from 'app/core/internationalization';
|
|
|
|
import { useQueryLibraryContext } from '../../explore/QueryLibrary/QueryLibraryContext';
|
|
|
|
import { QueryActionComponent, RowActionComponents } from './QueryActionComponent';
|
|
import { QueryEditorRowHeader } from './QueryEditorRowHeader';
|
|
import { QueryErrorAlert } from './QueryErrorAlert';
|
|
|
|
export interface Props<TQuery extends DataQuery> {
|
|
data: PanelData;
|
|
query: TQuery;
|
|
queries: TQuery[];
|
|
id: string;
|
|
index: number;
|
|
dataSource: DataSourceInstanceSettings;
|
|
onChangeDataSource?: (dsSettings: DataSourceInstanceSettings) => void;
|
|
onDataSourceLoaded?: (instance: DataSourceApi) => void;
|
|
renderHeaderExtras?: () => ReactNode;
|
|
onAddQuery: (query: TQuery) => void;
|
|
onRemoveQuery: (query: TQuery) => void;
|
|
onChange: (query: TQuery) => void;
|
|
onReplace?: (query: DataQuery) => void;
|
|
onRunQuery: () => void;
|
|
visualization?: ReactNode;
|
|
hideHideQueryButton?: boolean;
|
|
app?: CoreApp;
|
|
range: TimeRange;
|
|
history?: Array<HistoryItem<TQuery>>;
|
|
eventBus?: EventBusExtended;
|
|
hideActionButtons?: boolean;
|
|
onQueryCopied?: () => void;
|
|
onQueryRemoved?: () => void;
|
|
onQueryToggled?: (queryStatus?: boolean | undefined) => void;
|
|
onQueryReplacedFromLibrary?: () => void;
|
|
collapsable?: boolean;
|
|
hideRefId?: boolean;
|
|
}
|
|
|
|
interface State<TQuery extends DataQuery> {
|
|
/** DatasourceUid or ds variable expression used to resolve current datasource */
|
|
queriedDataSourceIdentifier?: string | null;
|
|
datasource: DataSourceApi<TQuery> | null;
|
|
datasourceUid?: string | null;
|
|
data?: PanelData;
|
|
isOpen?: boolean;
|
|
showingHelp: boolean;
|
|
}
|
|
|
|
export class QueryEditorRow<TQuery extends DataQuery> extends PureComponent<Props<TQuery>, State<TQuery>> {
|
|
dataSourceSrv = getDataSourceSrv();
|
|
id = '';
|
|
|
|
state: State<TQuery> = {
|
|
datasource: null,
|
|
data: undefined,
|
|
isOpen: true,
|
|
showingHelp: false,
|
|
};
|
|
|
|
componentDidMount() {
|
|
const { data, query, id } = this.props;
|
|
const dataFilteredByRefId = filterPanelDataToQuery(data, query.refId);
|
|
this.id = uniqueId(id + '_');
|
|
this.setState({ data: dataFilteredByRefId });
|
|
|
|
this.loadDatasource();
|
|
}
|
|
|
|
/**
|
|
* When datasource variables are used the query.datasource.uid property is a string variable expression
|
|
* DataSourceSettings.uid can also be this variable expression.
|
|
* This function always returns the current interpolated datasource uid.
|
|
*/
|
|
getInterpolatedDataSourceUID(): string | undefined {
|
|
if (this.props.query.datasource) {
|
|
const instanceSettings = this.dataSourceSrv.getInstanceSettings(this.props.query.datasource);
|
|
return instanceSettings?.rawRef?.uid ?? instanceSettings?.uid;
|
|
}
|
|
|
|
return this.props.dataSource.rawRef?.uid ?? this.props.dataSource.uid;
|
|
}
|
|
|
|
async loadDatasource() {
|
|
let datasource: DataSourceApi;
|
|
const interpolatedUID = this.getInterpolatedDataSourceUID();
|
|
|
|
try {
|
|
datasource = await this.dataSourceSrv.get(interpolatedUID);
|
|
} catch (error) {
|
|
// If the DS doesn't exist, it fails. Getting with no args returns the default DS.
|
|
datasource = await this.dataSourceSrv.get();
|
|
}
|
|
|
|
if (typeof this.props.onDataSourceLoaded === 'function') {
|
|
this.props.onDataSourceLoaded(datasource);
|
|
}
|
|
|
|
this.setState({
|
|
datasource: datasource as unknown as DataSourceApi<TQuery>,
|
|
queriedDataSourceIdentifier: interpolatedUID,
|
|
});
|
|
}
|
|
|
|
componentDidUpdate(prevProps: Props<TQuery>) {
|
|
const { datasource, queriedDataSourceIdentifier } = this.state;
|
|
const { data, query } = this.props;
|
|
|
|
if (prevProps.id !== this.props.id) {
|
|
this.id = uniqueId(this.props.id + '_');
|
|
}
|
|
|
|
if (data !== prevProps.data) {
|
|
const dataFilteredByRefId = filterPanelDataToQuery(data, query.refId);
|
|
|
|
this.setState({ data: dataFilteredByRefId });
|
|
}
|
|
|
|
// check if we need to load another datasource
|
|
if (datasource && queriedDataSourceIdentifier !== this.getInterpolatedDataSourceUID()) {
|
|
this.loadDatasource();
|
|
return;
|
|
}
|
|
}
|
|
|
|
getQueryEditor(ds: DataSourceApi<TQuery>) {
|
|
if (!ds) {
|
|
return;
|
|
}
|
|
|
|
switch (this.props.app) {
|
|
case CoreApp.Explore:
|
|
return (
|
|
ds.components?.ExploreMetricsQueryField ||
|
|
ds.components?.ExploreLogsQueryField ||
|
|
ds.components?.ExploreQueryField ||
|
|
ds.components?.QueryEditor
|
|
);
|
|
case CoreApp.PanelEditor:
|
|
case CoreApp.Dashboard:
|
|
default:
|
|
return ds.components?.QueryEditor;
|
|
}
|
|
}
|
|
|
|
isWaitingForDatasourceToLoad(): boolean {
|
|
// if we not yet have loaded the datasource in state the
|
|
// ds in props and the ds in state will have different values.
|
|
return this.getInterpolatedDataSourceUID() !== this.state.queriedDataSourceIdentifier;
|
|
}
|
|
|
|
renderPluginEditor = () => {
|
|
const { query, onChange, queries, onRunQuery, onAddQuery, range, app = CoreApp.PanelEditor, history } = this.props;
|
|
const { datasource, data } = this.state;
|
|
|
|
if (this.isWaitingForDatasourceToLoad()) {
|
|
return null;
|
|
}
|
|
|
|
if (datasource) {
|
|
let QueryEditor = this.getQueryEditor(datasource);
|
|
|
|
if (QueryEditor) {
|
|
return (
|
|
<DataSourcePluginContextProvider instanceSettings={this.props.dataSource}>
|
|
<QueryEditor
|
|
key={datasource?.name}
|
|
query={query}
|
|
datasource={datasource}
|
|
onChange={onChange}
|
|
onRunQuery={onRunQuery}
|
|
onAddQuery={onAddQuery}
|
|
data={data}
|
|
range={range}
|
|
queries={queries}
|
|
app={app}
|
|
history={history}
|
|
/>
|
|
</DataSourcePluginContextProvider>
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<Trans i18nKey="query-operation.query-editor-not-exported">
|
|
Data source plugin does not export any Query Editor component
|
|
</Trans>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
onRemoveQuery = () => {
|
|
const { onRemoveQuery, query, onQueryRemoved } = this.props;
|
|
onRemoveQuery(query);
|
|
|
|
if (onQueryRemoved) {
|
|
onQueryRemoved();
|
|
}
|
|
};
|
|
|
|
onCopyQuery = () => {
|
|
const { query, onAddQuery, onQueryCopied } = this.props;
|
|
const copy = cloneDeep(query);
|
|
onAddQuery(copy);
|
|
|
|
if (onQueryCopied) {
|
|
onQueryCopied();
|
|
}
|
|
};
|
|
|
|
onHideQuery = () => {
|
|
const { query, onChange, onRunQuery, onQueryToggled } = this.props;
|
|
onChange({ ...query, hide: !query.hide });
|
|
onRunQuery();
|
|
|
|
if (onQueryToggled) {
|
|
onQueryToggled(query.hide);
|
|
}
|
|
|
|
reportInteraction('query_editor_row_hide_query_clicked', {
|
|
hide: !query.hide,
|
|
});
|
|
};
|
|
|
|
onToggleHelp = () => {
|
|
this.setState((state) => ({
|
|
showingHelp: !state.showingHelp,
|
|
}));
|
|
};
|
|
|
|
onClickExample = (query: TQuery) => {
|
|
if (query.datasource === undefined) {
|
|
query.datasource = getDataSourceRef(this.props.dataSource);
|
|
}
|
|
|
|
this.props.onChange({
|
|
...query,
|
|
refId: this.props.query.refId,
|
|
});
|
|
this.onToggleHelp();
|
|
};
|
|
|
|
renderCollapsedText(): string | null {
|
|
const { datasource } = this.state;
|
|
if (datasource?.getQueryDisplayText) {
|
|
return datasource.getQueryDisplayText(this.props.query);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
renderWarnings = (type: string): JSX.Element | null => {
|
|
const { data, query } = this.props;
|
|
const dataFilteredByRefId = filterPanelDataToQuery(data, query.refId)?.series ?? [];
|
|
|
|
const allWarnings = dataFilteredByRefId.reduce((acc: QueryResultMetaNotice[], serie) => {
|
|
if (!serie.meta?.notices) {
|
|
return acc;
|
|
}
|
|
|
|
const warnings = filter(serie.meta.notices, (item: QueryResultMetaNotice) => item.severity === type) ?? [];
|
|
return acc.concat(warnings);
|
|
}, []);
|
|
|
|
const uniqueWarnings = uniqBy(allWarnings, 'text');
|
|
|
|
const hasWarnings = uniqueWarnings.length > 0;
|
|
if (!hasWarnings) {
|
|
return null;
|
|
}
|
|
|
|
const key = 'query-' + type + 's';
|
|
const colour = type === 'warning' ? 'orange' : 'blue';
|
|
const iconName = type === 'warning' ? 'exclamation-triangle' : 'file-landscape-alt';
|
|
|
|
const listItems = uniqueWarnings.map((warning) => warning.text);
|
|
const serializedWarnings = <List items={listItems} renderItem={(item) => <>{item}</>} />;
|
|
|
|
return (
|
|
<Badge
|
|
key={key}
|
|
color={colour}
|
|
icon={iconName}
|
|
text={
|
|
<>
|
|
{uniqueWarnings.length} {pluralize(type, uniqueWarnings.length)}
|
|
</>
|
|
}
|
|
tooltip={serializedWarnings}
|
|
/>
|
|
);
|
|
};
|
|
|
|
renderExtraActions = () => {
|
|
const { query, queries, data, onAddQuery, dataSource, app } = this.props;
|
|
|
|
const unscopedActions = RowActionComponents.getAllExtraRenderAction();
|
|
|
|
let scopedActions: QueryActionComponent[] = [];
|
|
|
|
if (app !== undefined) {
|
|
scopedActions = RowActionComponents.getScopedExtraRenderAction(app);
|
|
}
|
|
|
|
const extraActions = [...unscopedActions, ...scopedActions]
|
|
.map((action, index) =>
|
|
action({
|
|
query,
|
|
queries,
|
|
timeRange: data.timeRange,
|
|
onAddQuery: onAddQuery as (query: DataQuery) => void,
|
|
dataSource,
|
|
key: index,
|
|
})
|
|
)
|
|
.filter(Boolean);
|
|
|
|
extraActions.push(this.renderWarnings('info'));
|
|
extraActions.push(this.renderWarnings('warning'));
|
|
extraActions.push(<AdaptiveTelemetryQueryActions key="adaptive-telemetry-actions" query={query} />);
|
|
|
|
return extraActions;
|
|
};
|
|
|
|
renderActions = (props: QueryOperationRowRenderProps) => {
|
|
const {
|
|
query,
|
|
hideHideQueryButton: hideHideQueryButton = false,
|
|
onReplace,
|
|
onQueryReplacedFromLibrary,
|
|
} = this.props;
|
|
const { datasource, showingHelp } = this.state;
|
|
const isHidden = !!query.hide;
|
|
|
|
const hasEditorHelp = datasource?.components?.QueryEditorHelp;
|
|
|
|
return (
|
|
<>
|
|
{hasEditorHelp && (
|
|
<QueryOperationToggleAction
|
|
title={t('query-operation.header.datasource-help', 'Show data source help')}
|
|
icon="question-circle"
|
|
onClick={this.onToggleHelp}
|
|
active={showingHelp}
|
|
/>
|
|
)}
|
|
{this.renderExtraActions()}
|
|
<MaybeQueryLibrarySaveButton query={query} />
|
|
<QueryOperationAction
|
|
title={t('query-operation.header.duplicate-query', 'Duplicate query')}
|
|
icon="copy"
|
|
onClick={this.onCopyQuery}
|
|
/>
|
|
<ReplaceQueryFromLibrary
|
|
datasourceFilters={datasource?.name ? [datasource.name] : []}
|
|
onSelectQuery={(query) => {
|
|
onQueryReplacedFromLibrary?.();
|
|
onReplace?.(query);
|
|
}}
|
|
/>
|
|
{!hideHideQueryButton ? (
|
|
<QueryOperationToggleAction
|
|
dataTestId={selectors.components.QueryEditorRow.actionButton('Hide response')}
|
|
title={
|
|
query.hide
|
|
? t('query-operation.header.show-response', 'Show response')
|
|
: t('query-operation.header.hide-response', 'Hide response')
|
|
}
|
|
icon={isHidden ? 'eye-slash' : 'eye'}
|
|
active={isHidden}
|
|
onClick={this.onHideQuery}
|
|
/>
|
|
) : null}
|
|
<QueryOperationAction
|
|
title={t('query-operation.header.remove-query', 'Remove query')}
|
|
icon="trash-alt"
|
|
onClick={this.onRemoveQuery}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
renderHeader = (props: QueryOperationRowRenderProps) => {
|
|
const { app, query, dataSource, onChangeDataSource, onChange, queries, renderHeaderExtras, hideRefId } = this.props;
|
|
|
|
return (
|
|
<QueryEditorRowHeader
|
|
query={query}
|
|
queries={queries}
|
|
onChangeDataSource={onChangeDataSource}
|
|
dataSource={dataSource}
|
|
hidden={query.hide}
|
|
onChange={onChange}
|
|
collapsedText={!props.isOpen ? this.renderCollapsedText() : null}
|
|
renderExtras={renderHeaderExtras}
|
|
alerting={app === CoreApp.UnifiedAlerting}
|
|
hideRefId={hideRefId}
|
|
/>
|
|
);
|
|
};
|
|
|
|
render() {
|
|
const { query, index, visualization, collapsable, hideActionButtons } = this.props;
|
|
const { datasource, showingHelp, data } = this.state;
|
|
const isHidden = query.hide;
|
|
const error =
|
|
data?.error && data.error.refId === query.refId ? data.error : data?.errors?.find((e) => e.refId === query.refId);
|
|
const rowClasses = classNames('query-editor-row', {
|
|
'query-editor-row--disabled': isHidden,
|
|
'gf-form-disabled': isHidden,
|
|
});
|
|
|
|
if (!datasource) {
|
|
return null;
|
|
}
|
|
|
|
const editor = this.renderPluginEditor();
|
|
const DatasourceCheatsheet = datasource.components?.QueryEditorHelp;
|
|
|
|
return (
|
|
<div data-testid="query-editor-row" aria-label={selectors.components.QueryEditorRows.rows}>
|
|
<QueryOperationRow
|
|
id={this.id}
|
|
draggable={!hideActionButtons}
|
|
collapsable={collapsable}
|
|
index={index}
|
|
headerElement={this.renderHeader}
|
|
actions={hideActionButtons ? undefined : this.renderActions}
|
|
>
|
|
<div className={rowClasses} id={this.id}>
|
|
<ErrorBoundaryAlert>
|
|
{showingHelp && DatasourceCheatsheet && (
|
|
<OperationRowHelp>
|
|
<DatasourceCheatsheet
|
|
onClickExample={(query) => this.onClickExample(query)}
|
|
query={this.props.query}
|
|
datasource={datasource}
|
|
/>
|
|
</OperationRowHelp>
|
|
)}
|
|
{editor}
|
|
</ErrorBoundaryAlert>
|
|
{error && <QueryErrorAlert error={error} />}
|
|
{visualization}
|
|
</div>
|
|
</QueryOperationRow>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a version of the PanelData limited to the query we are looking at
|
|
*/
|
|
export function filterPanelDataToQuery(data: PanelData, refId: string): PanelData | undefined {
|
|
const series = data.series.filter((series) => series.refId === refId);
|
|
|
|
// If there was an error with no data and the panel is not in a loading state, pass it to the QueryEditors
|
|
if (data.state !== LoadingState.Loading && (data.error || data.errors?.length) && !data.series.length) {
|
|
return {
|
|
...data,
|
|
state: LoadingState.Error,
|
|
};
|
|
}
|
|
|
|
// Only say this is an error if the error links to the query
|
|
let state = data.state;
|
|
let error = data.errors?.find((e) => e.refId === refId);
|
|
if (!error && data.error) {
|
|
error = data.error.refId === refId ? data.error : undefined;
|
|
}
|
|
|
|
if (state !== LoadingState.Loading) {
|
|
if (error) {
|
|
state = LoadingState.Error;
|
|
} else if (data.state === LoadingState.Error) {
|
|
state = LoadingState.Done;
|
|
}
|
|
}
|
|
|
|
const timeRange = data.timeRange;
|
|
|
|
return {
|
|
...data,
|
|
state,
|
|
series,
|
|
error,
|
|
errors: error ? [error] : undefined,
|
|
timeRange,
|
|
};
|
|
}
|
|
|
|
// Will render anything only if query library is enabled
|
|
function MaybeQueryLibrarySaveButton(props: { query: DataQuery }) {
|
|
const { renderSaveQueryButton } = useQueryLibraryContext();
|
|
return renderSaveQueryButton(props.query);
|
|
}
|
|
|
|
interface ReplaceQueryFromLibraryProps<TQuery extends DataQuery> {
|
|
datasourceFilters: string[];
|
|
onSelectQuery: (query: DataQuery) => void;
|
|
}
|
|
|
|
function ReplaceQueryFromLibrary<TQuery extends DataQuery>({
|
|
datasourceFilters,
|
|
onSelectQuery,
|
|
}: ReplaceQueryFromLibraryProps<TQuery>) {
|
|
const { openDrawer, queryLibraryEnabled } = useQueryLibraryContext();
|
|
|
|
const onReplaceQueryFromLibrary = () => {
|
|
openDrawer(datasourceFilters, onSelectQuery);
|
|
};
|
|
|
|
return queryLibraryEnabled ? (
|
|
<QueryOperationAction
|
|
title={t('query-operation.header.replace-query-from-library', 'Replace with query from library')}
|
|
icon="book"
|
|
onClick={onReplaceQueryFromLibrary}
|
|
/>
|
|
) : null;
|
|
}
|
|
|
|
function AdaptiveTelemetryQueryActions({ query }: { query: DataQuery }) {
|
|
try {
|
|
const { isLoading, components } = usePluginComponents<PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context>({
|
|
extensionPointId: PluginExtensionPoints.QueryEditorRowAdaptiveTelemetryV1,
|
|
});
|
|
|
|
if (isLoading || !components.length) {
|
|
return null;
|
|
}
|
|
|
|
return renderLimitedComponents({
|
|
props: { query, contextHints: ['queryeditorrow', 'header'] },
|
|
components,
|
|
limit: 1,
|
|
pluginId: /grafana-adaptive.*/,
|
|
});
|
|
} catch (error) {
|
|
// If `usePluginComponents` isn't properly resolved, tests will fail with 'setPluginComponentsHook(options) can only be used after the Grafana instance has started.'
|
|
// This will be resolved in https://github.com/grafana/grafana/pull/92983
|
|
// In this case, Return `null` like when there are no extensions.
|
|
return null;
|
|
}
|
|
}
|