Tempo: Remove duplicated code (#81476)

pull/83383/head
Fabrizio 1 year ago committed by GitHub
parent 80d6bf6da0
commit 2fa4ac2a73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .betterer.results
  2. 14
      packages/grafana-o11y-ds-frontend/src/TemporaryAlert.tsx
  3. 18
      public/app/plugins/datasource/tempo/NativeSearch/NativeSearch.tsx
  4. 78
      public/app/plugins/datasource/tempo/NativeSearch/TagsField/TagsField.tsx
  5. 5
      public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx
  6. 154
      public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx
  7. 6
      public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.test.tsx
  8. 2
      public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.tsx
  9. 2
      public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx
  10. 11
      public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx
  11. 2
      public/app/plugins/datasource/tempo/ServiceGraphSection.tsx
  12. 128
      public/app/plugins/datasource/tempo/_importedDependencies/actions/appNotification.ts
  13. 5
      public/app/plugins/datasource/tempo/_importedDependencies/actions/index.ts
  14. 36
      public/app/plugins/datasource/tempo/_importedDependencies/actions/types/appNotifications.ts
  15. 1
      public/app/plugins/datasource/tempo/_importedDependencies/actions/types/index.ts
  16. 0
      public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/types.ts
  17. 46
      public/app/plugins/datasource/tempo/_importedDependencies/core/appNotification.ts
  18. 21
      public/app/plugins/datasource/tempo/_importedDependencies/core/errors.ts
  19. 27
      public/app/plugins/datasource/tempo/_importedDependencies/store.ts
  20. 2
      public/app/plugins/datasource/tempo/package.json
  21. 12
      public/app/plugins/datasource/tempo/traceql/TraceQLEditor.tsx
  22. 2
      public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts
  23. 15
      public/app/plugins/datasource/tempo/traceql/autocomplete.ts
  24. 2
      yarn.lock

@ -5638,12 +5638,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/store.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/test/helpers/createFetchResponse.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]

@ -58,5 +58,17 @@ export const TemporaryAlert = (props: AlertProps) => {
}
}, [props.severity, props.text]);
return <>{visible && <Alert className={style} elevated={true} title={props.text} severity={props.severity} />}</>;
return (
<>
{visible && (
<Alert
className={style}
elevated={true}
onRemove={() => setVisible(false)}
severity={props.severity}
title={props.text}
/>
)}
</>
);
};

@ -2,12 +2,10 @@ import { css } from '@emotion/css';
import React, { useCallback, useState, useEffect, useMemo } from 'react';
import { GrafanaTheme2, isValidGoDuration, SelectableValue, toOption } from '@grafana/data';
import { TemporaryAlert } from '@grafana/o11y-ds-frontend';
import { FetchError, getTemplateSrv, isFetchError, TemplateSrv } from '@grafana/runtime';
import { InlineFieldRow, InlineField, Input, Alert, useStyles2, fuzzyMatch, Select } from '@grafana/ui';
import { notifyApp } from '../_importedDependencies/actions/appNotification';
import { createErrorNotification } from '../_importedDependencies/core/appNotification';
import { dispatch } from '../_importedDependencies/store';
import { DEFAULT_LIMIT, TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider';
import { TempoQuery } from '../types';
@ -26,6 +24,7 @@ const durationPlaceholder = 'e.g. 1.2s, 100ms';
const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props) => {
const styles = useStyles2(getStyles);
const [alertText, setAlertText] = useState<string>();
const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]);
const [serviceOptions, setServiceOptions] = useState<Array<SelectableValue<string>>>();
const [spanOptions, setSpanOptions] = useState<Array<SelectableValue<string>>>();
@ -47,19 +46,21 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props
try {
const options = await languageProvider.getOptionsV1(lpName);
const filteredOptions = options.filter((item) => (item.value ? fuzzyMatch(item.value, query).found : false));
setAlertText(undefined);
setError(null);
return filteredOptions;
} catch (error) {
if (isFetchError(error) && error?.status === 404) {
setError(error);
} else if (error instanceof Error) {
dispatch(notifyApp(createErrorNotification('Error', error)));
setAlertText(`Error: ${error.message}`);
}
return [];
} finally {
setIsLoading((prevValue) => ({ ...prevValue, [name]: false }));
}
},
[languageProvider]
[languageProvider, setAlertText]
);
useEffect(() => {
@ -74,17 +75,19 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props
spans.push(toOption(query.spanName));
}
setSpanOptions(spans);
setAlertText(undefined);
setError(null);
} catch (error) {
// Display message if Tempo is connected but search 404's
if (isFetchError(error) && error?.status === 404) {
setError(error);
} else if (error instanceof Error) {
dispatch(notifyApp(createErrorNotification('Error', error)));
setAlertText(`Error: ${error.message}`);
}
}
};
fetchOptions();
}, [languageProvider, loadOptions, query.serviceName, query.spanName]);
}, [languageProvider, loadOptions, query.serviceName, query.spanName, setAlertText]);
const onKeyDown = (keyEvent: React.KeyboardEvent) => {
if (keyEvent.key === 'Enter' && (keyEvent.shiftKey || keyEvent.ctrlKey)) {
@ -255,6 +258,7 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props
configure it in the <a href={`/datasources/edit/${datasource.uid}`}>datasource settings</a>.
</Alert>
) : null}
{alertText && <TemporaryAlert severity="error" text={alertText} />}
</>
);
};

