Loki: Sync query direction with sort order in Explore and Dashboards (#98722)

* Logs: sync direction and sort order for loki queries in explore

* Logs: emit event on sort order change

* Loki query editor: listen to sort change events and update direction

* Loki query editor: unsubscribe to sort event

* Logs: don't publish events in Explore

* LokiQueryBuilderOptions: use stored order as default value when in explore

* Query builder options: initialize query direction

* Logs: unit test

* LogsPanel: update unit test

* Update tests

* LokiQueryBuilderOptions: unit test

* Update public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx

Co-authored-by: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com>

* Update public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx

---------

Co-authored-by: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com>
This commit is contained in:
Matias Chomicki 2025-01-10 17:59:03 +00:00 committed by GitHub
parent 90e65f6ce6
commit 3eace5f7c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 340 additions and 22 deletions

View File

@ -764,6 +764,8 @@ export {
type DataSourceWithQueryModificationSupport, type DataSourceWithQueryModificationSupport,
hasToggleableQueryFiltersSupport, hasToggleableQueryFiltersSupport,
hasQueryModificationSupport, hasQueryModificationSupport,
LogSortOrderChangeEvent,
type LogSortOrderChangePayload,
} from './types/logs'; } from './types/logs';
export { export {
type AnnotationQuery, type AnnotationQuery,

View File

@ -1,6 +1,8 @@
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { DataQuery } from '@grafana/schema'; import { DataQuery, LogsSortOrder } from '@grafana/schema';
import { BusEventWithPayload } from '../events/types';
import { KeyValue, Labels } from './data'; import { KeyValue, Labels } from './data';
import { DataFrame } from './dataFrame'; import { DataFrame } from './dataFrame';
@ -366,3 +368,11 @@ export const hasQueryModificationSupport = <TQuery extends DataQuery>(
'getSupportedQueryModifications' in datasource 'getSupportedQueryModifications' in datasource
); );
}; };
export interface LogSortOrderChangePayload {
order: LogsSortOrder;
}
export class LogSortOrderChangeEvent extends BusEventWithPayload<LogSortOrderChangePayload> {
static type = 'logs-sort-order-change';
}

View File

