Logs: Feature flag logsInfiniteScrolling removed (#113585)

* Remove feature flag definition

* Remove feature flag from code

* Update imports

* LogsNavigationPages: remove deprecated component

* Finally deprecate navigation pages

* Update tests

* Update translations

* Remove unused code

* More cleanup

* Test cleanup

* Remove deprecated props

* More props removal

* Update feature flags

* Revert changes
This commit is contained in:
Matias Chomicki
2025-11-21 11:51:56 +01:00
committed by GitHub
parent e09905df35
commit b17ba6677e
24 changed files with 47 additions and 755 deletions
@@ -41,7 +41,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | Yes |
| `dashboardSceneSolo` | Enables rendering dashboards using scenes for solo panels | Yes |
| `dashboardScene` | Enables dashboard rendering using scenes for all roles | Yes |
| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | Yes |
| `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | |
| `onPremToCloudMigrations` | Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack. | Yes |
| `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | Yes |
-5
View File
@@ -381,11 +381,6 @@ export interface FeatureToggles {
*/
timeComparison?: boolean;
/**
* Enables infinite scrolling for the Logs panel in Explore and Dashboards
* @default true
*/
logsInfiniteScrolling?: boolean;
/**
* Enables shared crosshair in table panel
*/
tableSharedCrosshair?: boolean;
-8
View File
@@ -615,14 +615,6 @@ var (
FrontendOnly: true,
Owner: grafanaDatavizSquad,
},
{
Name: "logsInfiniteScrolling",
Description: "Enables infinite scrolling for the Logs panel in Explore and Dashboards",
Stage: FeatureStageGeneralAvailability,
Expression: "true",
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "tableSharedCrosshair",
Description: "Enables shared crosshair in table panel",
-1
View File
@@ -85,7 +85,6 @@ panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true
pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
timeComparison,experimental,@grafana/dataviz-squad,false,false,true
logsInfiniteScrolling,GA,@grafana/observability-logs,false,false,true
tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true
kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true
cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
85 pdfTables preview @grafana/grafana-operator-experience-squad false false false
86 canvasPanelPanZoom preview @grafana/dataviz-squad false false true
87 timeComparison experimental @grafana/dataviz-squad false false true
logsInfiniteScrolling GA @grafana/observability-logs false false true
88 tableSharedCrosshair experimental @grafana/dataviz-squad false false true
89 kubernetesFeatureToggles experimental @grafana/grafana-operator-experience-squad false false true
90 cloudRBACRoles preview @grafana/identity-access-team false true false
+2 -1
View File
@@ -2545,7 +2545,8 @@
"metadata": {
"name": "logsInfiniteScrolling",
"resourceVersion": "1753448760331",
"creationTimestamp": "2023-11-09T10:54:03Z"
"creationTimestamp": "2023-11-09T10:54:03Z",
"deletionTimestamp": "2025-11-07T10:59:01Z"
},
"spec": {
"description": "Enables infinite scrolling for the Logs panel in Explore and Dashboards",
-17
View File
@@ -164,7 +164,6 @@ describe('buildShortUrl', () => {
describe('getLogsPermalinkRange', () => {
let row: LogRowModel, rows: LogRowModel[];
beforeEach(() => {
config.featureToggles.logsInfiniteScrolling = true;
row = createLogRow({
timeEpochMs: 1111112222222,
});
@@ -175,22 +174,6 @@ describe('getLogsPermalinkRange', () => {
row,
];
});
afterAll(() => {
config.featureToggles.logsInfiniteScrolling = false;
});
it('returns the original range if infinite scrolling is not enabled', () => {
config.featureToggles.logsInfiniteScrolling = false;
const range = {
from: 1111111111111,
to: 1111112222222,
};
const expectedRange = {
from: new Date(1111111111111).toISOString(),
to: new Date(1111112222222).toISOString(),
};
expect(getLogsPermalinkRange(row, [row], range)).toEqual(expectedRange);
});
it('returns the range relative to the previous log line', () => {
const range = {
-8
View File
@@ -170,14 +170,6 @@ function getPreviousLog(row: LogRowModel, allLogs: LogRowModel[]): LogRowModel |
}
export function getLogsPermalinkRange(row: LogRowModel, rows: LogRowModel[], absoluteRange: AbsoluteTimeRange) {
const range = {
from: new Date(absoluteRange.from).toISOString(),
to: new Date(absoluteRange.to).toISOString(),
};
if (!config.featureToggles.logsInfiniteScrolling) {
return range;
}
// With infinite scrolling, the time range of the log line can be after the absolute range or beyond the request line limit, so we need to adjust
// Look for the previous sibling log, and use its timestamp
const allLogs = rows.filter((logRow) => logRow.dataFrame.refId === row.dataFrame.refId);
-1
View File
@@ -475,7 +475,6 @@ export class Explore extends PureComponent<Props, ExploreState> {
onStopScanning={this.onStopScanning}
eventBus={this.logsEventBus}
splitOpenFn={this.splitOpenFnLogs}
scrollElement={this.scrollElement}
isFilterLabelActive={this.isFilterLabelActive}
onClickFilterString={this.onClickFilterString}
onClickFilterOutString={this.onClickFilterOutString}
+2 -60
View File
@@ -65,6 +65,8 @@ describe('Logs', () => {
let originalHref = window.location.href;
beforeEach(() => {
window.HTMLElement.prototype.scrollIntoView = jest.fn();
window.HTMLElement.prototype.scroll = jest.fn();
localStorage.clear();
jest.clearAllMocks();
});
@@ -128,9 +130,7 @@ describe('Logs', () => {
to: toUtc('2019-01-01 16:00:00'),
raw: { from: 'now-1h', to: 'now' },
}}
addResultsToCache={() => {}}
onChangeTime={() => {}}
clearCache={() => {}}
getFieldLinks={() => {
return [];
}}
@@ -160,39 +160,6 @@ describe('Logs', () => {
return { ...rendered, store: fakeStore };
};
describe('scrolling behavior', () => {
let originalInnerHeight: number;
beforeEach(() => {
originalInnerHeight = window.innerHeight;
window.innerHeight = 1000;
window.HTMLElement.prototype.scrollIntoView = jest.fn();
window.HTMLElement.prototype.scroll = jest.fn();
});
afterEach(() => {
window.innerHeight = originalInnerHeight;
});
it('should call `scrollElement.scroll`', () => {
const logs = [];
for (let i = 0; i < 50; i++) {
logs.push(makeLog({ uid: `uid${i}`, rowId: `id${i}`, timeEpochMs: i }));
}
const scrollElementMock = {
scroll: jest.fn(),
scrollTop: 920,
};
setup(
{ scrollElement: scrollElementMock as unknown as HTMLDivElement, panelState: { logs: { id: 'uid47' } } },
undefined,
logs
);
// element.getBoundingClientRect().top will always be 0 for jsdom
// calc will be `scrollElement.scrollTop - window.innerHeight / 2` -> 920 - 500 = 420
expect(scrollElementMock.scroll).toBeCalledWith({ behavior: 'smooth', top: 420 });
});
});
it('should render logs', () => {
setup();
const logsSection = screen.getByTestId('logRows');
@@ -246,9 +213,7 @@ describe('Logs', () => {
to: toUtc('2019-01-01 16:00:00'),
raw: { from: 'now-1h', to: 'now' },
}}
addResultsToCache={() => {}}
onChangeTime={() => {}}
clearCache={() => {}}
getFieldLinks={() => {
return [];
}}
@@ -296,9 +261,7 @@ describe('Logs', () => {
to: toUtc('2019-01-01 16:00:00'),
raw: { from: 'now-1h', to: 'now' },
}}
addResultsToCache={() => {}}
onChangeTime={() => {}}
clearCache={() => {}}
getFieldLinks={() => {
return [];
}}
@@ -349,9 +312,7 @@ describe('Logs', () => {
to: toUtc('2019-01-01 16:00:00'),
raw: { from: 'now-1h', to: 'now' },
}}
addResultsToCache={() => {}}
onChangeTime={() => {}}
clearCache={() => {}}
getFieldLinks={() => {
return [];
}}
@@ -412,22 +373,6 @@ describe('Logs', () => {
expect(fakeChangePanelState).toHaveBeenCalledWith('right', 'logs', { logs: {} });
});
it('should scroll the scrollElement into view if rows contain id', () => {
const panelState = { logs: { id: '3' } };
const scrollElementMock = { scroll: jest.fn() };
setup({ loading: false, scrollElement: scrollElementMock as unknown as HTMLDivElement, panelState });
expect(scrollElementMock.scroll).toHaveBeenCalled();
});
it('should not scroll the scrollElement into view if rows does not contain id', () => {
const panelState = { logs: { id: 'not-included' } };
const scrollElementMock = { scroll: jest.fn() };
setup({ loading: false, scrollElement: scrollElementMock as unknown as HTMLDivElement, panelState });
expect(scrollElementMock.scroll).not.toHaveBeenCalled();
});
it('should call reportInteraction on permalinkClick', async () => {
const panelState = { logs: { id: 'not-included' } };
const rows = [
@@ -479,8 +424,6 @@ describe('Logs', () => {
});
it('should call createAndCopyShortLink on permalinkClick - with infinite scrolling', async () => {
const featureToggleValue = config.featureToggles.logsInfiniteScrolling;
config.featureToggles.logsInfiniteScrolling = true;
const rows = [
makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1 }),
makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 1 }),
@@ -503,7 +446,6 @@ describe('Logs', () => {
)
);
expect(createAndCopyShortLink).toHaveBeenCalledWith(expect.stringMatching('visualisationType%22:%22logs'));
config.featureToggles.logsInfiniteScrolling = featureToggleValue;
});
});
+18 -68
View File
@@ -119,11 +119,8 @@ interface Props extends Themeable2 {
) => Promise<DataQuery | null>;
getLogRowContextUi?: (row: LogRowModel, runContextQuery?: () => void) => React.ReactNode;
getFieldLinks: GetFieldLinksFn;
addResultsToCache: () => void;
clearCache: () => void;
eventBus: EventBus;
panelState?: ExplorePanelsState;
scrollElement?: HTMLDivElement;
isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
logsFrames?: DataFrame[];
range: TimeRange;
@@ -183,8 +180,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
getFieldLinks,
theme,
logsQueries,
clearCache,
addResultsToCache,
exploreId,
getRowContext,
getLogRowContextUi,
@@ -193,7 +188,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
panelState,
eventBus,
onPinLineCallback,
scrollElement,
} = props;
const [showLabels, setShowLabels] = useState<boolean>(store.getBool(SETTINGS_KEYS.showLabels, false));
const [showTime, setShowTime] = useState<boolean>(store.getBool(SETTINGS_KEYS.showTime, true));
@@ -365,29 +359,17 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
[props.eventBus]
);
const scrollIntoView = useCallback(
(element: HTMLElement) => {
if (config.featureToggles.logsInfiniteScrolling) {
if (logsContainerRef.current) {
topLogsRef.current?.scrollIntoView();
logsContainerRef.current.scroll({
behavior: 'smooth',
top: logsContainerRef.current.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2,
});
}
const scrollIntoView = useCallback((element: HTMLElement) => {
if (logsContainerRef.current) {
topLogsRef.current?.scrollIntoView?.();
logsContainerRef.current.scroll({
behavior: 'smooth',
top: logsContainerRef.current.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2,
});
}
return;
}
if (scrollElement) {
scrollElement.scroll({
behavior: 'smooth',
top: scrollElement.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2,
});
}
},
[scrollElement]
);
return;
}, []);
const sortOrderChanged = useCallback(
(newSortOrder: LogsSortOrder) => {
@@ -626,13 +608,11 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
);
const scrollToTopLogs = useCallback(() => {
if (config.featureToggles.logsInfiniteScrolling) {
if (logsContainerRef.current) {
logsContainerRef.current.scroll({
behavior: 'auto',
top: 0,
});
}
if (logsContainerRef.current) {
logsContainerRef.current.scroll({
behavior: 'auto',
top: 0,
});
}
topLogsRef.current?.scrollIntoView();
}, []);
@@ -684,7 +664,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
);
const { dedupedRows, dedupCount } = useMemo(() => dedupRows(logRows, dedupStrategy), [dedupStrategy, logRows]);
const navigationRange = useMemo(() => createNavigationRange(logRows), [logRows]);
const infiniteScrollAvailable = useMemo(
() => !logsQueries?.some((query) => 'direction' in query && query.direction === LokiQueryDirection.Scan),
[logsQueries]
@@ -1060,11 +1039,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
visualisationType === 'logs' &&
hasData && (
<>
<div
className={config.featureToggles.logsInfiniteScrolling ? styles.scrollableLogRows : styles.logRows}
data-testid="logRows"
ref={logsContainerRef}
>
<div className={styles.scrollableLogRows} data-testid="logRows" ref={logsContainerRef}>
<InfiniteScroll
loading={loading}
loadMoreLogs={infiniteScrollAvailable ? loadMoreLogs : undefined}
@@ -1113,18 +1088,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
/>
</InfiniteScroll>
</div>
<LogsNavigation
logsSortOrder={logsSortOrder}
visibleRange={navigationRange ?? absoluteRange}
absoluteRange={absoluteRange}
timeZone={timeZone}
onChangeTime={onChangeTime}
loading={loading}
queries={logsQueries ?? []}
scrollToTopLogs={scrollToTopLogs}
addResultsToCache={addResultsToCache}
clearCache={clearCache}
/>
<LogsNavigation logsSortOrder={logsSortOrder} scrollToTopLogs={scrollToTopLogs} />
</>
)}
{config.featureToggles.newLogsPanel && visualisationType === 'logs' && (
@@ -1277,7 +1241,7 @@ const getStyles = (theme: GrafanaTheme2, wrapLogMessage: boolean, tableHeight: n
}),
stickyNavigation: css({
overflow: 'visible',
...(config.featureToggles.logsInfiniteScrolling && { marginBottom: '0px' }),
marginBottom: '0px',
}),
logsVolumePanel: css({
marginBottom: theme.spacing(1.5),
@@ -1290,17 +1254,3 @@ const dedupRows = (logRows: LogRowModel[], dedupStrategy: LogsDedupStrategy) =>
const dedupCount = dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0);
return { dedupedRows, dedupCount };
};
const createNavigationRange = (logRows: LogRowModel[]): { from: number; to: number } | undefined => {
if (!logRows || logRows.length === 0) {
return undefined;
}
const firstTimeStamp = logRows[0].timeEpochMs;
const lastTimeStamp = logRows[logRows.length - 1].timeEpochMs;
if (lastTimeStamp < firstTimeStamp) {
return { from: lastTimeStamp, to: firstTimeStamp };
}
return { from: firstTimeStamp, to: lastTimeStamp };
};
@@ -31,13 +31,7 @@ import { ExploreItemState } from 'app/types/explore';
import { StoreState } from 'app/types/store';
import { getTimeZone } from '../../profile/state/selectors';
import {
addResultsToCache,
clearCache,
loadSupplementaryQueryData,
selectIsWaitingForData,
setSupplementaryQueryEnabled,
} from '../state/query';
import { loadSupplementaryQueryData, selectIsWaitingForData, setSupplementaryQueryEnabled } from '../state/query';
import { updateTimeRange, loadMoreLogs } from '../state/time';
import { LiveTailControls } from '../useLiveTailControls';
import { getFieldLinksForExplore } from '../utils/links';
@@ -58,7 +52,6 @@ interface LogsContainerProps extends PropsFromRedux {
onStopScanning: () => void;
eventBus: EventBus;
splitOpenFn: SplitOpen;
scrollElement?: HTMLDivElement;
isFilterLabelActive: (key: string, value: string, refId?: string) => Promise<boolean>;
onClickFilterString: (value: string, refId?: string) => void;
onClickFilterOutString: (value: string, refId?: string) => void;
@@ -260,14 +253,6 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState
);
};
addResultsToCache = () => {
this.props.addResultsToCache(this.props.exploreId);
};
clearCache = () => {
this.props.clearCache(this.props.exploreId);
};
loadLogsVolumeData = () => {
this.props.loadSupplementaryQueryData(this.props.exploreId, SupplementaryQueryType.LogsVolume);
};
@@ -298,7 +283,6 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState
isLive,
exploreId,
logsVolume,
scrollElement,
onPinLineCallback,
} = this.props;
@@ -358,12 +342,9 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState
getRowContextQuery={this.getLogRowContextQuery}
getLogRowContextUi={this.getLogRowContextUi}
getFieldLinks={this.getFieldLinks}
addResultsToCache={this.addResultsToCache}
clearCache={this.clearCache}
eventBus={this.props.eventBus}
panelState={this.props.panelState}
logsFrames={this.props.logsFrames}
scrollElement={scrollElement}
isFilterLabelActive={this.logDetailsFilterAvailable() ? this.props.isFilterLabelActive : undefined}
range={range}
onPinLineCallback={onPinLineCallback}
@@ -419,8 +400,6 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }
const mapDispatchToProps = {
updateTimeRange,
loadMoreLogs,
addResultsToCache,
clearCache,
loadSupplementaryQueryData,
setSupplementaryQueryEnabled,
};
@@ -2,9 +2,6 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ComponentProps } from 'react';
import { LogsSortOrder } from '@grafana/data';
import { DataQuery } from '@grafana/schema';
import LogsNavigation from './LogsNavigation';
// we have to mock out reportInteraction, otherwise it crashes the test.
@@ -15,16 +12,8 @@ jest.mock('@grafana/runtime', () => ({
type LogsNavigationProps = ComponentProps<typeof LogsNavigation>;
const defaultProps: LogsNavigationProps = {
absoluteRange: { from: 1637319381811, to: 1637322981811 },
timeZone: 'local',
queries: [],
loading: false,
logsSortOrder: undefined,
visibleRange: { from: 1637322959000, to: 1637322981811 },
onChangeTime: jest.fn(),
scrollToTopLogs: jest.fn(),
addResultsToCache: jest.fn(),
clearCache: jest.fn(),
};
const setup = (propOverrides?: Partial<LogsNavigationProps>) => {
@@ -37,132 +26,13 @@ const setup = (propOverrides?: Partial<LogsNavigationProps>) => {
};
describe('LogsNavigation', () => {
it('should always render 3 navigation buttons', () => {
it('should render scroll to top with default logs order', async () => {
setup();
expect(screen.getByTestId('newerLogsButton')).toBeInTheDocument();
expect(screen.getByTestId('olderLogsButton')).toBeInTheDocument();
expect(screen.getByTestId('scrollToTop')).toBeInTheDocument();
});
it('should render 3 navigation buttons in correct order when default logs order', () => {
const { container } = setup();
const expectedOrder = ['newerLogsButton', 'olderLogsButton', 'scrollToTop'];
const elements = container.querySelectorAll(
'[data-testid=newerLogsButton],[data-testid=olderLogsButton],[data-testid=scrollToTop]'
);
expect(Array.from(elements).map((el) => el.getAttribute('data-testid'))).toMatchObject(expectedOrder);
});
await userEvent.click(screen.getByTestId('scrollToTop'));
it('should render 3 navigation buttons in correct order when flipped logs order', () => {
const { container } = setup({ logsSortOrder: LogsSortOrder.Ascending });
const expectedOrder = ['olderLogsButton', 'newerLogsButton', 'scrollToTop'];
const elements = container.querySelectorAll(
'[data-testid=newerLogsButton],[data-testid=olderLogsButton],[data-testid=scrollToTop]'
);
expect(Array.from(elements).map((el) => el.getAttribute('data-testid'))).toMatchObject(expectedOrder);
});
it('should disable fetch buttons when logs are loading', () => {
setup({ loading: true });
const olderLogsButton = screen.getByTestId('olderLogsButton');
const newerLogsButton = screen.getByTestId('newerLogsButton');
expect(olderLogsButton).toBeDisabled();
expect(newerLogsButton).toBeDisabled();
});
it('should render logs navigation pages section', () => {
setup();
expect(screen.getByTestId('logsNavigationPages')).toBeInTheDocument();
});
it('should correctly request older logs when flipped order', async () => {
const onChangeTimeMock = jest.fn();
const { rerender } = setup({ onChangeTime: onChangeTimeMock });
await userEvent.click(screen.getByTestId('olderLogsButton'));
expect(onChangeTimeMock).toHaveBeenCalledWith({ from: 1637319359000, to: 1637322959000 });
rerender(
<LogsNavigation
{...defaultProps}
absoluteRange={{ from: 1637319359000, to: 1637322959000 }}
visibleRange={{ from: 1637322938000, to: 1637322959000 }}
onChangeTime={onChangeTimeMock}
logsSortOrder={LogsSortOrder.Ascending}
/>
);
await userEvent.click(screen.getByTestId('olderLogsButton'));
expect(onChangeTimeMock).toHaveBeenCalledWith({ from: 1637319338000, to: 1637322938000 });
});
it('should correctly display the active page', async () => {
const queries: DataQuery[] = [];
const { rerender } = setup({
absoluteRange: { from: 1704737384139, to: 1704737684139 },
visibleRange: { from: 1704737384207, to: 1704737683316 },
queries,
logsSortOrder: LogsSortOrder.Descending,
});
expect(await screen.findByTestId('page1')).toBeInTheDocument();
expect(screen.getByTestId('page1').firstChild).toHaveClass('selectedBg');
expect(screen.queryByTestId('page2')).not.toBeInTheDocument();
await userEvent.click(screen.getByTestId('olderLogsButton'));
rerender(
<LogsNavigation
{...defaultProps}
absoluteRange={{ from: 1704737084207, to: 1704737384207 }}
visibleRange={{ from: 1704737084627, to: 1704737383765 }}
onChangeTime={jest.fn()}
logsSortOrder={LogsSortOrder.Descending}
queries={queries}
/>
);
expect(await screen.findByTestId('page1')).toBeInTheDocument();
expect(screen.getByTestId('page1').firstChild).not.toHaveClass('selectedBg');
expect(await screen.findByTestId('page2')).toBeInTheDocument();
expect(screen.getByTestId('page2').firstChild).toHaveClass('selectedBg');
expect(screen.queryByTestId('page3')).not.toBeInTheDocument();
});
it('should reset the scroll when pagination is clicked', async () => {
const scrollToTopLogsMock = jest.fn();
setup({ scrollToTopLogs: scrollToTopLogsMock });
expect(scrollToTopLogsMock).not.toHaveBeenCalled();
await userEvent.click(screen.getByTestId('olderLogsButton'));
expect(scrollToTopLogsMock).toHaveBeenCalled();
});
it('should not trigger actions while loading', async () => {
const scrollToTopLogs = jest.fn();
const changeTimeMock = jest.fn();
setup({ scrollToTopLogs, onChangeTime: changeTimeMock, loading: true });
expect(scrollToTopLogs).not.toHaveBeenCalled();
expect(changeTimeMock).not.toHaveBeenCalled();
await userEvent.click(screen.getByTestId('olderLogsButton'));
await userEvent.click(screen.getByTestId('newerLogsButton'));
expect(scrollToTopLogs).not.toHaveBeenCalled();
expect(changeTimeMock).not.toHaveBeenCalled();
});
it('should not add results to cache unless pagination is used', async () => {
const addResultsToCache = jest.fn();
setup({ addResultsToCache });
expect(addResultsToCache).not.toHaveBeenCalled();
expect(screen.getByTestId('olderLogsButton')).not.toBeDisabled();
expect(screen.getByTestId('newerLogsButton')).toBeDisabled();
await userEvent.click(screen.getByTestId('olderLogsButton'));
await userEvent.click(screen.getByTestId('newerLogsButton'));
expect(addResultsToCache).toHaveBeenCalledTimes(1);
expect(defaultProps.scrollToTopLogs).toHaveBeenCalledTimes(1);
});
});
@@ -1,220 +1,30 @@
import { css } from '@emotion/css';
import { isEqual } from 'lodash';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback } from 'react';
import { AbsoluteTimeRange, GrafanaTheme2, LogsSortOrder } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { GrafanaTheme2, LogsSortOrder } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { DataQuery, TimeZone } from '@grafana/schema';
import { Button, Icon, Spinner, useTheme2 } from '@grafana/ui';
import { Button, Icon, useTheme2 } from '@grafana/ui';
import { getChromeHeaderLevelHeight } from 'app/core/components/AppChrome/TopBar/useChromeHeaderHeight';
import { LogsNavigationPages } from './LogsNavigationPages';
type Props = {
absoluteRange: AbsoluteTimeRange;
timeZone: TimeZone;
queries: DataQuery[];
loading: boolean;
visibleRange: AbsoluteTimeRange;
logsSortOrder?: LogsSortOrder | null;
onChangeTime: (range: AbsoluteTimeRange) => void;
scrollToTopLogs: () => void;
scrollToBottomLogs?: () => void;
addResultsToCache: () => void;
clearCache: () => void;
};
export type LogsPage = {
logsRange: AbsoluteTimeRange;
queryRange: AbsoluteTimeRange;
};
function LogsNavigation({
absoluteRange,
logsSortOrder,
timeZone,
loading,
onChangeTime,
scrollToTopLogs,
scrollToBottomLogs,
visibleRange,
queries,
clearCache,
addResultsToCache,
}: Props) {
const [pages, setPages] = useState<LogsPage[]>([]);
// These refs are to determine, if we want to clear up logs navigation when totally new query is run
const expectedQueriesRef = useRef<DataQuery[]>();
const expectedRangeRef = useRef<AbsoluteTimeRange>();
// This ref is to store range span for future queres based on firstly selected time range
// e.g. if last 5 min selected, always run 5 min range
const rangeSpanRef = useRef(0);
const currentPageIndex = useMemo(
() =>
pages.findIndex((page) => {
return page.queryRange.to === absoluteRange.to;
}),
[absoluteRange.to, pages]
);
function LogsNavigation({ logsSortOrder, scrollToTopLogs }: Props) {
const oldestLogsFirst = logsSortOrder === LogsSortOrder.Ascending;
const onFirstPage = oldestLogsFirst ? currentPageIndex === pages.length - 1 : currentPageIndex === 0;
const onLastPage = oldestLogsFirst ? currentPageIndex === 0 : currentPageIndex === pages.length - 1;
const theme = useTheme2();
const styles = getStyles(theme, oldestLogsFirst);
// Main effect to set pages and index
useEffect(() => {
const newPage = { logsRange: visibleRange, queryRange: absoluteRange };
let newPages: LogsPage[] = [];
// We want to start new pagination if queries change or if absolute range is different than expected
if (!isEqual(expectedRangeRef.current, absoluteRange) || !isEqual(expectedQueriesRef.current, queries)) {
clearCache();
setPages([newPage]);
expectedQueriesRef.current = queries;
rangeSpanRef.current = absoluteRange.to - absoluteRange.from;
} else {
setPages((pages) => {
// Remove duplicates with new query
newPages = pages.filter((page) => !isEqual(newPage.queryRange, page.queryRange));
// Sort pages based on logsOrder so they visually align with displayed logs
newPages = [...newPages, newPage].sort((a, b) => sortPages(a, b, logsSortOrder));
return newPages;
});
}
}, [visibleRange, absoluteRange, logsSortOrder, queries, clearCache, addResultsToCache]);
const changeTime = useCallback(
({ from, to }: AbsoluteTimeRange) => {
addResultsToCache();
expectedRangeRef.current = { from, to };
onChangeTime({ from, to });
},
[onChangeTime, addResultsToCache]
);
const sortPages = (a: LogsPage, b: LogsPage, logsSortOrder?: LogsSortOrder | null) => {
if (logsSortOrder === LogsSortOrder.Ascending) {
return a.queryRange.to > b.queryRange.to ? 1 : -1;
}
return a.queryRange.to > b.queryRange.to ? -1 : 1;
};
const olderLogsButton = (
<Button
data-testid="olderLogsButton"
className={styles.navButton}
variant="secondary"
onClick={() => {
//If we are not on the last page, use next page's range
reportInteraction('grafana_explore_logs_pagination_clicked', {
pageType: 'olderLogsButton',
});
if (!onLastPage) {
const indexChange = oldestLogsFirst ? -1 : 1;
changeTime({
from: pages[currentPageIndex + indexChange].queryRange.from,
to: pages[currentPageIndex + indexChange].queryRange.to,
});
} else {
//If we are on the last page, create new range
changeTime({ from: visibleRange.from - rangeSpanRef.current, to: visibleRange.from });
}
scrollToTopLogs();
}}
disabled={loading}
>
<div className={styles.navButtonContent}>
{loading ? <Spinner /> : <Icon name={oldestLogsFirst ? 'angle-up' : 'angle-down'} size="lg" />}
<Trans i18nKey={'logs.logs-navigation.older-logs'}>Older logs</Trans>
</div>
</Button>
);
const newerLogsButton = (
<Button
data-testid="newerLogsButton"
className={styles.navButton}
variant="secondary"
onClick={() => {
reportInteraction('grafana_explore_logs_pagination_clicked', {
pageType: 'newerLogsButton',
});
//If we are not on the first page, use previous page's range
if (!onFirstPage) {
const indexChange = oldestLogsFirst ? 1 : -1;
changeTime({
from: pages[currentPageIndex + indexChange].queryRange.from,
to: pages[currentPageIndex + indexChange].queryRange.to,
});
}
scrollToTopLogs();
//If we are on the first page, button is disabled and we do nothing
}}
disabled={loading || onFirstPage}
>
<div className={styles.navButtonContent}>
{loading && <Spinner />}
{onFirstPage || loading ? null : <Icon name={oldestLogsFirst ? 'angle-down' : 'angle-up'} size="lg" />}
{onFirstPage
? t('logs.logs-navigation.start-of-range', 'Start of range')
: t('logs.logs-navigation.newer-logs', 'Newer logs')}
</div>
</Button>
);
const onPageClick = useCallback(
(page: LogsPage, pageNumber: number) => {
reportInteraction('grafana_explore_logs_pagination_clicked', {
pageType: 'page',
pageNumber,
});
changeTime({ from: page.queryRange.from, to: page.queryRange.to });
scrollToTopLogs();
},
[changeTime, scrollToTopLogs]
);
const onScrollToTopClick = useCallback(() => {
reportInteraction('grafana_explore_logs_scroll_top_clicked');
scrollToTopLogs();
}, [scrollToTopLogs]);
const onScrollToBottomClick = useCallback(() => {
reportInteraction('grafana_explore_logs_scroll_bottom_clicked');
scrollToBottomLogs?.();
}, [scrollToBottomLogs]);
return (
<div className={styles.navContainer}>
{!config.featureToggles.logsInfiniteScrolling && (
<>
{oldestLogsFirst ? olderLogsButton : newerLogsButton}
<LogsNavigationPages
pages={pages}
currentPageIndex={currentPageIndex}
oldestLogsFirst={oldestLogsFirst}
timeZone={timeZone}
loading={loading}
onClick={onPageClick}
/>
{oldestLogsFirst ? newerLogsButton : olderLogsButton}
</>
)}
{scrollToBottomLogs && (
<Button
data-testid="scrollToBottom"
className={styles.scrollToBottomButton}
variant="secondary"
onClick={onScrollToBottomClick}
title={t('logs.logs-navigation.scroll-bottom', 'Scroll to bottom')}
>
<Icon name="arrow-down" size="lg" />
</Button>
)}
<Button
data-testid="scrollToTop"
className={styles.scrollToTopButton}
@@ -239,33 +49,11 @@ const getStyles = (theme: GrafanaTheme2, oldestLogsFirst: boolean) => {
width: oldestLogsFirst && !config.featureToggles.newLogsPanel ? '58px' : 'auto',
display: 'flex',
flexDirection: 'column',
justifyContent: config.featureToggles.logsInfiniteScrolling
? 'flex-end'
: oldestLogsFirst
? 'flex-start'
: 'space-between',
justifyContent: 'flex-end',
position: 'sticky',
top: theme.spacing(2),
right: 0,
}),
navButton: css({
width: '58px',
height: '68px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
lineHeight: 1,
}),
navButtonContent: css({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
whiteSpace: 'normal',
}),
scrollToBottomButton: css({
width: '40px',
height: '40px',
@@ -1,54 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ComponentProps } from 'react';
import { LogsNavigationPages } from './LogsNavigationPages';
type LogsNavigationPagesProps = ComponentProps<typeof LogsNavigationPages>;
const setup = (propOverrides?: Partial<LogsNavigationPagesProps>) => {
const props: LogsNavigationPagesProps = {
pages: [
{
logsRange: { from: 1619081941000, to: 1619081945930 },
queryRange: { from: 1619081645930, to: 1619081945930 },
},
{
logsRange: { from: 1619081951000, to: 1619081955930 },
queryRange: { from: 1619081655930, to: 1619081955930 },
},
],
currentPageIndex: 0,
oldestLogsFirst: false,
timeZone: 'local',
loading: false,
onClick: jest.fn(),
...propOverrides,
};
return render(<LogsNavigationPages {...props} />);
};
describe('LogsNavigationPages', () => {
it('should render logs navigation pages', () => {
setup();
expect(screen.getByTestId('logsNavigationPages')).toBeInTheDocument();
});
it('should render logs pages with correct range if normal order', () => {
setup();
expect(screen.getByText(/02:59:05 — 02:59:01/i)).toBeInTheDocument();
expect(screen.getByText(/02:59:15 — 02:59:11/i)).toBeInTheDocument();
});
it('should render logs pages with correct range if flipped order', () => {
setup({ oldestLogsFirst: true });
expect(screen.getByText(/02:59:11 — 02:59:15/i)).toBeInTheDocument();
expect(screen.getByText(/02:59:01 — 02:59:05/i)).toBeInTheDocument();
});
it('should invoke the callback when clicked', async () => {
const onPageClicked = jest.fn();
setup({ onClick: onPageClicked });
expect(onPageClicked).not.toHaveBeenCalled();
await userEvent.click(screen.getByText(/02:59:05 — 02:59:01/i));
expect(onPageClicked).toHaveBeenCalled();
});
});
@@ -1,113 +0,0 @@
import { css, cx } from '@emotion/css';
import { dateTimeFormat, systemDateFormats, GrafanaTheme2 } from '@grafana/data';
import { TimeZone } from '@grafana/schema';
import { ScrollContainer, Spinner, useTheme2, clearButtonStyles } from '@grafana/ui';
import { LogsPage } from './LogsNavigation';
type Props = {
pages: LogsPage[];
currentPageIndex: number;
oldestLogsFirst: boolean;
timeZone: TimeZone;
loading: boolean;
onClick: (page: LogsPage, pageNumber: number) => void;
};
export function LogsNavigationPages({ pages, currentPageIndex, oldestLogsFirst, timeZone, loading, onClick }: Props) {
const formatTime = (time: number) => {
return `${dateTimeFormat(time, {
format: systemDateFormats.interval.second,
timeZone: timeZone,
})}`;
};
const createPageContent = (page: LogsPage, index: number) => {
if (currentPageIndex === index && loading) {
return <Spinner />;
}
const topContent = formatTime(oldestLogsFirst ? page.logsRange.from : page.logsRange.to);
const bottomContent = formatTime(oldestLogsFirst ? page.logsRange.to : page.logsRange.from);
return `${topContent} — ${bottomContent}`;
};
const theme = useTheme2();
const styles = getStyles(theme, loading);
return (
<ScrollContainer>
<div className={styles.pagesWrapper} data-testid="logsNavigationPages">
<div className={styles.pagesContainer}>
{pages.map((page: LogsPage, index: number) => (
<button
type="button"
data-testid={`page${index + 1}`}
className={cx(clearButtonStyles(theme), styles.page)}
key={page.queryRange.to}
onClick={() => {
onClick(page, index + 1);
}}
disabled={loading}
>
<div className={cx(styles.line, { selectedBg: currentPageIndex === index })} />
<div className={cx(styles.time, { selectedText: currentPageIndex === index })}>
{createPageContent(page, index)}
</div>
</button>
))}
</div>
</div>
</ScrollContainer>
);
}
const getStyles = (theme: GrafanaTheme2, loading: boolean) => {
return {
pagesWrapper: css({
height: '100%',
paddingLeft: theme.spacing(0.5),
display: 'flex',
flexDirection: 'column',
'&::after': {
content: "''",
display: 'block',
background: `repeating-linear-gradient(135deg, ${theme.colors.background.primary}, ${theme.colors.background.primary} 5px, ${theme.colors.background.secondary} 5px, ${theme.colors.background.secondary} 15px)`,
width: '3px',
height: 'inherit',
marginBottom: theme.spacing(1),
},
}),
pagesContainer: css({
display: 'flex',
padding: 0,
flexDirection: 'column',
}),
page: css({
display: 'flex',
margin: theme.spacing(2, 0),
cursor: loading ? 'auto' : 'pointer',
whiteSpace: 'normal',
'.selectedBg': {
background: theme.colors.primary.main,
},
'.selectedText': {
color: theme.colors.primary.main,
},
}),
line: css({
width: '3px',
height: '100%',
alignItems: 'center',
background: theme.colors.text.secondary,
}),
time: css({
width: '60px',
minHeight: '80px',
fontSize: theme.v1.typography.size.sm,
paddingLeft: theme.spacing(0.5),
display: 'flex',
alignItems: 'center',
}),
};
};
@@ -13,7 +13,6 @@ import {
SplitOpen,
TimeRange,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { LogsVisualisationType } from '../../explore/Logs/Logs';
@@ -150,7 +149,7 @@ const LogRowsComponent = forwardRef<HTMLDivElement | null, LogRowsComponentProps
if (ref) {
return styles.forwardedScrollableLogRows;
}
return config.featureToggles.logsInfiniteScrolling ? styles.scrollableLogRows : styles.logRows;
return styles.scrollableLogRows;
}, [ref]);
const scrollIntoView = useCallback(
@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event';
import { useEffect, useRef, useState } from 'react';
import { CoreApp, LogRowModel, dateTimeForTimeZone, rangeUtil } from '@grafana/data';
import { config } from '@grafana/runtime';
import { LogsSortOrder } from '@grafana/schema';
import { InfiniteScroll, Props, SCROLLING_THRESHOLD } from './InfiniteScroll';
@@ -106,14 +105,6 @@ function setup(
return { element, events, scrollTo, wheel };
}
const originalState = config.featureToggles.logsInfiniteScrolling;
beforeAll(() => {
config.featureToggles.logsInfiniteScrolling = true;
});
afterAll(() => {
config.featureToggles.logsInfiniteScrolling = originalState;
});
describe('InfiniteScroll', () => {
test('Wraps components without adding DOM elements', async () => {
const { container } = render(
@@ -4,7 +4,7 @@ import { ReactNode, MutableRefObject, useCallback, useEffect, useRef, useState }
import { AbsoluteTimeRange, CoreApp, LogRowModel, TimeRange, rangeUtil } from '@grafana/data';
// import { convertRawToRange, isRelativeTime, isRelativeTimeRange } from '@grafana/data/internal';
import { Trans } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { reportInteraction } from '@grafana/runtime';
import { LogsSortOrder, TimeZone } from '@grafana/schema';
import { Button, Icon } from '@grafana/ui';
@@ -86,7 +86,7 @@ export const InfiniteScroll = ({
}
function handleScroll(event: Event | WheelEvent) {
if (!scrollElement || !loadMoreLogs || !rows.length || loading || !config.featureToggles.logsInfiniteScrolling) {
if (!scrollElement || !loadMoreLogs || !rows.length || loading) {
return;
}
const scrollDirection = shouldLoadMore(event, lastEvent.current, countRef, scrollElement, lastScroll.current);
@@ -2,7 +2,6 @@ import { act, render, screen } from '@testing-library/react';
import { VariableSizeList } from 'react-window';
import { createTheme, dateTimeForTimeZone, rangeUtil } from '@grafana/data';
import { config } from '@grafana/runtime';
import { LogsSortOrder } from '@grafana/schema';
import { ScrollDirection, SCROLLING_THRESHOLD } from '../InfiniteScroll';
@@ -110,14 +109,6 @@ function setup(
return { element, events, scrollTo, wheel };
}
const originalState = config.featureToggles.logsInfiniteScrolling;
beforeAll(() => {
config.featureToggles.logsInfiniteScrolling = true;
});
afterAll(() => {
config.featureToggles.logsInfiniteScrolling = originalState;
});
describe('InfiniteScroll', () => {
describe.each([LogsSortOrder.Descending, LogsSortOrder.Ascending])(
'When the sort order is descending',
@@ -4,7 +4,7 @@ import { ListChildComponentProps, ListOnItemsRenderedProps } from 'react-window'
import { AbsoluteTimeRange, LogsSortOrder, TimeRange } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { reportInteraction } from '@grafana/runtime';
import { Spinner, useStyles2 } from '@grafana/ui';
import { canScrollBottom, canScrollTop, getVisibleRange, ScrollDirection, shouldLoadMore } from '../InfiniteScroll';
@@ -139,7 +139,7 @@ export const InfiniteScroll = ({
);
useEffect(() => {
if (!scrollElement || !loadMore || !config.featureToggles.logsInfiniteScrolling) {
if (!scrollElement || !loadMore) {
return;
}
+6 -11
View File
@@ -21,7 +21,6 @@ import {
sortDataFrame,
toDataFrame,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { LokiQueryDirection } from 'app/plugins/datasource/loki/dataquery.gen';
import { getMockFrames } from 'app/plugins/datasource/loki/mocks/frames';
@@ -292,7 +291,7 @@ describe('dataFrameToLogsModel', () => {
expect(logsModel.meta).toHaveLength(2);
expect(logsModel.meta![0]).toMatchObject({
label: '',
value: `2 lines returned`,
value: `2 lines displayed`,
kind: LogsMetaKind.String,
});
expect(logsModel.meta![1]).toMatchObject({
@@ -374,7 +373,7 @@ describe('dataFrameToLogsModel', () => {
expect(logsModel.meta).toHaveLength(2);
expect(logsModel.meta![0]).toMatchObject({
label: '',
value: `2 lines returned`,
value: `2 lines displayed`,
kind: LogsMetaKind.String,
});
expect(logsModel.meta![1]).toMatchObject({
@@ -386,9 +385,7 @@ describe('dataFrameToLogsModel', () => {
});
});
it('with infinite scrolling enabled it should return expected logs model', () => {
config.featureToggles.logsInfiniteScrolling = true;
it('it should return expected logs model', () => {
const series: DataFrame[] = [
createDataFrame({
fields: [
@@ -421,8 +418,6 @@ describe('dataFrameToLogsModel', () => {
value: `1 line displayed`,
kind: LogsMetaKind.String,
});
config.featureToggles.logsInfiniteScrolling = false;
});
it('given one series with limit as custom meta property should return correct limit', () => {
@@ -430,7 +425,7 @@ describe('dataFrameToLogsModel', () => {
const logsModel = dataFrameToLogsModel(series, 1);
expect(logsModel.meta![0]).toMatchObject({
label: '',
value: `2 lines returned`,
value: `2 lines displayed`,
kind: LogsMetaKind.String,
});
});
@@ -639,7 +634,7 @@ describe('dataFrameToLogsModel', () => {
expect(logsModel.meta).toHaveLength(2);
expect(logsModel.meta![0]).toMatchObject({
label: '',
value: `2 lines returned`,
value: `2 lines displayed`,
kind: LogsMetaKind.String,
});
expect(logsModel.meta![1]).toMatchObject({
@@ -758,7 +753,7 @@ describe('dataFrameToLogsModel', () => {
expect(logsModel.meta).toHaveLength(3);
expect(logsModel.meta![0]).toMatchObject({
label: '',
value: `2 lines returned`,
value: `2 lines displayed`,
kind: LogsMetaKind.String,
});
expect(logsModel.meta![1]).toMatchObject({
+1 -3
View File
@@ -41,7 +41,6 @@ import {
} from '@grafana/data';
import { SIPrefix } from '@grafana/data/internal';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { BarAlignment, GraphDrawStyle, StackingMode } from '@grafana/schema';
import { colors } from '@grafana/ui';
import { getThemeColor } from 'app/core/utils/colors';
@@ -576,8 +575,7 @@ function adjustMetaInfo(logsModel: LogsModel, visibleRangeMs?: number, requested
metaLimitValue = `${limit} lines shown — ${coverage}% (${rangeUtil.msRangeToTimeString(visibleRangeMs)}) of ${rangeUtil.msRangeToTimeString(requestedRangeMs)}`;
}
} else {
const description = config.featureToggles.logsInfiniteScrolling ? 'displayed' : 'returned';
metaLimitValue = `${logsModel.rows.length} ${logsModel.rows.length > 1 ? 'lines' : 'line'} ${description}`;
metaLimitValue = `${logsModel.rows.length} ${logsModel.rows.length > 1 ? 'lines' : 'line'} displayed`;
}
logsModelMeta[limitIndex] = {
+1 -1
View File
@@ -464,7 +464,7 @@ export const LogsPanel = ({
const loadMoreLogs = useCallback(
async (scrollRange: AbsoluteTimeRange) => {
if (!data.request || !config.featureToggles.logsInfiniteScrolling || loadingRef.current) {
if (!data.request || loadingRef.current) {
return;
}
+1 -5
View File
@@ -9973,11 +9973,7 @@
"wrap-lines": "Wrap lines"
},
"logs-navigation": {
"newer-logs": "Newer logs",
"older-logs": "Older logs",
"scroll-bottom": "Scroll to bottom",
"scroll-top": "Scroll to top",
"start-of-range": "Start of range"
"scroll-top": "Scroll to top"
},
"logs-panel": {
"render-common-labels": {