@ -1,12 +1,10 @@
import { css } from '@emotion/css';
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { TemporaryAlert } from '@grafana/o11y-ds-frontend';
import { CodeEditor, Monaco, monacoTypes, useTheme2 } from '@grafana/ui';
import { notifyApp } from '../../_importedDependencies/actions/appNotification';
import { createErrorNotification } from '../../_importedDependencies/core/appNotification';
import { dispatch } from '../../_importedDependencies/store';
import { TempoDatasource } from '../../datasource';
import { CompletionProvider } from './autocomplete';
@ -21,40 +19,44 @@ interface Props {
}
export function TagsField(props: Props) {
const [alertText, setAlertText] = useState<string>();
const { onChange, onBlur, placeholder } = props;
const setupAutocompleteFn = useAutocomplete(props.datasource);
const setupAutocompleteFn = useAutocomplete(props.datasource, setAlertText);
const theme = useTheme2();
const styles = getStyles(theme, placeholder);
return (
<CodeEditor
value={props.value}
language={langId}
onBlur={onBlur}
onChange={onChange}
containerStyles={styles.queryField}
monacoOptions={{
folding: false,
fontSize: 14,
lineNumbers: 'off',
overviewRulerLanes: 0,
renderLineHighlight: 'none',
scrollbar: {
vertical: 'hidden',
verticalScrollbarSize: 8, // used as "padding-right"
horizontal: 'hidden',
horizontalScrollbarSize: 0,
},
scrollBeyondLastLine: false,
wordWrap: 'on',
}}
onBeforeEditorMount={ensureTraceQL}
onEditorDidMount={(editor, monaco) => {
setupAutocompleteFn(editor, monaco);
setupPlaceholder(editor, monaco, styles);
setupAutoSize(editor);
}}
/>
<>
<CodeEditor
value={props.value}
language={langId}
onBlur={onBlur}
onChange={onChange}
containerStyles={styles.queryField}
monacoOptions={{
folding: false,
fontSize: 14,
lineNumbers: 'off',
overviewRulerLanes: 0,
renderLineHighlight: 'none',
scrollbar: {
vertical: 'hidden',
verticalScrollbarSize: 8, // used as "padding-right"
horizontal: 'hidden',
horizontalScrollbarSize: 0,
},
scrollBeyondLastLine: false,
wordWrap: 'on',
}}
onBeforeEditorMount={ensureTraceQL}
onEditorDidMount={(editor, monaco) => {
setupAutocompleteFn(editor, monaco);
setupPlaceholder(editor, monaco, styles);
setupAutoSize(editor);
}}
/>
{alertText && <TemporaryAlert severity="error" text={alertText} />}
</>
);
}
@ -103,9 +105,10 @@ function setupAutoSize(editor: monacoTypes.editor.IStandaloneCodeEditor) {
/**
* Hook that returns function that will set up monaco autocomplete for the label selector
* @param datasource
* @param datasource the Tempo datasource instance
* @param setAlertText setter for the alert text
*/
function useAutocomplete(datasource: TempoDatasource) {
function useAutocomplete(datasource: TempoDatasource, setAlertText: (text?: string) => void) {
// We need the provider ref so we can pass it the label/values data later. This is because we run the call for the
// values here but there is additional setup needed for the provider later on. We could run the getSeries() in the
// returned function but that is run after the monaco is mounted so would delay the request a bit when it does not
@ -118,14 +121,15 @@ function useAutocomplete(datasource: TempoDatasource) {
const fetchTags = async () => {
try {
await datasource.languageProvider.start();
setAlertText(undefined);
} catch (error) {
if (error instanceof Error) {
dispatch(notifyApp(createErrorNotification('Error', error)));
setAlertText(`Error: ${error.message}`);
}
}
};
fetchTags();
}, [datasource]);
}, [datasource, setAlertText]);
const autocompleteDisposeFun = useRef<(() => void) | null>(null);
useEffect(() => {

@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { LanguageProvider } from '@grafana/data';
import { FetchError } from '@grafana/runtime';
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
@ -290,9 +289,7 @@ const renderSearchField = (
datasource={datasource}
updateFilter={updateFilter}
filter={filter}
setError={function (error: FetchError): void {
throw error;
}}
setError={() => {}}
tags={tags || []}
hideTag={hideTag}
query={'{}'}

@ -4,12 +4,10 @@ import React, { useState, useEffect, useMemo } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { SelectableValue } from '@grafana/data';
import { TemporaryAlert } from '@grafana/o11y-ds-frontend';
import { FetchError, getTemplateSrv, isFetchError } from '@grafana/runtime';
import { Select, HorizontalGroup, useStyles2 } from '@grafana/ui';
import { notifyApp } from '../_importedDependencies/actions/appNotification';
import { createErrorNotification } from '../_importedDependencies/core/appNotification';
import { dispatch } from '../_importedDependencies/store';
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import { operators as allOperators, stringOperators, numberOperators, keywordOperators } from '../traceql/traceql';
@ -26,7 +24,8 @@ interface Props {
filter: TraceqlFilter;
datasource: TempoDatasource;
updateFilter: (f: TraceqlFilter) => void;
setError: (error: FetchError) => void;
deleteFilter?: (f: TraceqlFilter) => void;
setError: (error: FetchError | null) => void;
isTagsLoading?: boolean;
tags: string[];
hideScope?: boolean;
@ -51,6 +50,7 @@ const SearchField = ({
allowCustomValue = true,
}: Props) => {
const styles = useStyles2(getStyles);
const [alertText, setAlertText] = useState<string>();
const scopedTag = useMemo(() => filterScopedTag(filter), [filter]);
// We automatically change the operator to the regex op when users select 2 or more values
// However, they expect this to be automatically rolled back to the previous operator once
@ -60,13 +60,16 @@ const SearchField = ({
const updateOptions = async () => {
try {
return filter.tag ? await datasource.languageProvider.getOptionsV2(scopedTag, query) : [];
const result = filter.tag ? await datasource.languageProvider.getOptionsV2(scopedTag, query) : [];
setAlertText(undefined);
setError(null);
return result;
} catch (error) {
// Display message if Tempo is connected but search 404's
if (isFetchError(error) && error?.status === 404) {
setError(error);
} else if (error instanceof Error) {
dispatch(notifyApp(createErrorNotification('Error', error)));
setAlertText(`Error: ${error.message}`);
}
}
return [];
@ -135,78 +138,85 @@ const SearchField = ({
};
return (
<HorizontalGroup spacing={'none'} width={'auto'}>
{!hideScope && (
<>
<HorizontalGroup spacing={'none'} width={'auto'}>
{!hideScope && (
<Select
className={styles.dropdown}
inputId={`${filter.id}-scope`}
options={withTemplateVariableOptions(scopeOptions)}
value={filter.scope}
onChange={(v) => {
updateFilter({ ...filter, scope: v?.value });
}}
placeholder="Select scope"
aria-label={`select ${filter.id} scope`}
/>
)}
{!hideTag && (
<Select
className={styles.dropdown}
inputId={`${filter.id}-tag`}
isLoading={isTagsLoading}
// Add the current tag to the list if it doesn't exist in the tags prop, otherwise the field will be empty even though the state has a value
options={withTemplateVariableOptions(
(filter.tag !== undefined ? uniq([filter.tag, ...tags]) : tags).map((t) => ({
label: t,
value: t,
}))
)}
value={filter.tag}
onChange={(v) => {
updateFilter({ ...filter, tag: v?.value, value: [] });
}}
placeholder="Select tag"
isClearable
aria-label={`select ${filter.id} tag`}
allowCustomValue={true}
/>
)}
<Select
className={styles.dropdown}
inputId={`${filter.id}-scope`}
options={withTemplateVariableOptions(scopeOptions)}
value={filter.scope}
inputId={`${filter.id}-operator`}
options={withTemplateVariableOptions(operatorList.map(operatorSelectableValue))}
value={filter.operator}
onChange={(v) => {
updateFilter({ ...filter, scope: v?.value });
updateFilter({ ...filter, operator: v?.value });
}}
placeholder="Select scope"
aria-label={`select ${filter.id} scope`}
/>
)}
{!hideTag && (
<Select
className={styles.dropdown}
inputId={`${filter.id}-tag`}
isLoading={isTagsLoading}
// Add the current tag to the list if it doesn't exist in the tags prop, otherwise the field will be empty even though the state has a value
options={withTemplateVariableOptions(
(filter.tag !== undefined ? uniq([filter.tag, ...tags]) : tags).map((t) => ({
label: t,
value: t,
}))
)}
value={filter.tag}
onChange={(v) => {
updateFilter({ ...filter, tag: v?.value, value: [] });
}}
placeholder="Select tag"
isClearable
aria-label={`select ${filter.id} tag`}
isClearable={false}
aria-label={`select ${filter.id} operator`}
allowCustomValue={true}
width={8}
/>
)}
<Select
className={styles.dropdown}
inputId={`${filter.id}-operator`}
options={withTemplateVariableOptions(operatorList.map(operatorSelectableValue))}
value={filter.operator}
onChange={(v) => {
updateFilter({ ...filter, operator: v?.value });
}}
isClearable={false}
aria-label={`select ${filter.id} operator`}
allowCustomValue={true}
width={8}
/>
{!hideValue && (
<Select
className={styles.dropdown}
inputId={`${filter.id}-value`}
isLoading={isLoadingValues}
options={withTemplateVariableOptions(options)}
value={filter.value}
onChange={(val) => {
if (Array.isArray(val)) {
updateFilter({ ...filter, value: val.map((v) => v.value), valueType: val[0]?.type || uniqueOptionType });
} else {
updateFilter({ ...filter, value: val?.value, valueType: val?.type || uniqueOptionType });
}
}}
placeholder="Select value"
isClearable={true}
aria-label={`select ${filter.id} value`}
allowCustomValue={allowCustomValue}
isMulti={isMulti}
allowCreateWhileLoading
/>
)}
</HorizontalGroup>
{!hideValue && (
<Select
className={styles.dropdown}
inputId={`${filter.id}-value`}
isLoading={isLoadingValues}
options={withTemplateVariableOptions(options)}
value={filter.value}
onChange={(val) => {
if (Array.isArray(val)) {
updateFilter({
...filter,
value: val.map((v) => v.value),
valueType: val[0]?.type || uniqueOptionType,
});
} else {
updateFilter({ ...filter, value: val?.value, valueType: val?.type || uniqueOptionType });
}
}}
placeholder="Select value"
isClearable={true}
aria-label={`select ${filter.id} value`}
allowCustomValue={allowCustomValue}
isMulti={isMulti}
allowCreateWhileLoading
/>
)}
</HorizontalGroup>
{alertText && <TemporaryAlert severity="error" text={alertText} />}
</>
);
};

@ -2,8 +2,6 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { FetchError } from '@grafana/runtime';
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider';
@ -124,9 +122,7 @@ describe('TagsInput', () => {
updateFilter={jest.fn}
deleteFilter={jest.fn}
filters={[filter]}
setError={function (error: FetchError): void {
throw error;
}}
setError={() => {}}
staticTags={[]}
isTagsLoading={false}
query={''}

@ -34,7 +34,7 @@ interface Props {
deleteFilter: (f: TraceqlFilter) => void;
filters: TraceqlFilter[];
datasource: TempoDatasource;
setError: (error: FetchError) => void;
setError: (error: FetchError | null) => void;
staticTags: Array<string | undefined>;
isTagsLoading: boolean;
hideValues?: boolean;

@ -137,8 +137,8 @@ describe('TraceQLSearch', () => {
expect(screen.queryAllByLabelText(/Remove tag/).length).toBe(1); // filled in the default tag, so can remove values
await user.click(screen.getAllByLabelText(/Remove tag/)[0]);
jest.advanceTimersByTime(1000);
await act(async () => {
jest.advanceTimersByTime(1000);
expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the default tag, so no need to add another one
expect(screen.queryAllByLabelText(/Remove tag/).length).toBe(0); // mot filled in the default tag, so no values to remove
});

@ -2,13 +2,11 @@ import { css } from '@emotion/css';
import React, { useCallback, useEffect, useState } from 'react';
import { CoreApp, GrafanaTheme2 } from '@grafana/data';
import { TemporaryAlert } from '@grafana/o11y-ds-frontend';
import { config, FetchError, getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { Alert, Button, HorizontalGroup, Select, useStyles2 } from '@grafana/ui';
import { notifyApp } from '../_importedDependencies/actions/appNotification';
import { createErrorNotification } from '../_importedDependencies/core/appNotification';
import { RawQuery } from '../_importedDependencies/datasources/prometheus/RawQuery';
import { dispatch } from '../_importedDependencies/store';
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import { TempoQueryBuilderOptions } from '../traceql/TempoQueryBuilderOptions';
@ -35,6 +33,7 @@ const hardCodedFilterIds = ['min-duration', 'max-duration', 'status'];
const TraceQLSearch = ({ datasource, query, onChange, onClearResults, app }: Props) => {
const styles = useStyles2(getStyles);
const [alertText, setAlertText] = useState<string>();
const [error, setError] = useState<Error | FetchError | null>(null);
const [isTagsLoading, setIsTagsLoading] = useState(true);
@ -73,14 +72,15 @@ const TraceQLSearch = ({ datasource, query, onChange, onClearResults, app }: Pro
try {
await datasource.languageProvider.start();
setIsTagsLoading(false);
setAlertText(undefined);
} catch (error) {
if (error instanceof Error) {
dispatch(notifyApp(createErrorNotification('Error', error)));
setAlertText(`Error: ${error.message}`);
}
}
};
fetchTags();
}, [datasource]);
}, [datasource, setAlertText]);
useEffect(() => {
// Initialize state with configured static filters that already have a value from the config
@ -250,6 +250,7 @@ const TraceQLSearch = ({ datasource, query, onChange, onClearResults, app }: Pro
configure it in the <a href={`/datasources/edit/${datasource.uid}`}>datasource settings</a>.
</Alert>
) : null}
{alertText && <TemporaryAlert severity={'error'} text={alertText} />}
</>
);
};

@ -6,8 +6,8 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Alert, InlineField, InlineFieldRow, useStyles2 } from '@grafana/ui';
import { AdHocFilter } from './_importedDependencies/components/AdHocFilter/AdHocFilter';
import { AdHocVariableFilter } from './_importedDependencies/components/AdHocFilter/types';
import { PrometheusDatasource } from './_importedDependencies/datasources/prometheus/types';
import { AdHocVariableFilter } from './_importedDependencies/types';
import { TempoQuery } from './types';
import { getDS } from './utils';

@ -1,128 +0,0 @@
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { AppNotification, AppNotificationSeverity, AppNotificationsState } from './types/appNotifications';
const MAX_STORED_NOTIFICATIONS = 25;
export const STORAGE_KEY = 'notifications';
export const NEW_NOTIFS_KEY = `${STORAGE_KEY}/lastRead`;
type StoredNotification = Omit<AppNotification, 'component'>;
export const initialState: AppNotificationsState = {
byId: deserializeNotifications(),
lastRead: Number.parseInt(window.localStorage.getItem(NEW_NOTIFS_KEY) ?? `${Date.now()}`, 10),
};
/**
* Reducer and action to show toast notifications of various types (success, warnings, errors etc). Use to show
* transient info to user, like errors that cannot be otherwise handled or success after an action.
*
* Use factory functions in core/copy/appNotifications to create the payload.
*/
const appNotificationsSlice = createSlice({
name: 'appNotifications',
initialState,
reducers: {
notifyApp: (state, { payload: newAlert }: PayloadAction<AppNotification>) => {
if (Object.values(state.byId).some((alert) => isSimilar(newAlert, alert) && alert.showing)) {
return;
}
state.byId[newAlert.id] = newAlert;
serializeNotifications(state.byId);
},
hideAppNotification: (state, { payload: alertId }: PayloadAction<string>) => {
if (!(alertId in state.byId)) {
return;
}
state.byId[alertId].showing = false;
serializeNotifications(state.byId);
},
clearNotification: (state, { payload: alertId }: PayloadAction<string>) => {
delete state.byId[alertId];
serializeNotifications(state.byId);
},
clearAllNotifications: (state) => {
state.byId = {};
serializeNotifications(state.byId);
},
readAllNotifications: (state, { payload: timestamp }: PayloadAction<number>) => {
state.lastRead = timestamp;
},
},
});
export const { notifyApp, hideAppNotification, clearNotification, clearAllNotifications, readAllNotifications } =
appNotificationsSlice.actions;
export const appNotificationsReducer = appNotificationsSlice.reducer;
// Selectors
export const selectLastReadTimestamp = (state: AppNotificationsState) => state.lastRead;
export const selectById = (state: AppNotificationsState) => state.byId;
export const selectAll = createSelector(selectById, (byId) =>
Object.values(byId).sort((a, b) => b.timestamp - a.timestamp)
);
export const selectWarningsAndErrors = createSelector(selectAll, (all) => all.filter(isAtLeastWarning));
export const selectVisible = createSelector(selectById, (byId) => Object.values(byId).filter((n) => n.showing));
// Helper functions
function isSimilar(a: AppNotification, b: AppNotification): boolean {
return a.icon === b.icon && a.severity === b.severity && a.text === b.text && a.title === b.title;
}
function isAtLeastWarning(notif: AppNotification) {
return notif.severity === AppNotificationSeverity.Warning || notif.severity === AppNotificationSeverity.Error;
}
function isStoredNotification(obj: unknown): obj is StoredNotification {
return typeof obj === 'object' && obj !== null && 'id' in obj && 'icon' in obj && 'title' in obj && 'text' in obj;
}
// (De)serialization
export function deserializeNotifications(): Record<string, StoredNotification> {
const storedNotifsRaw = window.localStorage.getItem(STORAGE_KEY);
if (!storedNotifsRaw) {
return {};
}
const parsed = JSON.parse(storedNotifsRaw);
if (!Object.values(parsed).every((v) => isStoredNotification(v))) {
return {};
}
return parsed;
}
function serializeNotifications(notifs: Record<string, StoredNotification>) {
const reducedNotifs = Object.values(notifs)
.filter(isAtLeastWarning)
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, MAX_STORED_NOTIFICATIONS)
.reduce<Record<string, StoredNotification>>((prev, cur) => {
prev[cur.id] = {
id: cur.id,
severity: cur.severity,
icon: cur.icon,
title: cur.title,
text: cur.text,
traceId: cur.traceId,
timestamp: cur.timestamp,
// we don't care about still showing toasts after refreshing
// https://github.com/grafana/grafana/issues/71932
showing: false,
};
return prev;
}, {});
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(reducedNotifs));
} catch (err) {
console.error('Unable to persist notifications to local storage');
console.error(err);
}
}

@ -1,5 +0,0 @@
import { appNotificationsReducer as appNotifications } from './appNotification';
export default {
appNotifications,
};

@ -1,36 +0,0 @@
export interface AppNotification {
id: string;
severity: AppNotificationSeverity;
icon: string;
title: string;
text: string;
traceId?: string;
component?: React.ReactElement;
showing: boolean;
timestamp: number;
}
export enum AppNotificationSeverity {
Success = 'success',
Warning = 'warning',
Error = 'error',
Info = 'info',
}
export enum AppNotificationTimeout {
Success = 3000,
Warning = 5000,
Error = 7000,
}
export const timeoutMap = {
[AppNotificationSeverity.Success]: AppNotificationTimeout.Success,
[AppNotificationSeverity.Warning]: AppNotificationTimeout.Warning,
[AppNotificationSeverity.Error]: AppNotificationTimeout.Error,
[AppNotificationSeverity.Info]: AppNotificationTimeout.Success,
};
export interface AppNotificationsState {
byId: Record<string, AppNotification>;
lastRead: number;
}

@ -1,46 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { AppNotification, AppNotificationSeverity } from '../actions/types';
import { getMessageFromError } from './errors';
const defaultSuccessNotification = {
title: '',
text: '',
severity: AppNotificationSeverity.Success,
icon: 'check',
};
const defaultErrorNotification = {
title: '',
text: '',
severity: AppNotificationSeverity.Error,
icon: 'exclamation-triangle',
};
export const createSuccessNotification = (title: string, text = '', traceId?: string): AppNotification => ({
...defaultSuccessNotification,
title,
text,
id: uuidv4(),
timestamp: Date.now(),
showing: true,
});
export const createErrorNotification = (
title: string,
text: string | Error = '',
traceId?: string,
component?: React.ReactElement
): AppNotification => {
return {
...defaultErrorNotification,
text: getMessageFromError(text),
title,
id: uuidv4(),
traceId,
component,
timestamp: Date.now(),
showing: true,
};
};

@ -1,21 +0,0 @@
import { isFetchError } from '@grafana/runtime';
export function getMessageFromError(err: unknown): string {
if (typeof err === 'string') {
return err;
}
if (err) {
if (err instanceof Error) {
return err.message;
} else if (isFetchError(err)) {
if (err.data && err.data.message) {
return err.data.message;
} else if (err.statusText) {
return err.statusText;
}
}
}
return JSON.stringify(err);
}

@ -1,27 +0,0 @@
import { Store } from 'redux';
export let store: Store<StoreState>;
export const initialKeyedVariablesState: any = { keys: {} };
type StoreState = ReturnType<ReturnType<any>>;
export function setStore(newStore: Store<StoreState>) {
store = newStore;
}
export function getState(): StoreState {
if (!store || !store.getState) {
return { templating: { ...initialKeyedVariablesState, lastKey: 'key' } } as StoreState; // used by tests
}
return store.getState();
}
// This was `any` before
export function dispatch(action: any) {
if (!store || !store.getState) {
return;
}
return store.dispatch(action);
}

@ -20,7 +20,6 @@
"@opentelemetry/api": "1.7.0",
"@opentelemetry/exporter-collector": "0.25.0",
"@opentelemetry/semantic-conventions": "1.21.0",
"@reduxjs/toolkit": "1.9.5",
"buffer": "6.0.3",
"events": "3.3.0",
"i18next": "^23.0.0",
@ -31,7 +30,6 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-use": "17.5.0",
"redux": "4.2.1",
"rxjs": "7.8.1",
"semver": "7.6.0",
"stream-browserify": "3.0.0",

@ -23,7 +23,7 @@ interface Props {
}
export function TraceQLEditor(props: Props) {
const [alertText, setAlertText] = useState('');
const [alertText, setAlertText] = useState<string>();
const { query, onChange, onRunQuery, placeholder } = props;
const setupAutocompleteFn = useAutocomplete(props.datasource, setAlertText);
@ -116,7 +116,7 @@ export function TraceQLEditor(props: Props) {
});
}}
/>
{alertText && <TemporaryAlert severity={'error'} text={alertText} />}
{alertText && <TemporaryAlert severity="error" text={alertText} />}
</>
);
}
@ -193,23 +193,23 @@ function setupAutoSize(editor: monacoTypes.editor.IStandaloneCodeEditor) {
* @param datasource the Tempo datasource instance
* @param setAlertText setter for alert's text
*/
function useAutocomplete(datasource: TempoDatasource, setAlertText: (text: string) => void) {
function useAutocomplete(datasource: TempoDatasource, setAlertText: (text?: string) => void) {
// We need the provider ref so we can pass it the label/values data later. This is because we run the call for the
// values here but there is additional setup needed for the provider later on. We could run the getSeries() in the
// returned function but that is run after the monaco is mounted so would delay the request a bit when it does not
// need to.
const providerRef = useRef<CompletionProvider>(
new CompletionProvider({ languageProvider: datasource.languageProvider })
new CompletionProvider({ languageProvider: datasource.languageProvider, setAlertText })
);
useEffect(() => {
const fetchTags = async () => {
try {
await datasource.languageProvider.start();
setAlertText(undefined);
} catch (error) {
if (error instanceof Error) {
console.error(error);
setAlertText(error.message);
setAlertText(`Error: ${error.message}`);
}
}
};

@ -395,7 +395,7 @@ function setup(value: string, offset: number, tagsV1?: string[], tagsV2?: Scope[
} else if (tagsV2) {
lp.setV2Tags(tagsV2);
}
const provider = new CompletionProvider({ languageProvider: lp });
const provider = new CompletionProvider({ languageProvider: lp, setAlertText: () => {} });
const model = makeModel(value, offset);
provider.monaco = {
Range: {

@ -4,9 +4,6 @@ import { SelectableValue } from '@grafana/data';
import { isFetchError } from '@grafana/runtime';
import type { Monaco, monacoTypes } from '@grafana/ui';
import { notifyApp } from '../_importedDependencies/actions/appNotification';
import { createErrorNotification } from '../_importedDependencies/core/appNotification';
import { dispatch } from '../_importedDependencies/store';
import TempoLanguageProvider from '../language_provider';
import { getSituation, Situation } from './situation';
@ -14,6 +11,7 @@ import { intrinsics, scopes } from './traceql';
interface Props {
languageProvider: TempoLanguageProvider;
setAlertText: (text?: string) => void;
}
type MinimalCompletionItem = {
@ -33,9 +31,11 @@ type MinimalCompletionItem = {
export class CompletionProvider implements monacoTypes.languages.CompletionItemProvider {
languageProvider: TempoLanguageProvider;
registerInteractionCommandId: string | null;
setAlertText: (text?: string) => void;
constructor(props: Props) {
this.languageProvider = props.languageProvider;
this.setAlertText = props.setAlertText;
this.registerInteractionCommandId = null;
}
@ -243,7 +243,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
const { range, offset } = getRangeAndOffset(this.monaco, model, position);
const situation = getSituation(model.getValue(), offset);
const completionItems = situation != null ? this.getCompletions(situation) : Promise.resolve([]);
const completionItems = situation != null ? this.getCompletions(situation, this.setAlertText) : Promise.resolve([]);
return completionItems.then((items) => {
// monaco by-default alphabetically orders the items.
@ -298,7 +298,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
* @param situation
* @private
*/
private async getCompletions(situation: Situation): Promise<Completion[]> {
private async getCompletions(situation: Situation, setAlertText: (text?: string) => void): Promise<Completion[]> {
switch (situation.type) {
// This should only happen for cases that we do not support yet
case 'UNKNOWN': {
@ -370,11 +370,12 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
let tagValues;
try {
tagValues = await this.getTagValues(situation.tagName, situation.query);
setAlertText(undefined);
} catch (error) {
if (isFetchError(error)) {
dispatch(notifyApp(createErrorNotification(error.data.error, new Error(error.data.message))));
setAlertText(error.data.error);
} else if (error instanceof Error) {
dispatch(notifyApp(createErrorNotification('Error', error)));
setAlertText(`Error: ${error.message}`);
}
}

@ -3445,7 +3445,6 @@ __metadata:
"@opentelemetry/api": "npm:1.7.0"
"@opentelemetry/exporter-collector": "npm:0.25.0"
"@opentelemetry/semantic-conventions": "npm:1.21.0"
"@reduxjs/toolkit": "npm:1.9.5"
"@testing-library/jest-dom": "npm:6.4.2"
"@testing-library/react": "npm:14.2.1"
"@testing-library/user-event": "npm:14.5.2"
@ -3469,7 +3468,6 @@ __metadata:
react-dom: "npm:18.2.0"
react-select-event: "npm:5.5.1"
react-use: "npm:17.5.0"
redux: "npm:4.2.1"
rxjs: "npm:7.8.1"
semver: "npm:7.6.0"
stream-browserify: "npm:3.0.0"

Loading…
Cancel
Save