@ -14,10 +14,12 @@ import {
toUtc, toUtc,
createDataFrame, createDataFrame,
ExploreLogsPanelState, ExploreLogsPanelState,
DataQuery,
} from '@grafana/data'; } from '@grafana/data';
import { organizeFieldsTransformer } from '@grafana/data/src/transformations/transformers/organize'; import { organizeFieldsTransformer } from '@grafana/data/src/transformations/transformers/organize';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields'; import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields';
import { LokiQueryDirection } from 'app/plugins/datasource/loki/dataquery.gen';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
import { initialExploreState } from '../state/main'; import { initialExploreState } from '../state/main';
@ -46,6 +48,17 @@ jest.mock('../state/explorePane', () => ({
changePanelState: (exploreId: string, panel: 'logs', panelState: {} | ExploreLogsPanelState) => { changePanelState: (exploreId: string, panel: 'logs', panelState: {} | ExploreLogsPanelState) => {
return fakeChangePanelState(exploreId, panel, panelState); return fakeChangePanelState(exploreId, panel, panelState);
}, },
changeQueries: (args: { queries: DataQuery[]; exploreId: string | undefined }) => {
return fakeChangeQueries(args);
},
}));
const fakeChangeQueries = jest.fn().mockReturnValue({ type: 'fakeChangeQueries' });
jest.mock('../state/query', () => ({
...jest.requireActual('../state/query'),
changeQueries: (args: { queries: DataQuery[]; exploreId: string | undefined }) => {
return fakeChangeQueries(args);
},
})); }));
describe('Logs', () => { describe('Logs', () => {
@ -377,6 +390,26 @@ describe('Logs', () => {
expect(logRows[2].textContent).toContain('log message 3'); expect(logRows[2].textContent).toContain('log message 3');
}); });
it('should sync the query direction when changing the order of loki queries', async () => {
const query = { expr: '{a="b"}', refId: 'A', datasource: { type: 'loki' } };
setup({ logsQueries: [query] });
const oldestFirstSelection = screen.getByLabelText('Oldest first');
await userEvent.click(oldestFirstSelection);
expect(fakeChangeQueries).toHaveBeenCalledWith({
exploreId: 'left',
queries: [{ ...query, direction: LokiQueryDirection.Forward }],
});
});
it('should not change the query direction when changing the order of non-loki queries', async () => {
fakeChangeQueries.mockClear();
const query = { refId: 'B' };
setup({ logsQueries: [query] });
const oldestFirstSelection = screen.getByLabelText('Oldest first');
await userEvent.click(oldestFirstSelection);
expect(fakeChangeQueries).not.toHaveBeenCalled();
});
describe('for permalinking', () => { describe('for permalinking', () => {
it('should dispatch a `changePanelState` event without the id', () => { it('should dispatch a `changePanelState` event without the id', () => {
const panelState = { logs: { id: '1' } }; const panelState = { logs: { id: '1' } };

View File

@ -58,6 +58,7 @@ import { LogRowContextModal } from 'app/features/logs/components/log-context/Log
import { LogLevelColor, dedupLogRows, filterLogLevels } from 'app/features/logs/logsModel'; import { LogLevelColor, dedupLogRows, filterLogLevels } from 'app/features/logs/logsModel';
import { getLogLevel, getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils'; import { getLogLevel, getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils';
import { LokiQueryDirection } from 'app/plugins/datasource/loki/dataquery.gen'; import { LokiQueryDirection } from 'app/plugins/datasource/loki/dataquery.gen';
import { isLokiQuery } from 'app/plugins/datasource/loki/queryUtils';
import { getState } from 'app/store/store'; import { getState } from 'app/store/store';
import { ExploreItemState, useDispatch } from 'app/types'; import { ExploreItemState, useDispatch } from 'app/types';
@ -72,6 +73,7 @@ import {
import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext'; import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext';
import { getUrlStateFromPaneState } from '../hooks/useStateSync'; import { getUrlStateFromPaneState } from '../hooks/useStateSync';
import { changePanelState } from '../state/explorePane'; import { changePanelState } from '../state/explorePane';
import { changeQueries } from '../state/query';
import { LogsFeedback } from './LogsFeedback'; import { LogsFeedback } from './LogsFeedback';
import { LogsMetaRow } from './LogsMetaRow'; import { LogsMetaRow } from './LogsMetaRow';
@ -468,6 +470,31 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const newSortOrder = const newSortOrder =
logsSortOrder === LogsSortOrder.Descending ? LogsSortOrder.Ascending : LogsSortOrder.Descending; logsSortOrder === LogsSortOrder.Descending ? LogsSortOrder.Ascending : LogsSortOrder.Descending;
store.set(SETTINGS_KEYS.logsSortOrder, newSortOrder); store.set(SETTINGS_KEYS.logsSortOrder, newSortOrder);
if (logsQueries) {
let hasLokiQueries = false;
const newQueries = logsQueries.map((query) => {
if (query.datasource?.type !== 'loki' || !isLokiQuery(query)) {
return query;
}
hasLokiQueries = true;
if (query.direction === LokiQueryDirection.Scan) {
// Don't override Scan. When the direction is Scan it means that the user specifically assigned this direction to the query.
return query;
}
const newDirection =
newSortOrder === LogsSortOrder.Ascending ? LokiQueryDirection.Forward : LokiQueryDirection.Backward;
if (newDirection !== query.direction) {
query.direction = newDirection;
}
return query;
});
if (hasLokiQueries) {
dispatch(changeQueries({ exploreId, queries: newQueries }));
}
}
setLogsSortOrder(newSortOrder); setLogsSortOrder(newSortOrder);
}, 0); }, 0);
cancelFlippingTimer.current = window.setTimeout(() => setIsFlipping(false), 1000); cancelFlippingTimer.current = window.setTimeout(() => setIsFlipping(false), 1000);

View File

@ -15,6 +15,9 @@ import { LokiQueryEditorProps } from './types';
jest.mock('@grafana/runtime', () => { jest.mock('@grafana/runtime', () => {
return { return {
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getAppEvents: jest.fn().mockReturnValue({
subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }),
}),
reportInteraction: jest.fn(), reportInteraction: jest.fn(),
}; };
}); });

View File

@ -9,6 +9,13 @@ import { testIds as regularTestIds } from './LokiQueryEditor';
import { LokiQueryEditorByApp } from './LokiQueryEditorByApp'; import { LokiQueryEditorByApp } from './LokiQueryEditorByApp';
import { testIds as alertingTestIds } from './LokiQueryEditorForAlerting'; import { testIds as alertingTestIds } from './LokiQueryEditorForAlerting';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getAppEvents: jest.fn().mockReturnValue({
subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }),
}),
}));
function setup(app: CoreApp): RenderResult { function setup(app: CoreApp): RenderResult {
const dataSource = createLokiDatasource(); const dataSource = createLokiDatasource();
dataSource.metadataRequest = jest.fn(); dataSource.metadataRequest = jest.fn();

View File

@ -6,6 +6,13 @@ import { createLokiDatasource } from '../../__mocks__/datasource';
import { MonacoQueryFieldWrapper, Props } from './MonacoQueryFieldWrapper'; import { MonacoQueryFieldWrapper, Props } from './MonacoQueryFieldWrapper';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getAppEvents: jest.fn().mockReturnValue({
subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }),
}),
}));
function renderComponent({ initialValue = '', onChange = jest.fn(), onRunQuery = jest.fn() }: Partial<Props> = {}) { function renderComponent({ initialValue = '', onChange = jest.fn(), onRunQuery = jest.fn() }: Partial<Props> = {}) {
const datasource = createLokiDatasource(); const datasource = createLokiDatasource();

View File

@ -1,9 +1,37 @@
import { render, screen } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { LokiQuery, LokiQueryType } from '../../types'; import { CoreApp, LogSortOrderChangeEvent, LogsSortOrder, store } from '@grafana/data';
import { config, getAppEvents } from '@grafana/runtime';
import { LokiQueryBuilderOptions } from './LokiQueryBuilderOptions'; import { LokiQuery, LokiQueryDirection, LokiQueryType } from '../../types';
import { LokiQueryBuilderOptions, Props } from './LokiQueryBuilderOptions';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
...jest.requireActual('@grafana/runtime').config,
featureToggles: {
...jest.requireActual('@grafana/runtime').featureToggles,
lokiShardSplitting: true,
},
},
getAppEvents: jest.fn(),
}));
const subscribeMock = jest.fn();
beforeAll(() => {
config.featureToggles.lokiShardSplitting = true;
subscribeMock.mockImplementation(() => ({ unsubscribe: jest.fn() }));
jest.mocked(getAppEvents).mockReturnValue({
publish: jest.fn(),
getStream: jest.fn(),
subscribe: subscribeMock,
removeAllListeners: jest.fn(),
newScopedBus: jest.fn(),
});
});
describe('LokiQueryBuilderOptions', () => { describe('LokiQueryBuilderOptions', () => {
it('can change query type', async () => { it('can change query type', async () => {
@ -86,7 +114,7 @@ describe('LokiQueryBuilderOptions', () => {
}); });
it('shows correct options for log query', async () => { it('shows correct options for log query', async () => {
setup({ expr: '{foo="bar"}' }); setup({ expr: '{foo="bar"}', direction: LokiQueryDirection.Backward });
expect(screen.getByText('Line limit: 20')).toBeInTheDocument(); expect(screen.getByText('Line limit: 20')).toBeInTheDocument();
expect(screen.getByText('Type: Range')).toBeInTheDocument(); expect(screen.getByText('Type: Range')).toBeInTheDocument();
expect(screen.getByText('Direction: Backward')).toBeInTheDocument(); expect(screen.getByText('Direction: Backward')).toBeInTheDocument();
@ -184,9 +212,91 @@ describe('LokiQueryBuilderOptions', () => {
step: '4s', step: '4s',
}); });
}); });
describe('Query direction', () => {
it("initializes query direction when it's empty", async () => {
const onChange = jest.fn();
setup({ expr: '{foo="bar"}' }, onChange);
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith({
expr: '{foo="bar"}',
refId: 'A',
direction: LokiQueryDirection.Backward,
})
);
});
it('uses backward as default in Explore with no previous stored preference', async () => {
const onChange = jest.fn();
store.delete('grafana.explore.logs.sortOrder');
setup({ expr: '{foo="bar"}' }, onChange, { app: CoreApp.Explore });
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith({
expr: '{foo="bar"}',
refId: 'A',
direction: LokiQueryDirection.Backward,
})
);
});
it('uses the stored sorting option to determine direction in Explore', async () => {
store.set('grafana.explore.logs.sortOrder', LogsSortOrder.Ascending);
const onChange = jest.fn();
setup({ expr: '{foo="bar"}' }, onChange, { app: CoreApp.Explore });
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith({
expr: '{foo="bar"}',
refId: 'A',
direction: LokiQueryDirection.Forward,
})
);
store.delete('grafana.explore.logs.sortOrder');
});
describe('Event handling', () => {
let listener: (event: LogSortOrderChangeEvent) => void = jest.fn();
const onChangeMock = jest.fn();
beforeEach(() => {
onChangeMock.mockClear();
listener = jest.fn();
subscribeMock.mockImplementation((_: unknown, callback: (event: LogSortOrderChangeEvent) => void) => {
listener = callback;
return { unsubscribe: jest.fn() };
});
});
it('subscribes to sort change event and updates the direction', () => {
setup({ expr: '{foo="bar"}', direction: LokiQueryDirection.Backward }, onChangeMock, {
app: CoreApp.Dashboard,
});
expect(screen.getByText(/Direction: Backward/)).toBeInTheDocument();
listener(
new LogSortOrderChangeEvent({
order: LogsSortOrder.Ascending,
})
);
expect(onChangeMock).toHaveBeenCalledTimes(1);
expect(onChangeMock).toHaveBeenCalledWith({
direction: 'forward',
expr: '{foo="bar"}',
refId: 'A',
});
});
it('does not change the direction when the current direction is scan', () => {
setup({ expr: '{foo="bar"}', direction: LokiQueryDirection.Scan }, onChangeMock, { app: CoreApp.Dashboard });
expect(screen.getByText(/Direction: Scan/)).toBeInTheDocument();
listener(
new LogSortOrderChangeEvent({
order: LogsSortOrder.Ascending,
})
);
expect(onChangeMock).not.toHaveBeenCalled();
});
});
});
}); });
function setup(queryOverrides: Partial<LokiQuery> = {}, onChange = jest.fn()) { function setup(queryOverrides: Partial<LokiQuery> = {}, onChange = jest.fn(), propOverrides: Partial<Props> = {}) {
const props = { const props = {
query: { query: {
refId: 'A', refId: 'A',
@ -197,6 +307,7 @@ function setup(queryOverrides: Partial<LokiQuery> = {}, onChange = jest.fn()) {
onChange, onChange,
maxLines: 20, maxLines: 20,
queryStats: { streams: 0, chunks: 0, bytes: 0, entries: 0 }, queryStats: { streams: 0, chunks: 0, bytes: 0, entries: 0 },
...propOverrides,
}; };
const { container } = render(<LokiQueryBuilderOptions {...props} />); const { container } = render(<LokiQueryBuilderOptions {...props} />);

View File

@ -1,10 +1,18 @@
import { trim } from 'lodash'; import { trim } from 'lodash';
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import * as React from 'react'; import * as React from 'react';
import { CoreApp, isValidDuration, isValidGrafanaDuration, SelectableValue } from '@grafana/data'; import {
CoreApp,
isValidDuration,
isValidGrafanaDuration,
LogSortOrderChangeEvent,
LogsSortOrder,
SelectableValue,
store,
} from '@grafana/data';
import { EditorField, EditorRow, QueryOptionGroup } from '@grafana/experimental'; import { EditorField, EditorRow, QueryOptionGroup } from '@grafana/experimental';
import { config, reportInteraction } from '@grafana/runtime'; import { config, getAppEvents, reportInteraction } from '@grafana/runtime';
import { Alert, AutoSizeInput, RadioButtonGroup, Select } from '@grafana/ui'; import { Alert, AutoSizeInput, RadioButtonGroup, Select } from '@grafana/ui';
import { import {
@ -30,6 +38,13 @@ export const LokiQueryBuilderOptions = React.memo<Props>(
({ app, query, onChange, onRunQuery, maxLines, queryStats }) => { ({ app, query, onChange, onRunQuery, maxLines, queryStats }) => {
const [splitDurationValid, setSplitDurationValid] = useState(true); const [splitDurationValid, setSplitDurationValid] = useState(true);
useEffect(() => {
// Initialize the query direction according to the current environment.
if (!query.direction) {
onChange({ ...query, direction: getDefaultQueryDirection(app) });
}
}, [app, onChange, query]);
useEffect(() => { useEffect(() => {
if (query.step && !isValidGrafanaDuration(`${query.step}`) && parseInt(query.step, 10)) { if (query.step && !isValidGrafanaDuration(`${query.step}`) && parseInt(query.step, 10)) {
onChange({ onChange({
@ -44,10 +59,13 @@ export const LokiQueryBuilderOptions = React.memo<Props>(
onRunQuery(); onRunQuery();
}; };
const onQueryDirectionChange = (value: LokiQueryDirection) => { const onQueryDirectionChange = useCallback(
onChange({ ...query, direction: value }); (value: LokiQueryDirection) => {
onRunQuery(); onChange({ ...query, direction: value });
}; onRunQuery();
},
[onChange, onRunQuery, query]
);
const onResolutionChange = (option: SelectableValue<number>) => { const onResolutionChange = (option: SelectableValue<number>) => {
reportInteraction('grafana_loki_resolution_clicked', { reportInteraction('grafana_loki_resolution_clicked', {
@ -87,14 +105,33 @@ export const LokiQueryBuilderOptions = React.memo<Props>(
onRunQuery(); onRunQuery();
} }
useEffect(() => {
if (app !== CoreApp.Dashboard && app !== CoreApp.PanelEditor) {
return;
}
const subscription = getAppEvents().subscribe(LogSortOrderChangeEvent, (sortEvent: LogSortOrderChangeEvent) => {
if (query.direction === LokiQueryDirection.Scan) {
return;
}
const newDirection =
sortEvent.payload.order === LogsSortOrder.Ascending
? LokiQueryDirection.Forward
: LokiQueryDirection.Backward;
if (newDirection !== query.direction) {
onQueryDirectionChange(newDirection);
}
});
return () => {
subscription.unsubscribe();
};
}, [app, onQueryDirectionChange, query.direction]);
let queryType = getLokiQueryType(query); let queryType = getLokiQueryType(query);
const isLogQuery = isLogsQuery(query.expr); const isLogQuery = isLogsQuery(query.expr);
const filteredQueryTypeOptions = isLogQuery const filteredQueryTypeOptions = isLogQuery
? queryTypeOptions.filter((o) => o.value !== LokiQueryType.Instant) ? queryTypeOptions.filter((o) => o.value !== LokiQueryType.Instant)
: queryTypeOptions; : queryTypeOptions;
const queryDirection = query.direction ?? LokiQueryDirection.Backward;
// if the state's queryType is still Instant, trigger a change to range for log queries // if the state's queryType is still Instant, trigger a change to range for log queries
if (isLogQuery && queryType === LokiQueryType.Instant) { if (isLogQuery && queryType === LokiQueryType.Instant) {
onChange({ ...query, queryType: LokiQueryType.Range }); onChange({ ...query, queryType: LokiQueryType.Range });
@ -112,7 +149,7 @@ export const LokiQueryBuilderOptions = React.memo<Props>(
<EditorRow> <EditorRow>
<QueryOptionGroup <QueryOptionGroup
title="Options" title="Options"
collapsedInfo={getCollapsedInfo(query, queryType, maxLines, isLogQuery, isValidStep, queryDirection)} collapsedInfo={getCollapsedInfo(query, queryType, maxLines, isLogQuery, isValidStep, query.direction)}
queryStats={queryStats} queryStats={queryStats}
> >
<EditorField <EditorField
@ -145,7 +182,7 @@ export const LokiQueryBuilderOptions = React.memo<Props>(
/> />
</EditorField> </EditorField>
<EditorField label="Direction" tooltip="Direction to search for logs."> <EditorField label="Direction" tooltip="Direction to search for logs.">
<RadioButtonGroup options={queryDirections} value={queryDirection} onChange={onQueryDirectionChange} /> <RadioButtonGroup options={queryDirections} value={query.direction} onChange={onQueryDirectionChange} />
</EditorField> </EditorField>
</> </>
)} )}
@ -214,7 +251,7 @@ function getCollapsedInfo(
maxLines: number, maxLines: number,
isLogQuery: boolean, isLogQuery: boolean,
isValidStep: boolean, isValidStep: boolean,
direction: LokiQueryDirection direction: LokiQueryDirection | undefined
): string[] { ): string[] {
const queryTypeLabel = queryTypeOptions.find((x) => x.value === queryType); const queryTypeLabel = queryTypeOptions.find((x) => x.value === queryType);
const resolutionLabel = RESOLUTION_OPTIONS.find((x) => x.value === (query.resolution ?? 1)); const resolutionLabel = RESOLUTION_OPTIONS.find((x) => x.value === (query.resolution ?? 1));
@ -227,7 +264,7 @@ function getCollapsedInfo(
items.push(`Type: ${queryTypeLabel?.label}`); items.push(`Type: ${queryTypeLabel?.label}`);
if (isLogQuery) { if (isLogQuery && direction) {
items.push(`Line limit: ${query.maxLines ?? maxLines}`); items.push(`Line limit: ${query.maxLines ?? maxLines}`);
items.push(`Direction: ${getQueryDirectionLabel(direction)}`); items.push(`Direction: ${getQueryDirectionLabel(direction)}`);
} else { } else {
@ -243,4 +280,20 @@ function getCollapsedInfo(
return items; return items;
} }
function getDefaultQueryDirection(app?: CoreApp) {
if (app !== CoreApp.Explore) {
/**
* The default direction is backward because the default sort order is Descending.
* See:
* - public/app/features/explore/Logs/Logs.tsx
* - public/app/plugins/panel/logs/module.tsx
*/
return LokiQueryDirection.Backward;
}
// See app/features/explore/Logs/utils/logs
const key = 'grafana.explore.logs.sortOrder';
const storedOrder = store.get(key) || LogsSortOrder.Descending;
return storedOrder === LogsSortOrder.Ascending ? LokiQueryDirection.Forward : LokiQueryDirection.Backward;
}
LokiQueryBuilderOptions.displayName = 'LokiQueryBuilderOptions'; LokiQueryBuilderOptions.displayName = 'LokiQueryBuilderOptions';

View File

@ -13,7 +13,9 @@ import {
LogsDedupStrategy, LogsDedupStrategy,
EventBusSrv, EventBusSrv,
DataFrameType, DataFrameType,
LogSortOrderChangeEvent,
} from '@grafana/data'; } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime';
import * as grafanaUI from '@grafana/ui'; import * as grafanaUI from '@grafana/ui';
import * as styles from 'app/features/logs/components/getLogRowStyles'; import * as styles from 'app/features/logs/components/getLogRowStyles';
import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal'; import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal';
@ -39,6 +41,7 @@ const datasourceSrv = new DatasourceSrvMock(defaultDs, {
const getDataSourceSrvMock = jest.fn().mockReturnValue(datasourceSrv); const getDataSourceSrvMock = jest.fn().mockReturnValue(datasourceSrv);
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getAppEvents: jest.fn(),
getDataSourceSrv: () => getDataSourceSrvMock(), getDataSourceSrv: () => getDataSourceSrvMock(),
})); }));
@ -69,7 +72,35 @@ const defaultProps = {
scopedVars: {}, scopedVars: {},
startTime: 1, startTime: 1,
}, },
series: [], series: [
createDataFrame({
refId: 'A',
fields: [
{
name: 'timestamp',
type: FieldType.time,
values: ['2019-04-26T09:28:11.352440161Z'],
},
{
name: 'body',
type: FieldType.string,
values: ['logline text'],
},
{
name: 'labels',
type: FieldType.other,
values: [
{
app: 'common_app',
},
],
},
],
meta: {
type: DataFrameType.LogLines,
},
}),
],
state: LoadingState.Done, state: LoadingState.Done,
timeRange: getDefaultTimeRange(), timeRange: getDefaultTimeRange(),
}, },
@ -103,7 +134,32 @@ const defaultProps = {
onChangeTimeRange: jest.fn(), onChangeTimeRange: jest.fn(),
}; };
const publishMock = jest.fn();
beforeAll(() => {
jest.mocked(getAppEvents).mockReturnValue({
publish: publishMock,
getStream: jest.fn(),
subscribe: jest.fn(),
removeAllListeners: jest.fn(),
newScopedBus: jest.fn(),
});
});
describe('LogsPanel', () => { describe('LogsPanel', () => {
it('publishes an event with the current sort order', async () => {
publishMock.mockClear();
setup();
await screen.findByText('logline text');
expect(publishMock).toHaveBeenCalledTimes(1);
expect(publishMock).toHaveBeenCalledWith(
new LogSortOrderChangeEvent({
order: LogsSortOrder.Descending,
})
);
});
describe('when returned series include common labels', () => { describe('when returned series include common labels', () => {
const seriesWithCommonLabels = [ const seriesWithCommonLabels = [
createDataFrame({ createDataFrame({

View File

@ -27,9 +27,10 @@ import {
TimeZone, TimeZone,
toUtc, toUtc,
urlUtil, urlUtil,
LogSortOrderChangeEvent,
} from '@grafana/data'; } from '@grafana/data';
import { convertRawToRange } from '@grafana/data/src/datetime/rangeutil'; import { convertRawToRange } from '@grafana/data/src/datetime/rangeutil';
import { config } from '@grafana/runtime'; import { config, getAppEvents } from '@grafana/runtime';
import { ScrollContainer, usePanelContext, useStyles2 } from '@grafana/ui'; import { ScrollContainer, usePanelContext, useStyles2 } from '@grafana/ui';
import { getFieldLinksForExplore } from 'app/features/explore/utils/links'; import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll'; import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll';
@ -143,8 +144,16 @@ export const LogsPanel = ({
// 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(false); const keepScrollPositionRef = useRef(false);
let closeCallback = useRef<() => void>(); let closeCallback = useRef<() => void>();
const { eventBus, onAddAdHocFilter } = usePanelContext(); const { eventBus, onAddAdHocFilter } = usePanelContext();
useEffect(() => {
getAppEvents().publish(
new LogSortOrderChangeEvent({
order: sortOrder,
})
);
}, [sortOrder]);
const onLogRowHover = useCallback( const onLogRowHover = useCallback(
(row?: LogRowModel) => { (row?: LogRowModel) => {
if (row) { if (row) {