Compare commits

..

10 Commits

Author SHA1 Message Date
Will Assis
861af005e0 unified-storage: fix listModifiedSince in storage_backend.go and enable its tests for both badger and sqlkv 2026-01-14 19:24:15 -03:00
Will Assis
14a05137e1 fmt 2026-01-14 15:43:30 -03:00
Will Assis
cfe86378a1 dont run tests on sqlite (yet) 2026-01-14 15:41:51 -03:00
Will Assis
f7d7e09626 unified-storage: sqlkv enable more tests 2026-01-14 15:25:42 -03:00
Will Assis
ba416eab4e unified-storage: dont use polling notifier with sqlite in sqlkv (#116283)
* unified-storage: dont use polling notifier with sqlite in sqlkv
2026-01-14 18:22:39 +00:00
Alan Martin
189d50d815 UI: Use react-table column header types in InteractiveTable with story and tests (#116091)
* feat(InteractiveTable): allow custom header rendering

* docs(InteractiveTable): add story for custom header rendering

* test(InteractiveTable): add tests for custom header rendering

* docs(InteractiveTable): add custom header rendering documentation

* fix: test failure from non-a11y code
2026-01-14 17:59:03 +00:00
Mariell Hoversholm
450eaba447 test: skip integration test in short mode (#116280) 2026-01-14 18:33:55 +01:00
Kristina Demeshchik
87f5d5e741 Dashboard: Hide export options in collapsible row (#116155)
* Introduce export options

* Reset keys

* Introduce a new key

* Generate new keys

* Rename the label

* re-generate key

* Fix the spacing

* Remove debuggers

* Add subtitle

* refactor component

* update labels

* faield tests

* Update tooltip

* Linting issue
2026-01-14 12:12:33 -05:00
Andrew Hackmann
5e68b07cac Elasticsearch: Make code editor look more like prometheus (#115461)
* Make code editor look more prometheus

* add warning when switching builders

* address adam's feedback

* yarn
2026-01-14 09:50:35 -07:00
Adela Almasan
99acd3766d Suggestions: Update empty state (#116172) 2026-01-14 10:37:42 -06:00
59 changed files with 793 additions and 251 deletions

View File

@@ -22,7 +22,7 @@ import { getBarColorByDiff, getBarColorByPackage, getBarColorByValue } from './c
import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform'; import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform';
type RenderOptions = { type RenderOptions = {
canvasRef: RefObject<HTMLCanvasElement | null>; canvasRef: RefObject<HTMLCanvasElement>;
data: FlameGraphDataContainer; data: FlameGraphDataContainer;
root: LevelItem; root: LevelItem;
direction: 'children' | 'parents'; direction: 'children' | 'parents';
@@ -373,7 +373,7 @@ function useColorFunction(
); );
} }
function useSetupCanvas(canvasRef: RefObject<HTMLCanvasElement | null>, wrapperWidth: number, numberOfLevels: number) { function useSetupCanvas(canvasRef: RefObject<HTMLCanvasElement>, wrapperWidth: number, numberOfLevels: number) {
const [ctx, setCtx] = useState<CanvasRenderingContext2D>(); const [ctx, setCtx] = useState<CanvasRenderingContext2D>();
useEffect(() => { useEffect(() => {

View File

@@ -7,7 +7,7 @@ const CAUGHT_KEYS = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', 'Tab'];
/** @internal */ /** @internal */
export interface UseListFocusProps { export interface UseListFocusProps {
localRef: RefObject<HTMLUListElement | null>; localRef: RefObject<HTMLUListElement>;
options: TimeOption[]; options: TimeOption[];
} }

View File

@@ -1,7 +1,7 @@
import { RefObject, useRef } from 'react'; import { RefObject, useRef } from 'react';
export function useFocus(): [RefObject<HTMLInputElement | null>, () => void] { export function useFocus(): [RefObject<HTMLInputElement>, () => void] {
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement>(null);
const setFocus = () => { const setFocus = () => {
ref.current && ref.current.focus(); ref.current && ref.current.focus();
}; };

View File

@@ -117,6 +117,44 @@ export const MyComponent = () => {
}; };
``` ```
### Custom Header Rendering
Column headers can be customized using strings, React elements, or renderer functions. The `header` property accepts any value that matches React Table's `Renderer` type.
**Important:** When using custom header content, prefer inline elements (like `<span>`) over block elements (like `<div>`) to avoid layout issues. Block-level elements can cause extra spacing and alignment problems in table headers because they disrupt the table's inline flow. Use `display: inline-flex` or `display: inline-block` when you need flexbox or block-like behavior.
```tsx
const columns: Array<Column<TableData>> = [
// React element header
{
id: 'checkbox',
header: (
<>
<label htmlFor="select-all" className="sr-only">
Select all rows
</label>
<Checkbox id="select-all" />
</>
),
cell: () => <Checkbox aria-label="Select row" />,
},
// Function renderer header
{
id: 'firstName',
header: () => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<Icon name="user" size="sm" />
<span>First Name</span>
</span>
),
},
// String header
{ id: 'lastName', header: 'Last name' },
];
```
### Custom Cell Rendering ### Custom Cell Rendering
Individual cells can be rendered using custom content dy defining a `cell` property on the column definition. Individual cells can be rendered using custom content dy defining a `cell` property on the column definition.

View File

@@ -3,8 +3,11 @@ import { useCallback, useMemo, useState } from 'react';
import { CellProps } from 'react-table'; import { CellProps } from 'react-table';
import { LinkButton } from '../Button/Button'; import { LinkButton } from '../Button/Button';
import { Checkbox } from '../Forms/Checkbox';
import { Field } from '../Forms/Field'; import { Field } from '../Forms/Field';
import { Icon } from '../Icon/Icon';
import { Input } from '../Input/Input'; import { Input } from '../Input/Input';
import { Text } from '../Text/Text';
import { FetchDataArgs, InteractiveTable, InteractiveTableHeaderTooltip } from './InteractiveTable'; import { FetchDataArgs, InteractiveTable, InteractiveTableHeaderTooltip } from './InteractiveTable';
import mdx from './InteractiveTable.mdx'; import mdx from './InteractiveTable.mdx';
@@ -297,4 +300,40 @@ export const WithControlledSort: StoryFn<typeof InteractiveTable> = (args) => {
return <InteractiveTable {...args} data={data} pageSize={15} fetchData={fetchData} />; return <InteractiveTable {...args} data={data} pageSize={15} fetchData={fetchData} />;
}; };
export const WithCustomHeader: TableStoryObj = {
args: {
columns: [
// React element header
{
id: 'checkbox',
header: (
<>
<label htmlFor="select-all" className="sr-only">
Select all rows
</label>
<Checkbox id="select-all" />
</>
),
cell: () => <Checkbox aria-label="Select row" />,
},
// Function renderer header
{
id: 'firstName',
header: () => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<Icon name="user" size="sm" />
<Text element="span">First Name</Text>
</span>
),
sortType: 'string',
},
// String header
{ id: 'lastName', header: 'Last name', sortType: 'string' },
{ id: 'car', header: 'Car', sortType: 'string' },
{ id: 'age', header: 'Age', sortType: 'number' },
],
data: pageableData.slice(0, 10),
getRowId: (r) => r.id,
},
};
export default meta; export default meta;

View File

@@ -2,6 +2,9 @@ import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import * as React from 'react'; import * as React from 'react';
import { Checkbox } from '../Forms/Checkbox';
import { Icon } from '../Icon/Icon';
import { InteractiveTable } from './InteractiveTable'; import { InteractiveTable } from './InteractiveTable';
import { Column } from './types'; import { Column } from './types';
@@ -247,4 +250,104 @@ describe('InteractiveTable', () => {
expect(fetchData).toHaveBeenCalledWith({ sortBy: [{ id: 'id', desc: false }] }); expect(fetchData).toHaveBeenCalledWith({ sortBy: [{ id: 'id', desc: false }] });
}); });
}); });
describe('custom header rendering', () => {
it('should render string headers', () => {
const columns: Array<Column<TableData>> = [{ id: 'id', header: 'ID' }];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />);
expect(screen.getByRole('columnheader', { name: 'ID' })).toBeInTheDocument();
});
it('should render React element headers', () => {
const columns: Array<Column<TableData>> = [
{
id: 'checkbox',
header: (
<>
<label htmlFor="select-all" className="sr-only">
Select all rows
</label>
<Checkbox id="select-all" data-testid="header-checkbox" />
</>
),
cell: () => <Checkbox data-testid="cell-checkbox" aria-label="Select row" />,
},
];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />);
expect(screen.getByTestId('header-checkbox')).toBeInTheDocument();
expect(screen.getByTestId('cell-checkbox')).toBeInTheDocument();
expect(screen.getByLabelText('Select all rows')).toBeInTheDocument();
expect(screen.getByLabelText('Select row')).toBeInTheDocument();
expect(screen.getByText('Select all rows')).toBeInTheDocument();
});
it('should render function renderer headers', () => {
const columns: Array<Column<TableData>> = [
{
id: 'firstName',
header: () => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<Icon name="user" size="sm" data-testid="header-icon" />
<span>First Name</span>
</span>
),
sortType: 'string',
},
];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />);
expect(screen.getByTestId('header-icon')).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /first name/i })).toBeInTheDocument();
});
it('should render all header types together', () => {
const columns: Array<Column<TableData>> = [
{
id: 'checkbox',
header: (
<>
<label htmlFor="select-all" className="sr-only">
Select all rows
</label>
<Checkbox id="select-all" data-testid="header-checkbox" />
</>
),
cell: () => <Checkbox aria-label="Select row" />,
},
{
id: 'id',
header: () => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<Icon name="user" size="sm" data-testid="header-icon" />
<span>ID</span>
</span>
),
sortType: 'string',
},
{ id: 'country', header: 'Country', sortType: 'string' },
{ id: 'value', header: 'Value' },
];
const data: TableData[] = [
{ id: '1', value: 'Value 1', country: 'Sweden' },
{ id: '2', value: 'Value 2', country: 'Norway' },
];
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />);
expect(screen.getByTestId('header-checkbox')).toBeInTheDocument();
expect(screen.getByTestId('header-icon')).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Country' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Value' })).toBeInTheDocument();
// Verify data is rendered
expect(screen.getByText('Sweden')).toBeInTheDocument();
expect(screen.getByText('Norway')).toBeInTheDocument();
expect(screen.getByText('Value 1')).toBeInTheDocument();
expect(screen.getByText('Value 2')).toBeInTheDocument();
});
});
}); });

View File

