Explore: Clear live logs (#64237)

* feat: add clear logs to explorer's live logs

* refactor live logs pause/resume updating logic

* add tests for clearing live logs

* rm unused imports

* move `onClear` live logs logic to redux

* move clear logs tests to `query.test`

* use utils `sortLogsRows` and Button's icon props

* rename `filterLogRows` and add `clearedAt` as an arg

* mv clearedAt type closer to live tailing items and add jsdoc

* mv `filterLogRowsByTime` to `/utils` and use it at `LiveLogs` component

* make `Exit live` button consistent with other actions

* use `sortLogRows` and fix timestamp by id on `makeLogs`

* change clear live logs from based on timestamp to index on logRows

* assign `null` to `clearedAtIndex` on first batch of logs live

* move `isFirstStreaming` implementation to `clearLogs` reducer

* fix `clearLogs` tests

* Update public/app/features/explore/state/query.ts

Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>

---------

Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>
This commit is contained in:
Abdul
2023-04-20 10:21:28 +02:00
committed by GitHub
co-authored by Ivana Huckova
parent 0adacd1dd3
commit 3a013cbe48
9 changed files with 247 additions and 58 deletions
+22 -33
View File
@@ -1,9 +1,10 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { LogLevel, LogRowModel, MutableDataFrame } from '@grafana/data'; import { LogRowModel } from '@grafana/data';
import { LiveLogsWithTheme } from './LiveLogs'; import { LiveLogsWithTheme } from './LiveLogs';
import { makeLogs } from './__mocks__/makeLogs';
const setup = (rows: LogRowModel[]) => const setup = (rows: LogRowModel[]) =>
render( render(
@@ -13,36 +14,16 @@ const setup = (rows: LogRowModel[]) =>
stopLive={() => {}} stopLive={() => {}}
onPause={() => {}} onPause={() => {}}
onResume={() => {}} onResume={() => {}}
onClear={() => {}}
clearedAtIndex={null}
isPaused={true} isPaused={true}
/> />
); );
const makeLog = (overrides: Partial<LogRowModel>): LogRowModel => {
const uid = overrides.uid || '1';
const entry = `log message ${uid}`;
return {
uid,
entryFieldIndex: 0,
rowIndex: 0,
dataFrame: new MutableDataFrame(),
logLevel: LogLevel.debug,
entry,
hasAnsi: false,
hasUnescapedContent: false,
labels: {},
raw: entry,
timeFromNow: '',
timeEpochMs: 1,
timeEpochNs: '1000000',
timeLocal: '',
timeUtc: '',
...overrides,
};
};
describe('LiveLogs', () => { describe('LiveLogs', () => {
it('renders logs', () => { it('renders logs', () => {
setup([makeLog({ uid: '1' }), makeLog({ uid: '2' }), makeLog({ uid: '3' })]); const logRows = makeLogs(3);
setup(logRows);
expect(screen.getByRole('cell', { name: 'log message 1' })).toBeInTheDocument(); expect(screen.getByRole('cell', { name: 'log message 1' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: 'log message 2' })).toBeInTheDocument(); expect(screen.getByRole('cell', { name: 'log message 2' })).toBeInTheDocument();
@@ -50,14 +31,19 @@ describe('LiveLogs', () => {
}); });
it('renders new logs only when not paused', () => { it('renders new logs only when not paused', () => {
const { rerender } = setup([makeLog({ uid: '1' }), makeLog({ uid: '2' }), makeLog({ uid: '3' })]); const logRows = makeLogs(6);
const firstLogs = logRows.slice(0, 3);
const secondLogs = logRows.slice(3, 6);
const { rerender } = setup(firstLogs);
rerender( rerender(
<LiveLogsWithTheme <LiveLogsWithTheme
logRows={[makeLog({ uid: '4' }), makeLog({ uid: '5' }), makeLog({ uid: '6' })]} logRows={secondLogs}
timeZone={'utc'} timeZone={'utc'}
stopLive={() => {}} stopLive={() => {}}
onPause={() => {}} onPause={() => {}}
onClear={() => {}}
clearedAtIndex={null}
onResume={() => {}} onResume={() => {}}
isPaused={true} isPaused={true}
/> />
@@ -72,11 +58,13 @@ describe('LiveLogs', () => {
rerender( rerender(
<LiveLogsWithTheme <LiveLogsWithTheme
logRows={[makeLog({ uid: '4' }), makeLog({ uid: '5' }), makeLog({ uid: '6' })]} logRows={secondLogs}
timeZone={'utc'} timeZone={'utc'}
stopLive={() => {}} stopLive={() => {}}
onPause={() => {}} onPause={() => {}}
onResume={() => {}} onResume={() => {}}
onClear={() => {}}
clearedAtIndex={null}
isPaused={false} isPaused={false}
/> />
); );
@@ -87,11 +75,12 @@ describe('LiveLogs', () => {
}); });
it('renders ansi logs', () => { it('renders ansi logs', () => {
setup([ const commonLog = makeLogs(1);
makeLog({ uid: '1' }), const firstAnsiLog = makeLogs(1, { hasAnsi: true, raw: 'log message \u001B[31m2\u001B[0m', uid: '2' });
makeLog({ hasAnsi: true, raw: 'log message \u001B[31m2\u001B[0m', uid: '2' }), const secondAnsiLog = makeLogs(1, { hasAnsi: true, raw: 'log message \u001B[33m3\u001B[0m', uid: '3' });
makeLog({ hasAnsi: true, raw: 'log message \u001B[33m3\u001B[0m', uid: '3' }), const logRows = [...commonLog, ...firstAnsiLog, ...secondAnsiLog];
]);
setup(logRows);
expect(screen.getByRole('cell', { name: 'log message 1' })).toBeInTheDocument(); expect(screen.getByRole('cell', { name: 'log message 1' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: 'log message 2' })).toBeInTheDocument(); expect(screen.getByRole('cell', { name: 'log message 2' })).toBeInTheDocument();
+37 -21
View File
@@ -2,13 +2,15 @@ import { css, cx } from '@emotion/css';
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { LogRowModel, TimeZone, dateTimeFormat, GrafanaTheme2 } from '@grafana/data'; import { LogRowModel, TimeZone, dateTimeFormat, GrafanaTheme2, LogsSortOrder } from '@grafana/data';
import { Icon, Button, Themeable2, withTheme2 } from '@grafana/ui'; import { Button, Themeable2, withTheme2 } from '@grafana/ui';
import { LogMessageAnsi } from '../logs/components/LogMessageAnsi'; import { LogMessageAnsi } from '../logs/components/LogMessageAnsi';
import { getLogRowStyles } from '../logs/components/getLogRowStyles'; import { getLogRowStyles } from '../logs/components/getLogRowStyles';
import { sortLogRows } from '../logs/utils';
import { ElapsedTime } from './ElapsedTime'; import { ElapsedTime } from './ElapsedTime';
import { filterLogRowsByIndex } from './state/utils';
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
logsRowsLive: css` logsRowsLive: css`
@@ -57,6 +59,8 @@ export interface Props extends Themeable2 {
stopLive: () => void; stopLive: () => void;
onPause: () => void; onPause: () => void;
onResume: () => void; onResume: () => void;
onClear: () => void;
clearedAtIndex: number | null;
isPaused: boolean; isPaused: boolean;
} }
@@ -76,16 +80,22 @@ class LiveLogs extends PureComponent<Props, State> {
} }
static getDerivedStateFromProps(nextProps: Props, state: State) { static getDerivedStateFromProps(nextProps: Props, state: State) {
if (!nextProps.isPaused) { if (nextProps.isPaused && nextProps.clearedAtIndex) {
return { return {
// We update what we show only if not paused. We keep any background subscriptions running and keep updating logRowsToRender: filterLogRowsByIndex(nextProps.clearedAtIndex, state.logRowsToRender),
// our state, but we do not show the updates, this allows us start again showing correct result after resuming
// without creating a gap in the log results.
logRowsToRender: nextProps.logRows,
}; };
} else { }
if (nextProps.isPaused) {
return null; return null;
} }
return {
// We update what we show only if not paused. We keep any background subscriptions running and keep updating
// our state, but we do not show the updates, this allows us start again showing correct result after resuming
// without creating a gap in the log results.
logRowsToRender: nextProps.logRows,
};
} }
/** /**
@@ -107,13 +117,13 @@ class LiveLogs extends PureComponent<Props, State> {
let { logRowsToRender: rowsToRender = [] } = this.state; let { logRowsToRender: rowsToRender = [] } = this.state;
if (!isPaused) { if (!isPaused) {
// A perf optimisation here. Show just 100 rows when streaming and full length when the streaming is paused. // A perf optimisation here. Show just 100 rows when streaming and full length when the streaming is paused.
rowsToRender = rowsToRender.slice(-100); rowsToRender = sortLogRows(rowsToRender, LogsSortOrder.Ascending).slice(-100);
} }
return rowsToRender; return rowsToRender;
}; };
render() { render() {
const { theme, timeZone, onPause, onResume, isPaused } = this.props; const { theme, timeZone, onPause, onResume, onClear, isPaused } = this.props;
const styles = getStyles(theme); const styles = getStyles(theme);
const { logsRow, logsRowLocalTime, logsRowMessage } = getLogRowStyles(theme); const { logsRow, logsRowLocalTime, logsRowMessage } = getLogRowStyles(theme);
@@ -147,20 +157,26 @@ class LiveLogs extends PureComponent<Props, State> {
</tbody> </tbody>
</table> </table>
<div className={styles.logsRowsIndicator}> <div className={styles.logsRowsIndicator}>
<Button variant="secondary" onClick={isPaused ? onResume : onPause} className={styles.button}> <Button
<Icon name={isPaused ? 'play' : 'pause'} /> icon={isPaused ? 'play' : 'pause'}
&nbsp; variant="secondary"
onClick={isPaused ? onResume : onPause}
className={styles.button}
>
{isPaused ? 'Resume' : 'Pause'} {isPaused ? 'Resume' : 'Pause'}
</Button> </Button>
<Button variant="secondary" onClick={this.props.stopLive} className={styles.button}> <Button icon="trash-alt" variant="secondary" onClick={onClear} className={styles.button}>
<Icon name="square-shape" size="lg" type="mono" /> Clear logs
&nbsp; Exit live mode
</Button> </Button>
{isPaused || ( <Button icon="square-shape" variant="secondary" onClick={this.props.stopLive} className={styles.button}>
<span> Exit live mode
Last line received: <ElapsedTime resetKey={this.props.logRows} humanize={true} /> ago </Button>
</span> {isPaused ||
)} (this.rowsToRender().length > 0 && (
<span>
Last line received: <ElapsedTime resetKey={this.props.logRows} humanize={true} /> ago
</span>
))}
</div> </div>
</div> </div>
); );
@@ -137,6 +137,8 @@ class LogsContainer extends PureComponent<LogsContainerProps> {
isPaused={this.props.isPaused} isPaused={this.props.isPaused}
onPause={controls.pause} onPause={controls.pause}
onResume={controls.resume} onResume={controls.resume}
onClear={controls.clear}
clearedAtIndex={this.props.clearedAtIndex}
/> />
)} )}
</LiveTailControls> </LiveTailControls>
@@ -195,6 +197,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }
datasourceInstance, datasourceInstance,
isLive, isLive,
isPaused, isPaused,
clearedAtIndex,
range, range,
absoluteRange, absoluteRange,
supplementaryQueries, supplementaryQueries,
@@ -214,6 +217,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }
datasourceInstance, datasourceInstance,
isLive, isLive,
isPaused, isPaused,
clearedAtIndex,
range, range,
absoluteRange, absoluteRange,
logsVolume, logsVolume,
@@ -0,0 +1,32 @@
import { MutableDataFrame, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data';
import { sortLogRows } from 'app/features/logs/utils';
export const makeLogs = (numberOfLogsToCreate: number, overrides?: Partial<LogRowModel>): LogRowModel[] => {
const array: LogRowModel[] = [];
for (let i = 0; i < numberOfLogsToCreate; i++) {
const uuid = (i + 1).toString();
const entry = `log message ${uuid}`;
const timeInMs = overrides?.timeEpochMs || new Date().getTime();
array.push({
uid: uuid,
entryFieldIndex: 0,
rowIndex: 0,
dataFrame: new MutableDataFrame(),
logLevel: LogLevel.debug,
entry,
hasAnsi: false,
hasUnescapedContent: false,
labels: {},
raw: entry,
timeFromNow: '',
timeEpochMs: timeInMs + i,
timeEpochNs: (timeInMs * 1000000 + i).toString(),
timeLocal: '',
timeUtc: '',
...overrides,
});
}
return sortLogRows(array, LogsSortOrder.Ascending);
};
@@ -22,6 +22,7 @@ import { ExploreId, ExploreItemState, StoreState, ThunkDispatch } from 'app/type
import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { configureStore } from '../../../store/configureStore'; import { configureStore } from '../../../store/configureStore';
import { setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv'; import { setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv';
import { makeLogs } from '../__mocks__/makeLogs';
import { supplementaryQueryTypes } from '../utils/supplementaryQueries'; import { supplementaryQueryTypes } from '../utils/supplementaryQueries';
import { createDefaultInitialState } from './helpers'; import { createDefaultInitialState } from './helpers';
@@ -41,6 +42,9 @@ import {
setSupplementaryQueryEnabled, setSupplementaryQueryEnabled,
addQueryRow, addQueryRow,
cleanSupplementaryQueryDataProviderAction, cleanSupplementaryQueryDataProviderAction,
clearLogs,
queryStreamUpdatedAction,
QueryEndedPayload,
changeQueries, changeQueries,
} from './query'; } from './query';
import * as actions from './query'; import * as actions from './query';
@@ -924,4 +928,77 @@ describe('reducer', () => {
).toBeUndefined(); ).toBeUndefined();
}); });
}); });
describe('clear live logs', () => {
it('should clear current log rows', async () => {
const logRows = makeLogs(10);
const { dispatch, getState }: { dispatch: ThunkDispatch; getState: () => StoreState } = configureStore({
...defaultInitialState,
explore: {
[ExploreId.left]: {
...defaultInitialState.explore[ExploreId.left],
queryResponse: {
state: LoadingState.Streaming,
},
logsResult: {
hasUniqueLabels: false,
rows: logRows,
},
},
},
} as unknown as Partial<StoreState>);
expect(getState().explore[ExploreId.left].logsResult?.rows.length).toBe(logRows.length);
await dispatch(clearLogs({ exploreId: ExploreId.left }));
expect(getState().explore[ExploreId.left].logsResult?.rows.length).toBe(0);
expect(getState().explore[ExploreId.left].clearedAtIndex).toBe(logRows.length - 1);
});
it('should filter new log rows', async () => {
const oldLogRows = makeLogs(10);
const newLogRows = makeLogs(5);
const allLogRows = [...oldLogRows, ...newLogRows];
const { dispatch, getState }: { dispatch: ThunkDispatch; getState: () => StoreState } = configureStore({
...defaultInitialState,
explore: {
[ExploreId.left]: {
...defaultInitialState.explore[ExploreId.left],
isLive: true,
queryResponse: {
state: LoadingState.Streaming,
},
logsResult: {
hasUniqueLabels: false,
rows: oldLogRows,
},
},
},
} as unknown as Partial<StoreState>);
expect(getState().explore[ExploreId.left].logsResult?.rows.length).toBe(oldLogRows.length);
await dispatch(clearLogs({ exploreId: ExploreId.left }));
await dispatch(
queryStreamUpdatedAction({
exploreId: ExploreId.left,
response: {
request: true,
traceFrames: [],
nodeGraphFrames: [],
rawPrometheusFrames: [],
flameGraphFrames: [],
logsResult: {
hasUniqueLabels: false,
rows: allLogRows,
},
},
} as unknown as QueryEndedPayload)
);
expect(getState().explore[ExploreId.left].logsResult?.rows.length).toBe(newLogRows.length);
expect(getState().explore[ExploreId.left].clearedAtIndex).toBe(oldLogRows.length - 1);
});
});
}); });
+45 -3
View File
@@ -61,7 +61,7 @@ import {
import { addHistoryItem, historyUpdatedAction, loadRichHistory } from './history'; import { addHistoryItem, historyUpdatedAction, loadRichHistory } from './history';
import { stateSave } from './main'; import { stateSave } from './main';
import { updateTime } from './time'; import { updateTime } from './time';
import { createCacheKey, getResultsFromCache } from './utils'; import { createCacheKey, getResultsFromCache, filterLogRowsByIndex } from './utils';
// //
// Actions and Payloads // Actions and Payloads
@@ -191,6 +191,10 @@ export interface SetPausedStatePayload {
} }
export const setPausedStateAction = createAction<SetPausedStatePayload>('explore/setPausedState'); export const setPausedStateAction = createAction<SetPausedStatePayload>('explore/setPausedState');
export interface ClearLogsPayload {
exploreId: ExploreId;
}
export const clearLogs = createAction<ClearLogsPayload>('explore/clearLogs');
/** /**
* Start a scan for more results using the given scanner. * Start a scan for more results using the given scanner.
* @param exploreId Explore area * @param exploreId Explore area
@@ -1089,6 +1093,41 @@ export const queryReducer = (state: ExploreItemState, action: AnyAction): Explor
}; };
} }
if (clearLogs.match(action)) {
if (!state.logsResult) {
return {
...state,
clearedAtIndex: null,
};
}
// When in loading state, clear logs and set clearedAtIndex as null.
// Initially loaded logs will be fully replaced by incoming streamed logs, which may have a different length.
if (state.queryResponse.state === LoadingState.Loading) {
return {
...state,
clearedAtIndex: null,
logsResult: {
...state.logsResult,
rows: [],
},
};
}
const lastItemIndex = state.clearedAtIndex
? state.clearedAtIndex + state.logsResult.rows.length
: state.logsResult.rows.length - 1;
return {
...state,
clearedAtIndex: lastItemIndex,
logsResult: {
...state.logsResult,
rows: [],
},
};
}
return state; return state;
}; };
@@ -1113,7 +1152,6 @@ const getCorrelations = () => {
} }
}); });
}; };
export const processQueryResponse = ( export const processQueryResponse = (
state: ExploreItemState, state: ExploreItemState,
action: PayloadAction<QueryEndedPayload> action: PayloadAction<QueryEndedPayload>
@@ -1169,7 +1207,10 @@ export const processQueryResponse = (
graphResult, graphResult,
tableResult, tableResult,
rawPrometheusResult, rawPrometheusResult,
logsResult, logsResult:
state.isLive && logsResult
? { ...logsResult, rows: filterLogRowsByIndex(state.clearedAtIndex, logsResult.rows) }
: logsResult,
loading: loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming, loading: loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming,
showLogs: !!logsResult, showLogs: !!logsResult,
showMetrics: !!graphResult, showMetrics: !!graphResult,
@@ -1178,5 +1219,6 @@ export const processQueryResponse = (
showNodeGraph: !!nodeGraphFrames.length, showNodeGraph: !!nodeGraphFrames.length,
showRawPrometheus: !!rawPrometheusFrames.length, showRawPrometheus: !!rawPrometheusFrames.length,
showFlameGraph: !!flameGraphFrames.length, showFlameGraph: !!flameGraphFrames.length,
clearedAtIndex: state.isLive ? state.clearedAtIndex : null,
}; };
}; };
@@ -8,6 +8,7 @@ import {
getDefaultTimeRange, getDefaultTimeRange,
HistoryItem, HistoryItem,
LoadingState, LoadingState,
LogRowModel,
PanelData, PanelData,
} from '@grafana/data'; } from '@grafana/data';
import { DataSourceRef } from '@grafana/schema'; import { DataSourceRef } from '@grafana/schema';
@@ -58,6 +59,7 @@ export const makeExplorePaneState = (): ExploreItemState => ({
tableResult: null, tableResult: null,
graphResult: null, graphResult: null,
logsResult: null, logsResult: null,
clearedAtIndex: null,
rawPrometheusResult: null, rawPrometheusResult: null,
eventBridge: null as unknown as EventBusExtended, eventBridge: null as unknown as EventBusExtended,
cache: [], cache: [],
@@ -158,3 +160,19 @@ export function getResultsFromCache(
const cacheValue = cacheIdx >= 0 ? cache[cacheIdx].value : undefined; const cacheValue = cacheIdx >= 0 ? cache[cacheIdx].value : undefined;
return cacheValue; return cacheValue;
} }
export const filterLogRowsByIndex = (
clearedAtIndex: ExploreItemState['clearedAtIndex'],
logRows?: LogRowModel[]
): LogRowModel[] => {
if (!logRows) {
return [];
}
if (clearedAtIndex) {
const filteredRows = logRows.slice(clearedAtIndex + 1);
return filteredRows;
}
return logRows;
};
@@ -5,7 +5,7 @@ import { useDispatch } from 'app/types';
import { ExploreId } from '../../types'; import { ExploreId } from '../../types';
import { setPausedStateAction, runQueries } from './state/query'; import { setPausedStateAction, runQueries, clearLogs } from './state/query';
import { changeRefreshInterval } from './state/time'; import { changeRefreshInterval } from './state/time';
/** /**
@@ -38,11 +38,16 @@ export function useLiveTailControls(exploreId: ExploreId) {
dispatch(changeRefreshInterval(exploreId, RefreshPicker.liveOption.value)); dispatch(changeRefreshInterval(exploreId, RefreshPicker.liveOption.value));
}, [exploreId, dispatch]); }, [exploreId, dispatch]);
const clear = useCallback(() => {
dispatch(clearLogs({ exploreId }));
}, [exploreId, dispatch]);
return { return {
pause, pause,
resume, resume,
stop, stop,
start, start,
clear,
}; };
} }
+6
View File
@@ -175,6 +175,12 @@ export interface ExploreItemState {
*/ */
isPaused: boolean; isPaused: boolean;
/**
* Index of the last item in the list of logs
* when the live tailing views gets cleared.
*/
clearedAtIndex: number | null;
querySubscription?: Unsubscribable; querySubscription?: Unsubscribable;
queryResponse: ExplorePanelData; queryResponse: ExplorePanelData;