diff --git a/.betterer.results b/.betterer.results index 43b807f1052..b6a79b98afd 100644 --- a/.betterer.results +++ b/.betterer.results @@ -540,9 +540,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], [0, 0, 0, "No untranslated strings in text props. Wrap text with 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 or use t()", "1"], [0, 0, 0, "No untranslated strings in text props. Wrap text with 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 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"] diff --git a/e2e/dashboards-suite/utils/makeDashboard.ts b/e2e/dashboards-suite/utils/makeDashboard.ts index 4deaaa773ce..2ada48db7a9 100644 --- a/e2e/dashboards-suite/utils/makeDashboard.ts +++ b/e2e/dashboards-suite/utils/makeDashboard.ts @@ -48,8 +48,8 @@ export function makeNewDashboardRequestBody(dashboardName: string, folderUid?: s timezone: '', title: dashboardName, version: 0, - weekStart: '', uid: '', + weekStart: '', }, message: '', overwrite: false, diff --git a/package.json b/package.json index cc2ba8bfcf8..012ee9da149 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/grafana-data/src/field/standardFieldConfigEditorRegistry.ts b/packages/grafana-data/src/field/standardFieldConfigEditorRegistry.ts index 83b7f5050e3..584c3779d23 100644 --- a/packages/grafana-data/src/field/standardFieldConfigEditorRegistry.ts +++ b/packages/grafana-data/src/field/standardFieldConfigEditorRegistry.ts @@ -16,6 +16,7 @@ export interface StandardEditorContext { options?: TOptions; instanceState?: TState; isOverride?: boolean; + annotations?: DataFrame[]; } export interface StandardEditorProps { diff --git a/packages/grafana-data/src/transformations/transformers/histogram.ts b/packages/grafana-data/src/transformations/transformers/histogram.ts index 00549ad07ae..8fa69f64a40 100644 --- a/packages/grafana-data/src/transformations/transformers/histogram.ts +++ b/packages/grafana-data/src/transformations/transformers/histogram.ts @@ -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: { diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index 23e52f913b8..38a20ad12b8 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -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', } diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 84091bf6c14..cda45576882 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -256,4 +256,5 @@ export interface FeatureToggles { alertingJiraIntegration?: boolean; alertingRuleVersionHistoryRestore?: boolean; newShareReportDrawer?: boolean; + rendererDisableAppPluginsPreload?: boolean; } diff --git a/packages/grafana-data/src/types/options.ts b/packages/grafana-data/src/types/options.ts index d23ceb35c5e..84f45884a36 100644 --- a/packages/grafana-data/src/types/options.ts +++ b/packages/grafana-data/src/types/options.ts @@ -51,5 +51,5 @@ export interface OptionEditorConfig { /** * 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; } diff --git a/packages/grafana-data/src/types/time.ts b/packages/grafana-data/src/types/time.ts index f3eeefac9ce..688c896ee6e 100644 --- a/packages/grafana-data/src/types/time.ts +++ b/packages/grafana-data/src/types/time.ts @@ -76,7 +76,7 @@ export function getDefaultTimeRange(): TimeRange { } /** - * Returns the default realtive time range. + * Returns the default relative time range. * * @public */ diff --git a/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.test.tsx index c9ff1d55cd2..c832a547bdc 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.test.tsx @@ -35,9 +35,13 @@ describe('MetricCombobox', () => { } as unknown as DataSourceInstanceSettings; 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', () => { diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index eff1830e872..87cc03f93b7 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -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", diff --git a/packages/grafana-ui/src/components/Badge/Badge.tsx b/packages/grafana-ui/src/components/Badge/Badge.tsx index 73eca08b108..59cd9074a2d 100644 --- a/packages/grafana-ui/src/components/Badge/Badge.tsx +++ b/packages/grafana-ui/src/components/Badge/Badge.tsx @@ -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', diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx index 6127c007e56..e54c94d1977 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx @@ -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(); @@ -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', () => { diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index f69d15b3038..2a1ea6ae28c 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -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 onBlur?: () => void; } -const RECOMMENDED_ITEMS_AMOUNT = 100_000; - type ClearableConditionals = | { /** @@ -102,7 +98,6 @@ export type ComboboxProps = ComboboxBaseProps & ClearableConditionals; 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 = (props: ComboboxProps) => { const { - options, + options: allOptions, onChange, value: valueProp, placeholder: placeholderProp, @@ -135,45 +130,13 @@ export const Combobox = (props: ComboboxProps) => // 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>, 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 = (props: ComboboxProps) => 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 = (props: ComboboxProps) => } 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 = (props: ComboboxProps) => 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 = (props: ComboboxProps) => menuId, labelId, inputId: id, - items, + items: filteredOptions, itemToString, selectedItem, @@ -267,48 +212,9 @@ export const Combobox = (props: ComboboxProps) => 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 = (props: ComboboxProps) => 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 = (props: ComboboxProps) => }, }); - 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 = (props: ComboboxProps) => {!asyncError && (
    {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const item = filteredOptions[virtualRow.index]; + return (
  • (props: ComboboxProps) => transform: `translateY(${virtualRow.start}px)`, }} {...getItemProps({ - item: items[virtualRow.index], + item: item, index: virtualRow.index, })} >
    - - {items[virtualRow.index].label ?? items[virtualRow.index].value} - - {items[virtualRow.index].description && ( - {items[virtualRow.index].description} - )} + {item.label ?? item.value} + {item.description && {item.description}}
  • ); @@ -463,7 +376,7 @@ export const Combobox = (props: ComboboxProps) => )}
    {asyncError && } - {items.length === 0 && !asyncError && } + {filteredOptions.length === 0 && !asyncError && }
    )} diff --git a/packages/grafana-ui/src/components/Combobox/useOptions.ts b/packages/grafana-ui/src/components/Combobox/useOptions.ts index fc2747cee1e..fffad1613cb 100644 --- a/packages/grafana-ui/src/components/Combobox/useOptions.ts +++ b/packages/grafana-ui/src/components/Combobox/useOptions.ts @@ -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(rawOptions: AsyncOptions { - 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(rawOptions: AsyncOptions { + 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 }; } diff --git a/packages/grafana-ui/src/components/DateTimePickers/WeekStartPicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/WeekStartPicker.tsx index aebcc7075d2..139a03713bd 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/WeekStartPicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/WeekStartPicker.tsx @@ -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 ( ( 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'; + }) + ); + }, }); } diff --git a/pkg/services/authz/rbac.go b/pkg/services/authz/rbac.go index f83a2b4d4a7..fcb398667ab 100644 --- a/pkg/services/authz/rbac.go +++ b/pkg/services/authz/rbac.go @@ -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 diff --git a/pkg/services/authz/rbac_settings.go b/pkg/services/authz/rbac_settings.go index 0ee661c2b50..9d643e73af5 100644 --- a/pkg/services/authz/rbac_settings.go +++ b/pkg/services/authz/rbac_settings.go @@ -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 +} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index a612113f87a..6ea80687ab7 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 159071fd378..1a74645d2a3 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 79eaf5a3a11..043d9446a3a 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -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" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index bf878a38563..67a16387684 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -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", diff --git a/pkg/services/ngalert/writer/prom.go b/pkg/services/ngalert/writer/prom.go index 9778d2f824b..ff735fecc26 100644 --- a/pkg/services/ngalert/writer/prom.go +++ b/pkg/services/ngalert/writer/prom.go @@ -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 } diff --git a/pkg/services/ngalert/writer/prom_test.go b/pkg/services/ngalert/writer/prom_test.go index af5bd5e9f29..c300d700d93 100644 --- a/pkg/services/ngalert/writer/prom_test.go +++ b/pkg/services/ngalert/writer/prom_test.go @@ -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, diff --git a/pkg/storage/unified/sql/continue.go b/pkg/storage/unified/resource/continue.go similarity index 97% rename from pkg/storage/unified/sql/continue.go rename to pkg/storage/unified/resource/continue.go index 77bcac8c5d4..70ca18a89ba 100644 --- a/pkg/storage/unified/sql/continue.go +++ b/pkg/storage/unified/resource/continue.go @@ -1,4 +1,4 @@ -package sql +package resource import ( "encoding/base64" diff --git a/pkg/storage/unified/resource/continue_test.go b/pkg/storage/unified/resource/continue_test.go new file mode 100644 index 00000000000..d82b74a4d85 --- /dev/null +++ b/pkg/storage/unified/resource/continue_test.go @@ -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()) +} diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index 4ef0c456cac..7853f9125db 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -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 diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go index 4c6d00addd4..024bc5ab7d0 100644 --- a/pkg/storage/unified/sql/backend.go +++ b/pkg/storage/unified/sql/backend.go @@ -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) } diff --git a/pkg/storage/unified/testing/storage_backend.go b/pkg/storage/unified/testing/storage_backend.go index d700f6cbbd7..d34cf3c0c71 100644 --- a/pkg/storage/unified/testing/storage_backend.go +++ b/pkg/storage/unified/testing/storage_backend.go @@ -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, } diff --git a/public/app/app.ts b/public/app/app.ts index 697e909fc37..7a3ba0bf140 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -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(); diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx index 4379283f3c5..23ed7d4fc84 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx @@ -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 && {iconElement}} {link.text} - {link.isNew && } + {link.isNew && } diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx index fff3bd62ecb..dcc1a250133 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx @@ -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 { 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 { data-testid={selectors.components.WeekStartPicker.containerV2} > diff --git a/public/app/features/alerting/unified/hooks/useFolder.ts b/public/app/features/alerting/unified/hooks/useFolder.ts index 8939ae8ad4c..7b06bf92020 100644 --- a/public/app/features/alerting/unified/hooks/useFolder.ts +++ b/public/app/features/alerting/unified/hooks/useFolder.ts @@ -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('/', '\\/'); } diff --git a/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx b/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx index 3da0bc08e07..8c33574bf6c 100644 --- a/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx +++ b/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx @@ -61,7 +61,7 @@ function useStateHistoryModal() { {implementation === StateHistoryImplementation.Loki && } {implementation === StateHistoryImplementation.Annotations && ( - + )} diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 91adf4a5568..5b03e36fe6b 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -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 => withSerializedError(backendSrv.getFolderByUid(uid, { withAccessControl: true })) -); - -export const fetchFolderIfNotFetchedAction = (uid: string): ThunkResult => { - return (dispatch, getState) => { - if (!getState().unifiedAlerting.folders[uid]?.dispatched) { - dispatch(fetchFolderAction(uid)); - } - }; -}; - export const fetchAlertGroupsAction = createAsyncThunk( 'unifiedalerting/fetchAlertGroups', (alertManagerSourceName: string): Promise => { diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index 19153a3bee1..71ba6a54e66 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -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, diff --git a/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts b/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts index a172c9cd19d..96bc19b4aff 100644 --- a/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts +++ b/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts @@ -400,6 +400,14 @@ export const cloudNotifierTypes: Array> = [ }, } ), + 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, ], }, diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index d4d0d69cca4..6badeb1ddc2 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -451,6 +451,7 @@ export const { useDeleteItemsMutation, useGetAffectedItemsQuery, useGetFolderQuery, + useLazyGetFolderQuery, useMoveFolderMutation, useMoveItemsMutation, useNewFolderMutation, diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap index 8146499446c..585b51daf4e 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap @@ -334,7 +334,6 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back "title": "Repeating rows", "uid": "Repeating-rows-uid", "version": 1, - "weekStart": "", } `; diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index b5ca18ea717..b271697a87e 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -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, diff --git a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx index ef7ae0489e6..d688616afc0 100644 --- a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx +++ b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx @@ -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 */} diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts index b57465de211..5ad1d7836d6 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.ts @@ -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', diff --git a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx index 7be88761ec9..6c66c66dc82 100644 --- a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx @@ -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 { onChangeFiscalYearStartMonth={this.onChangeFiscalYearStartMonth} isOnCanvas={isOnCanvas} onToolbarTimePickerClick={this.props.onToolbarTimePickerClick} - weekStart={weekStart} + weekStart={isWeekStart(weekStart) ? weekStart : undefined} quickRanges={quick_ranges} /> { + const onWeekStartChange = (weekStart?: WeekStart) => { dashboard.weekStart = weekStart; setRenderCounter(renderCounter + 1); updateWeekStart(weekStart); diff --git a/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx index d88e3deaa64..1b31c3ee035 100644 --- a/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx @@ -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 { this.props.onTimeZoneChange(timeZone); }; - onWeekStartChange = (weekStart: WeekStart) => { + onWeekStartChange = (weekStart?: WeekStart) => { this.props.onWeekStartChange(weekStart); }; diff --git a/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.test.ts b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.test.ts index 01f12b81f1c..6a64a1d02ea 100644 --- a/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.test.ts +++ b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.test.ts @@ -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(() => { + 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(() => { + 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(() => { + 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'); + }); }); }); diff --git a/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx index be19274ca5d..b62d0493c2e 100644 --- a/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx @@ -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; } diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 3f9b8d529f8..ed28663f745 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -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 => + (weekStart?: WeekStart): ThunkResult => (dispatch) => { dispatch(updateWeekStartForSession(weekStart)); getTimeSrv().refreshTimeModel(); diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 23b36244519..71c5cbf79eb 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -273,7 +273,7 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { } // set week start - if (dashboard.weekStart !== '') { + if (dashboard.weekStart !== '' && dashboard.weekStart !== undefined) { setWeekStart(dashboard.weekStart); } else { setWeekStart(config.bootData.user.weekStart); diff --git a/public/app/features/explore/DrilldownAlertBox.tsx b/public/app/features/explore/DrilldownAlertBox.tsx new file mode 100644 index 00000000000..b6618f3f479 --- /dev/null +++ b/public/app/features/explore/DrilldownAlertBox.tsx @@ -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 && ( + { + setDismissed(true); + }} + > + + + + Looking for the Grafana Explore apps? They are now called the Grafana Drilldown apps and can be found + under Menu > Drilldown + + + + Go to Grafana Drilldown + + + + ) + ); +} diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 4927fc5be4a..7229276849e 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -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 { if (showCorrelationHelper && correlationEditorHelperData !== undefined) { correlationsBox = ; } - const isDsCompatibleWithDrilldown = ['prometheus', 'loki', 'tempo', 'grafana-pyroscope-datasource'].includes( - datasourceInstance?.type || '' - ); return ( @@ -600,27 +594,7 @@ export class Explore extends PureComponent { <> - {isDsCompatibleWithDrilldown && ( - - - - - Looking for the Grafana Explore apps? They are now called the Grafana Drilldown apps - and can be found under Menu > Drilldown - - - - Go to Grafana Drilldown - - - - )} + {correlationsBox} }; }; -export const updateWeekStartForSession = (weekStart: string): ThunkResult => { +export const updateWeekStartForSession = (weekStart?: WeekStart): ThunkResult => { return async (dispatch) => { - if (!isString(weekStart) || isEmpty(weekStart)) { - weekStart = config?.bootData?.user?.weekStart; + if (!weekStart) { + weekStart = getWeekStart(); } set(contextSrv, 'user.weekStart', weekStart); diff --git a/public/app/plugins/panel/histogram/HistogramPanel.tsx b/public/app/plugins/panel/histogram/HistogramPanel.tsx index 03938736458..517f41187fc 100644 --- a/public/app/plugins/panel/histogram/HistogramPanel.tsx +++ b/public/app/plugins/panel/histogram/HistogramPanel.tsx @@ -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; } diff --git a/public/app/plugins/panel/nodeGraph/Edge.tsx b/public/app/plugins/panel/nodeGraph/Edge.tsx index 405d07f1a9e..e4802236cef 100644 --- a/public/app/plugins/panel/nodeGraph/Edge.tsx +++ b/public/app/plugins/panel/nodeGraph/Edge.tsx @@ -15,10 +15,11 @@ interface Props { onClick: (event: MouseEvent, 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) { onClick(event, edge)} style={{ cursor: 'pointer' }} aria-label={`Edge from: ${source.id} to: ${target.id}`} diff --git a/public/app/plugins/panel/nodeGraph/NodeGraph.tsx b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx index 02a7f5018d6..ffd741ca52c 100644 --- a/public/app/plugins/panel/nodeGraph/NodeGraph.tsx +++ b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx @@ -227,6 +227,7 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode } onMouseEnter={setEdgeHover} onMouseLeave={clearEdgeHover} svgIdNamespace={svgIdNamespace} + processedNodesLength={processed.nodes.length} /> )} , 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) => ( ))} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 7d657396dde..8849f1b3a69 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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" }, diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index aa5a7951a0e..fd11d47872d 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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ū" }, diff --git a/yarn.lock b/yarn.lock index e6c2c660e45..eb8360924ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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