@@ -1,5 +1,5 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { CellProps, DefaultSortTypes, IdType, SortByFn } from 'react-table'; import { CellProps, DefaultSortTypes, HeaderProps, IdType, Renderer, SortByFn } from 'react-table';
export interface Column<TableData extends object> { export interface Column<TableData extends object> {
/** /**
@@ -11,9 +11,9 @@ export interface Column<TableData extends object> {
*/ */
cell?: (props: CellProps<TableData>) => ReactNode; cell?: (props: CellProps<TableData>) => ReactNode;
/** /**
* Header name. if `undefined` the header will be empty. Useful for action columns. * Header name. Can be a string, renderer function, or undefined. If `undefined` the header will be empty. Useful for action columns.
*/ */
header?: string; header?: Renderer<HeaderProps<TableData>>;
/** /**
* Column sort type. If `undefined` the column will not be sortable. * Column sort type. If `undefined` the column will not be sortable.
* */ * */

View File

@@ -6,7 +6,7 @@ const UNFOCUSED = -1;
/** @internal */ /** @internal */
export interface UseMenuFocusProps { export interface UseMenuFocusProps {
localRef: RefObject<HTMLDivElement | null>; localRef: RefObject<HTMLDivElement>;
isMenuOpen?: boolean; isMenuOpen?: boolean;
close?: () => void; close?: () => void;
onOpen?: (focusOnItem: (itemId: number) => void) => void; onOpen?: (focusOnItem: (itemId: number) => void) => void;

View File

@@ -22,7 +22,7 @@ interface Props extends Omit<BoxProps, 'display' | 'direction' | 'element' | 'fl
* *
* https://developers.grafana.com/ui/latest/index.html?path=/docs/layout-scrollcontainer--docs * https://developers.grafana.com/ui/latest/index.html?path=/docs/layout-scrollcontainer--docs
*/ */
export const ScrollContainer = forwardRef<HTMLDivElement | null, PropsWithChildren<Props>>( export const ScrollContainer = forwardRef<HTMLDivElement, PropsWithChildren<Props>>(
( (
{ {
children, children,

View File

@@ -20,7 +20,7 @@ export interface TableCellTooltipProps {
field: Field; field: Field;
getActions: (field: Field, rowIdx: number) => ActionModel[]; getActions: (field: Field, rowIdx: number) => ActionModel[];
getTextColorForBackground: (bgColor: string) => string; getTextColorForBackground: (bgColor: string) => string;
gridRef: RefObject<DataGridHandle | null>; gridRef: RefObject<DataGridHandle>;
height: number; height: number;
placement?: TableCellTooltipPlacement; placement?: TableCellTooltipPlacement;
renderer: TableCellRenderer; renderer: TableCellRenderer;

View File

@@ -463,7 +463,7 @@ export function useColumnResize(
return dataGridResizeHandler; return dataGridResizeHandler;
} }
export function useScrollbarWidth(ref: RefObject<DataGridHandle | null>, height: number) { export function useScrollbarWidth(ref: RefObject<DataGridHandle>, height: number) {
const [scrollbarWidth, setScrollbarWidth] = useState(0); const [scrollbarWidth, setScrollbarWidth] = useState(0);
useLayoutEffect(() => { useLayoutEffect(() => {

View File

@@ -135,7 +135,7 @@ export const Table = memo((props: Props) => {
// `useTableStateReducer`, which is needed to construct options for `useTable` (the hook that returns // `useTableStateReducer`, which is needed to construct options for `useTable` (the hook that returns
// `toggleAllRowsExpanded`), and if we used a variable, that variable would be undefined at the time // `toggleAllRowsExpanded`), and if we used a variable, that variable would be undefined at the time
// we initialize `useTableStateReducer`. // we initialize `useTableStateReducer`.
const toggleAllRowsExpandedRef = useRef<((value?: boolean) => void) | null>(null); const toggleAllRowsExpandedRef = useRef<(value?: boolean) => void>();
// Internal react table state reducer // Internal react table state reducer
const stateReducer = useTableStateReducer({ const stateReducer = useTableStateReducer({

View File

@@ -14,8 +14,8 @@ import { GrafanaTableState } from './types';
Select the scrollbar element from the VariableSizeList scope Select the scrollbar element from the VariableSizeList scope
*/ */
export function useFixScrollbarContainer( export function useFixScrollbarContainer(
variableSizeListScrollbarRef: React.RefObject<HTMLDivElement | null>, variableSizeListScrollbarRef: React.RefObject<HTMLDivElement>,
tableDivRef: React.RefObject<HTMLDivElement | null> tableDivRef: React.RefObject<HTMLDivElement>
) { ) {
useEffect(() => { useEffect(() => {
if (variableSizeListScrollbarRef.current && tableDivRef.current) { if (variableSizeListScrollbarRef.current && tableDivRef.current) {
@@ -43,7 +43,7 @@ export function useFixScrollbarContainer(
*/ */
export function useResetVariableListSizeCache( export function useResetVariableListSizeCache(
extendedState: GrafanaTableState, extendedState: GrafanaTableState,
listRef: React.RefObject<VariableSizeList | null>, listRef: React.RefObject<VariableSizeList>,
data: DataFrame, data: DataFrame,
hasUniqueId: boolean hasUniqueId: boolean
) { ) {

View File

@@ -19,7 +19,7 @@ interface EventsCanvasProps {
} }
export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords, config }: EventsCanvasProps) { export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords, config }: EventsCanvasProps) {
const plotInstance = useRef<uPlot | null>(null); const plotInstance = useRef<uPlot>();
// render token required to re-render annotation markers. Rendering lines happens in uPlot and the props do not change // render token required to re-render annotation markers. Rendering lines happens in uPlot and the props do not change
// so we need to force the re-render when the draw hook was performed by uPlot // so we need to force the re-render when the draw hook was performed by uPlot
const [renderToken, setRenderToken] = useState(0); const [renderToken, setRenderToken] = useState(0);

View File

@@ -140,7 +140,7 @@ export const TooltipPlugin2 = ({
const [{ plot, isHovering, isPinned, contents, style, dismiss }, setState] = useReducer(mergeState, null, initState); const [{ plot, isHovering, isPinned, contents, style, dismiss }, setState] = useReducer(mergeState, null, initState);
const sizeRef = useRef<TooltipContainerSize | null>(null); const sizeRef = useRef<TooltipContainerSize>();
const styles = useStyles2(getStyles, maxWidth); const styles = useStyles2(getStyles, maxWidth);
const renderRef = useRef(render); const renderRef = useRef(render);

View File

@@ -96,7 +96,7 @@ export interface GraphNGState {
export class GraphNG extends Component<GraphNGProps, GraphNGState> { export class GraphNG extends Component<GraphNGProps, GraphNGState> {
static contextType = PanelContextRoot; static contextType = PanelContextRoot;
panelContext: PanelContext = {} as PanelContext; panelContext: PanelContext = {} as PanelContext;
private plotInstance: React.RefObject<uPlot | null>; private plotInstance: React.RefObject<uPlot>;
private subscription = new Subscription(); private subscription = new Subscription();

View File

@@ -184,9 +184,9 @@ func (n *eventStore) Get(ctx context.Context, key EventKey) (Event, error) {
} }
// ListSince returns a sequence of events since the given resource version. // ListSince returns a sequence of events since the given resource version.
func (n *eventStore) ListKeysSince(ctx context.Context, sinceRV int64) iter.Seq2[string, error] { func (n *eventStore) ListKeysSince(ctx context.Context, sinceRV int64, sortOrder SortOrder) iter.Seq2[string, error] {
opts := ListOptions{ opts := ListOptions{
Sort: SortOrderAsc, Sort: sortOrder,
StartKey: fmt.Sprintf("%d", sinceRV), StartKey: fmt.Sprintf("%d", sinceRV),
} }
return func(yield func(string, error) bool) { return func(yield func(string, error) bool) {
@@ -202,9 +202,9 @@ func (n *eventStore) ListKeysSince(ctx context.Context, sinceRV int64) iter.Seq2
} }
} }
func (n *eventStore) ListSince(ctx context.Context, sinceRV int64) iter.Seq2[Event, error] { func (n *eventStore) ListSince(ctx context.Context, sinceRV int64, sortOrder SortOrder) iter.Seq2[Event, error] {
return func(yield func(Event, error) bool) { return func(yield func(Event, error) bool) {
for evtKey, err := range n.ListKeysSince(ctx, sinceRV) { for evtKey, err := range n.ListKeysSince(ctx, sinceRV, sortOrder) {
if err != nil { if err != nil {
yield(Event{}, err) yield(Event{}, err)
return return

View File

@@ -369,7 +369,7 @@ func testEventStoreListKeysSince(t *testing.T, ctx context.Context, store *event
// List events since RV 1500 (should get events with RV 2000 and 3000) // List events since RV 1500 (should get events with RV 2000 and 3000)
retrievedEvents := make([]string, 0, 2) retrievedEvents := make([]string, 0, 2)
for eventKey, err := range store.ListKeysSince(ctx, 1500) { for eventKey, err := range store.ListKeysSince(ctx, 1500, SortOrderAsc) {
require.NoError(t, err) require.NoError(t, err)
retrievedEvents = append(retrievedEvents, eventKey) retrievedEvents = append(retrievedEvents, eventKey)
} }
@@ -429,7 +429,7 @@ func testEventStoreListSince(t *testing.T, ctx context.Context, store *eventStor
// List events since RV 1500 (should get events with RV 2000 and 3000) // List events since RV 1500 (should get events with RV 2000 and 3000)
retrievedEvents := make([]Event, 0, 2) retrievedEvents := make([]Event, 0, 2)
for event, err := range store.ListSince(ctx, 1500) { for event, err := range store.ListSince(ctx, 1500, SortOrderAsc) {
require.NoError(t, err) require.NoError(t, err)
retrievedEvents = append(retrievedEvents, event) retrievedEvents = append(retrievedEvents, event)
} }
@@ -453,7 +453,7 @@ func TestEventStore_ListSince_Empty(t *testing.T) {
func testEventStoreListSinceEmpty(t *testing.T, ctx context.Context, store *eventStore) { func testEventStoreListSinceEmpty(t *testing.T, ctx context.Context, store *eventStore) {
// List events when store is empty // List events when store is empty
retrievedEvents := make([]Event, 0) retrievedEvents := make([]Event, 0)
for event, err := range store.ListSince(ctx, 0) { for event, err := range store.ListSince(ctx, 0, SortOrderAsc) {
require.NoError(t, err) require.NoError(t, err)
retrievedEvents = append(retrievedEvents, event) retrievedEvents = append(retrievedEvents, event)
} }
@@ -825,7 +825,7 @@ func testListKeysSinceWithSnowflakeTime(t *testing.T, ctx context.Context, store
// List events since 90 minutes ago using subtractDurationFromSnowflake // List events since 90 minutes ago using subtractDurationFromSnowflake
sinceRV := subtractDurationFromSnowflake(snowflakeFromTime(now), 90*time.Minute) sinceRV := subtractDurationFromSnowflake(snowflakeFromTime(now), 90*time.Minute)
retrievedEvents := make([]string, 0) retrievedEvents := make([]string, 0)
for eventKey, err := range store.ListKeysSince(ctx, sinceRV) { for eventKey, err := range store.ListKeysSince(ctx, sinceRV, SortOrderAsc) {
require.NoError(t, err) require.NoError(t, err)
retrievedEvents = append(retrievedEvents, eventKey) retrievedEvents = append(retrievedEvents, eventKey)
} }
@@ -842,7 +842,7 @@ func testListKeysSinceWithSnowflakeTime(t *testing.T, ctx context.Context, store
// List events since 30 minutes ago using subtractDurationFromSnowflake // List events since 30 minutes ago using subtractDurationFromSnowflake
sinceRV = subtractDurationFromSnowflake(snowflakeFromTime(now), 30*time.Minute) sinceRV = subtractDurationFromSnowflake(snowflakeFromTime(now), 30*time.Minute)
retrievedEvents = make([]string, 0) retrievedEvents = make([]string, 0)
for eventKey, err := range store.ListKeysSince(ctx, sinceRV) { for eventKey, err := range store.ListKeysSince(ctx, sinceRV, SortOrderAsc) {
require.NoError(t, err) require.NoError(t, err)
retrievedEvents = append(retrievedEvents, eventKey) retrievedEvents = append(retrievedEvents, eventKey)
} }

View File

@@ -19,13 +19,18 @@ const (
defaultBufferSize = 10000 defaultBufferSize = 10000
) )
type notifier struct { type notifier interface {
Watch(context.Context, watchOptions) <-chan Event
}
type pollingNotifier struct {
eventStore *eventStore eventStore *eventStore
log logging.Logger log logging.Logger
} }
type notifierOptions struct { type notifierOptions struct {
log logging.Logger log logging.Logger
useChannelNotifier bool
} }
type watchOptions struct { type watchOptions struct {
@@ -44,15 +49,26 @@ func defaultWatchOptions() watchOptions {
} }
} }
func newNotifier(eventStore *eventStore, opts notifierOptions) *notifier { func newNotifier(eventStore *eventStore, opts notifierOptions) notifier {
if opts.log == nil { if opts.log == nil {
opts.log = &logging.NoOpLogger{} opts.log = &logging.NoOpLogger{}
} }
return &notifier{eventStore: eventStore, log: opts.log}
if opts.useChannelNotifier {
return &channelNotifier{}
}
return &pollingNotifier{eventStore: eventStore, log: opts.log}
}
type channelNotifier struct{}
func (cn *channelNotifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
return nil
} }
// Return the last resource version from the event store // Return the last resource version from the event store
func (n *notifier) lastEventResourceVersion(ctx context.Context) (int64, error) { func (n *pollingNotifier) lastEventResourceVersion(ctx context.Context) (int64, error) {
e, err := n.eventStore.LastEventKey(ctx) e, err := n.eventStore.LastEventKey(ctx)
if err != nil { if err != nil {
return 0, err return 0, err
@@ -60,11 +76,11 @@ func (n *notifier) lastEventResourceVersion(ctx context.Context) (int64, error)
return e.ResourceVersion, nil return e.ResourceVersion, nil
} }
func (n *notifier) cacheKey(evt Event) string { func (n *pollingNotifier) cacheKey(evt Event) string {
return fmt.Sprintf("%s~%s~%s~%s~%d", evt.Namespace, evt.Group, evt.Resource, evt.Name, evt.ResourceVersion) return fmt.Sprintf("%s~%s~%s~%s~%d", evt.Namespace, evt.Group, evt.Resource, evt.Name, evt.ResourceVersion)
} }
func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { func (n *pollingNotifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
if opts.MinBackoff <= 0 { if opts.MinBackoff <= 0 {
opts.MinBackoff = defaultMinBackoff opts.MinBackoff = defaultMinBackoff
} }
@@ -103,7 +119,7 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
return return
case <-time.After(currentInterval): case <-time.After(currentInterval):
foundEvents := false foundEvents := false
for evt, err := range n.eventStore.ListSince(ctx, subtractDurationFromSnowflake(lastRV, opts.LookbackPeriod)) { for evt, err := range n.eventStore.ListSince(ctx, subtractDurationFromSnowflake(lastRV, opts.LookbackPeriod), SortOrderAsc) {
if err != nil { if err != nil {
n.log.Error("Failed to list events since", "error", err) n.log.Error("Failed to list events since", "error", err)
continue continue

View File

@@ -13,7 +13,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func setupTestNotifier(t *testing.T) (*notifier, *eventStore) { func setupTestNotifier(t *testing.T) (*pollingNotifier, *eventStore) {
db := setupTestBadgerDB(t) db := setupTestBadgerDB(t)
t.Cleanup(func() { t.Cleanup(func() {
err := db.Close() err := db.Close()
@@ -22,10 +22,10 @@ func setupTestNotifier(t *testing.T) (*notifier, *eventStore) {
kv := NewBadgerKV(db) kv := NewBadgerKV(db)
eventStore := newEventStore(kv) eventStore := newEventStore(kv)
notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}}) notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}})
return notifier, eventStore return notifier.(*pollingNotifier), eventStore
} }
func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) { func setupTestNotifierSqlKv(t *testing.T) (*pollingNotifier, *eventStore) {
dbstore := db.InitTestDB(t) dbstore := db.InitTestDB(t)
eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil)
require.NoError(t, err) require.NoError(t, err)
@@ -33,7 +33,7 @@ func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) {
require.NoError(t, err) require.NoError(t, err)
eventStore := newEventStore(kv) eventStore := newEventStore(kv)
notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}}) notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}})
return notifier, eventStore return notifier.(*pollingNotifier), eventStore
} }
func TestNewNotifier(t *testing.T) { func TestNewNotifier(t *testing.T) {
@@ -49,7 +49,7 @@ func TestDefaultWatchOptions(t *testing.T) {
assert.Equal(t, defaultBufferSize, opts.BufferSize) assert.Equal(t, defaultBufferSize, opts.BufferSize)
} }
func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) (*notifier, *eventStore), testFn func(*testing.T, context.Context, *notifier, *eventStore)) { func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) (*pollingNotifier, *eventStore), testFn func(*testing.T, context.Context, *pollingNotifier, *eventStore)) {
t.Run(storeName, func(t *testing.T) { t.Run(storeName, func(t *testing.T) {
ctx := context.Background() ctx := context.Background()
notifier, eventStore := newStoreFn(t) notifier, eventStore := newStoreFn(t)
@@ -62,7 +62,7 @@ func TestNotifier_lastEventResourceVersion(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion) runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion)
} }
func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
// Test with no events // Test with no events
rv, err := notifier.lastEventResourceVersion(ctx) rv, err := notifier.lastEventResourceVersion(ctx)
assert.Error(t, err) assert.Error(t, err)
@@ -113,7 +113,7 @@ func TestNotifier_cachekey(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey) runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey)
} }
func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
tests := []struct { tests := []struct {
name string name string
event Event event Event
@@ -167,7 +167,7 @@ func TestNotifier_Watch_NoEvents(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents) runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents)
} }
func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel() defer cancel()
@@ -208,7 +208,7 @@ func TestNotifier_Watch_WithExistingEvents(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents) runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents)
} }
func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second) ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() defer cancel()
@@ -282,7 +282,7 @@ func TestNotifier_Watch_EventDeduplication(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication) runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication)
} }
func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second) ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() defer cancel()
@@ -348,7 +348,7 @@ func TestNotifier_Watch_ContextCancellation(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation) runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation)
} }
func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
// Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound // Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound
@@ -394,7 +394,7 @@ func TestNotifier_Watch_MultipleEvents(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents) runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents)
} }
func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second) ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel() defer cancel()
rv := time.Now().UnixNano() rv := time.Now().UnixNano()

View File

@@ -61,7 +61,7 @@ type kvStorageBackend struct {
bulkLock *BulkLock bulkLock *BulkLock
dataStore *dataStore dataStore *dataStore
eventStore *eventStore eventStore *eventStore
notifier *notifier notifier notifier
builder DocumentBuilder builder DocumentBuilder
log logging.Logger log logging.Logger
withPruner bool withPruner bool
@@ -91,6 +91,7 @@ type KVBackendOptions struct {
Tracer trace.Tracer // TODO add tracing Tracer trace.Tracer // TODO add tracing
Reg prometheus.Registerer // TODO add metrics Reg prometheus.Registerer // TODO add metrics
UseChannelNotifier bool
// Adding RvManager overrides the RV generated with snowflake in order to keep backwards compatibility with // Adding RvManager overrides the RV generated with snowflake in order to keep backwards compatibility with
// unified/sql // unified/sql
RvManager *rvmanager.ResourceVersionManager RvManager *rvmanager.ResourceVersionManager
@@ -121,7 +122,7 @@ func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) {
bulkLock: NewBulkLock(), bulkLock: NewBulkLock(),
dataStore: newDataStore(kv), dataStore: newDataStore(kv),
eventStore: eventStore, eventStore: eventStore,
notifier: newNotifier(eventStore, notifierOptions{}), notifier: newNotifier(eventStore, notifierOptions{useChannelNotifier: opts.UseChannelNotifier}),
snowflake: s, snowflake: s,
builder: StandardDocumentBuilder(), // For now we use the standard document builder. builder: StandardDocumentBuilder(), // For now we use the standard document builder.
log: &logging.NoOpLogger{}, // Make this configurable log: &logging.NoOpLogger{}, // Make this configurable
@@ -800,8 +801,20 @@ func (k *kvStorageBackend) ListModifiedSince(ctx context.Context, key Namespaced
} }
} }
// Generate a new resource version for the list latestEvent, err := k.eventStore.LastEventKey(ctx)
listRV := k.snowflake.Generate().Int64() if err != nil {
if errors.Is(err, ErrNotFound) {
return sinceRv, func(yield func(*ModifiedResource, error) bool) { /* nothing to return */ }
}
return 0, func(yield func(*ModifiedResource, error) bool) {
yield(nil, fmt.Errorf("error trying to retrieve last event key: %s", err))
}
}
if latestEvent.ResourceVersion == sinceRv {
return sinceRv, func(yield func(*ModifiedResource, error) bool) { /* nothing to return */ }
}
// Check if sinceRv is older than 1 hour // Check if sinceRv is older than 1 hour
sinceRvTimestamp := snowflake.ID(sinceRv).Time() sinceRvTimestamp := snowflake.ID(sinceRv).Time()
@@ -810,11 +823,11 @@ func (k *kvStorageBackend) ListModifiedSince(ctx context.Context, key Namespaced
if sinceRvAge > time.Hour { if sinceRvAge > time.Hour {
k.log.Debug("ListModifiedSince using data store", "sinceRv", sinceRv, "sinceRvAge", sinceRvAge) k.log.Debug("ListModifiedSince using data store", "sinceRv", sinceRv, "sinceRvAge", sinceRvAge)
return listRV, k.listModifiedSinceDataStore(ctx, key, sinceRv) return latestEvent.ResourceVersion, k.listModifiedSinceDataStore(ctx, key, sinceRv)
} }
k.log.Debug("ListModifiedSince using event store", "sinceRv", sinceRv, "sinceRvAge", sinceRvAge) k.log.Debug("ListModifiedSince using event store", "sinceRv", sinceRv, "sinceRvAge", sinceRvAge)
return listRV, k.listModifiedSinceEventStore(ctx, key, sinceRv) return latestEvent.ResourceVersion, k.listModifiedSinceEventStore(ctx, key, sinceRv)
} }
func convertEventType(action DataAction) resourcepb.WatchEvent_Type { func convertEventType(action DataAction) resourcepb.WatchEvent_Type {
@@ -915,9 +928,9 @@ func (k *kvStorageBackend) listModifiedSinceDataStore(ctx context.Context, key N
func (k *kvStorageBackend) listModifiedSinceEventStore(ctx context.Context, key NamespacedResource, sinceRv int64) iter.Seq2[*ModifiedResource, error] { func (k *kvStorageBackend) listModifiedSinceEventStore(ctx context.Context, key NamespacedResource, sinceRv int64) iter.Seq2[*ModifiedResource, error] {
return func(yield func(*ModifiedResource, error) bool) { return func(yield func(*ModifiedResource, error) bool) {
// store all events ordered by RV for the given tenant here // we only care about the latest revision of every resource in the list
eventKeys := make([]EventKey, 0) seen := make(map[string]struct{})
for evtKeyStr, err := range k.eventStore.ListKeysSince(ctx, subtractDurationFromSnowflake(sinceRv, defaultLookbackPeriod)) { for evtKeyStr, err := range k.eventStore.ListKeysSince(ctx, subtractDurationFromSnowflake(sinceRv, defaultLookbackPeriod), SortOrderDesc) {
if err != nil { if err != nil {
yield(&ModifiedResource{}, err) yield(&ModifiedResource{}, err)
return return
@@ -937,18 +950,11 @@ func (k *kvStorageBackend) listModifiedSinceEventStore(ctx context.Context, key
continue continue
} }
eventKeys = append(eventKeys, evtKey)
}
// we only care about the latest revision of every resource in the list
seen := make(map[string]struct{})
for i := len(eventKeys) - 1; i >= 0; i -= 1 {
evtKey := eventKeys[i]
if _, ok := seen[evtKey.Name]; ok { if _, ok := seen[evtKey.Name]; ok {
continue continue
} }
seen[evtKey.Name] = struct{}{}
seen[evtKey.Name] = struct{}{}
value, err := k.getValueFromDataStore(ctx, DataKey(evtKey)) value, err := k.getValueFromDataStore(ctx, DataKey(evtKey))
if err != nil { if err != nil {
yield(&ModifiedResource{}, err) yield(&ModifiedResource{}, err)
@@ -1306,7 +1312,7 @@ func (b *kvStorageBackend) ProcessBulk(ctx context.Context, setting BulkSettings
if setting.RebuildCollection { if setting.RebuildCollection {
for _, key := range setting.Collection { for _, key := range setting.Collection {
events := make([]string, 0) events := make([]string, 0)
for evtKeyStr, err := range b.eventStore.ListKeysSince(ctx, 1) { for evtKeyStr, err := range b.eventStore.ListKeysSince(ctx, 1, SortOrderAsc) {
if err != nil { if err != nil {
b.log.Error("failed to list event: %s", err) b.log.Error("failed to list event: %s", err)
return rsp return rsp

View File

@@ -99,6 +99,9 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
return nil, err return nil, err
} }
isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"),
opts.Cfg.SectionWithEnvOverrides("resource_api"))
if opts.Cfg.EnableSQLKVBackend { if opts.Cfg.EnableSQLKVBackend {
sqlkv, err := resource.NewSQLKV(eDB) sqlkv, err := resource.NewSQLKV(eDB)
if err != nil { if err != nil {
@@ -106,9 +109,10 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
} }
kvBackendOpts := resource.KVBackendOptions{ kvBackendOpts := resource.KVBackendOptions{
KvStore: sqlkv, KvStore: sqlkv,
Tracer: opts.Tracer, Tracer: opts.Tracer,
Reg: opts.Reg, Reg: opts.Reg,
UseChannelNotifier: !isHA,
} }
ctx := context.Background() ctx := context.Background()
@@ -140,9 +144,6 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
serverOptions.Backend = kvBackend serverOptions.Backend = kvBackend
serverOptions.Diagnostics = kvBackend serverOptions.Diagnostics = kvBackend
} else { } else {
isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"),
opts.Cfg.SectionWithEnvOverrides("resource_api"))
backend, err := NewBackend(BackendOptions{ backend, err := NewBackend(BackendOptions{
DBProvider: eDB, DBProvider: eDB,
Reg: opts.Reg, Reg: opts.Reg,

View File

@@ -23,6 +23,7 @@ import (
"github.com/grafana/authlib/types" "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/storage/unified/resourcepb"
sqldb "github.com/grafana/grafana/pkg/storage/unified/sql/db" sqldb "github.com/grafana/grafana/pkg/storage/unified/sql/db"
@@ -99,6 +100,10 @@ func RunStorageBackendTest(t *testing.T, newBackend NewBackendFunc, opts *TestOp
} }
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
if db.IsTestDbSQLite() {
t.Skip("Skipping tests on sqlite until channel notifier is implemented")
}
tc.fn(t, newBackend(context.Background()), opts.NSPrefix) tc.fn(t, newBackend(context.Background()), opts.NSPrefix)
}) })
} }
@@ -550,7 +555,7 @@ func runTestIntegrationBackendListModifiedSince(t *testing.T, backend resource.S
Resource: "resource", Resource: "resource",
} }
latestRv, seq := backend.ListModifiedSince(ctx, key, rvCreated) latestRv, seq := backend.ListModifiedSince(ctx, key, rvCreated)
require.Greater(t, latestRv, rvCreated) require.Equal(t, latestRv, rvDeleted)
counter := 0 counter := 0
for res, err := range seq { for res, err := range seq {
@@ -624,11 +629,11 @@ func runTestIntegrationBackendListModifiedSince(t *testing.T, backend resource.S
rvCreated3, _ := writeEvent(ctx, backend, "bItem", resourcepb.WatchEvent_ADDED, WithNamespace(ns)) rvCreated3, _ := writeEvent(ctx, backend, "bItem", resourcepb.WatchEvent_ADDED, WithNamespace(ns))
latestRv, seq := backend.ListModifiedSince(ctx, key, rvCreated1-1) latestRv, seq := backend.ListModifiedSince(ctx, key, rvCreated1-1)
require.Greater(t, latestRv, rvCreated3) require.Equal(t, latestRv, rvCreated3)
counter := 0 counter := 0
names := []string{"aItem", "bItem", "cItem"} names := []string{"bItem", "aItem", "cItem"}
rvs := []int64{rvCreated2, rvCreated3, rvCreated1} rvs := []int64{rvCreated3, rvCreated2, rvCreated1}
for res, err := range seq { for res, err := range seq {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, key.Namespace, res.Key.Namespace) require.Equal(t, key.Namespace, res.Key.Namespace)
@@ -1166,7 +1171,7 @@ func runTestIntegrationBackendCreateNewResource(t *testing.T, backend resource.S
})) }))
server := newServer(t, backend) server := newServer(t, backend)
ns := nsPrefix + "-create-resource" ns := nsPrefix + "-create-rsrce" // create-resource
ctx = request.WithNamespace(ctx, ns) ctx = request.WithNamespace(ctx, ns)
request := &resourcepb.CreateRequest{ request := &resourcepb.CreateRequest{
@@ -1607,7 +1612,7 @@ func (s *sliceBulkRequestIterator) RollbackRequested() bool {
func runTestIntegrationBackendOptimisticLocking(t *testing.T, backend resource.StorageBackend, nsPrefix string) { func runTestIntegrationBackendOptimisticLocking(t *testing.T, backend resource.StorageBackend, nsPrefix string) {
ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second))
ns := nsPrefix + "-optimistic-locking" ns := nsPrefix + "-optimis-lock" // optimistic-locking. need to cut down on characters to not exceed namespace character limit (40)
t.Run("concurrent updates with same RV - only one succeeds", func(t *testing.T) { t.Run("concurrent updates with same RV - only one succeeds", func(t *testing.T) {
// Create initial resource with rv0 (no previous RV) // Create initial resource with rv0 (no previous RV)

View File

@@ -36,6 +36,10 @@ func NewTestSqlKvBackend(t *testing.T, ctx context.Context, withRvManager bool)
KvStore: kv, KvStore: kv,
} }
if db.DriverName() == "sqlite3" {
kvOpts.UseChannelNotifier = true
}
if withRvManager { if withRvManager {
dialect := sqltemplate.DialectForDriver(db.DriverName()) dialect := sqltemplate.DialectForDriver(db.DriverName())
rvManager, err := rvmanager.NewResourceVersionManager(rvmanager.ResourceManagerOptions{ rvManager, err := rvmanager.NewResourceVersionManager(rvmanager.ResourceManagerOptions{

View File

@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/util/testutil"
) )
func TestBadgerKVStorageBackend(t *testing.T) { func TestBadgerKVStorageBackend(t *testing.T) {
@@ -29,26 +30,18 @@ func TestBadgerKVStorageBackend(t *testing.T) {
SkipTests: map[string]bool{ SkipTests: map[string]bool{
// TODO: fix these tests and remove this skip // TODO: fix these tests and remove this skip
TestBlobSupport: true, TestBlobSupport: true,
TestListModifiedSince: true,
// Badger does not support bulk import yet. // Badger does not support bulk import yet.
TestGetResourceLastImportTime: true, TestGetResourceLastImportTime: true,
}, },
}) })
} }
func TestSQLKVStorageBackend(t *testing.T) { func TestIntegrationSQLKVStorageBackend(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
skipTests := map[string]bool{ skipTests := map[string]bool{
TestWatchWriteEvents: true,
TestList: true,
TestBlobSupport: true, TestBlobSupport: true,
TestGetResourceStats: true,
TestListHistory: true,
TestListHistoryErrorReporting: true,
TestListModifiedSince: true,
TestListTrash: true,
TestCreateNewResource: true,
TestGetResourceLastImportTime: true, TestGetResourceLastImportTime: true,
TestOptimisticLocking: true,
} }
t.Run("Without RvManager", func(t *testing.T) { t.Run("Without RvManager", func(t *testing.T) {
@@ -56,7 +49,7 @@ func TestSQLKVStorageBackend(t *testing.T) {
backend, _ := NewTestSqlKvBackend(t, ctx, false) backend, _ := NewTestSqlKvBackend(t, ctx, false)
return backend return backend
}, &TestOptions{ }, &TestOptions{
NSPrefix: "sqlkvstorage-test", NSPrefix: "sqlkvstoragetest",
SkipTests: skipTests, SkipTests: skipTests,
}) })
}) })
@@ -66,7 +59,7 @@ func TestSQLKVStorageBackend(t *testing.T) {
backend, _ := NewTestSqlKvBackend(t, ctx, true) backend, _ := NewTestSqlKvBackend(t, ctx, true)
return backend return backend
}, &TestOptions{ }, &TestOptions{
NSPrefix: "sqlkvstorage-withrvmanager-test", NSPrefix: "sqlkvstoragetest-rvmanager",
SkipTests: skipTests, SkipTests: skipTests,
}) })
}) })

View File

@@ -109,7 +109,7 @@ const defaultMatchers = {
* "Time as X" core component, expects ascending x * "Time as X" core component, expects ascending x
*/ */
export class GraphNG extends Component<GraphNGProps, GraphNGState> { export class GraphNG extends Component<GraphNGProps, GraphNGState> {
private plotInstance: React.RefObject<uPlot | null>; private plotInstance: React.RefObject<uPlot>;
constructor(props: GraphNGProps) { constructor(props: GraphNGProps) {
super(props); super(props);

View File

@@ -25,6 +25,10 @@ export class ExportAsCode extends ShareExportTab {
public getTabLabel(): string { public getTabLabel(): string {
return t('export.json.title', 'Export dashboard'); return t('export.json.title', 'Export dashboard');
} }
public getSubtitle(): string | undefined {
return t('export.json.info-text', 'Copy or download a file containing the definition of your dashboard');
}
} }
function ExportAsCodeRenderer({ model }: SceneComponentProps<ExportAsCode>) { function ExportAsCodeRenderer({ model }: SceneComponentProps<ExportAsCode>) {
@@ -53,12 +57,6 @@ function ExportAsCodeRenderer({ model }: SceneComponentProps<ExportAsCode>) {
return ( return (
<div data-testid={selector.container} className={styles.container}> <div data-testid={selector.container} className={styles.container}>
<p>
<Trans i18nKey="export.json.info-text">
Copy or download a file containing the definition of your dashboard
</Trans>
</p>
{config.featureToggles.kubernetesDashboards ? ( {config.featureToggles.kubernetesDashboards ? (
<ResourceExport <ResourceExport
dashboardJson={dashboardJson} dashboardJson={dashboardJson}

View File

@@ -0,0 +1,189 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AsyncState } from 'react-use/lib/useAsync';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Dashboard } from '@grafana/schema';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { ExportMode, ResourceExport } from './ResourceExport';
type DashboardJsonState = AsyncState<{
json: Dashboard | DashboardV2Spec | { error: unknown };
hasLibraryPanels?: boolean;
initialSaveModelVersion: 'v1' | 'v2';
}>;
const selector = e2eSelectors.pages.ExportDashboardDrawer.ExportAsJson;
const createDefaultProps = (overrides?: Partial<Parameters<typeof ResourceExport>[0]>) => {
const defaultProps: Parameters<typeof ResourceExport>[0] = {
dashboardJson: {
loading: false,
value: {
json: { title: 'Test Dashboard' } as Dashboard,
hasLibraryPanels: false,
initialSaveModelVersion: 'v1',
},
} as DashboardJsonState,
isSharingExternally: false,
exportMode: ExportMode.Classic,
isViewingYAML: false,
onExportModeChange: jest.fn(),
onShareExternallyChange: jest.fn(),
onViewYAML: jest.fn(),
};
return { ...defaultProps, ...overrides };
};
const createV2DashboardJson = (hasLibraryPanels = false): DashboardJsonState => ({
loading: false,
value: {
json: {
title: 'Test V2 Dashboard',
spec: {
elements: {},
},
} as unknown as DashboardV2Spec,
hasLibraryPanels,
initialSaveModelVersion: 'v2',
},
});
const expandOptions = async () => {
const button = screen.getByRole('button', { expanded: false });
await userEvent.click(button);
};
describe('ResourceExport', () => {
describe('export mode options for v1 dashboard', () => {
it('should show three export mode options in correct order: Classic, V1 Resource, V2 Resource', async () => {
render(<ResourceExport {...createDefaultProps()} />);
await expandOptions();
const radioGroup = screen.getByRole('radiogroup', { name: /model/i });
const labels = within(radioGroup)
.getAllByRole('radio')
.map((radio) => radio.parentElement?.textContent?.trim());
expect(labels).toHaveLength(3);
expect(labels).toEqual(['Classic', 'V1 Resource', 'V2 Resource']);
});
it('should have first option selected by default when exportMode is Classic', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic })} />);
await expandOptions();
const radioGroup = screen.getByRole('radiogroup', { name: /model/i });
const radios = within(radioGroup).getAllByRole('radio');
expect(radios[0]).toBeChecked();
});
it('should call onExportModeChange when export mode is changed', async () => {
const onExportModeChange = jest.fn();
render(<ResourceExport {...createDefaultProps({ onExportModeChange })} />);
await expandOptions();
const radioGroup = screen.getByRole('radiogroup', { name: /model/i });
const radios = within(radioGroup).getAllByRole('radio');
await userEvent.click(radios[1]); // V1 Resource
expect(onExportModeChange).toHaveBeenCalledWith(ExportMode.V1Resource);
});
});
describe('export mode options for v2 dashboard', () => {
it('should not show export mode options', async () => {
render(<ResourceExport {...createDefaultProps({ dashboardJson: createV2DashboardJson() })} />);
await expandOptions();
expect(screen.queryByRole('radiogroup', { name: /model/i })).not.toBeInTheDocument();
});
});
describe('format options', () => {
it('should not show format options when export mode is Classic', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic })} />);
await expandOptions();
expect(screen.getByRole('radiogroup', { name: /model/i })).toBeInTheDocument();
expect(screen.queryByRole('radiogroup', { name: /format/i })).not.toBeInTheDocument();
});
it.each([ExportMode.V1Resource, ExportMode.V2Resource])(
'should show format options when export mode is %s',
async (exportMode) => {
render(<ResourceExport {...createDefaultProps({ exportMode })} />);
await expandOptions();
expect(screen.getByRole('radiogroup', { name: /model/i })).toBeInTheDocument();
expect(screen.getByRole('radiogroup', { name: /format/i })).toBeInTheDocument();
}
);
it('should have first format option selected when isViewingYAML is false', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.V1Resource, isViewingYAML: false })} />);
await expandOptions();
const formatGroup = screen.getByRole('radiogroup', { name: /format/i });
const formatRadios = within(formatGroup).getAllByRole('radio');
expect(formatRadios[0]).toBeChecked(); // JSON
});
it('should have second format option selected when isViewingYAML is true', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.V1Resource, isViewingYAML: true })} />);
await expandOptions();
const formatGroup = screen.getByRole('radiogroup', { name: /format/i });
const formatRadios = within(formatGroup).getAllByRole('radio');
expect(formatRadios[1]).toBeChecked(); // YAML
});
it('should call onViewYAML when format is changed', async () => {
const onViewYAML = jest.fn();
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.V1Resource, onViewYAML })} />);
await expandOptions();
const formatGroup = screen.getByRole('radiogroup', { name: /format/i });
const formatRadios = within(formatGroup).getAllByRole('radio');
await userEvent.click(formatRadios[1]); // YAML
expect(onViewYAML).toHaveBeenCalled();
});
});
describe('share externally switch', () => {
it('should show share externally switch for Classic mode', () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic })} />);
expect(screen.getByTestId(selector.exportExternallyToggle)).toBeInTheDocument();
});
it('should show share externally switch for V2Resource mode with V2 dashboard', () => {
render(
<ResourceExport
{...createDefaultProps({
dashboardJson: createV2DashboardJson(),
exportMode: ExportMode.V2Resource,
})}
/>
);
expect(screen.getByTestId(selector.exportExternallyToggle)).toBeInTheDocument();
});
it('should call onShareExternallyChange when switch is toggled', async () => {
const onShareExternallyChange = jest.fn();
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic, onShareExternallyChange })} />);
const switchElement = screen.getByTestId(selector.exportExternallyToggle);
await userEvent.click(switchElement);
expect(onShareExternallyChange).toHaveBeenCalled();
});
it('should reflect isSharingExternally value in switch', () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic, isSharingExternally: true })} />);
expect(screen.getByTestId(selector.exportExternallyToggle)).toBeChecked();
});
});
});

View File

@@ -4,7 +4,8 @@ import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n'; import { Trans, t } from '@grafana/i18n';
import { Dashboard } from '@grafana/schema'; import { Dashboard } from '@grafana/schema';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { Alert, Label, RadioButtonGroup, Stack, Switch } from '@grafana/ui'; import { Alert, Icon, Label, RadioButtonGroup, Stack, Switch, Box, Tooltip } from '@grafana/ui';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { DashboardJson } from 'app/features/manage-dashboards/types'; import { DashboardJson } from 'app/features/manage-dashboards/types';
import { ExportableResource } from '../ShareExportTab'; import { ExportableResource } from '../ShareExportTab';
@@ -48,80 +49,90 @@ export function ResourceExport({
const switchExportLabel = const switchExportLabel =
exportMode === ExportMode.V2Resource exportMode === ExportMode.V2Resource
? t('export.json.export-remove-ds-refs', 'Remove deployment details') ? t('dashboard-scene.resource-export.share-externally', 'Share dashboard with another instance')
: t('share-modal.export.share-externally-label', `Export for sharing externally`); : t('share-modal.export.share-externally-label', 'Export for sharing externally');
const switchExportTooltip = t(
'dashboard-scene.resource-export.share-externally-tooltip',
'Removes all instance-specific metadata and data source references from the resource before export.'
);
const switchExportModeLabel = t('export.json.export-mode', 'Model'); const switchExportModeLabel = t('export.json.export-mode', 'Model');
const switchExportFormatLabel = t('export.json.export-format', 'Format'); const switchExportFormatLabel = t('export.json.export-format', 'Format');
const exportResourceOptions = [
{
label: t('dashboard-scene.resource-export.label.classic', 'Classic'),
value: ExportMode.Classic,
},
{
label: t('dashboard-scene.resource-export.label.v1-resource', 'V1 Resource'),
value: ExportMode.V1Resource,
},
{
label: t('dashboard-scene.resource-export.label.v2-resource', 'V2 Resource'),
value: ExportMode.V2Resource,
},
];
return ( return (
<Stack gap={2} direction="column"> <>
<Stack gap={1} direction="column"> <QueryOperationRow
{initialSaveModelVersion === 'v1' && ( id="Advanced options"
<Stack alignItems="center"> index={0}
<Label>{switchExportModeLabel}</Label> title={t('dashboard-scene.resource-export.label.advanced-options', 'Advanced options')}
<RadioButtonGroup isOpen={false}
options={[ >
{ label: t('dashboard-scene.resource-export.label.classic', 'Classic'), value: ExportMode.Classic }, <Box marginTop={2}>
{ <Stack gap={1} direction="column">
label: t('dashboard-scene.resource-export.label.v1-resource', 'V1 Resource'), {initialSaveModelVersion === 'v1' && (
value: ExportMode.V1Resource, <Stack gap={1} alignItems="center">
}, <Label>{switchExportModeLabel}</Label>
{ <RadioButtonGroup
label: t('dashboard-scene.resource-export.label.v2-resource', 'V2 Resource'), options={exportResourceOptions}
value: ExportMode.V2Resource, value={exportMode}
}, onChange={(value) => onExportModeChange(value)}
]} aria-label={switchExportModeLabel}
value={exportMode} />
onChange={(value) => onExportModeChange(value)} </Stack>
/> )}
{exportMode !== ExportMode.Classic && (
<Stack gap={1} alignItems="center">
<Label>{switchExportFormatLabel}</Label>
<RadioButtonGroup
options={[
{ label: t('dashboard-scene.resource-export.label.json', 'JSON'), value: 'json' },
{ label: t('dashboard-scene.resource-export.label.yaml', 'YAML'), value: 'yaml' },
]}
value={isViewingYAML ? 'yaml' : 'json'}
onChange={onViewYAML}
aria-label={switchExportFormatLabel}
/>
</Stack>
)}
</Stack> </Stack>
)} </Box>
{initialSaveModelVersion === 'v2' && ( </QueryOperationRow>
<Stack alignItems="center">
<Label>{switchExportModeLabel}</Label> {(isV2Dashboard ||
<RadioButtonGroup exportMode === ExportMode.Classic ||
options={[ (initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && (
{ <Stack gap={1} alignItems="start">
label: t('dashboard-scene.resource-export.label.v2-resource', 'V2 Resource'), <Label>
value: ExportMode.V2Resource, <Stack gap={0.5} alignItems="center">
}, <Tooltip content={switchExportTooltip} placement="bottom">
{ <Icon name="info-circle" size="sm" />
label: t('dashboard-scene.resource-export.label.v1-resource', 'V1 Resource'), </Tooltip>
value: ExportMode.V1Resource, {switchExportLabel}
}, </Stack>
]} </Label>
value={exportMode} <Switch
onChange={(value) => onExportModeChange(value)} label={switchExportLabel}
/> value={isSharingExternally}
</Stack> onChange={onShareExternallyChange}
)} data-testid={selector.exportExternallyToggle}
{exportMode !== ExportMode.Classic && ( />
<Stack gap={1} alignItems="center"> </Stack>
<Label>{switchExportFormatLabel}</Label> )}
<RadioButtonGroup
options={[
{ label: t('dashboard-scene.resource-export.label.json', 'JSON'), value: 'json' },
{ label: t('dashboard-scene.resource-export.label.yaml', 'YAML'), value: 'yaml' },
]}
value={isViewingYAML ? 'yaml' : 'json'}
onChange={onViewYAML}
/>
</Stack>
)}
{(isV2Dashboard ||
exportMode === ExportMode.Classic ||
(initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && (
<Stack gap={1} alignItems="start">
<Label>{switchExportLabel}</Label>
<Switch
label={switchExportLabel}
value={isSharingExternally}
onChange={onShareExternallyChange}
data-testid={selector.exportExternallyToggle}
/>
</Stack>
)}
</Stack>
{showV2LibPanelAlert && ( {showV2LibPanelAlert && (
<Alert <Alert
@@ -130,6 +141,7 @@ export function ResourceExport({
'Library panels will be converted to regular panels' 'Library panels will be converted to regular panels'
)} )}
severity="warning" severity="warning"
topSpacing={2}
> >
<Trans i18nKey="dashboard-scene.save-dashboard-form.schema-v2-library-panels-export"> <Trans i18nKey="dashboard-scene.save-dashboard-form.schema-v2-library-panels-export">
Due to limitations in the new dashboard schema (V2), library panels will be converted to regular panels with Due to limitations in the new dashboard schema (V2), library panels will be converted to regular panels with
@@ -137,6 +149,6 @@ export function ResourceExport({
</Trans> </Trans>
</Alert> </Alert>
)} )}
</Stack> </>
); );
} }

View File

@@ -66,7 +66,12 @@ function ShareDrawerRenderer({ model }: SceneComponentProps<ShareDrawer>) {
const dashboard = getDashboardSceneFor(model); const dashboard = getDashboardSceneFor(model);
return ( return (
<Drawer title={activeShare?.getTabLabel()} onClose={model.onDismiss} size="md"> <Drawer
title={activeShare?.getTabLabel()}
subtitle={activeShare?.getSubtitle?.()}
onClose={model.onDismiss}
size="md"
>
<ShareDrawerContext.Provider value={{ dashboard, onDismiss: model.onDismiss }}> <ShareDrawerContext.Provider value={{ dashboard, onDismiss: model.onDismiss }}>
{activeShare && <activeShare.Component model={activeShare} />} {activeShare && <activeShare.Component model={activeShare} />}
</ShareDrawerContext.Provider> </ShareDrawerContext.Provider>

View File

@@ -66,6 +66,10 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
return t('share-modal.tab-title.export', 'Export'); return t('share-modal.tab-title.export', 'Export');
} }
public getSubtitle(): string | undefined {
return undefined;
}
public onShareExternallyChange = () => { public onShareExternallyChange = () => {
this.setState({ this.setState({
isSharingExternally: !this.state.isSharingExternally, isSharingExternally: !this.state.isSharingExternally,

View File

@@ -15,5 +15,6 @@ export interface SceneShareTab<T extends SceneShareTabState = SceneShareTabState
export interface ShareView extends SceneObject { export interface ShareView extends SceneObject {
getTabLabel(): string; getTabLabel(): string;
getSubtitle?(): string | undefined;
onDismiss?: () => void; onDismiss?: () => void;
} }

View File

@@ -33,7 +33,7 @@ global.ResizeObserver = jest.fn().mockImplementation((callback) => {
}); });
// Helper function to assign a mock div to a ref // Helper function to assign a mock div to a ref
function assignMockDivToRef(ref: React.RefObject<HTMLDivElement | null>, mockDiv: HTMLDivElement) { function assignMockDivToRef(ref: React.RefObject<HTMLDivElement>, mockDiv: HTMLDivElement) {
// Use type assertion to bypass readonly restriction in tests // Use type assertion to bypass readonly restriction in tests
(ref as { current: HTMLDivElement | null }).current = mockDiv; (ref as { current: HTMLDivElement | null }).current = mockDiv;
} }

View File

@@ -8,7 +8,7 @@ import grafanaTextLogoDarkSvg from 'img/grafana_text_logo_dark.svg';
import grafanaTextLogoLightSvg from 'img/grafana_text_logo_light.svg'; import grafanaTextLogoLightSvg from 'img/grafana_text_logo_light.svg';
interface SoloPanelPageLogoProps { interface SoloPanelPageLogoProps {
containerRef: React.RefObject<HTMLDivElement | null>; containerRef: React.RefObject<HTMLDivElement>;
isHovered: boolean; isHovered: boolean;
hideLogo?: UrlQueryValue; hideLogo?: UrlQueryValue;
} }

View File

@@ -61,7 +61,7 @@ interface State {
class UnThemedTransformationsEditor extends React.PureComponent<TransformationsEditorProps, State> { class UnThemedTransformationsEditor extends React.PureComponent<TransformationsEditorProps, State> {
subscription?: Unsubscribable; subscription?: Unsubscribable;
ref: RefObject<HTMLDivElement | null>; ref: RefObject<HTMLDivElement>;
constructor(props: TransformationsEditorProps) { constructor(props: TransformationsEditorProps) {
super(props); super(props);

View File

@@ -43,7 +43,7 @@ export const LibraryPanelsView = ({
} }
); );
const asyncDispatch = useMemo(() => asyncDispatcher(dispatch), [dispatch]); const asyncDispatch = useMemo(() => asyncDispatcher(dispatch), [dispatch]);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController>();
useDebounce( useDebounce(
() => { () => {

View File

@@ -12,7 +12,7 @@ export interface Props {}
export const LiveConnectionWarning = memo(function LiveConnectionWarning() { export const LiveConnectionWarning = memo(function LiveConnectionWarning() {
const [show, setShow] = useState<boolean | undefined>(undefined); const [show, setShow] = useState<boolean | undefined>(undefined);
const subscriptionRef = useRef<Unsubscribable | null>(null); const subscriptionRef = useRef<Unsubscribable>();
const styles = useStyles2(getStyle); const styles = useStyles2(getStyle);
useEffect(() => { useEffect(() => {

View File

@@ -2,8 +2,9 @@ import { render, screen } from '@testing-library/react';
import { defaultsDeep } from 'lodash'; import { defaultsDeep } from 'lodash';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { FieldType, getDefaultTimeRange, LoadingState } from '@grafana/data'; import { CoreApp, EventBusSrv, FieldType, getDefaultTimeRange, LoadingState } from '@grafana/data';
import { PanelDataErrorViewProps } from '@grafana/runtime'; import { config, PanelDataErrorViewProps } from '@grafana/runtime';
import { usePanelContext } from '@grafana/ui';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
import { PanelDataErrorView } from './PanelDataErrorView'; import { PanelDataErrorView } from './PanelDataErrorView';
@@ -16,7 +17,24 @@ jest.mock('app/features/dashboard/services/DashboardSrv', () => ({
}, },
})); }));
jest.mock('@grafana/ui', () => ({
...jest.requireActual('@grafana/ui'),
usePanelContext: jest.fn(),
}));
const mockUsePanelContext = jest.mocked(usePanelContext);
const RUN_QUERY_MESSAGE = 'Run a query to visualize it here or go to all visualizations to add other panel types';
const panelContextRoot = {
app: CoreApp.Dashboard,
eventsScope: 'global',
eventBus: new EventBusSrv(),
};
describe('PanelDataErrorView', () => { describe('PanelDataErrorView', () => {
beforeEach(() => {
mockUsePanelContext.mockReturnValue(panelContextRoot);
});
it('show No data when there is no data', () => { it('show No data when there is no data', () => {
renderWithProps(); renderWithProps();
@@ -70,6 +88,45 @@ describe('PanelDataErrorView', () => {
expect(screen.getByText('Query returned nothing')).toBeInTheDocument(); expect(screen.getByText('Query returned nothing')).toBeInTheDocument();
}); });
it('should show "Run a query..." message when no query is configured and feature toggle is enabled', () => {
mockUsePanelContext.mockReturnValue(panelContextRoot);
const originalFeatureToggle = config.featureToggles.newVizSuggestions;
config.featureToggles.newVizSuggestions = true;
renderWithProps({
data: {
state: LoadingState.Done,
series: [],
timeRange: getDefaultTimeRange(),
},
});
expect(screen.getByText(RUN_QUERY_MESSAGE)).toBeInTheDocument();
config.featureToggles.newVizSuggestions = originalFeatureToggle;
});
it('should show "No data" message when feature toggle is disabled even without queries', () => {
mockUsePanelContext.mockReturnValue(panelContextRoot);
const originalFeatureToggle = config.featureToggles.newVizSuggestions;
config.featureToggles.newVizSuggestions = false;
renderWithProps({
data: {
state: LoadingState.Done,
series: [],
timeRange: getDefaultTimeRange(),
},
});
expect(screen.getByText('No data')).toBeInTheDocument();
expect(screen.queryByText(RUN_QUERY_MESSAGE)).not.toBeInTheDocument();
config.featureToggles.newVizSuggestions = originalFeatureToggle;
});
}); });
function renderWithProps(overrides?: Partial<PanelDataErrorViewProps>) { function renderWithProps(overrides?: Partial<PanelDataErrorViewProps>) {

View File

@@ -5,14 +5,15 @@ import {
FieldType, FieldType,
getPanelDataSummary, getPanelDataSummary,
GrafanaTheme2, GrafanaTheme2,
PanelData,
PanelDataSummary, PanelDataSummary,
PanelPluginVisualizationSuggestion, PanelPluginVisualizationSuggestion,
} from '@grafana/data'; } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n'; import { t, Trans } from '@grafana/i18n';
import { PanelDataErrorViewProps, locationService } from '@grafana/runtime'; import { PanelDataErrorViewProps, locationService, config } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes'; import { VizPanel } from '@grafana/scenes';
import { usePanelContext, useStyles2 } from '@grafana/ui'; import { Icon, usePanelContext, useStyles2 } from '@grafana/ui';
import { CardButton } from 'app/core/components/CardButton'; import { CardButton } from 'app/core/components/CardButton';
import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants'; import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants';
import store from 'app/core/store'; import store from 'app/core/store';
@@ -24,6 +25,11 @@ import { findVizPanelByKey, getVizPanelKeyForPanelId } from 'app/features/dashbo
import { useDispatch } from 'app/types/store'; import { useDispatch } from 'app/types/store';
import { changePanelPlugin } from '../state/actions'; import { changePanelPlugin } from '../state/actions';
import { hasData } from '../suggestions/utils';
function hasNoQueryConfigured(data: PanelData): boolean {
return !data.request?.targets || data.request.targets.length === 0;
}
export function PanelDataErrorView(props: PanelDataErrorViewProps) { export function PanelDataErrorView(props: PanelDataErrorViewProps) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
@@ -93,8 +99,14 @@ export function PanelDataErrorView(props: PanelDataErrorViewProps) {
} }
}; };
const noData = !hasData(props.data);
const noQueryConfigured = hasNoQueryConfigured(props.data);
const showEmptyState =
config.featureToggles.newVizSuggestions && context.app === CoreApp.PanelEditor && noQueryConfigured && noData;
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
{showEmptyState && <Icon name="chart-line" size="xxxl" className={styles.emptyStateIcon} />}
<div className={styles.message} data-testid={selectors.components.Panels.Panel.PanelDataErrorMessage}> <div className={styles.message} data-testid={selectors.components.Panels.Panel.PanelDataErrorMessage}>
{message} {message}
</div> </div>
@@ -131,7 +143,17 @@ function getMessageFor(
return message; return message;
} }
if (!data.series || data.series.length === 0 || data.series.every((frame) => frame.length === 0)) { const noData = !hasData(data);
const noQueryConfigured = hasNoQueryConfigured(data);
if (config.featureToggles.newVizSuggestions && noQueryConfigured && noData) {
return t(
'dashboard.new-panel.empty-state-message',
'Run a query to visualize it here or go to all visualizations to add other panel types'
);
}
if (noData) {
return fieldConfig?.defaults.noValue ?? t('panel.panel-data-error-view.no-value.default', 'No data'); return fieldConfig?.defaults.noValue ?? t('panel.panel-data-error-view.no-value.default', 'No data');
} }
@@ -176,5 +198,9 @@ const getStyles = (theme: GrafanaTheme2) => {
width: '100%', width: '100%',
maxWidth: '600px', maxWidth: '600px',
}), }),
emptyStateIcon: css({
color: theme.colors.text.secondary,
marginBottom: theme.spacing(2),
}),
}; };
}; };

View File

@@ -38,7 +38,7 @@ export function useSearchKeyboardNavigation(
): ItemSelection { ): ItemSelection {
const highlightIndexRef = useRef<ItemSelection>({ x: 0, y: -1 }); const highlightIndexRef = useRef<ItemSelection>({ x: 0, y: -1 });
const [highlightIndex, setHighlightIndex] = useState<ItemSelection>({ x: 0, y: -1 }); const [highlightIndex, setHighlightIndex] = useState<ItemSelection>({ x: 0, y: -1 });
const urlsRef = useRef<Field | null>(null); const urlsRef = useRef<Field>();
// Clear selection when the search results change // Clear selection when the search results change
useEffect(() => { useEffect(() => {

View File

@@ -77,7 +77,7 @@ export const SuggestionsInput = ({
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme, inputHeight); const styles = getStyles(theme, inputHeight);
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null); const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>();
useEffect(() => { useEffect(() => {
scrollRef.current?.scrollTo(0, scrollTop); scrollRef.current?.scrollTo(0, scrollTop);

View File

@@ -11,7 +11,7 @@ import checkboxWhitePng from 'img/checkbox_white.png';
import { ALL_VARIABLE_VALUE } from '../../constants'; import { ALL_VARIABLE_VALUE } from '../../constants';
export interface Props extends Omit<React.HTMLProps<HTMLUListElement>, 'onToggle'>, Themeable2 { export interface Props extends React.HTMLProps<HTMLUListElement>, Themeable2 {
multi: boolean; multi: boolean;
values: VariableOption[]; values: VariableOption[];
selectedValues: VariableOption[]; selectedValues: VariableOption[];

View File

@@ -20,8 +20,8 @@ interface CodeEditorProps {
export const LogsQLCodeEditor = (props: CodeEditorProps) => { export const LogsQLCodeEditor = (props: CodeEditorProps) => {
const { query, datasource, onChange } = props; const { query, datasource, onChange } = props;
const monacoRef = useRef<Monaco | null>(null); const monacoRef = useRef<Monaco>();
const disposalRef = useRef<monacoType.IDisposable | undefined>(undefined); const disposalRef = useRef<monacoType.IDisposable>();
const onFocus = useCallback(async () => { const onFocus = useCallback(async () => {
disposalRef.current = await reRegisterCompletionProvider( disposalRef.current = await reRegisterCompletionProvider(

View File

@@ -42,8 +42,8 @@ interface LogsCodeEditorProps {
export const PPLQueryEditor = (props: LogsCodeEditorProps) => { export const PPLQueryEditor = (props: LogsCodeEditorProps) => {
const { query, datasource, onChange } = props; const { query, datasource, onChange } = props;
const monacoRef = useRef<Monaco | null>(null); const monacoRef = useRef<Monaco>();
const disposalRef = useRef<monacoType.IDisposable | undefined>(undefined); const disposalRef = useRef<monacoType.IDisposable>();
const onFocus = useCallback(async () => { const onFocus = useCallback(async () => {
disposalRef.current = await reRegisterCompletionProvider( disposalRef.current = await reRegisterCompletionProvider(

View File

@@ -20,8 +20,8 @@ interface SQLCodeEditorProps {
export const SQLQueryEditor = (props: SQLCodeEditorProps) => { export const SQLQueryEditor = (props: SQLCodeEditorProps) => {
const { query, datasource, onChange } = props; const { query, datasource, onChange } = props;
const monacoRef = useRef<Monaco | null>(null); const monacoRef = useRef<Monaco>();
const disposalRef = useRef<monacoType.IDisposable | undefined>(undefined); const disposalRef = useRef<monacoType.IDisposable>();
const onFocus = useCallback(async () => { const onFocus = useCallback(async () => {
disposalRef.current = await reRegisterCompletionProvider( disposalRef.current = await reRegisterCompletionProvider(

View File

@@ -1,29 +1,26 @@
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { RadioButtonGroup } from '@grafana/ui'; import { RadioButtonGroup } from '@grafana/ui';
import { useDispatch } from '../../hooks/useStatelessReducer';
import { EditorType } from '../../types'; import { EditorType } from '../../types';
import { useQuery } from './ElasticsearchQueryContext';
import { changeEditorTypeAndResetQuery } from './state';
const BASE_OPTIONS: Array<SelectableValue<EditorType>> = [ const BASE_OPTIONS: Array<SelectableValue<EditorType>> = [
{ value: 'builder', label: 'Builder' }, { value: 'builder', label: 'Builder' },
{ value: 'code', label: 'Code' }, { value: 'code', label: 'Code' },
]; ];
export const EditorTypeSelector = () => { interface Props {
const query = useQuery(); value: EditorType;
const dispatch = useDispatch(); onChange: (editorType: EditorType) => void;
}
// Default to 'builder' if editorType is empty
const editorType: EditorType = query.editorType === 'code' ? 'code' : 'builder';
const onChange = (newEditorType: EditorType) => {
dispatch(changeEditorTypeAndResetQuery(newEditorType));
};
export const EditorTypeSelector = ({ value, onChange }: Props) => {
return ( return (
<RadioButtonGroup<EditorType> fullWidth={false} options={BASE_OPTIONS} value={editorType} onChange={onChange} /> <RadioButtonGroup<EditorType>
data-testid="elasticsearch-editor-type-toggle"
size="sm"
options={BASE_OPTIONS}
value={value}
onChange={onChange}
/>
); );
}; };

View File

@@ -10,9 +10,13 @@ interface Props {
onRunQuery: () => void; onRunQuery: () => void;
} }
// This offset was chosen by testing to match Prometheus behavior
const EDITOR_HEIGHT_OFFSET = 2;
export function RawQueryEditor({ value, onChange, onRunQuery }: Props) { export function RawQueryEditor({ value, onChange, onRunQuery }: Props) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const editorRef = useRef<monacoTypes.editor.IStandaloneCodeEditor | null>(null); const editorRef = useRef<monacoTypes.editor.IStandaloneCodeEditor | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const handleEditorDidMount = useCallback( const handleEditorDidMount = useCallback(
(editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => { (editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => {
@@ -22,6 +26,22 @@ export function RawQueryEditor({ value, onChange, onRunQuery }: Props) {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {
onRunQuery(); onRunQuery();
}); });
// Make the editor resize itself so that the content fits (grows taller when necessary)
// this code comes from the Prometheus query editor.
// We may wish to consider abstracting it into the grafana/ui repo in the future
const updateElementHeight = () => {
const containerDiv = containerRef.current;
if (containerDiv !== null) {
const pixelHeight = editor.getContentHeight();
containerDiv.style.height = `${pixelHeight + EDITOR_HEIGHT_OFFSET}px`;
const pixelWidth = containerDiv.clientWidth;
editor.layout({ width: pixelWidth, height: pixelHeight });
}
};
editor.onDidContentSizeChange(updateElementHeight);
updateElementHeight();
}, },
[onRunQuery] [onRunQuery]
); );
@@ -65,7 +85,17 @@ export function RawQueryEditor({ value, onChange, onRunQuery }: Props) {
return ( return (
<Box> <Box>
<div className={styles.header}> <div ref={containerRef} className={styles.editorContainer}>
<CodeEditor
value={value ?? ''}
language="json"
width="100%"
onBlur={handleQueryChange}
monacoOptions={monacoOptions}
onEditorDidMount={handleEditorDidMount}
/>
</div>
<div className={styles.footer}>
<Stack gap={1}> <Stack gap={1}>
<Button <Button
size="sm" size="sm"
@@ -76,20 +106,8 @@ export function RawQueryEditor({ value, onChange, onRunQuery }: Props) {
> >
Format Format
</Button> </Button>
<Button size="sm" variant="primary" icon="play" onClick={onRunQuery} tooltip="Run query (Ctrl/Cmd+Enter)">
Run
</Button>
</Stack> </Stack>
</div> </div>
<CodeEditor
value={value ?? ''}
language="json"
height={200}
width="100%"
onBlur={handleQueryChange}
monacoOptions={monacoOptions}
onEditorDidMount={handleEditorDidMount}
/>
</Box> </Box>
); );
} }
@@ -100,7 +118,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
flexDirection: 'column', flexDirection: 'column',
gap: theme.spacing(1), gap: theme.spacing(1),
}), }),
header: css({ editorContainer: css({
width: '100%',
overflow: 'hidden',
}),
footer: css({
display: 'flex', display: 'flex',
justifyContent: 'flex-end', justifyContent: 'flex-end',
padding: theme.spacing(0.5, 0), padding: theme.spacing(0.5, 0),

View File

@@ -1,16 +1,16 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useEffect, useId, useState } from 'react'; import { useCallback, useEffect, useId, useState } from 'react';
import { SemVer } from 'semver'; import { SemVer } from 'semver';
import { getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; import { getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { Alert, InlineField, InlineLabel, Input, QueryField, useStyles2 } from '@grafana/ui'; import { Alert, ConfirmModal, InlineField, InlineLabel, Input, QueryField, useStyles2 } from '@grafana/ui';
import { ElasticsearchDataQuery } from '../../dataquery.gen'; import { ElasticsearchDataQuery } from '../../dataquery.gen';
import { ElasticDatasource } from '../../datasource'; import { ElasticDatasource } from '../../datasource';
import { useNextId } from '../../hooks/useNextId'; import { useNextId } from '../../hooks/useNextId';
import { useDispatch } from '../../hooks/useStatelessReducer'; import { useDispatch } from '../../hooks/useStatelessReducer';
import { ElasticsearchOptions } from '../../types'; import { EditorType, ElasticsearchOptions } from '../../types';
import { isSupportedVersion, isTimeSeriesQuery, unsupportedVersionMessage } from '../../utils'; import { isSupportedVersion, isTimeSeriesQuery, unsupportedVersionMessage } from '../../utils';
import { BucketAggregationsEditor } from './BucketAggregationsEditor'; import { BucketAggregationsEditor } from './BucketAggregationsEditor';
@@ -20,7 +20,7 @@ import { MetricAggregationsEditor } from './MetricAggregationsEditor';
import { metricAggregationConfig } from './MetricAggregationsEditor/utils'; import { metricAggregationConfig } from './MetricAggregationsEditor/utils';
import { QueryTypeSelector } from './QueryTypeSelector'; import { QueryTypeSelector } from './QueryTypeSelector';
import { RawQueryEditor } from './RawQueryEditor'; import { RawQueryEditor } from './RawQueryEditor';
import { changeAliasPattern, changeQuery, changeRawDSLQuery } from './state'; import { changeAliasPattern, changeEditorTypeAndResetQuery, changeQuery, changeRawDSLQuery } from './state';
export type ElasticQueryEditorProps = QueryEditorProps<ElasticDatasource, ElasticsearchDataQuery, ElasticsearchOptions>; export type ElasticQueryEditorProps = QueryEditorProps<ElasticDatasource, ElasticsearchDataQuery, ElasticsearchOptions>;
@@ -97,31 +97,61 @@ const QueryEditorForm = ({ value, onRunQuery }: Props & { onRunQuery: () => void
const inputId = useId(); const inputId = useId();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const [switchModalOpen, setSwitchModalOpen] = useState(false);
const [pendingEditorType, setPendingEditorType] = useState<EditorType | null>(null);
const isTimeSeries = isTimeSeriesQuery(value); const isTimeSeries = isTimeSeriesQuery(value);
const isCodeEditor = value.editorType === 'code'; const isCodeEditor = value.editorType === 'code';
const rawDSLFeatureEnabled = config.featureToggles.elasticsearchRawDSLQuery; const rawDSLFeatureEnabled = config.featureToggles.elasticsearchRawDSLQuery;
// Default to 'builder' if editorType is empty
const currentEditorType: EditorType = value.editorType === 'code' ? 'code' : 'builder';
const showBucketAggregationsEditor = value.metrics?.every( const showBucketAggregationsEditor = value.metrics?.every(
(metric) => metricAggregationConfig[metric.type].impliedQueryType === 'metrics' (metric) => metricAggregationConfig[metric.type].impliedQueryType === 'metrics'
); );
const onEditorTypeChange = useCallback((newEditorType: EditorType) => {
// Show warning modal when switching modes
setPendingEditorType(newEditorType);
setSwitchModalOpen(true);
}, []);
const confirmEditorTypeChange = useCallback(() => {
if (pendingEditorType) {
dispatch(changeEditorTypeAndResetQuery(pendingEditorType));
}
setSwitchModalOpen(false);
setPendingEditorType(null);
}, [dispatch, pendingEditorType]);
const cancelEditorTypeChange = useCallback(() => {
setSwitchModalOpen(false);
setPendingEditorType(null);
}, []);
return ( return (
<> <>
<ConfirmModal
isOpen={switchModalOpen}
title="Switch editor"
body="Switching between editors will reset your query. Are you sure you want to continue?"
confirmText="Continue"
onConfirm={confirmEditorTypeChange}
onDismiss={cancelEditorTypeChange}
/>
<div className={styles.root}> <div className={styles.root}>
<InlineLabel width={17}>Query type</InlineLabel> <InlineLabel width={17}>Query type</InlineLabel>
<div className={styles.queryItem}> <div className={styles.queryItem}>
<QueryTypeSelector /> <QueryTypeSelector />
</div> </div>
</div> {rawDSLFeatureEnabled && (
{rawDSLFeatureEnabled && ( <div style={{ marginLeft: 'auto' }}>
<div className={styles.root}> <EditorTypeSelector value={currentEditorType} onChange={onEditorTypeChange} />
<InlineLabel width={17}>Editor type</InlineLabel>
<div className={styles.queryItem}>
<EditorTypeSelector />
</div> </div>
</div> )}
)} </div>
{isCodeEditor && rawDSLFeatureEnabled && ( {isCodeEditor && rawDSLFeatureEnabled && (
<RawQueryEditor <RawQueryEditor

View File

@@ -94,7 +94,7 @@ const EDITOR_HEIGHT_OFFSET = 2;
* Hook that returns function that will set up monaco autocomplete for the label selector * Hook that returns function that will set up monaco autocomplete for the label selector
*/ */
function useAutocomplete(getLabelValues: (label: string) => Promise<string[]>, labels?: string[]) { function useAutocomplete(getLabelValues: (label: string) => Promise<string[]>, labels?: string[]) {
const providerRef = useRef<CompletionProvider | null>(null); const providerRef = useRef<CompletionProvider>();
if (providerRef.current === undefined) { if (providerRef.current === undefined) {
providerRef.current = new CompletionProvider(); providerRef.current = new CompletionProvider();
} }

View File

@@ -126,7 +126,7 @@ export function LokiContextUi(props: LokiContextUiProps) {
window.localStorage.getItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS) === 'true' window.localStorage.getItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS) === 'true'
); );
const timerHandle = useRef<number | null>(null); const timerHandle = useRef<number>();
const previousInitialized = useRef<boolean>(false); const previousInitialized = useRef<boolean>(false);
const previousContextFilters = useRef<ContextFilter[]>([]); const previousContextFilters = useRef<ContextFilter[]>([]);
@@ -191,18 +191,14 @@ export function LokiContextUi(props: LokiContextUiProps) {
}, 1500); }, 1500);
return () => { return () => {
if (timerHandle.current) { clearTimeout(timerHandle.current);
clearTimeout(timerHandle.current);
}
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [contextFilters, initialized]); }, [contextFilters, initialized]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (timerHandle.current) { clearTimeout(timerHandle.current);
clearTimeout(timerHandle.current);
}
onClose(); onClose();
}; };
}, [onClose]); }, [onClose]);

View File

@@ -52,7 +52,7 @@ export function TraceQLEditor(props: Props) {
const onRunQueryRef = useRef(onRunQuery); const onRunQueryRef = useRef(onRunQuery);
onRunQueryRef.current = onRunQuery; onRunQueryRef.current = onRunQuery;
const errorTimeoutId = useRef<number | null>(null); const errorTimeoutId = useRef<number>();
return ( return (
<> <>
@@ -103,9 +103,7 @@ export function TraceQLEditor(props: Props) {
} }
// Remove previous callback if existing, to prevent squiggles from been shown while the user is still typing // Remove previous callback if existing, to prevent squiggles from been shown while the user is still typing
if (errorTimeoutId.current) { window.clearTimeout(errorTimeoutId.current);
window.clearTimeout(errorTimeoutId.current);
}
const errorNodes = getErrorNodes(model.getValue()); const errorNodes = getErrorNodes(model.getValue());
const cursorPosition = changeEvent.changes[0].rangeOffset; const cursorPosition = changeEvent.changes[0].rangeOffset;

View File

@@ -1,5 +1,5 @@
export function renderHistogram( export function renderHistogram(
can: React.RefObject<HTMLCanvasElement | null>, can: React.RefObject<HTMLCanvasElement>,
histCanWidth: number, histCanWidth: number,
histCanHeight: number, histCanHeight: number,
xVals: number[], xVals: number[],

View File

@@ -196,7 +196,7 @@ export const LogsPanel = ({
const dataSourcesMap = useDatasourcesFromTargets(panelData.request?.targets); const dataSourcesMap = useDatasourcesFromTargets(panelData.request?.targets);
// Prevents the scroll position to change when new data from infinite scrolling is received // Prevents the scroll position to change when new data from infinite scrolling is received
const keepScrollPositionRef = useRef<null | 'infinite-scroll' | 'user'>(null); const keepScrollPositionRef = useRef<null | 'infinite-scroll' | 'user'>(null);
const closeCallback = useRef<(() => void) | null>(null); let closeCallback = useRef<() => void>();
const { app, eventBus, onAddAdHocFilter } = usePanelContext(); const { app, eventBus, onAddAdHocFilter } = usePanelContext();
useEffect(() => { useEffect(() => {

View File

@@ -70,7 +70,7 @@ export function useLayout(
const currentSignature = createDataSignature(rawNodes, rawEdges); const currentSignature = createDataSignature(rawNodes, rawEdges);
const isMounted = useMountedState(); const isMounted = useMountedState();
const layoutWorkerCancelRef = useRef<(() => void) | null>(null); const layoutWorkerCancelRef = useRef<(() => void) | undefined>();
useUnmount(() => { useUnmount(() => {
if (layoutWorkerCancelRef.current) { if (layoutWorkerCancelRef.current) {

View File

@@ -133,7 +133,7 @@ export const AnnotationsPlugin2 = ({
const newRangeRef = useRef(newRange); const newRangeRef = useRef(newRange);
newRangeRef.current = newRange; newRangeRef.current = newRange;
const xAxisRef = useRef<HTMLDivElement | null>(null); const xAxisRef = useRef<HTMLDivElement>();
useLayoutEffect(() => { useLayoutEffect(() => {
config.addHook('ready', (u) => { config.addHook('ready', (u) => {

View File

@@ -23,7 +23,7 @@ export const ExemplarsPlugin = ({
maxHeight, maxHeight,
maxWidth, maxWidth,
}: ExemplarsPluginProps) => { }: ExemplarsPluginProps) => {
const plotInstance = useRef<uPlot | null>(null); const plotInstance = useRef<uPlot>();
const [lockedExemplarRowIndex, setLockedExemplarRowIndex] = useState<number | undefined>(); const [lockedExemplarRowIndex, setLockedExemplarRowIndex] = useState<number | undefined>();

View File

@@ -11,7 +11,7 @@ interface ThresholdControlsPluginProps {
} }
export const OutsideRangePlugin = ({ config, onChangeTimeRange }: ThresholdControlsPluginProps) => { export const OutsideRangePlugin = ({ config, onChangeTimeRange }: ThresholdControlsPluginProps) => {
const plotInstance = useRef<uPlot | null>(null); const plotInstance = useRef<uPlot>();
const [timevalues, setTimeValues] = useState<number[] | TypedArray>([]); const [timevalues, setTimeValues] = useState<number[] | TypedArray>([]);
const [timeRange, setTimeRange] = useState<Scale | undefined>(); const [timeRange, setTimeRange] = useState<Scale | undefined>();

View File

@@ -16,7 +16,7 @@ interface ThresholdControlsPluginProps {
} }
export const ThresholdControlsPlugin = ({ config, fieldConfig, onThresholdsChange }: ThresholdControlsPluginProps) => { export const ThresholdControlsPlugin = ({ config, fieldConfig, onThresholdsChange }: ThresholdControlsPluginProps) => {
const plotInstance = useRef<uPlot | null>(null); const plotInstance = useRef<uPlot>();
const [renderToken, setRenderToken] = useState(0); const [renderToken, setRenderToken] = useState(0);
useLayoutEffect(() => { useLayoutEffect(() => {

View File

@@ -6383,12 +6383,15 @@
}, },
"resource-export": { "resource-export": {
"label": { "label": {
"advanced-options": "Advanced options",
"classic": "Classic", "classic": "Classic",
"json": "JSON", "json": "JSON",
"v1-resource": "V1 Resource", "v1-resource": "V1 Resource",
"v2-resource": "V2 Resource", "v2-resource": "V2 Resource",
"yaml": "YAML" "yaml": "YAML"
} },
"share-externally": "Share dashboard with another instance",
"share-externally-tooltip": "Removes all instance-specific metadata and data source references from the resource before export."
}, },
"revert-dashboard-modal": { "revert-dashboard-modal": {
"body-restore-version": "Are you sure you want to restore the dashboard to version {{version}}? All unsaved changes will be lost.", "body-restore-version": "Are you sure you want to restore the dashboard to version {{version}}? All unsaved changes will be lost.",
@@ -7842,7 +7845,6 @@
"export-externally-label": "Export the dashboard to use in another instance", "export-externally-label": "Export the dashboard to use in another instance",
"export-format": "Format", "export-format": "Format",
"export-mode": "Model", "export-mode": "Model",
"export-remove-ds-refs": "Remove deployment details",
"info-text": "Copy or download a file containing the definition of your dashboard", "info-text": "Copy or download a file containing the definition of your dashboard",
"title": "Export dashboard" "title": "Export dashboard"
}, },