diff --git a/packages/grafana-data/src/types/logs.ts b/packages/grafana-data/src/types/logs.ts index 71b93ab7e39..94f1d97518c 100644 --- a/packages/grafana-data/src/types/logs.ts +++ b/packages/grafana-data/src/types/logs.ts @@ -137,6 +137,8 @@ export interface LogRowContextOptions { direction?: LogRowContextQueryDirection; limit?: number; scopedVars?: ScopedVars; + // Optional. Size of the time window to get logs before of after the referenced entry. + timeWindowMs?: number; } export enum LogRowContextQueryDirection { @@ -181,6 +183,9 @@ export interface DataSourceWithLogsContextSupport { diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 629f9ee37c2..1d542268cd0 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -581,10 +581,12 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { let onCloseContext = useCallback(() => { setContextOpen(false); setContextRow(undefined); - reportInteraction('grafana_explore_logs_log_context_closed', { - datasourceType: contextRow?.datasourceType, - logRowUid: contextRow?.uid, - }); + if (!config.featureToggles.newLogContext) { + reportInteraction('grafana_explore_logs_log_context_closed', { + datasourceType: contextRow?.datasourceType, + logRowUid: contextRow?.uid, + }); + } onCloseCallbackRef?.current(); }, [contextRow?.datasourceType, contextRow?.uid, onCloseCallbackRef]); @@ -592,10 +594,12 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { // we are setting the `contextOpen` open state and passing it down to the `LogRow` in order to highlight the row when a LogContext is open setContextOpen(true); setContextRow(row); - reportInteraction('grafana_explore_logs_log_context_opened', { - datasourceType: row.datasourceType, - logRowUid: row.uid, - }); + if (!config.featureToggles.newLogContext) { + reportInteraction('grafana_explore_logs_log_context_opened', { + datasourceType: row.datasourceType, + logRowUid: row.uid, + }); + } onCloseCallbackRef.current = onClose; }, []); diff --git a/public/app/features/logs/components/panel/InfiniteScroll.tsx b/public/app/features/logs/components/panel/InfiniteScroll.tsx index 6dff87f65e9..ef78316b69f 100644 --- a/public/app/features/logs/components/panel/InfiniteScroll.tsx +++ b/public/app/features/logs/components/panel/InfiniteScroll.tsx @@ -26,6 +26,7 @@ export interface Props { displayedFields: string[]; handleOverflow: (index: number, id: string, height?: number) => void; infiniteScrollMode: InfiniteScrollMode; + loading?: boolean; loadMore?: LoadMoreLogsType; logs: LogListModel[]; onClick: (e: MouseEvent, log: LogListModel) => void; @@ -50,6 +51,7 @@ export const InfiniteScroll = ({ displayedFields, handleOverflow, infiniteScrollMode, + loading, loadMore, logs, onClick, @@ -102,12 +104,12 @@ export const InfiniteScroll = ({ }, [prevSortOrder, sortOrder]); useEffect(() => { - if (autoScroll) { + if (autoScroll && !loading) { setInitialScrollPosition(scrollToLogLineRef.current); scrollToLogLineRef.current = undefined; setAutoScroll(false); } - }, [autoScroll, setInitialScrollPosition]); + }, [autoScroll, loading, setInitialScrollPosition]); const onLoadMore = useCallback( (scrollDirection: ScrollDirection) => { diff --git a/public/app/features/logs/components/panel/LogLineContext.test.tsx b/public/app/features/logs/components/panel/LogLineContext.test.tsx index 31f77df4f3e..8b88a9cb2c9 100644 --- a/public/app/features/logs/components/panel/LogLineContext.test.tsx +++ b/public/app/features/logs/components/panel/LogLineContext.test.tsx @@ -10,7 +10,7 @@ import { import { dataFrameToLogsModel } from '../../logsModel'; -import { LogLineContext } from './LogLineContext'; +import { DEFAULT_TIME_WINDOW, LogLineContext, PAGE_SIZE } from './LogLineContext'; jest.mock('@grafana/assistant', () => ({ ...jest.requireActual('@grafana/assistant'), @@ -542,4 +542,31 @@ describe('LogLineContext', () => { await waitFor(() => expect(dispatchMock).toHaveBeenCalledWith(splitOpenSym)); }); + + test('Allows to change the time window surrounding the log', async () => { + row.datasourceType = 'loki'; + + render( + {}} + getRowContext={getRowContext} + timeZone={timeZone} + sortOrder={LogsSortOrder.Descending} + /> + ); + await waitFor(() => + expect(getRowContext).toHaveBeenCalledWith(expect.anything(), { + limit: PAGE_SIZE, + direction: LogRowContextQueryDirection.Forward, + timeWindowMs: DEFAULT_TIME_WINDOW, + }) + ); + expect(getRowContext).toHaveBeenCalledWith(expect.anything(), { + limit: PAGE_SIZE, + direction: LogRowContextQueryDirection.Backward, + timeWindowMs: DEFAULT_TIME_WINDOW, + }); + }); }); diff --git a/public/app/features/logs/components/panel/LogLineContext.tsx b/public/app/features/logs/components/panel/LogLineContext.tsx index db28cdc0fdb..c203eef3c9e 100644 --- a/public/app/features/logs/components/panel/LogLineContext.tsx +++ b/public/app/features/logs/components/panel/LogLineContext.tsx @@ -3,26 +3,30 @@ import { partition } from 'lodash'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + AbsoluteTimeRange, + CoreApp, DataQueryResponse, + DataSourceApi, DataSourceWithLogsContextSupport, + dateTime, + EventBusSrv, + formattedValueToString, + getValueFormat, GrafanaTheme2, + hasLogsContextSupport, + LoadingState, LogRowContextOptions, LogRowContextQueryDirection, + LogRowModel, LogsDedupStrategy, LogsSortOrder, - dateTime, - TimeRange, - LoadingState, - CoreApp, - LogRowModel, - AbsoluteTimeRange, - EventBusSrv, store, + TimeRange, } from '@grafana/data'; -import { Trans, t } from '@grafana/i18n'; -import { config, reportInteraction } from '@grafana/runtime'; +import { t, Trans } from '@grafana/i18n'; +import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; -import { Button, Collapse, Modal, useTheme2 } from '@grafana/ui'; +import { Button, Collapse, Combobox, ComboboxOption, InlineLabel, Modal, Stack, useTheme2 } from '@grafana/ui'; import { splitOpen } from 'app/features/explore/state/main'; import { useDispatch } from 'app/types/store'; @@ -56,7 +60,8 @@ interface LogLineContextProps { onClickHideField?: (key: string) => void; } -const PAGE_SIZE = 100; +export const PAGE_SIZE = 100; +export const DEFAULT_TIME_WINDOW = 7200000; export const LogLineContext = memo( ({ @@ -84,6 +89,14 @@ export const LogLineContext = memo( const [aboveState, setAboveState] = useState(LoadingState.NotStarted); const [belowState, setBelowState] = useState(LoadingState.NotStarted); const [showLog, setShowLog] = useState(false); + const [datasourceInstance, setDatasourceInstance] = useState< + (DataSourceApi & DataSourceWithLogsContextSupport) | null + >(null); + const defaultTimeWindow = logOptionsStorageKey + ? (store.get(`${logOptionsStorageKey}.contextTimeWindow`) ?? DEFAULT_TIME_WINDOW.toString()) + : DEFAULT_TIME_WINDOW.toString(); + const [timeWindow, setTimeWindow] = useState(parseInt(defaultTimeWindow, 10)); + const eventBusRef = useRef(new EventBusSrv()); const dispatch = useDispatch(); @@ -95,8 +108,7 @@ export const LogLineContext = memo( sortOrder === LogsSortOrder.Ascending ? allLogs[0].timeEpochMs : allLogs[allLogs.length - 1].timeEpochMs; let toMs = sortOrder === LogsSortOrder.Ascending ? allLogs[allLogs.length - 1].timeEpochMs : allLogs[0].timeEpochMs; - // In case we have a lot of logs and from and to have same millisecond - // we add 1 millisecond to toMs to make sure we have a range + // Add one millisecond to get a range when from and to are equal. if (fromMs === toMs) { toMs += 1; } @@ -119,24 +131,23 @@ export const LogLineContext = memo( setContextQuery(contextQuery); }, [log, getRowContextQuery]); - const updateResults = useCallback(async () => { - setAboveLogs([]); - setBelowLogs([]); - await updateContextQuery(); - setInitialized(false); - }, [updateContextQuery]); - useEffect(() => { if (open) { updateContextQuery(); + reportInteraction('logs_log_line_context_open', { + datasourceType: log.datasourceType, + uid: log.uid, + }); } - }, [updateContextQuery, open]); + }, [updateContextQuery, open, log]); const getContextLogs = useCallback( - async (place: 'above' | 'below', refLog: LogRowModel): Promise => { + async (place: 'above' | 'below', refLog: LogRowModel, timeWindowMs?: number): Promise => { const result = await getRowContext(normalizeLogRefId(refLog), { limit: PAGE_SIZE, direction: getLoadMoreDirection(place, sortOrder), + // Only on the initial request + timeWindowMs, }); const newLogs = dataFrameToLogsModel(result.data).rows; @@ -149,12 +160,12 @@ export const LogLineContext = memo( ); const loadMore = useCallback( - async (place: 'above' | 'below', refLog: LogRowModel) => { + async (place: 'above' | 'below', refLog: LogRowModel, timeWindow?: number) => { const setState = place === 'above' ? setAboveState : setBelowState; setState(LoadingState.Loading); try { - const newLogs = (await getContextLogs(place, refLog)).map((r) => + const newLogs = (await getContextLogs(place, refLog, timeWindow)).map((r) => // apply the original row's searchWords to all the rows for highlighting !r.searchWords || !r.searchWords?.length ? { ...r, searchWords: log.searchWords } : r ); @@ -188,10 +199,10 @@ export const LogLineContext = memo( return; } if (!initialized) { - Promise.all([loadMore('above', log), loadMore('below', log)]).then(() => {}); + Promise.all([loadMore('above', log, timeWindow), loadMore('below', log, timeWindow)]); setInitialized(true); } - }, [initialized, loadMore, log, open]); + }, [initialized, loadMore, log, open, timeWindow]); const handleLoadMore = useCallback( (_: AbsoluteTimeRange, direction: ScrollDirection) => { @@ -212,10 +223,70 @@ export const LogLineContext = memo( ); }, [log.uid]); + const onSplitViewClick = useCallback(() => { + if (!contextQuery) { + return; + } + let rowId = log.uid; + if (log.dataFrame.refId) { + // the orignal row has the refid from the base query and not the refid from the context query, so we need to replace it. + rowId = log.uid.replace(log.dataFrame.refId, contextQuery.refId); + } + + dispatch( + splitOpen({ + queries: [contextQuery], + range: timeRange, + datasourceUid: contextQuery.datasource!.uid!, + panelsState: { + logs: { + id: rowId, + }, + }, + }) + ); + onClose(); + reportInteraction('logs_log_line_context_open_in_split_clicked', { + datasourceType: log.datasourceType, + }); + }, [contextQuery, dispatch, log.dataFrame.refId, log.datasourceType, log.uid, onClose, timeRange]); + + const handleTimeWindowChange = useCallback( + (option: ComboboxOption) => { + if (logOptionsStorageKey) { + store.set(`${logOptionsStorageKey}.contextTimeWindow`, option.value); + } + setTimeWindow(parseInt(option.value, 10)); + setAboveLogs([]); + setBelowLogs([]); + setInitialized(false); + reportInteraction('logs_log_line_context_time_window_change', { + window_size: option.value, + }); + }, + [logOptionsStorageKey] + ); + + const handleClose = useCallback(() => { + reportInteraction('logs_log_line_context_closed', { + datasourceType: log.datasourceType, + uid: log.uid, + }); + onClose(); + }, [log.datasourceType, log.uid, onClose]); + + const updateResults = useCallback(async () => { + setAboveLogs([]); + setBelowLogs([]); + await updateContextQuery(); + setInitialized(false); + }, [updateContextQuery]); + const wrapLogMessage = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.wrapLogMessage`, true) : true; const syntaxHighlighting = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.syntaxHighlighting`, true) : true; + // @todo: Remove when the LogRows are deprecated const logListModel = useMemo( () => @@ -229,16 +300,28 @@ export const LogLineContext = memo( [log, timeZone, wrapLogMessage] ); + useEffect(() => { + if (log.datasourceUid) { + getDataSourceSrv() + .get({ uid: log.datasourceUid }) + .then((ds) => { + if (hasLogsContextSupport(ds)) { + setDatasourceInstance(ds); + } + }); + } + }, [log.datasourceUid]); + return ( {config.featureToggles.logsContextDatasourceUi && getLogRowContextUi && ( -
{getLogRowContextUi(log, updateResults)}
+
{getLogRowContextUi(log, updateResults)}
)} +
+ {datasourceInstance?.supportsAdjustableWindow && ( + + + {t('logs.log-line-context.time-window-label', 'Context time window')} + + + + )} + + {contextQuery?.datasource?.uid && ( + + )} +
{aboveState === LoadingState.Loading && ( No more logs available. )}
- - - - {contextQuery?.datasource?.uid && ( - - )} -
); } @@ -362,10 +440,6 @@ const getStyles = (theme: GrafanaTheme2) => { left: '50%', transform: 'translate(-50%, -50%)', }), - datasourceUi: css({ - display: 'flex', - alignItems: 'center', - }), loadingIndicator: css({ height: theme.spacing(3), minHeight: theme.spacing(3), @@ -377,8 +451,8 @@ const getStyles = (theme: GrafanaTheme2) => { wrapper: css({ border: `1px solid ${theme.colors.border.weak}`, padding: theme.spacing(0, 1, 1, 0), - flex: 1, - height: '100%', + flex: '1 1 auto', + minHeight: 0, }), logsContainer: css({ height: '100%', @@ -389,6 +463,7 @@ const getStyles = (theme: GrafanaTheme2) => { flexDirection: 'column', padding: theme.spacing(0, 3, 3, 3), height: '100%', + gap: theme.spacing(0.5), }), link: css({ color: theme.colors.text.secondary, @@ -404,6 +479,11 @@ const getStyles = (theme: GrafanaTheme2) => { width: '75vw', whiteSpace: 'nowrap', }), + controls: css({ + display: 'flex', + justifyContent: 'flex-end', + gap: theme.spacing(2), + }), }; }; @@ -445,3 +525,11 @@ const normalizeLogRefId = (log: LogRowModel): LogRowModel => { const containsRow = (rows: LogRowModel[], row: LogRowModel) => { return rows.some((r) => r.entry === row.entry && r.timeEpochNs === row.timeEpochNs); }; + +function getTimeWindowOptions() { + const intervals = [100, 500, 1000, 5000, 30000, 60000, 300000, 1800000, 3600000, DEFAULT_TIME_WINDOW]; + return intervals.map((interval) => ({ + label: formattedValueToString(getValueFormat('ms')(interval)), + value: interval.toString(), + })); +} diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 1fbc0b1fd6e..27e0b1ab9d8 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -96,7 +96,6 @@ type LogListComponentProps = Omit< | 'dedupStrategy' | 'displayedFields' | 'enableLogDetails' - | 'loading' | 'logOptionsStorageKey' | 'permalinkedLogId' | 'showTime' @@ -203,6 +202,7 @@ export const LogList = ({ grammar={grammar} initialScrollPosition={initialScrollPosition} infiniteScrollMode={infiniteScrollMode} + loading={loading} loadMore={loadMore} logs={logs} showControls={showControls} @@ -221,6 +221,7 @@ const LogListComponent = ({ grammar, initialScrollPosition = 'top', infiniteScrollMode = 'interval', + loading, loadMore, logs, showControls, @@ -452,6 +453,7 @@ const LogListComponent = ({ displayedFields={displayedFields} handleOverflow={handleOverflow} infiniteScrollMode={infiniteScrollMode} + loading={loading} logs={filteredLogs} loadMore={loadMore} onClick={handleLogLineClick} diff --git a/public/app/plugins/datasource/loki/LogContextProvider.ts b/public/app/plugins/datasource/loki/LogContextProvider.ts index 00873a8d920..3de58ba2e4a 100644 --- a/public/app/plugins/datasource/loki/LogContextProvider.ts +++ b/public/app/plugins/datasource/loki/LogContextProvider.ts @@ -71,7 +71,7 @@ export class LogContextProvider { this.cachedContextFilters = filters; } - return await this.prepareLogRowContextQueryTarget(row, limit, direction, origQuery); + return await this.prepareLogRowContextQueryTarget(row, limit, direction, origQuery, options?.timeWindowMs); } getLogRowContextQuery = async ( @@ -136,12 +136,11 @@ export class LogContextProvider { row: LogRowModel, limit: number, direction: LogRowContextQueryDirection, - origQuery?: LokiQuery + origQuery?: LokiQuery, + timeWindowMs = 2 * 60 * 60 * 1000 ): Promise<{ query: LokiQuery; range: TimeRange }> { const expr = this.prepareExpression(this.cachedContextFilters, origQuery); - const contextTimeBuffer = 2 * 60 * 60 * 1000; // 2h buffer - const queryDirection = direction === LogRowContextQueryDirection.Forward ? LokiQueryDirection.Forward : LokiQueryDirection.Backward; @@ -174,11 +173,11 @@ export class LogContextProvider { // because the are before but came it he response that should return only rows after. from: timestamp, // convert to ns, we lose some precision here but it is not that important at the far points of the context - to: toUtc(row.timeEpochMs + contextTimeBuffer), + to: toUtc(row.timeEpochMs + timeWindowMs), } : { // convert to ns, we lose some precision here but it is not that important at the far points of the context - from: toUtc(row.timeEpochMs - contextTimeBuffer), + from: toUtc(row.timeEpochMs - timeWindowMs), to: timestamp, }; diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index 7bdfcf2e1ee..c7b60f169a5 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -162,8 +162,12 @@ export class LokiDatasource }; this.variables = new LokiVariableSupport(this); this.logContextProvider = new LogContextProvider(this); + this.supportsAdjustableWindow = true; } + // Flag marking datasource as supporting adjusting the time range window in the logs context window: https://github.com/grafana/grafana/pull/109901 + public supportsAdjustableWindow; + /** * Implemented for DataSourceWithSupplementaryQueriesSupport. * It generates a DataQueryRequest for a specific supplementary query type. diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 37a908c0714..594c036e9d2 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -9482,6 +9482,8 @@ "no-more-logs-available": "No more logs available.", "older-logs": "older", "open-in-split-view": "Open in split view", + "time-window-label": "Context time window", + "time-window-tooltip": "Amount of time before and after the referenced log", "title-log-context": "Log context", "title-log-line": "Referenced log line" },