Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
+2
-9
@@ -540,9 +540,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Combobox/Combobox.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
@@ -635,9 +632,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/DateTimePickers/WeekStartPicker.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
|
||||
],
|
||||
@@ -3712,10 +3706,9 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "3"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "4"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "7"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
|
||||
],
|
||||
"public/app/features/dashboard/api/v1.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
|
||||
@@ -48,8 +48,8 @@ export function makeNewDashboardRequestBody(dashboardName: string, folderUid?: s
|
||||
timezone: '',
|
||||
title: dashboardName,
|
||||
version: 0,
|
||||
weekStart: '',
|
||||
uid: '',
|
||||
weekStart: '',
|
||||
},
|
||||
message: '',
|
||||
overwrite: false,
|
||||
|
||||
+1
-1
@@ -376,7 +376,7 @@
|
||||
"react-highlight-words": "0.21.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-inlinesvg": "4.1.5",
|
||||
"react-inlinesvg": "4.2.0",
|
||||
"react-loading-skeleton": "3.5.0",
|
||||
"react-moveable": "0.56.0",
|
||||
"react-redux": "9.2.0",
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface StandardEditorContext<TOptions, TState = any> {
|
||||
options?: TOptions;
|
||||
instanceState?: TState;
|
||||
isOverride?: boolean;
|
||||
annotations?: DataFrame[];
|
||||
}
|
||||
|
||||
export interface StandardEditorProps<TValue = any, TSettings = any, TOptions = any, TState = any> {
|
||||
|
||||
@@ -326,7 +326,11 @@ export function getHistogramFields(frame: DataFrame): HistogramFields | undefine
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export function buildHistogram(frames: DataFrame[], options?: HistogramTransformerOptions): HistogramFields | null {
|
||||
export function buildHistogram(
|
||||
frames: DataFrame[],
|
||||
options?: HistogramTransformerOptions,
|
||||
theme?: GrafanaTheme2
|
||||
): HistogramFields | null {
|
||||
let bucketSize = options?.bucketSize;
|
||||
let bucketCount = options?.bucketCount ?? DEFAULT_BUCKET_COUNT;
|
||||
let bucketOffset = options?.bucketOffset ?? 0;
|
||||
@@ -413,13 +417,20 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform
|
||||
if (field.type === FieldType.number) {
|
||||
let fieldHist = histogram(field.values, getBucket, histFilter, histSort);
|
||||
histograms.push(fieldHist);
|
||||
counts.push({
|
||||
|
||||
const count = {
|
||||
...field,
|
||||
config: {
|
||||
...field.config,
|
||||
unit: field.config.unit === 'short' ? 'short' : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
count.display = getDisplayProcessor({
|
||||
field: count,
|
||||
theme: theme ?? createTheme(),
|
||||
});
|
||||
counts.push(count);
|
||||
if (!config && field.config.unit) {
|
||||
config = field.config;
|
||||
}
|
||||
@@ -574,12 +585,6 @@ export function histogramFieldsToFrame(info: HistogramFields, theme?: GrafanaThe
|
||||
info.xMax.display = display;
|
||||
}
|
||||
|
||||
// ensure updated units are reflected on the count field used for y axis formatting
|
||||
info.counts[0].display = getDisplayProcessor({
|
||||
field: info.counts[0],
|
||||
theme: theme ?? createTheme(),
|
||||
});
|
||||
|
||||
return {
|
||||
length: info.xMin.values.length,
|
||||
meta: {
|
||||
|
||||
@@ -179,4 +179,6 @@ export enum FeatureState {
|
||||
privatePreview = 'private preview',
|
||||
/** used to mark features that are in public preview with low/medium risk, or as a shared badge for public and private previews */
|
||||
preview = 'preview',
|
||||
/** used to mark new GA features */
|
||||
new = 'new',
|
||||
}
|
||||
|
||||
@@ -256,4 +256,5 @@ export interface FeatureToggles {
|
||||
alertingJiraIntegration?: boolean;
|
||||
alertingRuleVersionHistoryRestore?: boolean;
|
||||
newShareReportDrawer?: boolean;
|
||||
rendererDisableAppPluginsPreload?: boolean;
|
||||
}
|
||||
|
||||
@@ -51,5 +51,5 @@ export interface OptionEditorConfig<TOptions, TSettings = any, TValue = any> {
|
||||
/**
|
||||
* Function that enables configuration of when option editor should be shown based on current panel option properties.
|
||||
*/
|
||||
showIf?: (currentOptions: TOptions, data?: DataFrame[]) => boolean | undefined;
|
||||
showIf?: (currentOptions: TOptions, data?: DataFrame[], annotations?: DataFrame[]) => boolean | undefined;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function getDefaultTimeRange(): TimeRange {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default realtive time range.
|
||||
* Returns the default relative time range.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
|
||||
@@ -35,9 +35,13 @@ describe('MetricCombobox', () => {
|
||||
} as unknown as DataSourceInstanceSettings<PromOptions>;
|
||||
|
||||
const mockDatasource = new PrometheusDatasource(instanceSettings);
|
||||
const mockValues = [{ label: 'random_metric' }, { label: 'unique_metric' }, { label: 'more_unique_metric' }];
|
||||
|
||||
// Mock metricFindQuery which will call backend API
|
||||
// Options returned when user first opens the combobox - returned by onGetMetrics
|
||||
const initialMockValues = [{ label: 'top_metric_one' }, { label: 'top_metric_two' }, { label: 'top_metric_three' }];
|
||||
const mockOnGetMetrics = jest.fn(() => Promise.resolve(initialMockValues.map((v) => ({ value: v.label }))));
|
||||
|
||||
// Options returned when user searches for a metric
|
||||
const mockValues = [{ label: 'random_metric' }, { label: 'unique_metric' }, { label: 'more_unique_metric' }];
|
||||
mockDatasource.metricFindQuery = jest.fn((query: string) => {
|
||||
// return Promise.resolve([]);
|
||||
// Use the label values regex to get the values inside the label_values function call
|
||||
@@ -61,7 +65,6 @@ describe('MetricCombobox', () => {
|
||||
});
|
||||
|
||||
const mockOnChange = jest.fn();
|
||||
const mockOnGetMetrics = jest.fn(() => Promise.resolve(mockValues.map((v) => ({ value: v.label }))));
|
||||
|
||||
const defaultProps: MetricComboboxProps = {
|
||||
metricLookupDisabled: false,
|
||||
@@ -92,10 +95,11 @@ describe('MetricCombobox', () => {
|
||||
const combobox = screen.getByPlaceholderText('Select metric');
|
||||
await userEvent.click(combobox);
|
||||
|
||||
expect(mockOnGetMetrics).toHaveBeenCalledTimes(1);
|
||||
|
||||
const item = await screen.findByRole('option', { name: 'random_metric' });
|
||||
const item = await screen.findByRole('option', { name: 'top_metric_one' });
|
||||
expect(item).toBeInTheDocument();
|
||||
|
||||
// This should be asserted by the above check, but double check anyway
|
||||
expect(mockOnGetMetrics).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fetches metrics for the users query', async () => {
|
||||
@@ -108,8 +112,9 @@ describe('MetricCombobox', () => {
|
||||
const item = await screen.findByRole('option', { name: 'unique_metric' });
|
||||
expect(item).toBeInTheDocument();
|
||||
|
||||
const negativeItem = screen.queryByRole('option', { name: 'random_metric' });
|
||||
expect(negativeItem).not.toBeInTheDocument();
|
||||
// This should be asserted by the above check, but double check anyway
|
||||
// This is the actual argument, created by formatKeyValueStringsForLabelValuesQuery()
|
||||
expect(mockDatasource.metricFindQuery).toHaveBeenCalledWith('label_values({__name__=~".*unique.*"},__name__)');
|
||||
});
|
||||
|
||||
it('calls onChange with the correct value when a metric is selected', async () => {
|
||||
@@ -118,10 +123,10 @@ describe('MetricCombobox', () => {
|
||||
const combobox = screen.getByPlaceholderText('Select metric');
|
||||
await userEvent.click(combobox);
|
||||
|
||||
const item = await screen.findByRole('option', { name: 'random_metric' });
|
||||
const item = await screen.findByRole('option', { name: 'top_metric_two' });
|
||||
await userEvent.click(item);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({ metric: 'random_metric', labels: [], operations: [] });
|
||||
expect(mockOnChange).toHaveBeenCalledWith({ metric: 'top_metric_two', labels: [], operations: [] });
|
||||
});
|
||||
|
||||
it('shows the metrics explorer button by default', () => {
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"react-highlight-words": "0.21.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-inlinesvg": "4.1.5",
|
||||
"react-inlinesvg": "4.2.0",
|
||||
"react-loading-skeleton": "3.5.0",
|
||||
"react-router-dom": "5.3.4",
|
||||
"react-router-dom-v5-compat": "^6.26.1",
|
||||
|
||||
@@ -79,7 +79,7 @@ const getStyles = (theme: GrafanaTheme2, color: BadgeColor) => {
|
||||
border: `1px solid ${borderColor}`,
|
||||
color: textColor,
|
||||
fontWeight: theme.typography.fontWeightRegular,
|
||||
gap: '2px',
|
||||
gap: theme.spacing(0.5),
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
lineHeight: theme.typography.bodySmall.lineHeight,
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, render, screen, fireEvent } from '@testing-library/react';
|
||||
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
@@ -395,7 +395,9 @@ describe('Combobox', () => {
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
|
||||
expect(asyncSpy).toHaveBeenCalledTimes(1); // Called on open
|
||||
expect(asyncSpy).not.toHaveBeenCalledTimes(1); // Not called yet
|
||||
act(() => jest.advanceTimersByTime(200)); // Add the debounce time
|
||||
expect(asyncSpy).toHaveBeenCalledTimes(1); // Then check if called on open
|
||||
asyncSpy.mockClear();
|
||||
|
||||
await user.keyboard('a');
|
||||
@@ -434,9 +436,9 @@ describe('Combobox', () => {
|
||||
});
|
||||
|
||||
it('should display message when there is an error loading async options', async () => {
|
||||
const asyncOptions = jest.fn(() => {
|
||||
throw new Error('Could not retrieve options');
|
||||
});
|
||||
const fetchData = jest.fn();
|
||||
const asyncOptions = fetchData.mockRejectedValue(new Error('Could not retrieve options'));
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
render(<Combobox options={asyncOptions} value={null} onChange={onChangeHandler} />);
|
||||
|
||||
@@ -445,12 +447,15 @@ describe('Combobox', () => {
|
||||
await user.type(input, 'test');
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersToNextTimer();
|
||||
jest.advanceTimersByTimeAsync(500);
|
||||
});
|
||||
expect(asyncOptions).rejects.toThrow('Could not retrieve options');
|
||||
await waitFor(() => expect(consoleErrorSpy).toHaveBeenCalled());
|
||||
|
||||
const emptyMessage = screen.queryByText('An error occurred while loading options.');
|
||||
|
||||
expect(emptyMessage).toBeInTheDocument();
|
||||
|
||||
asyncOptions.mockClear();
|
||||
});
|
||||
|
||||
describe('with a value already selected', () => {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCombobox } from 'downshift';
|
||||
import { debounce } from 'lodash';
|
||||
import { useCallback, useId, useMemo, useState } from 'react';
|
||||
import { useId, useMemo } from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { logOptions } from '../../utils';
|
||||
import { t } from '../../utils/i18n';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { AutoSizeInput } from '../Input/AutoSizeInput';
|
||||
@@ -14,11 +12,11 @@ import { Portal } from '../Portal/Portal';
|
||||
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
|
||||
|
||||
import { AsyncError, NotFoundError } from './MessageRows';
|
||||
import { fuzzyFind, itemToString } from './filter';
|
||||
import { itemToString } from './filter';
|
||||
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
|
||||
import { ComboboxOption } from './types';
|
||||
import { useComboboxFloat } from './useComboboxFloat';
|
||||
import { StaleResultError, useLatestAsyncCall } from './useLatestAsyncCall';
|
||||
import { useOptions } from './useOptions';
|
||||
|
||||
// TODO: It would be great if ComboboxOption["label"] was more generic so that if consumers do pass it in (for async),
|
||||
// then the onChange handler emits ComboboxOption with the label as non-undefined.
|
||||
@@ -64,8 +62,6 @@ export interface ComboboxBaseProps<T extends string | number>
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
const RECOMMENDED_ITEMS_AMOUNT = 100_000;
|
||||
|
||||
type ClearableConditionals<T extends number | string> =
|
||||
| {
|
||||
/**
|
||||
@@ -102,7 +98,6 @@ export type ComboboxProps<T extends string | number> = ComboboxBaseProps<T> &
|
||||
ClearableConditionals<T>;
|
||||
|
||||
const noop = () => {};
|
||||
const asyncNoop = () => Promise.resolve([]);
|
||||
|
||||
export const VIRTUAL_OVERSCAN_ITEMS = 4;
|
||||
|
||||
@@ -113,7 +108,7 @@ export const VIRTUAL_OVERSCAN_ITEMS = 4;
|
||||
*/
|
||||
export const Combobox = <T extends string | number>(props: ComboboxProps<T>) => {
|
||||
const {
|
||||
options,
|
||||
options: allOptions,
|
||||
onChange,
|
||||
value: valueProp,
|
||||
placeholder: placeholderProp,
|
||||
@@ -135,45 +130,13 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
// get a consistent Value from it
|
||||
const value = typeof valueProp === 'object' ? valueProp?.value : valueProp;
|
||||
|
||||
const isAsync = typeof options === 'function';
|
||||
const loadOptions = useLatestAsyncCall(isAsync ? options : asyncNoop); // loadOptions isn't called at all if not async
|
||||
const [asyncLoading, setAsyncLoading] = useState(false);
|
||||
const [asyncError, setAsyncError] = useState(false);
|
||||
|
||||
// A custom setter to always prepend the custom value at the beginning, if needed
|
||||
const [items, baseSetItems] = useState(isAsync ? [] : options);
|
||||
const setItems = useCallback(
|
||||
(items: Array<ComboboxOption<T>>, inputValue: string | undefined) => {
|
||||
let itemsToSet = items;
|
||||
logOptions(itemsToSet.length, RECOMMENDED_ITEMS_AMOUNT, id, ariaLabelledBy);
|
||||
if (inputValue && createCustomValue) {
|
||||
//Since the label of a normal option does not have to match its value and a custom option has the same value and label,
|
||||
//we just focus on the value to check if the option already exists
|
||||
const optionMatchingInput = items.find((opt) => opt.value === inputValue);
|
||||
|
||||
if (!optionMatchingInput) {
|
||||
const customValueOption = {
|
||||
label: inputValue,
|
||||
// Type casting needed to make this work when T is a number
|
||||
value: inputValue as T,
|
||||
description: t('combobox.custom-value.description', 'Use custom value'),
|
||||
};
|
||||
|
||||
itemsToSet = items.slice(0);
|
||||
itemsToSet.unshift(customValueOption);
|
||||
}
|
||||
}
|
||||
|
||||
baseSetItems(itemsToSet);
|
||||
},
|
||||
[createCustomValue, id, ariaLabelledBy]
|
||||
);
|
||||
|
||||
// Memoize for using in fuzzy search
|
||||
const stringifiedItems = useMemo(
|
||||
() => (isAsync ? [] : options.map((item) => itemToString(item))),
|
||||
[options, isAsync]
|
||||
);
|
||||
const {
|
||||
options: filteredOptions,
|
||||
updateOptions,
|
||||
asyncLoading,
|
||||
asyncError,
|
||||
} = useOptions(props.options, createCustomValue);
|
||||
const isAsync = typeof allOptions === 'function';
|
||||
|
||||
const selectedItemIndex = useMemo(() => {
|
||||
if (isAsync) {
|
||||
@@ -184,13 +147,13 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
return null;
|
||||
}
|
||||
|
||||
const index = options.findIndex((option) => option.value === value);
|
||||
const index = allOptions.findIndex((option) => option.value === value);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return index;
|
||||
}, [valueProp, options, value, isAsync]);
|
||||
}, [valueProp, allOptions, value, isAsync]);
|
||||
|
||||
const selectedItem = useMemo(() => {
|
||||
if (valueProp === undefined || valueProp === null) {
|
||||
@@ -198,11 +161,11 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
}
|
||||
|
||||
if (selectedItemIndex !== null && !isAsync) {
|
||||
return options[selectedItemIndex];
|
||||
return allOptions[selectedItemIndex];
|
||||
}
|
||||
|
||||
return typeof valueProp === 'object' ? valueProp : { value: valueProp, label: valueProp.toString() };
|
||||
}, [selectedItemIndex, isAsync, valueProp, options]);
|
||||
}, [selectedItemIndex, isAsync, valueProp, allOptions]);
|
||||
|
||||
const menuId = `downshift-${useId().replace(/:/g, '--')}-menu`;
|
||||
const labelId = `downshift-${useId().replace(/:/g, '--')}-label`;
|
||||
@@ -210,33 +173,15 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
|
||||
const virtualizerOptions = {
|
||||
count: items.length,
|
||||
count: filteredOptions.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: (index: number) => (items[index].description ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT),
|
||||
estimateSize: (index: number) =>
|
||||
filteredOptions[index].description ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT,
|
||||
overscan: VIRTUAL_OVERSCAN_ITEMS,
|
||||
};
|
||||
|
||||
const rowVirtualizer = useVirtualizer(virtualizerOptions);
|
||||
|
||||
const debounceAsync = useMemo(
|
||||
() =>
|
||||
debounce((inputValue: string) => {
|
||||
loadOptions(inputValue)
|
||||
.then((opts) => {
|
||||
setItems(opts, inputValue);
|
||||
setAsyncLoading(false);
|
||||
setAsyncError(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!(err instanceof StaleResultError)) {
|
||||
setAsyncError(true);
|
||||
setAsyncLoading(false);
|
||||
}
|
||||
});
|
||||
}, 200),
|
||||
[loadOptions, setItems]
|
||||
);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
highlightedIndex,
|
||||
@@ -250,7 +195,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
menuId,
|
||||
labelId,
|
||||
inputId: id,
|
||||
items,
|
||||
items: filteredOptions,
|
||||
itemToString,
|
||||
selectedItem,
|
||||
|
||||
@@ -267,48 +212,9 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
|
||||
scrollIntoView: () => {},
|
||||
|
||||
onInputValueChange: ({ inputValue, isOpen }) => {
|
||||
if (!isOpen) {
|
||||
// Prevent stale options from showing on reopen
|
||||
if (isAsync) {
|
||||
setItems([], '');
|
||||
}
|
||||
|
||||
// Otherwise there's nothing else to do when the menu isnt open
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAsync) {
|
||||
const filteredItems = fuzzyFind(options, stringifiedItems, inputValue);
|
||||
setItems(filteredItems, inputValue);
|
||||
} else {
|
||||
if (inputValue && createCustomValue) {
|
||||
setItems([], inputValue);
|
||||
}
|
||||
|
||||
setAsyncLoading(true);
|
||||
debounceAsync(inputValue);
|
||||
}
|
||||
},
|
||||
|
||||
onIsOpenChange: ({ isOpen, inputValue }) => {
|
||||
// Loading async options mostly happens in onInputValueChange, but if the menu is opened with an empty input
|
||||
// then onInputValueChange isn't called (because the input value hasn't changed)
|
||||
if (isAsync && isOpen && inputValue === '') {
|
||||
setAsyncLoading(true);
|
||||
// TODO: dedupe this loading logic with debounceAsync
|
||||
loadOptions(inputValue)
|
||||
.then((opts) => {
|
||||
setItems(opts, inputValue);
|
||||
setAsyncLoading(false);
|
||||
setAsyncError(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!(err instanceof StaleResultError)) {
|
||||
setAsyncError(true);
|
||||
setAsyncLoading(false);
|
||||
}
|
||||
});
|
||||
if (isOpen && inputValue === '') {
|
||||
updateOptions(inputValue);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -317,7 +223,16 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
rowVirtualizer.scrollToIndex(highlightedIndex);
|
||||
}
|
||||
},
|
||||
onStateChange: ({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) => {
|
||||
switch (type) {
|
||||
case useCombobox.stateChangeTypes.InputChange:
|
||||
updateOptions(newInputValue ?? '');
|
||||
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
stateReducer(state, actionAndChanges) {
|
||||
let { changes } = actionAndChanges;
|
||||
const menuBeingOpened = state.isOpen === false && changes.isOpen === true;
|
||||
@@ -353,7 +268,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
},
|
||||
});
|
||||
|
||||
const { inputRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, isOpen);
|
||||
const { inputRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(filteredOptions, isOpen);
|
||||
|
||||
const isAutoSize = width === 'auto';
|
||||
|
||||
@@ -429,14 +344,16 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
{!asyncError && (
|
||||
<ul style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const item = filteredOptions[virtualRow.index];
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`${items[virtualRow.index].value}-${virtualRow.index}`}
|
||||
key={`${item.value}-${virtualRow.index}`}
|
||||
data-index={virtualRow.index}
|
||||
className={cx(
|
||||
styles.optionBasic,
|
||||
styles.option,
|
||||
selectedItem && items[virtualRow.index].value === selectedItem.value && styles.optionSelected,
|
||||
selectedItem && item.value === selectedItem.value && styles.optionSelected,
|
||||
highlightedIndex === virtualRow.index && styles.optionFocused
|
||||
)}
|
||||
style={{
|
||||
@@ -444,17 +361,13 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
{...getItemProps({
|
||||
item: items[virtualRow.index],
|
||||
item: item,
|
||||
index: virtualRow.index,
|
||||
})}
|
||||
>
|
||||
<div className={styles.optionBody}>
|
||||
<span className={styles.optionLabel}>
|
||||
{items[virtualRow.index].label ?? items[virtualRow.index].value}
|
||||
</span>
|
||||
{items[virtualRow.index].description && (
|
||||
<span className={styles.optionDescription}>{items[virtualRow.index].description}</span>
|
||||
)}
|
||||
<span className={styles.optionLabel}>{item.label ?? item.value}</span>
|
||||
{item.description && <span className={styles.optionDescription}>{item.description}</span>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -463,7 +376,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
)}
|
||||
<div aria-live="polite">
|
||||
{asyncError && <AsyncError />}
|
||||
{items.length === 0 && !asyncError && <NotFoundError />}
|
||||
{filteredOptions.length === 0 && !asyncError && <NotFoundError />}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
import { t } from '../../utils/i18n';
|
||||
|
||||
import { itemFilter } from './filter';
|
||||
import { fuzzyFind, itemToString } from './filter';
|
||||
import { ComboboxOption } from './types';
|
||||
import { StaleResultError, useLatestAsyncCall } from './useLatestAsyncCall';
|
||||
|
||||
@@ -83,14 +83,11 @@ export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T
|
||||
|
||||
const updateOptions = useCallback(
|
||||
(inputValue: string) => {
|
||||
if (!isAsync) {
|
||||
setUserTypedSearch(inputValue);
|
||||
return;
|
||||
setUserTypedSearch(inputValue);
|
||||
if (isAsync) {
|
||||
setAsyncLoading(true);
|
||||
debouncedLoadOptions(inputValue);
|
||||
}
|
||||
|
||||
setAsyncLoading(true);
|
||||
|
||||
debouncedLoadOptions(inputValue);
|
||||
},
|
||||
[debouncedLoadOptions, isAsync]
|
||||
);
|
||||
@@ -122,12 +119,16 @@ export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T
|
||||
return reorganizeOptions;
|
||||
}, []);
|
||||
|
||||
const stringifiedOptions = useMemo(() => {
|
||||
return isAsync ? [] : rawOptions.map(itemToString);
|
||||
}, [isAsync, rawOptions]);
|
||||
|
||||
const finalOptions = useMemo(() => {
|
||||
const currentOptions = isAsync ? asyncOptions : rawOptions.filter(itemFilter(userTypedSearch));
|
||||
const currentOptions = isAsync ? asyncOptions : fuzzyFind(rawOptions, stringifiedOptions, userTypedSearch);
|
||||
const currentOptionsOrganised = organizeOptionsByGroup(currentOptions);
|
||||
|
||||
return addCustomValue(currentOptionsOrganised);
|
||||
}, [isAsync, organizeOptionsByGroup, addCustomValue, asyncOptions, rawOptions, userTypedSearch]);
|
||||
}, [isAsync, organizeOptionsByGroup, addCustomValue, asyncOptions, rawOptions, userTypedSearch, stringifiedOptions]);
|
||||
|
||||
return { options: finalOptions, updateOptions, asyncLoading, asyncError };
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { Combobox } from '../Combobox/Combobox';
|
||||
import { ComboboxOption } from '../Combobox/types';
|
||||
|
||||
export interface Props {
|
||||
onChange: (weekStart: WeekStart) => void;
|
||||
value: string;
|
||||
onChange: (weekStart?: WeekStart) => void;
|
||||
value?: WeekStart;
|
||||
width?: number;
|
||||
autoFocus?: boolean;
|
||||
onBlur?: () => void;
|
||||
@@ -24,9 +24,9 @@ const weekStarts: ComboboxOption[] = [
|
||||
{ value: 'monday', label: 'Monday' },
|
||||
];
|
||||
|
||||
const isWeekStart = (value: string): value is WeekStart => {
|
||||
export function isWeekStart(value: string): value is WeekStart {
|
||||
return ['saturday', 'sunday', 'monday'].includes(value);
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -57,13 +57,13 @@ export const WeekStartPicker = (props: Props) => {
|
||||
const onChangeWeekStart = useCallback(
|
||||
(selectable: ComboboxOption | null) => {
|
||||
if (selectable && selectable.value !== undefined) {
|
||||
onChange(selectable.value as WeekStart);
|
||||
onChange(isWeekStart(selectable.value) ? selectable.value : undefined);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const selected = useMemo(() => weekStarts.find((item) => item.value === value)?.value ?? null, [value]);
|
||||
const selected = useMemo(() => weekStarts.find((item) => item.value === value)?.value ?? '', [value]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FeatureState } from '@grafana/data';
|
||||
|
||||
import { t } from '../../utils/i18n';
|
||||
import { Badge, BadgeProps } from '../Badge/Badge';
|
||||
|
||||
export interface FeatureBadgeProps {
|
||||
@@ -30,21 +31,28 @@ function getPanelStateBadgeDisplayModel(featureState: FeatureState): BadgeProps
|
||||
|
||||
case FeatureState.experimental:
|
||||
return {
|
||||
text: 'Experimental',
|
||||
text: t('grafana-ui.feature-badge.experimental', 'Experimental'),
|
||||
icon: 'exclamation-triangle',
|
||||
color: 'orange',
|
||||
};
|
||||
|
||||
case FeatureState.preview:
|
||||
return {
|
||||
text: 'Preview',
|
||||
text: t('grafana-ui.feature-badge.preview', 'Preview'),
|
||||
icon: 'rocket',
|
||||
color: 'blue',
|
||||
};
|
||||
|
||||
case FeatureState.privatePreview:
|
||||
return {
|
||||
text: 'Private preview',
|
||||
text: t('grafana-ui.feature-badge.private-preview', 'Private preview'),
|
||||
icon: 'rocket',
|
||||
color: 'blue',
|
||||
};
|
||||
|
||||
case FeatureState.new:
|
||||
return {
|
||||
text: t('grafana-ui.feature-badge.new', 'New!'),
|
||||
icon: 'rocket',
|
||||
color: 'blue',
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ export { TimePickerTooltip } from './DateTimePickers/TimeRangePicker';
|
||||
export { TimeRangeLabel } from './DateTimePickers/TimeRangePicker/TimeRangeLabel';
|
||||
export { TimeOfDayPicker } from './DateTimePickers/TimeOfDayPicker';
|
||||
export { TimeZonePicker } from './DateTimePickers/TimeZonePicker';
|
||||
export { WeekStartPicker, getWeekStart, type WeekStart } from './DateTimePickers/WeekStartPicker';
|
||||
export { WeekStartPicker, getWeekStart, type WeekStart, isWeekStart } from './DateTimePickers/WeekStartPicker';
|
||||
export { DatePicker, type DatePickerProps } from './DateTimePickers/DatePicker/DatePicker';
|
||||
export {
|
||||
DatePickerWithInput,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PanelOptionsEditorBuilder } from '@grafana/data';
|
||||
import { DataFrame, PanelOptionsEditorBuilder } from '@grafana/data';
|
||||
import { OptionsWithTooltip, TooltipDisplayMode, SortOrder } from '@grafana/schema';
|
||||
|
||||
/** @internal */
|
||||
@@ -94,6 +94,13 @@ export function addTooltipOptions<T extends OptionsWithTooltip>(
|
||||
settings: {
|
||||
integer: true,
|
||||
},
|
||||
showIf: (options: T) => options.tooltip?.mode === TooltipDisplayMode.Multi,
|
||||
showIf: (options: T, data: DataFrame[] | undefined, annotations: DataFrame[] | undefined) => {
|
||||
return (
|
||||
options.tooltip?.mode === TooltipDisplayMode.Multi ||
|
||||
annotations?.some((df) => {
|
||||
return df.meta?.custom?.resultType === 'exemplar';
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,20 +151,24 @@ func RegisterRBACAuthZService(
|
||||
reg prometheus.Registerer,
|
||||
cache cache.Cache,
|
||||
exchangeClient authnlib.TokenExchanger,
|
||||
folderAPIURL string,
|
||||
cfg RBACServerSettings,
|
||||
) {
|
||||
var folderStore store.FolderStore
|
||||
// FIXME: for now we default to using database read proxy for folders if the api url is not configured.
|
||||
// we should remove this and the sql implementation once we have verified that is works correctly
|
||||
if folderAPIURL == "" {
|
||||
if cfg.Folder.Host == "" {
|
||||
folderStore = store.NewSQLFolderStore(db, tracer)
|
||||
} else {
|
||||
folderStore = store.NewAPIFolderStore(tracer, func(ctx context.Context) (*rest.Config, error) {
|
||||
return &rest.Config{
|
||||
Host: folderAPIURL,
|
||||
Host: cfg.Folder.Host,
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper {
|
||||
return &tokenExhangeRoundTripper{te: exchangeClient, rt: rt}
|
||||
},
|
||||
TLSClientConfig: rest.TLSClientConfig{
|
||||
Insecure: cfg.Folder.Insecure,
|
||||
CAFile: cfg.Folder.CAFile,
|
||||
},
|
||||
QPS: 50,
|
||||
Burst: 100,
|
||||
}, nil
|
||||
|
||||
@@ -57,3 +57,16 @@ func readAuthzClientSettings(cfg *setting.Cfg) (*authzClientSettings, error) {
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type RBACServerSettings struct {
|
||||
Folder FolderAPISettings
|
||||
}
|
||||
|
||||
type FolderAPISettings struct {
|
||||
// Host is hostname for folder api
|
||||
Host string
|
||||
// Insecure will skip verification of ceritificates. Should only be used for testing
|
||||
Insecure bool
|
||||
// CAFile is a filepath to trusted root certificates for server
|
||||
CAFile string
|
||||
}
|
||||
|
||||
@@ -1789,6 +1789,15 @@ var (
|
||||
HideFromAdminPage: true,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "rendererDisableAppPluginsPreload",
|
||||
Description: "Disable pre-loading app plugins when the request is coming from the renderer",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaSharingSquad,
|
||||
HideFromAdminPage: true,
|
||||
HideFromDocs: true,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -237,3 +237,4 @@ pluginsCDNSyncLoader,experimental,@grafana/plugins-platform-backend,false,false,
|
||||
alertingJiraIntegration,experimental,@grafana/alerting-squad,false,false,true
|
||||
alertingRuleVersionHistoryRestore,experimental,@grafana/alerting-squad,false,false,true
|
||||
newShareReportDrawer,experimental,@grafana/sharing-squad,false,false,false
|
||||
rendererDisableAppPluginsPreload,experimental,@grafana/sharing-squad,false,false,true
|
||||
|
||||
|
@@ -958,4 +958,8 @@ const (
|
||||
// FlagNewShareReportDrawer
|
||||
// Enables the report creation drawer in a dashboard
|
||||
FlagNewShareReportDrawer = "newShareReportDrawer"
|
||||
|
||||
// FlagRendererDisableAppPluginsPreload
|
||||
// Disable pre-loading app plugins when the request is coming from the renderer
|
||||
FlagRendererDisableAppPluginsPreload = "rendererDisableAppPluginsPreload"
|
||||
)
|
||||
|
||||
@@ -3593,6 +3593,21 @@
|
||||
"hideFromAdminPage": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "rendererDisableAppPluginsPreload",
|
||||
"resourceVersion": "1740386710764",
|
||||
"creationTimestamp": "2025-02-24T08:45:10Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Disable pre-loading app plugins when the request is coming from the renderer",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/sharing-squad",
|
||||
"frontend": true,
|
||||
"hideFromAdminPage": true,
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "reportingRetries",
|
||||
|
||||
@@ -25,10 +25,11 @@ const backendType = "prometheus"
|
||||
|
||||
const (
|
||||
// Fixed error messages
|
||||
MimirDuplicateTimestampError = "err-mimir-sample-duplicate-timestamp"
|
||||
MimirInvalidLabelError = "err-mimir-label-invalid"
|
||||
MimirMaxSeriesPerUserError = "err-mimir-max-series-per-user"
|
||||
MimirLabelValueTooLongError = "err-mimir-label-value-too-long"
|
||||
MimirDuplicateTimestampError = "err-mimir-sample-duplicate-timestamp"
|
||||
MimirInvalidLabelError = "err-mimir-label-invalid"
|
||||
MimirLabelValueTooLongError = "err-mimir-label-value-too-long"
|
||||
MimirMaxLabelNamesPerSeriesError = "err-mimir-max-label-names-per-series"
|
||||
MimirMaxSeriesPerUserError = "err-mimir-max-series-per-user"
|
||||
|
||||
// Best effort error messages
|
||||
PrometheusDuplicateTimestampError = "duplicate sample for timestamp"
|
||||
@@ -267,16 +268,12 @@ func checkWriteError(writeErr promremote.WriteError) (err error, ignored bool) {
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(msg, MimirInvalidLabelError) {
|
||||
return errors.Join(ErrRejectedWrite, writeErr), false
|
||||
}
|
||||
|
||||
// this can happen when user exceeded defined maximum of
|
||||
if strings.Contains(msg, MimirMaxSeriesPerUserError) {
|
||||
return errors.Join(ErrRejectedWrite, writeErr), false
|
||||
}
|
||||
|
||||
if strings.Contains(msg, MimirLabelValueTooLongError) {
|
||||
// Check for expected user errors.
|
||||
switch {
|
||||
case strings.Contains(msg, MimirInvalidLabelError),
|
||||
strings.Contains(msg, MimirMaxSeriesPerUserError),
|
||||
strings.Contains(msg, MimirMaxLabelNamesPerSeriesError),
|
||||
strings.Contains(msg, MimirLabelValueTooLongError):
|
||||
return errors.Join(ErrRejectedWrite, writeErr), false
|
||||
}
|
||||
|
||||
|
||||
@@ -240,7 +240,23 @@ func TestPrometheusWriter_Write(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("too long labels fit under the client error category", func(t *testing.T) {
|
||||
msg := "received a series whose label value length exceeds the limit, label: 'label-1', value: 'value-1' (truncated) series: 'some_series (err-mimir-label-value-too-long). To adjust the related per-tenant limit, configure -validation.max-length-label-value, or contact your service administrator."
|
||||
msg := "received a series whose label value length exceeds the limit, label: 'label-1', value: 'value-1' (truncated) series: 'some_series' (err-mimir-label-value-too-long). To adjust the related per-tenant limit, configure -validation.max-length-label-value, or contact your service administrator."
|
||||
clientErr := testClientWriteError{
|
||||
statusCode: http.StatusBadRequest,
|
||||
msg: &msg,
|
||||
}
|
||||
client.writeSeriesFunc = func(ctx context.Context, ts promremote.TSList, opts promremote.WriteOptions) (promremote.WriteResult, promremote.WriteError) {
|
||||
return promremote.WriteResult{}, clientErr
|
||||
}
|
||||
|
||||
err := writer.Write(ctx, "test", now, frames, 1, map[string]string{"extra": "label"})
|
||||
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrRejectedWrite)
|
||||
})
|
||||
|
||||
t.Run("too many labels fit under the client error category", func(t *testing.T) {
|
||||
msg := "received a series whose number of labels exceeds the limit (actual: 50, limit: 40) series: 'some_series' (err-mimir-max-label-names-per-series). To adjust the related per-tenant limit, configure -validation.max-label-names-per-series, or contact your service administrator."
|
||||
clientErr := testClientWriteError{
|
||||
statusCode: http.StatusBadRequest,
|
||||
msg: &msg,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package sql
|
||||
package resource
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
@@ -0,0 +1,15 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestContinueToken(t *testing.T) {
|
||||
token := &ContinueToken{
|
||||
ResourceVersion: 100,
|
||||
StartOffset: 50,
|
||||
}
|
||||
assert.Equal(t, "eyJvIjo1MCwidiI6MTAwfQ==", token.String())
|
||||
}
|
||||
@@ -923,6 +923,10 @@ func (s *server) initWatcher() error {
|
||||
// pipe all events
|
||||
v := <-events
|
||||
|
||||
if v == nil {
|
||||
s.log.Error("received nil event")
|
||||
continue
|
||||
}
|
||||
// Skip events during batch updates
|
||||
if v.PreviousRV < 0 {
|
||||
continue
|
||||
|
||||
@@ -588,11 +588,11 @@ type listIter struct {
|
||||
|
||||
// ContinueToken implements resource.ListIterator.
|
||||
func (l *listIter) ContinueToken() string {
|
||||
return ContinueToken{ResourceVersion: l.listRV, StartOffset: l.offset}.String()
|
||||
return resource.ContinueToken{ResourceVersion: l.listRV, StartOffset: l.offset}.String()
|
||||
}
|
||||
|
||||
func (l *listIter) ContinueTokenWithCurrentRV() string {
|
||||
return ContinueToken{ResourceVersion: l.rv, StartOffset: l.offset}.String()
|
||||
return resource.ContinueToken{ResourceVersion: l.rv, StartOffset: l.offset}.String()
|
||||
}
|
||||
|
||||
func (l *listIter) Error() error {
|
||||
@@ -679,7 +679,7 @@ func (b *backend) listAtRevision(ctx context.Context, req *resource.ListRequest,
|
||||
// Get the RV
|
||||
iter := &listIter{listRV: req.ResourceVersion}
|
||||
if req.NextPageToken != "" {
|
||||
continueToken, err := GetContinueToken(req.NextPageToken)
|
||||
continueToken, err := resource.GetContinueToken(req.NextPageToken)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get continue token: %w", err)
|
||||
}
|
||||
@@ -737,7 +737,7 @@ func (b *backend) getHistory(ctx context.Context, req *resource.ListRequest, cb
|
||||
|
||||
iter := &listIter{}
|
||||
if req.NextPageToken != "" {
|
||||
continueToken, err := GetContinueToken(req.NextPageToken)
|
||||
continueToken, err := resource.GetContinueToken(req.NextPageToken)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get continue token: %w", err)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
)
|
||||
|
||||
@@ -34,6 +33,7 @@ type NewBackendFunc func(ctx context.Context) resource.StorageBackend
|
||||
// TestOptions configures which tests to run
|
||||
type TestOptions struct {
|
||||
SkipTests map[string]bool // tests to skip
|
||||
|
||||
}
|
||||
|
||||
// RunStorageBackendTest runs the storage backend test suite
|
||||
@@ -294,21 +294,29 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend
|
||||
server := newServer(t, backend)
|
||||
|
||||
// Create a few resources before starting the watch
|
||||
rv1, _ := writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED)
|
||||
rv1, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv1, int64(0))
|
||||
rv2, _ := writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED)
|
||||
rv2, err := writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv2, rv1)
|
||||
rv3, _ := writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED)
|
||||
rv3, err := writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv3, rv2)
|
||||
rv4, _ := writeEvent(ctx, backend, "item4", resource.WatchEvent_ADDED)
|
||||
rv4, err := writeEvent(ctx, backend, "item4", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv4, rv3)
|
||||
rv5, _ := writeEvent(ctx, backend, "item5", resource.WatchEvent_ADDED)
|
||||
rv5, err := writeEvent(ctx, backend, "item5", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv5, rv4)
|
||||
rv6, _ := writeEvent(ctx, backend, "item2", resource.WatchEvent_MODIFIED)
|
||||
rv6, err := writeEvent(ctx, backend, "item2", resource.WatchEvent_MODIFIED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv6, rv5)
|
||||
rv7, _ := writeEvent(ctx, backend, "item3", resource.WatchEvent_DELETED)
|
||||
rv7, err := writeEvent(ctx, backend, "item3", resource.WatchEvent_DELETED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv7, rv6)
|
||||
rv8, _ := writeEvent(ctx, backend, "item6", resource.WatchEvent_ADDED)
|
||||
rv8, err := writeEvent(ctx, backend, "item6", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv8, rv7)
|
||||
|
||||
t.Run("fetch all latest", func(t *testing.T) {
|
||||
@@ -346,12 +354,12 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, res.Error)
|
||||
require.Len(t, res.Items, 3)
|
||||
continueToken, err := sql.GetContinueToken(res.NextPageToken)
|
||||
continueToken, err := resource.GetContinueToken(res.NextPageToken)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "item1 ADDED", string(res.Items[0].Value))
|
||||
require.Equal(t, "item2 MODIFIED", string(res.Items[1].Value))
|
||||
require.Equal(t, "item4 ADDED", string(res.Items[2].Value))
|
||||
require.Equal(t, rv8, continueToken.ResourceVersion)
|
||||
require.GreaterOrEqual(t, continueToken.ResourceVersion, rv8)
|
||||
})
|
||||
|
||||
t.Run("list at revision", func(t *testing.T) {
|
||||
@@ -394,13 +402,13 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend
|
||||
require.Equal(t, "item2 MODIFIED", string(res.Items[1].Value))
|
||||
require.Equal(t, "item4 ADDED", string(res.Items[2].Value))
|
||||
|
||||
continueToken, err := sql.GetContinueToken(res.NextPageToken)
|
||||
continueToken, err := resource.GetContinueToken(res.NextPageToken)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rv7, continueToken.ResourceVersion)
|
||||
})
|
||||
|
||||
t.Run("fetch second page at revision", func(t *testing.T) {
|
||||
continueToken := &sql.ContinueToken{
|
||||
continueToken := &resource.ContinueToken{
|
||||
ResourceVersion: rv8,
|
||||
StartOffset: 2,
|
||||
}
|
||||
@@ -421,7 +429,7 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend
|
||||
require.Equal(t, "item4 ADDED", string(res.Items[0].Value))
|
||||
require.Equal(t, "item5 ADDED", string(res.Items[1].Value))
|
||||
|
||||
continueToken, err = sql.GetContinueToken(res.NextPageToken)
|
||||
continueToken, err = resource.GetContinueToken(res.NextPageToken)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rv8, continueToken.ResourceVersion)
|
||||
require.Equal(t, int64(4), continueToken.StartOffset)
|
||||
@@ -478,14 +486,14 @@ func runTestIntegrationBackendListHistory(t *testing.T, backend resource.Storage
|
||||
require.Equal(t, "item1 MODIFIED", string(res.Items[2].Value))
|
||||
require.Equal(t, rvHistory3, res.Items[2].ResourceVersion)
|
||||
|
||||
continueToken, err := sql.GetContinueToken(res.NextPageToken)
|
||||
continueToken, err := resource.GetContinueToken(res.NextPageToken)
|
||||
require.NoError(t, err)
|
||||
// should return the furthest back RV as the next page token
|
||||
require.Equal(t, rvHistory3, continueToken.ResourceVersion)
|
||||
})
|
||||
|
||||
t.Run("fetch second page of history at revision", func(t *testing.T) {
|
||||
continueToken := &sql.ContinueToken{
|
||||
continueToken := &resource.ContinueToken{
|
||||
ResourceVersion: rvHistory3,
|
||||
StartOffset: 2,
|
||||
}
|
||||
|
||||
+4
-1
@@ -205,7 +205,10 @@ export class GrafanaApp {
|
||||
setDataSourceSrv(dataSourceSrv);
|
||||
initWindowRuntime();
|
||||
|
||||
if (contextSrv.user.orgRole !== '') {
|
||||
// Do not pre-load apps if rendererDisableAppPluginsPreload is true and the request comes from the image renderer
|
||||
const skipAppPluginsPreload =
|
||||
config.featureToggles.rendererDisableAppPluginsPreload && contextSrv.user.authenticatedBy === 'render';
|
||||
if (contextSrv.user.orgRole !== '' && !skipAppPluginsPreload) {
|
||||
const appPluginsToAwait = getAppPluginsToAwait();
|
||||
const appPluginsToPreload = getAppPluginsToPreload();
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@ import * as React from 'react';
|
||||
import { useLocation } from 'react-router-dom-v5-compat';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, NavModelItem, toIconName } from '@grafana/data';
|
||||
import { useStyles2, Text, IconButton, Icon, Stack, Badge } from '@grafana/ui';
|
||||
import { FeatureState, GrafanaTheme2, NavModelItem, toIconName } from '@grafana/data';
|
||||
import { useStyles2, Text, IconButton, Icon, Stack, FeatureBadge } from '@grafana/ui';
|
||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { Indent } from '../../Indent/Indent';
|
||||
|
||||
@@ -108,7 +107,7 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick, onPin, isPi
|
||||
>
|
||||
{level === 0 && iconElement && <FeatureHighlightWrapper>{iconElement}</FeatureHighlightWrapper>}
|
||||
<Text truncate>{link.text}</Text>
|
||||
{link.isNew && <Badge text={t('navigation.megamenu.new', 'New!')} color={'blue'} />}
|
||||
{link.isNew && <FeatureBadge featureState={FeatureState.new} />}
|
||||
</div>
|
||||
</MegaMenuItemText>
|
||||
</div>
|
||||
|
||||
@@ -18,13 +18,14 @@ import {
|
||||
Combobox,
|
||||
ComboboxOption,
|
||||
TextLink,
|
||||
WeekStart,
|
||||
isWeekStart,
|
||||
} from '@grafana/ui';
|
||||
import { DashboardPicker } from 'app/core/components/Select/DashboardPicker';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { LANGUAGES, PSEUDO_LOCALE } from 'app/core/internationalization/constants';
|
||||
import { PreferencesService } from 'app/core/services/PreferencesService';
|
||||
import { changeTheme } from 'app/core/services/theme';
|
||||
|
||||
export interface Props {
|
||||
resourceUri: string;
|
||||
disabled?: boolean;
|
||||
@@ -152,8 +153,8 @@ export class SharedPreferences extends PureComponent<Props, State> {
|
||||
this.setState({ timezone: timezone });
|
||||
};
|
||||
|
||||
onWeekStartChanged = (weekStart: string) => {
|
||||
this.setState({ weekStart: weekStart });
|
||||
onWeekStartChanged = (weekStart?: WeekStart) => {
|
||||
this.setState({ weekStart: weekStart ?? '' });
|
||||
};
|
||||
|
||||
onHomeDashboardChanged = (dashboardUID: string) => {
|
||||
@@ -249,7 +250,7 @@ export class SharedPreferences extends PureComponent<Props, State> {
|
||||
data-testid={selectors.components.WeekStartPicker.containerV2}
|
||||
>
|
||||
<WeekStartPicker
|
||||
value={weekStart || ''}
|
||||
value={weekStart && isWeekStart(weekStart) ? weekStart : undefined}
|
||||
onChange={this.onWeekStartChanged}
|
||||
inputId="shared-preferences-week-start-picker"
|
||||
/>
|
||||
|
||||
@@ -1,35 +1,23 @@
|
||||
import { useEffect } from 'react';
|
||||
import { skipToken } from '@reduxjs/toolkit/query/react';
|
||||
|
||||
import { FolderDTO, useDispatch } from 'app/types';
|
||||
|
||||
import { fetchFolderIfNotFetchedAction } from '../state/actions';
|
||||
import { initialAsyncRequestState } from '../utils/redux';
|
||||
|
||||
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
|
||||
import { useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
import { FolderDTO } from 'app/types';
|
||||
|
||||
interface ReturnBag {
|
||||
folder?: FolderDTO;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a folderDTO for the given uid – uses cached values
|
||||
* @TODO propagate error state
|
||||
*/
|
||||
export function useFolder(uid?: string): ReturnBag {
|
||||
const dispatch = useDispatch();
|
||||
const folderRequests = useUnifiedAlertingSelector((state) => state.folders);
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
dispatch(fetchFolderIfNotFetchedAction(uid));
|
||||
}
|
||||
}, [dispatch, uid]);
|
||||
const fetchFolderState = useGetFolderQuery(uid || skipToken);
|
||||
|
||||
if (uid) {
|
||||
const request = folderRequests[uid] || initialAsyncRequestState;
|
||||
return {
|
||||
folder: request.result,
|
||||
loading: request.loading,
|
||||
};
|
||||
}
|
||||
return {
|
||||
loading: false,
|
||||
loading: fetchFolderState.isLoading,
|
||||
folder: fetchFolderState.data,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +27,6 @@ export function stringifyFolder({ title, parents }: FolderDTO) {
|
||||
: encodeTitle(title);
|
||||
}
|
||||
|
||||
export function encodeTitle(title: string): string {
|
||||
function encodeTitle(title: string): string {
|
||||
return title.replaceAll('/', '\\/');
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ function useStateHistoryModal() {
|
||||
<Suspense fallback={'Loading...'}>
|
||||
{implementation === StateHistoryImplementation.Loki && <LokiStateHistory ruleUID={rule.grafana_alert.uid} />}
|
||||
{implementation === StateHistoryImplementation.Annotations && (
|
||||
<AnnotationsStateHistory ruleUID={rule.grafana_alert.id ?? ''} />
|
||||
<AnnotationsStateHistory ruleUID={rule.grafana_alert.uid ?? ''} />
|
||||
)}
|
||||
</Suspense>
|
||||
</Modal>
|
||||
|
||||
@@ -10,11 +10,10 @@ import {
|
||||
Receiver,
|
||||
TestReceiversAlert,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { FolderDTO, ThunkResult } from 'app/types';
|
||||
import { ThunkResult } from 'app/types';
|
||||
import { RuleIdentifier, RuleNamespace, StateHistoryItem } from 'app/types/unified-alerting';
|
||||
import { RulerRuleDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { backendSrv } from '../../../../core/services/backend_srv';
|
||||
import { withPromRulesMetadataLogging, withRulerRulesMetadataLogging } from '../Analytics';
|
||||
import {
|
||||
deleteAlertManagerConfig,
|
||||
@@ -241,19 +240,6 @@ export const updateAlertManagerConfigAction = createAsyncThunk<void, UpdateAlert
|
||||
)
|
||||
);
|
||||
|
||||
export const fetchFolderAction = createAsyncThunk(
|
||||
'unifiedalerting/fetchFolder',
|
||||
(uid: string): Promise<FolderDTO> => withSerializedError(backendSrv.getFolderByUid(uid, { withAccessControl: true }))
|
||||
);
|
||||
|
||||
export const fetchFolderIfNotFetchedAction = (uid: string): ThunkResult<void> => {
|
||||
return (dispatch, getState) => {
|
||||
if (!getState().unifiedAlerting.folders[uid]?.dispatched) {
|
||||
dispatch(fetchFolderAction(uid));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchAlertGroupsAction = createAsyncThunk(
|
||||
'unifiedalerting/fetchAlertGroups',
|
||||
(alertManagerSourceName: string): Promise<AlertmanagerGroup[]> => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { createAsyncMapSlice, createAsyncSlice } from '../utils/redux';
|
||||
import {
|
||||
deleteAlertManagerConfigAction,
|
||||
fetchAlertGroupsAction,
|
||||
fetchFolderAction,
|
||||
fetchGrafanaAnnotationsAction,
|
||||
fetchPromRulesAction,
|
||||
fetchRulerRulesAction,
|
||||
@@ -19,7 +18,6 @@ export const reducer = combineReducers({
|
||||
.reducer,
|
||||
saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer,
|
||||
deleteAMConfig: createAsyncSlice('deleteAMConfig', deleteAlertManagerConfigAction).reducer,
|
||||
folders: createAsyncMapSlice('folders', fetchFolderAction, (uid) => uid).reducer,
|
||||
amAlertGroups: createAsyncMapSlice(
|
||||
'amAlertGroups',
|
||||
fetchAlertGroupsAction,
|
||||
|
||||
@@ -400,6 +400,14 @@ export const cloudNotifierTypes: Array<NotifierDTO<CloudNotifierType>> = [
|
||||
},
|
||||
}
|
||||
),
|
||||
option(
|
||||
'timeout',
|
||||
'Timeout',
|
||||
'The maximum time to wait for a webhook request to complete, before failing the request and allowing it to be retried. The default value of 0s indicates that no timeout should be applied. NOTE: This will have no effect if set higher than the group_interval.',
|
||||
{
|
||||
placeholder: 'Use duration format, for example: 1.2s, 100ms',
|
||||
}
|
||||
),
|
||||
httpConfigOption,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -451,6 +451,7 @@ export const {
|
||||
useDeleteItemsMutation,
|
||||
useGetAffectedItemsQuery,
|
||||
useGetFolderQuery,
|
||||
useLazyGetFolderQuery,
|
||||
useMoveFolderMutation,
|
||||
useMoveItemsMutation,
|
||||
useNewFolderMutation,
|
||||
|
||||
-1
@@ -334,7 +334,6 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
|
||||
"title": "Repeating rows",
|
||||
"uid": "Repeating-rows-uid",
|
||||
"version": 1,
|
||||
"weekStart": "",
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
SceneInteractionProfileEvent,
|
||||
SceneObjectState,
|
||||
} from '@grafana/scenes';
|
||||
import { isWeekStart } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||
@@ -274,7 +275,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
|
||||
to: oldModel.time.to,
|
||||
fiscalYearStartMonth: oldModel.fiscalYearStartMonth,
|
||||
timeZone: oldModel.timezone,
|
||||
weekStart: oldModel.weekStart,
|
||||
weekStart: isWeekStart(oldModel.weekStart) ? oldModel.weekStart : undefined,
|
||||
UNSAFE_nowDelay: oldModel.timepicker?.nowDelay,
|
||||
}),
|
||||
$variables: variables,
|
||||
|
||||
@@ -123,7 +123,7 @@ export class GeneralSettingsEditView
|
||||
});
|
||||
};
|
||||
|
||||
public onWeekStartChange = (value: WeekStart) => {
|
||||
public onWeekStartChange = (value?: WeekStart) => {
|
||||
this.getTimeRange().setState({ weekStart: value });
|
||||
};
|
||||
|
||||
@@ -258,7 +258,7 @@ export class GeneralSettingsEditView
|
||||
nowDelay={nowDelay || ''}
|
||||
liveNow={liveNow}
|
||||
timezone={timeZone || ''}
|
||||
weekStart={weekStart || ''}
|
||||
weekStart={weekStart}
|
||||
/>
|
||||
|
||||
{/* @todo: Update "Graph tooltip" description to remove prompt about reloading when resolving #46581 */}
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
GridLayoutItemKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
|
||||
import { DashboardLink, DataTransformerConfig } from '@grafana/schema/src/raw/dashboard/x/dashboard_types.gen';
|
||||
import { WeekStart } from '@grafana/ui';
|
||||
import { isWeekStart, WeekStart } from '@grafana/ui';
|
||||
import {
|
||||
AnnoKeyCreatedBy,
|
||||
AnnoKeyDashboardGnetId,
|
||||
@@ -161,8 +161,7 @@ export function ensureV2Response(
|
||||
fiscalYearStartMonth: dashboard.fiscalYearStartMonth || timeSettingsDefaults.fiscalYearStartMonth,
|
||||
hideTimepicker: dashboard.timepicker?.hidden || timeSettingsDefaults.hideTimepicker,
|
||||
quickRanges: dashboard.timepicker?.quick_ranges,
|
||||
// casting WeekStart here to avoid editing old schema
|
||||
weekStart: (dashboard.weekStart as WeekStart) || timeSettingsDefaults.weekStart,
|
||||
weekStart: getWeekStart(dashboard.weekStart, timeSettingsDefaults.weekStart),
|
||||
nowDelay: dashboard.timepicker?.nowDelay || timeSettingsDefaults.nowDelay,
|
||||
},
|
||||
links: dashboard.links || [],
|
||||
@@ -332,6 +331,13 @@ function isRowPanel(panel: Panel | RowPanel): panel is RowPanel {
|
||||
return panel.type === 'row';
|
||||
}
|
||||
|
||||
function getWeekStart(weekStart?: string, defaultWeekStart?: WeekStart): WeekStart | undefined {
|
||||
if (!weekStart || !isWeekStart(weekStart)) {
|
||||
return defaultWeekStart;
|
||||
}
|
||||
return weekStart;
|
||||
}
|
||||
|
||||
function buildRowKind(p: RowPanel, elements: GridLayoutItemKind[]): GridLayoutRowKind {
|
||||
return {
|
||||
kind: 'GridLayoutRow',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Unsubscribable } from 'rxjs';
|
||||
|
||||
import { dateMath, TimeRange, TimeZone } from '@grafana/data';
|
||||
import { TimeRangeUpdatedEvent } from '@grafana/runtime';
|
||||
import { defaultIntervals, RefreshPicker } from '@grafana/ui';
|
||||
import { defaultIntervals, isWeekStart, RefreshPicker } from '@grafana/ui';
|
||||
import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePickerWithHistory';
|
||||
import { appEvents } from 'app/core/core';
|
||||
import { t } from 'app/core/internationalization';
|
||||
@@ -121,7 +121,7 @@ export class DashNavTimeControls extends Component<Props> {
|
||||
onChangeFiscalYearStartMonth={this.onChangeFiscalYearStartMonth}
|
||||
isOnCanvas={isOnCanvas}
|
||||
onToolbarTimePickerClick={this.props.onToolbarTimePickerClick}
|
||||
weekStart={weekStart}
|
||||
weekStart={isWeekStart(weekStart) ? weekStart : undefined}
|
||||
quickRanges={quick_ranges}
|
||||
/>
|
||||
<RefreshPicker
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TextArea,
|
||||
Box,
|
||||
Stack,
|
||||
WeekStart,
|
||||
} from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||
@@ -97,7 +98,7 @@ export function GeneralSettingsUnconnected({
|
||||
updateTimeZone(timeZone);
|
||||
};
|
||||
|
||||
const onWeekStartChange = (weekStart: string) => {
|
||||
const onWeekStartChange = (weekStart?: WeekStart) => {
|
||||
dashboard.weekStart = weekStart;
|
||||
setRenderCounter(renderCounter + 1);
|
||||
updateWeekStart(weekStart);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { t } from 'app/core/internationalization';
|
||||
import { AutoRefreshIntervals } from './AutoRefreshIntervals';
|
||||
|
||||
interface Props {
|
||||
onWeekStartChange: (weekStart: WeekStart) => void;
|
||||
onWeekStartChange: (weekStart?: WeekStart) => void;
|
||||
onTimeZoneChange: (timeZone: TimeZone) => void;
|
||||
onRefreshIntervalChange: (interval: string[]) => void;
|
||||
onNowDelayChange: (nowDelay: string) => void;
|
||||
@@ -20,7 +20,7 @@ interface Props {
|
||||
timePickerHidden?: boolean;
|
||||
nowDelay?: string;
|
||||
timezone: TimeZone;
|
||||
weekStart: string;
|
||||
weekStart?: WeekStart;
|
||||
liveNow?: boolean;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export class TimePickerSettings extends PureComponent<Props, State> {
|
||||
this.props.onTimeZoneChange(timeZone);
|
||||
};
|
||||
|
||||
onWeekStartChange = (weekStart: WeekStart) => {
|
||||
onWeekStartChange = (weekStart?: WeekStart) => {
|
||||
this.props.onWeekStartChange(weekStart);
|
||||
};
|
||||
|
||||
|
||||
+385
-146
@@ -1,162 +1,401 @@
|
||||
import { EventBusSrv, FieldType, getDefaultTimeRange, LoadingState, toDataFrame } from '@grafana/data';
|
||||
import {
|
||||
EventBusSrv,
|
||||
FieldConfigOptionsRegistry,
|
||||
FieldConfigPropertyItem,
|
||||
FieldType,
|
||||
getDefaultTimeRange,
|
||||
LoadingState,
|
||||
PanelPlugin,
|
||||
Registry,
|
||||
toDataFrame,
|
||||
} from '@grafana/data';
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
|
||||
import { getStandardEditorContext } from './getVisualizationOptions';
|
||||
import { getStandardEditorContext, getVisualizationOptions2 } from './getVisualizationOptions';
|
||||
|
||||
describe('getStandardEditorContext', () => {
|
||||
it('defaults the series data to an empty array', () => {
|
||||
const editorContext = getStandardEditorContext({
|
||||
data: undefined,
|
||||
replaceVariables: jest.fn(),
|
||||
options: {},
|
||||
eventBus: new EventBusSrv(),
|
||||
instanceState: {},
|
||||
describe('getVisualizationOptions', () => {
|
||||
describe('getStandardEditorContext', () => {
|
||||
it('defaults the series data to an empty array', () => {
|
||||
const editorContext = getStandardEditorContext({
|
||||
data: undefined,
|
||||
replaceVariables: jest.fn(),
|
||||
options: {},
|
||||
eventBus: new EventBusSrv(),
|
||||
instanceState: {},
|
||||
});
|
||||
|
||||
expect(editorContext.data).toEqual([]);
|
||||
});
|
||||
|
||||
expect(editorContext.data).toEqual([]);
|
||||
});
|
||||
it('returns suggestions for empty data', () => {
|
||||
const editorContext = getStandardEditorContext({
|
||||
data: undefined,
|
||||
replaceVariables: jest.fn(),
|
||||
options: {},
|
||||
eventBus: new EventBusSrv(),
|
||||
instanceState: {},
|
||||
});
|
||||
|
||||
it('returns suggestions for empty data', () => {
|
||||
const editorContext = getStandardEditorContext({
|
||||
data: undefined,
|
||||
replaceVariables: jest.fn(),
|
||||
options: {},
|
||||
eventBus: new EventBusSrv(),
|
||||
instanceState: {},
|
||||
expect(editorContext.getSuggestions).toBeDefined();
|
||||
expect(editorContext.getSuggestions?.()).toEqual([
|
||||
{
|
||||
documentation: 'Name of the series',
|
||||
label: 'Name',
|
||||
origin: 'series',
|
||||
value: '__series.name',
|
||||
},
|
||||
{
|
||||
documentation: 'Field name of the clicked datapoint (in ms epoch)',
|
||||
label: 'Name',
|
||||
origin: 'field',
|
||||
value: '__field.name',
|
||||
},
|
||||
{
|
||||
documentation: 'Adds current variables',
|
||||
label: 'All variables',
|
||||
origin: 'template',
|
||||
value: '__all_variables',
|
||||
},
|
||||
{
|
||||
documentation: 'Adds current time range',
|
||||
label: 'Time range',
|
||||
origin: 'built-in',
|
||||
value: '__url_time_range',
|
||||
},
|
||||
{
|
||||
documentation: "Adds current time range's from value",
|
||||
label: 'Time range: from',
|
||||
origin: 'built-in',
|
||||
value: '__from',
|
||||
},
|
||||
{
|
||||
documentation: "Adds current time range's to value",
|
||||
label: 'Time range: to',
|
||||
origin: 'built-in',
|
||||
value: '__to',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
expect(editorContext.getSuggestions).toBeDefined();
|
||||
expect(editorContext.getSuggestions?.()).toEqual([
|
||||
{
|
||||
documentation: 'Name of the series',
|
||||
label: 'Name',
|
||||
origin: 'series',
|
||||
value: '__series.name',
|
||||
},
|
||||
{
|
||||
documentation: 'Field name of the clicked datapoint (in ms epoch)',
|
||||
label: 'Name',
|
||||
origin: 'field',
|
||||
value: '__field.name',
|
||||
},
|
||||
{
|
||||
documentation: 'Adds current variables',
|
||||
label: 'All variables',
|
||||
origin: 'template',
|
||||
value: '__all_variables',
|
||||
},
|
||||
{
|
||||
documentation: 'Adds current time range',
|
||||
label: 'Time range',
|
||||
origin: 'built-in',
|
||||
value: '__url_time_range',
|
||||
},
|
||||
{
|
||||
documentation: "Adds current time range's from value",
|
||||
label: 'Time range: from',
|
||||
origin: 'built-in',
|
||||
value: '__from',
|
||||
},
|
||||
{
|
||||
documentation: "Adds current time range's to value",
|
||||
label: 'Time range: to',
|
||||
origin: 'built-in',
|
||||
value: '__to',
|
||||
},
|
||||
]);
|
||||
it('returns suggestions for non-empty data', () => {
|
||||
const series = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time },
|
||||
{ name: 'score', type: FieldType.number },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const panelData = {
|
||||
series,
|
||||
timeRange: getDefaultTimeRange(),
|
||||
state: LoadingState.Done,
|
||||
};
|
||||
|
||||
const editorContext = getStandardEditorContext({
|
||||
data: panelData,
|
||||
replaceVariables: jest.fn(),
|
||||
options: {},
|
||||
eventBus: new EventBusSrv(),
|
||||
instanceState: {},
|
||||
});
|
||||
|
||||
expect(editorContext.getSuggestions).toBeDefined();
|
||||
expect(editorContext.getSuggestions?.()).toEqual([
|
||||
{
|
||||
documentation: 'Name of the series',
|
||||
label: 'Name',
|
||||
origin: 'series',
|
||||
value: '__series.name',
|
||||
},
|
||||
{
|
||||
documentation: 'Field name of the clicked datapoint (in ms epoch)',
|
||||
label: 'Name',
|
||||
origin: 'field',
|
||||
value: '__field.name',
|
||||
},
|
||||
{
|
||||
documentation: 'Formatted value for time on the same row',
|
||||
label: 'time',
|
||||
origin: 'fields',
|
||||
value: '__data.fields.time',
|
||||
},
|
||||
{
|
||||
documentation: 'Formatted value for score on the same row',
|
||||
label: 'score',
|
||||
origin: 'fields',
|
||||
value: '__data.fields.score',
|
||||
},
|
||||
{
|
||||
documentation: 'Enter the field order',
|
||||
label: 'Select by index',
|
||||
origin: 'fields',
|
||||
value: '__data.fields[0]',
|
||||
},
|
||||
{
|
||||
documentation: 'the numeric field value',
|
||||
label: 'Show numeric value',
|
||||
origin: 'fields',
|
||||
value: '__data.fields.score.numeric',
|
||||
},
|
||||
{
|
||||
documentation: 'the text value',
|
||||
label: 'Show text value',
|
||||
origin: 'fields',
|
||||
value: '__data.fields.score.text',
|
||||
},
|
||||
{
|
||||
documentation: 'Adds current variables',
|
||||
label: 'All variables',
|
||||
origin: 'template',
|
||||
value: '__all_variables',
|
||||
},
|
||||
{
|
||||
documentation: 'Adds current time range',
|
||||
label: 'Time range',
|
||||
origin: 'built-in',
|
||||
value: '__url_time_range',
|
||||
},
|
||||
{
|
||||
documentation: "Adds current time range's from value",
|
||||
label: 'Time range: from',
|
||||
origin: 'built-in',
|
||||
value: '__from',
|
||||
},
|
||||
{
|
||||
documentation: "Adds current time range's to value",
|
||||
label: 'Time range: to',
|
||||
origin: 'built-in',
|
||||
value: '__to',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns suggestions for non-empty data', () => {
|
||||
const series = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time },
|
||||
{ name: 'score', type: FieldType.number },
|
||||
],
|
||||
}),
|
||||
];
|
||||
describe('getVisualizationOptions2', () => {
|
||||
it('should create an options list with the right number of categories and items', () => {
|
||||
const vizPanel = new VizPanel({
|
||||
title: 'Panel A',
|
||||
pluginId: 'timeseries',
|
||||
key: 'panel-12',
|
||||
});
|
||||
|
||||
const panelData = {
|
||||
series,
|
||||
timeRange: getDefaultTimeRange(),
|
||||
state: LoadingState.Done,
|
||||
const property1: FieldConfigPropertyItem = {
|
||||
id: 'custom.property1', // Match field properties
|
||||
path: 'property1', // Match field properties
|
||||
isCustom: true,
|
||||
process: (value) => value,
|
||||
shouldApply: () => true,
|
||||
override: jest.fn(),
|
||||
editor: jest.fn(),
|
||||
name: 'Property 1',
|
||||
};
|
||||
|
||||
const property2: FieldConfigPropertyItem = {
|
||||
id: 'custom.property2', // Match field properties
|
||||
path: 'property2', // Match field properties
|
||||
isCustom: true,
|
||||
process: (value) => value,
|
||||
shouldApply: () => true,
|
||||
override: jest.fn(),
|
||||
editor: jest.fn(),
|
||||
name: 'Property 2',
|
||||
};
|
||||
|
||||
const property3: FieldConfigPropertyItem = {
|
||||
id: 'custom.property3.nested', // Match field properties
|
||||
path: 'property3.nested', // Match field properties
|
||||
isCustom: true,
|
||||
process: (value) => value,
|
||||
shouldApply: () => true,
|
||||
override: jest.fn(),
|
||||
editor: jest.fn(),
|
||||
name: 'Property 3',
|
||||
};
|
||||
|
||||
const customFieldRegistry: FieldConfigOptionsRegistry = new Registry<FieldConfigPropertyItem>(() => {
|
||||
return [property1, property2, property3];
|
||||
});
|
||||
|
||||
const plugin = {
|
||||
meta: { skipDataQuery: false },
|
||||
getPanelOptionsSupplier: jest.fn,
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
} as unknown as PanelPlugin;
|
||||
|
||||
const vizOptions = getVisualizationOptions2({
|
||||
panel: vizPanel,
|
||||
eventBus: new EventBusSrv(),
|
||||
plugin: plugin,
|
||||
instanceState: {},
|
||||
});
|
||||
|
||||
expect(vizOptions.length).toEqual(1);
|
||||
expect(vizOptions[0].items.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('should not show items when the showIf evaluates to false', () => {
|
||||
const vizPanel = new VizPanel({
|
||||
title: 'Panel A',
|
||||
pluginId: 'timeseries',
|
||||
key: 'panel-12',
|
||||
});
|
||||
|
||||
const property1: FieldConfigPropertyItem = {
|
||||
id: 'custom.property1', // Match field properties
|
||||
path: 'property1', // Match field properties
|
||||
isCustom: true,
|
||||
process: (value) => value,
|
||||
shouldApply: () => true,
|
||||
override: jest.fn(),
|
||||
editor: jest.fn(),
|
||||
name: 'Property 1',
|
||||
showIf: () => false,
|
||||
};
|
||||
|
||||
const property2: FieldConfigPropertyItem = {
|
||||
id: 'custom.property2', // Match field properties
|
||||
path: 'property2', // Match field properties
|
||||
isCustom: true,
|
||||
process: (value) => value,
|
||||
shouldApply: () => true,
|
||||
override: jest.fn(),
|
||||
editor: jest.fn(),
|
||||
name: 'Property 2',
|
||||
};
|
||||
|
||||
const property3: FieldConfigPropertyItem = {
|
||||
id: 'custom.property3.nested', // Match field properties
|
||||
path: 'property3.nested', // Match field properties
|
||||
isCustom: true,
|
||||
process: (value) => value,
|
||||
shouldApply: () => true,
|
||||
override: jest.fn(),
|
||||
editor: jest.fn(),
|
||||
name: 'Property 3',
|
||||
};
|
||||
|
||||
const customFieldRegistry: FieldConfigOptionsRegistry = new Registry<FieldConfigPropertyItem>(() => {
|
||||
return [property1, property2, property3];
|
||||
});
|
||||
|
||||
const plugin = {
|
||||
meta: { skipDataQuery: false },
|
||||
getPanelOptionsSupplier: jest.fn,
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
} as unknown as PanelPlugin;
|
||||
|
||||
const vizOptions = getVisualizationOptions2({
|
||||
panel: vizPanel,
|
||||
eventBus: new EventBusSrv(),
|
||||
plugin: plugin,
|
||||
instanceState: {},
|
||||
});
|
||||
|
||||
expect(vizOptions.length).toEqual(1);
|
||||
expect(vizOptions[0].items.length).toEqual(2);
|
||||
});
|
||||
|
||||
const fieldConfig = {
|
||||
defaults: {
|
||||
displayName: 'default',
|
||||
custom: {
|
||||
displayName: 'custom',
|
||||
},
|
||||
},
|
||||
overrides: [],
|
||||
};
|
||||
|
||||
const editorContext = getStandardEditorContext({
|
||||
data: panelData,
|
||||
replaceVariables: jest.fn(),
|
||||
options: {},
|
||||
eventBus: new EventBusSrv(),
|
||||
instanceState: {},
|
||||
const vizPanel = new VizPanel({
|
||||
title: 'Panel A',
|
||||
pluginId: 'timeseries',
|
||||
key: 'panel-12',
|
||||
fieldConfig: fieldConfig,
|
||||
});
|
||||
|
||||
expect(editorContext.getSuggestions).toBeDefined();
|
||||
expect(editorContext.getSuggestions?.()).toEqual([
|
||||
{
|
||||
documentation: 'Name of the series',
|
||||
label: 'Name',
|
||||
origin: 'series',
|
||||
value: '__series.name',
|
||||
},
|
||||
{
|
||||
documentation: 'Field name of the clicked datapoint (in ms epoch)',
|
||||
label: 'Name',
|
||||
origin: 'field',
|
||||
value: '__field.name',
|
||||
},
|
||||
{
|
||||
documentation: 'Formatted value for time on the same row',
|
||||
label: 'time',
|
||||
origin: 'fields',
|
||||
value: '__data.fields.time',
|
||||
},
|
||||
{
|
||||
documentation: 'Formatted value for score on the same row',
|
||||
label: 'score',
|
||||
origin: 'fields',
|
||||
value: '__data.fields.score',
|
||||
},
|
||||
{
|
||||
documentation: 'Enter the field order',
|
||||
label: 'Select by index',
|
||||
origin: 'fields',
|
||||
value: '__data.fields[0]',
|
||||
},
|
||||
{
|
||||
documentation: 'the numeric field value',
|
||||
label: 'Show numeric value',
|
||||
origin: 'fields',
|
||||
value: '__data.fields.score.numeric',
|
||||
},
|
||||
{
|
||||
documentation: 'the text value',
|
||||
label: 'Show text value',
|
||||
origin: 'fields',
|
||||
value: '__data.fields.score.text',
|
||||
},
|
||||
{
|
||||
documentation: 'Adds current variables',
|
||||
label: 'All variables',
|
||||
origin: 'template',
|
||||
value: '__all_variables',
|
||||
},
|
||||
{
|
||||
documentation: 'Adds current time range',
|
||||
label: 'Time range',
|
||||
origin: 'built-in',
|
||||
value: '__url_time_range',
|
||||
},
|
||||
{
|
||||
documentation: "Adds current time range's from value",
|
||||
label: 'Time range: from',
|
||||
origin: 'built-in',
|
||||
value: '__from',
|
||||
},
|
||||
{
|
||||
documentation: "Adds current time range's to value",
|
||||
label: 'Time range: to',
|
||||
origin: 'built-in',
|
||||
value: '__to',
|
||||
},
|
||||
]);
|
||||
const getOnePropVizPlugin = (isCustom: boolean, showIfSpy: jest.Mock) => {
|
||||
const property1: FieldConfigPropertyItem = {
|
||||
id: 'custom.property1', // Match field properties
|
||||
path: 'property1', // Match field properties
|
||||
isCustom: isCustom,
|
||||
process: (value) => value,
|
||||
shouldApply: () => true,
|
||||
override: jest.fn(),
|
||||
editor: jest.fn(),
|
||||
name: 'Property 1',
|
||||
showIf: showIfSpy,
|
||||
};
|
||||
|
||||
const customFieldRegistry: FieldConfigOptionsRegistry = new Registry<FieldConfigPropertyItem>(() => {
|
||||
return [property1];
|
||||
});
|
||||
|
||||
return {
|
||||
meta: { skipDataQuery: false },
|
||||
getPanelOptionsSupplier: jest.fn,
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
} as unknown as PanelPlugin;
|
||||
};
|
||||
|
||||
it('showIf should get custom fieldConfig if isCustom is true', () => {
|
||||
const showIfSpy = jest.fn().mockReturnValue(true);
|
||||
|
||||
const plugin = getOnePropVizPlugin(true, showIfSpy);
|
||||
|
||||
const vizOptions = getVisualizationOptions2({
|
||||
panel: vizPanel,
|
||||
eventBus: new EventBusSrv(),
|
||||
plugin: plugin,
|
||||
instanceState: {},
|
||||
data: {
|
||||
state: LoadingState.Done,
|
||||
series: [],
|
||||
timeRange: getDefaultTimeRange(),
|
||||
annotations: [
|
||||
{
|
||||
fields: [{ name: 'test', type: FieldType.string, config: { displayName: 'annotation' }, values: [1] }],
|
||||
length: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(vizOptions.length).toEqual(1);
|
||||
expect(vizOptions[0].items.length).toEqual(1);
|
||||
expect(showIfSpy.mock.calls.length).toEqual(1);
|
||||
expect(showIfSpy.mock.calls[0][0].displayName).toBe('custom');
|
||||
expect(showIfSpy.mock.calls[0][2][0].fields[0].config.displayName).toBe('annotation');
|
||||
});
|
||||
|
||||
it('showIf should get normal fieldConfig if isCustom is false', () => {
|
||||
const showIfSpy = jest.fn().mockReturnValue(true);
|
||||
|
||||
const plugin = getOnePropVizPlugin(false, showIfSpy);
|
||||
|
||||
const vizOptions = getVisualizationOptions2({
|
||||
panel: vizPanel,
|
||||
eventBus: new EventBusSrv(),
|
||||
plugin: plugin,
|
||||
instanceState: {},
|
||||
data: {
|
||||
state: LoadingState.Done,
|
||||
series: [],
|
||||
timeRange: getDefaultTimeRange(),
|
||||
annotations: [
|
||||
{
|
||||
fields: [{ name: 'test', type: FieldType.string, config: { displayName: 'annotation' }, values: [1] }],
|
||||
length: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(vizOptions.length).toEqual(1);
|
||||
expect(vizOptions[0].items.length).toEqual(1);
|
||||
expect(showIfSpy.mock.calls.length).toEqual(1);
|
||||
expect(showIfSpy.mock.calls[0][0].displayName).toBe('default');
|
||||
expect(showIfSpy.mock.calls[0][2][0].fields[0].config.displayName).toBe('annotation');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ export function getStandardEditorContext({
|
||||
eventBus,
|
||||
getSuggestions: (scope?: VariableSuggestionsScope) => getDataLinksVariableSuggestions(dataSeries, scope),
|
||||
instanceState,
|
||||
annotations: data?.annotations,
|
||||
};
|
||||
|
||||
return context;
|
||||
@@ -102,11 +103,14 @@ export function getVisualizationOptions(props: OptionPaneRenderProps): OptionsPa
|
||||
*/
|
||||
for (const fieldOption of plugin.fieldConfigRegistry.list()) {
|
||||
if (fieldOption.isCustom) {
|
||||
if (fieldOption.showIf && !fieldOption.showIf(currentFieldConfig.defaults.custom, data?.series)) {
|
||||
if (
|
||||
fieldOption.showIf &&
|
||||
!fieldOption.showIf(currentFieldConfig.defaults.custom, data?.series, data?.annotations)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (fieldOption.showIf && !fieldOption.showIf(currentFieldConfig.defaults, data?.series)) {
|
||||
if (fieldOption.showIf && !fieldOption.showIf(currentFieldConfig.defaults, data?.series, data?.annotations)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -240,8 +244,8 @@ export function getVisualizationOptions2(props: OptionPaneRenderProps2): Options
|
||||
const hideOption =
|
||||
fieldOption.showIf &&
|
||||
(fieldOption.isCustom
|
||||
? !fieldOption.showIf(currentFieldConfig.defaults.custom, data?.series)
|
||||
: !fieldOption.showIf(currentFieldConfig.defaults, data?.series));
|
||||
? !fieldOption.showIf(currentFieldConfig.defaults.custom, data?.series, data?.annotations)
|
||||
: !fieldOption.showIf(currentFieldConfig.defaults, data?.series, data?.annotations));
|
||||
if (fieldOption.hideFromDefaults || hideOption) {
|
||||
continue;
|
||||
}
|
||||
@@ -298,7 +302,7 @@ export function fillOptionsPaneItems(
|
||||
supplier(builder, context);
|
||||
|
||||
for (const pluginOption of builder.getItems()) {
|
||||
if (pluginOption.showIf && !pluginOption.showIf(context.options, context.data)) {
|
||||
if (pluginOption.showIf && !pluginOption.showIf(context.options, context.data, context.annotations)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TimeZone } from '@grafana/data';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { WeekStart } from '@grafana/ui';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
||||
@@ -56,7 +57,7 @@ export const updateTimeZoneDashboard =
|
||||
};
|
||||
|
||||
export const updateWeekStartDashboard =
|
||||
(weekStart: string): ThunkResult<void> =>
|
||||
(weekStart?: WeekStart): ThunkResult<void> =>
|
||||
(dispatch) => {
|
||||
dispatch(updateWeekStartForSession(weekStart));
|
||||
getTimeSrv().refreshTimeModel();
|
||||
|
||||
@@ -273,7 +273,7 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
|
||||
}
|
||||
|
||||
// set week start
|
||||
if (dashboard.weekStart !== '') {
|
||||
if (dashboard.weekStart !== '' && dashboard.weekStart !== undefined) {
|
||||
setWeekStart(dashboard.weekStart);
|
||||
} else {
|
||||
setWeekStart(config.bootData.user.weekStart);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useLocalStorage } from 'react-use';
|
||||
|
||||
import { Alert, LinkButton, Stack } from '@grafana/ui';
|
||||
|
||||
import { t, Trans } from '../../core/internationalization';
|
||||
|
||||
type Props = {
|
||||
datasourceType: string;
|
||||
};
|
||||
|
||||
export function DrilldownAlertBox(props: Props) {
|
||||
const isDsCompatibleWithDrilldown = ['prometheus', 'loki', 'tempo', 'grafana-pyroscope-datasource'].includes(
|
||||
props.datasourceType
|
||||
);
|
||||
|
||||
const [dismissed, setDismissed] = useLocalStorage('grafana.explore.drilldownsBoxDismissed', false);
|
||||
|
||||
return (
|
||||
isDsCompatibleWithDrilldown &&
|
||||
!dismissed && (
|
||||
<Alert
|
||||
severity={'info'}
|
||||
title={t('explore.drilldownInfo.title', 'Explore Metrics, Logs, Traces and Profiles have moved!')}
|
||||
onRemove={() => {
|
||||
setDismissed(true);
|
||||
}}
|
||||
>
|
||||
<Stack gap={1} alignItems="flex-end" justifyContent={'space-between'}>
|
||||
<span>
|
||||
<Trans i18nKey={'explore.drilldownInfo.description'}>
|
||||
Looking for the Grafana Explore apps? They are now called the Grafana Drilldown apps and can be found
|
||||
under <b>Menu > Drilldown</b>
|
||||
</Trans>
|
||||
</span>
|
||||
<LinkButton variant={'secondary'} href="/drilldown">
|
||||
<Trans i18nKey={'explore.drilldownInfo.action'}>Go to Grafana Drilldown</Trans>
|
||||
</LinkButton>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -23,18 +23,14 @@ import { getDataSourceSrv, reportInteraction } from '@grafana/runtime';
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
import {
|
||||
AdHocFilterItem,
|
||||
Alert,
|
||||
ErrorBoundaryAlert,
|
||||
LinkButton,
|
||||
PanelContainer,
|
||||
ScrollContainer,
|
||||
Stack,
|
||||
Themeable2,
|
||||
withTheme2,
|
||||
} from '@grafana/ui';
|
||||
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/src/components/Table/types';
|
||||
import { supportedFeatures } from 'app/core/history/richHistoryStorageProvider';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
import { StoreState } from 'app/types';
|
||||
|
||||
@@ -45,6 +41,7 @@ import { ContentOutlineContextProvider } from './ContentOutline/ContentOutlineCo
|
||||
import { ContentOutlineItem } from './ContentOutline/ContentOutlineItem';
|
||||
import { CorrelationHelper } from './CorrelationHelper';
|
||||
import { CustomContainer } from './CustomContainer';
|
||||
import { DrilldownAlertBox } from './DrilldownAlertBox';
|
||||
import { ExploreToolbar } from './ExploreToolbar';
|
||||
import { FlameGraphExploreContainer } from './FlameGraph/FlameGraphExploreContainer';
|
||||
import { GraphContainer } from './Graph/GraphContainer';
|
||||
@@ -568,9 +565,6 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
if (showCorrelationHelper && correlationEditorHelperData !== undefined) {
|
||||
correlationsBox = <CorrelationHelper exploreId={exploreId} correlations={correlationEditorHelperData} />;
|
||||
}
|
||||
const isDsCompatibleWithDrilldown = ['prometheus', 'loki', 'tempo', 'grafana-pyroscope-datasource'].includes(
|
||||
datasourceInstance?.type || ''
|
||||
);
|
||||
|
||||
return (
|
||||
<ContentOutlineContextProvider refreshDependencies={this.props.queries}>
|
||||
@@ -600,27 +594,7 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
<>
|
||||
<ContentOutlineItem panelId="Queries" title="Queries" icon="arrow" mergeSingleChild={true}>
|
||||
<PanelContainer className={styles.queryContainer}>
|
||||
{isDsCompatibleWithDrilldown && (
|
||||
<Alert
|
||||
severity={'info'}
|
||||
title={t(
|
||||
'explore.drilldownInfo.title',
|
||||
'Explore Metrics, Logs, Traces and Profiles have moved!'
|
||||
)}
|
||||
>
|
||||
<Stack gap={1} alignItems="flex-end" justifyContent={'space-between'}>
|
||||
<span>
|
||||
<Trans i18nKey={'explore.drilldownInfo.description'}>
|
||||
Looking for the Grafana Explore apps? They are now called the Grafana Drilldown apps
|
||||
and can be found under <b>Menu > Drilldown</b>
|
||||
</Trans>
|
||||
</span>
|
||||
<LinkButton variant={'secondary'} href="/drilldown">
|
||||
<Trans i18nKey={'explore.drilldownInfo.action'}>Go to Grafana Drilldown</Trans>
|
||||
</LinkButton>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
<DrilldownAlertBox datasourceType={datasourceInstance?.type || ''} />
|
||||
{correlationsBox}
|
||||
<QueryRows exploreId={exploreId} />
|
||||
<SecondaryActions
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { isEmpty, isString, set } from 'lodash';
|
||||
|
||||
import { dateTimeFormatTimeAgo, setWeekStart, TimeZone } from '@grafana/data';
|
||||
import { getWeekStart, WeekStart } from '@grafana/ui';
|
||||
import config from 'app/core/config';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { Team, ThunkResult, UserDTO, UserOrg, UserSession } from 'app/types';
|
||||
@@ -116,10 +117,10 @@ export const updateTimeZoneForSession = (timeZone: TimeZone): ThunkResult<void>
|
||||
};
|
||||
};
|
||||
|
||||
export const updateWeekStartForSession = (weekStart: string): ThunkResult<void> => {
|
||||
export const updateWeekStartForSession = (weekStart?: WeekStart): ThunkResult<void> => {
|
||||
return async (dispatch) => {
|
||||
if (!isString(weekStart) || isEmpty(weekStart)) {
|
||||
weekStart = config?.bootData?.user?.weekStart;
|
||||
if (!weekStart) {
|
||||
weekStart = getWeekStart();
|
||||
}
|
||||
|
||||
set(contextSrv, 'user.weekStart', weekStart);
|
||||
|
||||
@@ -46,7 +46,7 @@ export const HistogramPanel = ({ data, options, width, height }: Props) => {
|
||||
return histogramFieldsToFrame(joinHistograms(histograms), theme);
|
||||
}
|
||||
}
|
||||
const hist = buildHistogram(data.series, options);
|
||||
const hist = buildHistogram(data.series, options, theme);
|
||||
if (!hist) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -15,10 +15,11 @@ interface Props {
|
||||
onClick: (event: MouseEvent<SVGElement>, link: EdgeDatumLayout) => void;
|
||||
onMouseEnter: (id: string) => void;
|
||||
onMouseLeave: (id: string) => void;
|
||||
processedNodesLength: number;
|
||||
}
|
||||
|
||||
export const Edge = memo(function Edge(props: Props) {
|
||||
const { edge, onClick, onMouseEnter, onMouseLeave, hovering, svgIdNamespace } = props;
|
||||
const { edge, onClick, onMouseEnter, onMouseLeave, hovering, svgIdNamespace, processedNodesLength } = props;
|
||||
|
||||
// Not great typing but after we do layout these properties are full objects not just references
|
||||
const { source, target, sourceNodeRadius, targetNodeRadius } = edge as {
|
||||
@@ -56,6 +57,7 @@ export const Edge = memo(function Edge(props: Props) {
|
||||
<EdgeArrowMarker id={markerId} fill={edgeColor} headHeight={arrowHeadHeight} />
|
||||
<EdgeArrowMarker id={coloredMarkerId} fill={highlightedEdgeColor} headHeight={arrowHeadHeight} />
|
||||
<g
|
||||
key={`${edge.id}-${edge.source.y ?? ''}-${processedNodesLength}-g`}
|
||||
onClick={(event) => onClick(event, edge)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
aria-label={`Edge from: ${source.id} to: ${target.id}`}
|
||||
|
||||
@@ -227,6 +227,7 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode }
|
||||
onMouseEnter={setEdgeHover}
|
||||
onMouseLeave={clearEdgeHover}
|
||||
svgIdNamespace={svgIdNamespace}
|
||||
processedNodesLength={processed.nodes.length}
|
||||
/>
|
||||
)}
|
||||
<Nodes
|
||||
@@ -348,13 +349,14 @@ interface EdgesProps {
|
||||
onClick: (event: MouseEvent<SVGElement>, link: EdgeDatumLayout) => void;
|
||||
onMouseEnter: (id: string) => void;
|
||||
onMouseLeave: (id: string) => void;
|
||||
processedNodesLength: number;
|
||||
}
|
||||
const Edges = memo(function Edges(props: EdgesProps) {
|
||||
return (
|
||||
<>
|
||||
{props.edges.map((e) => (
|
||||
<Edge
|
||||
key={e.id}
|
||||
key={`${e.id}-${e.source.y ?? ''}-${props.processedNodesLength}`}
|
||||
edge={e}
|
||||
hovering={
|
||||
(e.source as NodeDatum).id === props.nodeHoveringId ||
|
||||
@@ -365,6 +367,7 @@ const Edges = memo(function Edges(props: EdgesProps) {
|
||||
onMouseEnter={props.onMouseEnter}
|
||||
onMouseLeave={props.onMouseLeave}
|
||||
svgIdNamespace={props.svgIdNamespace}
|
||||
processedNodesLength={props.processedNodesLength}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -1750,6 +1750,12 @@
|
||||
"drawer": {
|
||||
"close": "Close"
|
||||
},
|
||||
"feature-badge": {
|
||||
"experimental": "Experimental",
|
||||
"new": "New!",
|
||||
"preview": "Preview",
|
||||
"private-preview": "Private preview"
|
||||
},
|
||||
"field-link-list": {
|
||||
"external-links-heading": "External links"
|
||||
},
|
||||
@@ -2756,7 +2762,6 @@
|
||||
"close": "Close menu",
|
||||
"dock": "Dock menu",
|
||||
"list-label": "Navigation",
|
||||
"new": "New!",
|
||||
"open": "Open menu",
|
||||
"undock": "Undock menu"
|
||||
},
|
||||
|
||||
@@ -1750,6 +1750,12 @@
|
||||
"drawer": {
|
||||
"close": "Cľőşę"
|
||||
},
|
||||
"feature-badge": {
|
||||
"experimental": "Ēχpęřįmęʼnŧäľ",
|
||||
"new": "Ńęŵ!",
|
||||
"preview": "Přęvįęŵ",
|
||||
"private-preview": "Přįväŧę přęvįęŵ"
|
||||
},
|
||||
"field-link-list": {
|
||||
"external-links-heading": "Ēχŧęřʼnäľ ľįʼnĸş"
|
||||
},
|
||||
@@ -2756,7 +2762,6 @@
|
||||
"close": "Cľőşę męʼnū",
|
||||
"dock": "Đőčĸ męʼnū",
|
||||
"list-label": "Ńävįģäŧįőʼn",
|
||||
"new": "Ńęŵ!",
|
||||
"open": "Øpęʼn męʼnū",
|
||||
"undock": "Ůʼnđőčĸ męʼnū"
|
||||
},
|
||||
|
||||
@@ -4080,7 +4080,7 @@ __metadata:
|
||||
react-highlight-words: "npm:0.21.0"
|
||||
react-hook-form: "npm:^7.49.2"
|
||||
react-i18next: "npm:^15.0.0"
|
||||
react-inlinesvg: "npm:4.1.5"
|
||||
react-inlinesvg: "npm:4.2.0"
|
||||
react-loading-skeleton: "npm:3.5.0"
|
||||
react-router-dom: "npm:5.3.4"
|
||||
react-router-dom-v5-compat: "npm:^6.26.1"
|
||||
@@ -14872,15 +14872,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.3.7, debug@npm:~4.3.1, debug@npm:~4.3.2, debug@npm:~4.3.4":
|
||||
version: 4.3.7
|
||||
resolution: "debug@npm:4.3.7"
|
||||
"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.3.7, debug@npm:^4.4.0":
|
||||
version: 4.4.0
|
||||
resolution: "debug@npm:4.4.0"
|
||||
dependencies:
|
||||
ms: "npm:^2.1.3"
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
checksum: 10/71168908b9a78227ab29d5d25fe03c5867750e31ce24bf2c44a86efc5af041758bb56569b0a3d48a9b5344c00a24a777e6f4100ed6dfd9534a42c1dde285125a
|
||||
checksum: 10/1847944c2e3c2c732514b93d11886575625686056cd765336212dc15de2d2b29612b6cd80e1afba767bb8e1803b778caf9973e98169ef1a24a7a7009e1820367
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -14893,15 +14893,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"debug@npm:^4.4.0":
|
||||
version: 4.4.0
|
||||
resolution: "debug@npm:4.4.0"
|
||||
"debug@npm:~4.3.1, debug@npm:~4.3.2, debug@npm:~4.3.4":
|
||||
version: 4.3.7
|
||||
resolution: "debug@npm:4.3.7"
|
||||
dependencies:
|
||||
ms: "npm:^2.1.3"
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
checksum: 10/1847944c2e3c2c732514b93d11886575625686056cd765336212dc15de2d2b29612b6cd80e1afba767bb8e1803b778caf9973e98169ef1a24a7a7009e1820367
|
||||
checksum: 10/71168908b9a78227ab29d5d25fe03c5867750e31ce24bf2c44a86efc5af041758bb56569b0a3d48a9b5344c00a24a777e6f4100ed6dfd9534a42c1dde285125a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -14922,20 +14922,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"decimal.js@npm:10":
|
||||
"decimal.js@npm:10, decimal.js@npm:^10.4.1":
|
||||
version: 10.5.0
|
||||
resolution: "decimal.js@npm:10.5.0"
|
||||
checksum: 10/714d49cf2f2207b268221795ede330e51452b7c451a0c02a770837d2d4faed47d603a729c2aa1d952eb6c4102d999e91c9b952c1aa016db3c5cba9fc8bf4cda2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"decimal.js@npm:^10.4.1":
|
||||
version: 10.4.3
|
||||
resolution: "decimal.js@npm:10.4.3"
|
||||
checksum: 10/de663a7bc4d368e3877db95fcd5c87b965569b58d16cdc4258c063d231ca7118748738df17cd638f7e9dd0be8e34cec08d7234b20f1f2a756a52fc5a38b188d0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"decode-uri-component@npm:^0.2.0":
|
||||
version: 0.2.2
|
||||
resolution: "decode-uri-component@npm:0.2.2"
|
||||
@@ -17740,7 +17733,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-tsconfig@npm:^4.10.0":
|
||||
"get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.7.0":
|
||||
version: 4.10.0
|
||||
resolution: "get-tsconfig@npm:4.10.0"
|
||||
dependencies:
|
||||
@@ -17749,15 +17742,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-tsconfig@npm:^4.7.0":
|
||||
version: 4.8.1
|
||||
resolution: "get-tsconfig@npm:4.8.1"
|
||||
dependencies:
|
||||
resolve-pkg-maps: "npm:^1.0.0"
|
||||
checksum: 10/3fb5a8ad57b9633eaea085d81661e9e5c9f78b35d8f8689eaf8b8b45a2a3ebf3b3422266d4d7df765e308cc1e6231648d114803ab3d018332e29916f2c1de036
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-user-locale@npm:^2.2.1":
|
||||
version: 2.3.0
|
||||
resolution: "get-user-locale@npm:2.3.0"
|
||||
@@ -18384,7 +18368,7 @@ __metadata:
|
||||
react-highlight-words: "npm:0.21.0"
|
||||
react-hook-form: "npm:^7.49.2"
|
||||
react-i18next: "npm:^15.0.0"
|
||||
react-inlinesvg: "npm:4.1.5"
|
||||
react-inlinesvg: "npm:4.2.0"
|
||||
react-loading-skeleton: "npm:3.5.0"
|
||||
react-moveable: "npm:0.56.0"
|
||||
react-redux: "npm:9.2.0"
|
||||
@@ -26372,12 +26356,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-from-dom@npm:^0.7.3":
|
||||
version: 0.7.3
|
||||
resolution: "react-from-dom@npm:0.7.3"
|
||||
"react-from-dom@npm:^0.7.5":
|
||||
version: 0.7.5
|
||||
resolution: "react-from-dom@npm:0.7.5"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 10/55d6365af5b2aeaa0f2d80808dfa96114367e63849821b7ea277d193d4f6b1fce020e9754d4527ebc7f8628c5333f19dae38fc7b7956f69d5f640ac28b37ab0f
|
||||
react: 16.8 - 19
|
||||
checksum: 10/57459e775b2e2a12f3fc6bcc5365b88505ae856bd82188edd9e3b15c6318295316071789d06666533ee89ba4ccfd43ec7a5c68fbf4fbcaccb9e495eb02961947
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -26528,14 +26512,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-inlinesvg@npm:4.1.5":
|
||||
version: 4.1.5
|
||||
resolution: "react-inlinesvg@npm:4.1.5"
|
||||
"react-inlinesvg@npm:4.2.0":
|
||||
version: 4.2.0
|
||||
resolution: "react-inlinesvg@npm:4.2.0"
|
||||
dependencies:
|
||||
react-from-dom: "npm:^0.7.3"
|
||||
react-from-dom: "npm:^0.7.5"
|
||||
peerDependencies:
|
||||
react: 16.8 - 19
|
||||
checksum: 10/475666855056007bfec56968d61793530f2089997d8a2956d603e03d9da8cd353a00368f220f07ef554c65feb4f5839112cefb2c84b22e511d8a470bea8eee61
|
||||
checksum: 10/cf55657efce21c4891ab5875800722cf0184017bc821606ddc3775561c47e0648da1956bc4af14e13e18139d206af9eb834d7601098ff7b37caad7d010e99c2d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user