Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Ryan McKinley
2025-02-24 21:16:55 +03:00
61 changed files with 795 additions and 517 deletions
+2 -9
View File
@@ -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"]
+1 -1
View File
@@ -48,8 +48,8 @@ export function makeNewDashboardRequestBody(dashboardName: string, folderUid?: s
timezone: '',
title: dashboardName,
version: 0,
weekStart: '',
uid: '',
weekStart: '',
},
message: '',
overwrite: false,
+1 -1
View File
@@ -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: {
+2
View File
@@ -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;
}
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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', () => {
+1 -1
View File
@@ -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',
};
+1 -1
View File
@@ -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';
})
);
},
});
}
+7 -3
View File
@@ -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
+13
View File
@@ -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
}
+9
View File
@@ -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,
},
}
)
+1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
237 alertingJiraIntegration experimental @grafana/alerting-squad false false true
238 alertingRuleVersionHistoryRestore experimental @grafana/alerting-squad false false true
239 newShareReportDrawer experimental @grafana/sharing-squad false false false
240 rendererDisableAppPluginsPreload experimental @grafana/sharing-squad false false true
+4
View File
@@ -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"
)
+15
View File
@@ -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",
+11 -14
View File
@@ -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
}
+17 -1
View File
@@ -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())
}
+4
View File
@@ -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
+4 -4
View File
@@ -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)
}
+24 -16
View File
@@ -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
View File
@@ -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,
@@ -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);
};
@@ -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 &gt; Drilldown</b>
</Trans>
</span>
<LinkButton variant={'secondary'} href="/drilldown">
<Trans i18nKey={'explore.drilldownInfo.action'}>Go to Grafana Drilldown</Trans>
</LinkButton>
</Stack>
</Alert>
)
);
}
+2 -28
View File
@@ -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 &gt; 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;
}
+3 -1
View File
@@ -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}
/>
))}
</>
+6 -1
View File
@@ -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"
},
+6 -1
View File
@@ -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ū"
},
+22 -38
View File
@@ -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