From ec5165d1a43805ead9070a05d632564abe381964 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Tue, 2 Dec 2025 18:08:48 +0100 Subject: [PATCH] Logs Volume: Show visible range of logs in Explore (#114501) * Logs Volume: Show visible range of logs * Translations * Prettier and translations * Unfocus test --- public/app/features/explore/Logs/Logs.tsx | 1 + .../explore/Logs/LogsVolumePanelList.test.tsx | 54 +++++++++++++++++-- .../explore/Logs/LogsVolumePanelList.tsx | 40 +++++++++++++- public/app/features/logs/utils.ts | 14 +++++ public/locales/en-US/grafana.json | 3 +- 5 files changed, 104 insertions(+), 8 deletions(-) diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index b958aebcc62..7fed4aa6cf2 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -808,6 +808,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { onDisplayedSeriesChanged={onDisplayedSeriesChanged} eventBus={logsVolumeEventBus} onClose={() => onToggleLogsVolumeCollapse(true)} + logs={logRows} /> )} diff --git a/public/app/features/explore/Logs/LogsVolumePanelList.test.tsx b/public/app/features/explore/Logs/LogsVolumePanelList.test.tsx index 959fc9fb6a3..123b95abf75 100644 --- a/public/app/features/explore/Logs/LogsVolumePanelList.test.tsx +++ b/public/app/features/explore/Logs/LogsVolumePanelList.test.tsx @@ -1,7 +1,10 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { DataQueryResponse, LoadingState, EventBusSrv } from '@grafana/data'; +import { DataQueryResponse, LoadingState, EventBusSrv, LogRowModel, arrayToDataFrame, DataTopic } from '@grafana/data'; +import { createLogLine } from 'app/features/logs/components/mocks/logRow'; + +import * as logUtils from '../../logs/utils'; import { LogsVolumePanelList } from './LogsVolumePanelList'; @@ -12,7 +15,7 @@ jest.mock('../Graph/ExploreGraph', () => { }; }); -function renderPanel(logsVolumeData?: DataQueryResponse, onLoadLogsVolume = () => {}) { +function renderPanel(logsVolumeData?: DataQueryResponse, onLoadLogsVolume = () => {}, logs: LogRowModel[] = []) { render( null} eventBus={new EventBusSrv()} + logs={logs} /> ); } @@ -74,8 +78,48 @@ describe('LogsVolumePanelList', () => { expect(screen.getByText('No logs volume available')).toBeInTheDocument(); }); - it('does not show an info message with empty responses when the current state is streaming', async () => { - renderPanel({ state: LoadingState.Streaming, data: [] }); - expect(screen.queryByText('No logs volume available')).not.toBeInTheDocument(); + describe('Visible range', () => { + it('computes the visible range when logs are passed', async () => { + const spy = jest.spyOn(logUtils, 'getLogsVisibleRange'); + spy.mockClear(); + + renderPanel({ state: LoadingState.Streaming, data: [] }); + + expect(spy).not.toHaveBeenCalled(); + + const logs = [createLogLine({ timeEpochMs: 1 }), createLogLine({ timeEpochMs: 2 })]; + + renderPanel({ state: LoadingState.Streaming, data: [] }, undefined, logs); + + expect(spy).toHaveBeenCalledWith(logs); + }); + + it('does not compute the visible range when an annotation frame is present', async () => { + const spy = jest.spyOn(logUtils, 'getLogsVisibleRange'); + spy.mockClear(); + const logs = [createLogLine({ timeEpochMs: 1 }), createLogLine({ timeEpochMs: 2 })]; + const loadingFrame = arrayToDataFrame([ + { + time: 0, + timeEnd: 1, + isRegion: true, + color: 'rgba(120, 120, 120, 0.1)', + }, + ]); + loadingFrame.meta = { + dataTopic: DataTopic.Annotations, + }; + + renderPanel( + { + state: LoadingState.Streaming, + data: [loadingFrame], + }, + undefined, + logs + ); + + expect(spy).not.toHaveBeenCalled(); + }); }); }); diff --git a/public/app/features/explore/Logs/LogsVolumePanelList.tsx b/public/app/features/explore/Logs/LogsVolumePanelList.tsx index cfd35f7a73c..3ba5ec47a35 100644 --- a/public/app/features/explore/Logs/LogsVolumePanelList.tsx +++ b/public/app/features/explore/Logs/LogsVolumePanelList.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { AbsoluteTimeRange, + arrayToDataFrame, DataFrame, DataQueryResponse, DataTopic, @@ -13,6 +14,7 @@ import { getFrameDisplayName, GrafanaTheme2, LoadingState, + LogRowModel, shallowCompare, SplitOpen, TimeRange, @@ -22,7 +24,12 @@ import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { Button, InlineField, Alert, useStyles2, SeriesVisibilityChangeMode } from '@grafana/ui'; -import { mergeLogsVolumeDataFrames, isLogsVolumeLimited, getLogsVolumeMaximumRange } from '../../logs/utils'; +import { + mergeLogsVolumeDataFrames, + isLogsVolumeLimited, + getLogsVolumeMaximumRange, + getLogsVisibleRange, +} from '../../logs/utils'; import { SupplementaryResultError } from '../SupplementaryResultError'; import { LogsVolumePanel } from './LogsVolumePanel'; @@ -40,8 +47,10 @@ type Props = { eventBus: EventBus; onClose?(): void; toggleLegendRef?: React.MutableRefObject<(name: string | undefined, mode: SeriesVisibilityChangeMode) => void>; + logs: LogRowModel[]; }; +export const VISIBLE_RANGE_FRAME_NAME = 'Visible range'; export const LogsVolumePanelList = ({ logsVolumeData, absoluteRange, @@ -54,6 +63,7 @@ export const LogsVolumePanelList = ({ timeZone, onClose, toggleLegendRef, + logs, }: Props) => { const { logVolumes, @@ -63,6 +73,7 @@ export const LogsVolumePanelList = ({ } = useMemo(() => { let maximumValue = -Infinity; const data = logsVolumeData?.data.filter((frame: DataFrame) => frame.meta?.dataTopic !== DataTopic.Annotations); + // Loading frame from query splitting. const annotations = logsVolumeData?.data.filter((frame: DataFrame) => frame.meta?.dataTopic === DataTopic.Annotations) || []; const sorted = sortBy(data || [], 'meta.custom.datasourceName'); @@ -73,13 +84,38 @@ export const LogsVolumePanelList = ({ return mergedData.dataFrames; }); const maximumRange = getLogsVolumeMaximumRange(flatten(Object.values(logVolumes))); + + // No loading frame, show visible range + if (!annotations.length && logs.length) { + const { start, end } = getLogsVisibleRange(logs); + if (start > 0 && end > 0) { + const frame = arrayToDataFrame([ + { + color: 'rgba(58, 113, 255, 0.3)', + isRegion: true, + text: t( + 'explore.logs-volume-panel-list.visible-range-description', + 'Range from oldest to newest logs in display' + ), + time: start, + timeEnd: end, + }, + ]); + frame.name = VISIBLE_RANGE_FRAME_NAME; + frame.meta = { + dataTopic: DataTopic.Annotations, + }; + annotations.push(frame); + } + } + return { maximumValue, maximumRange, logVolumes, annotations, }; - }, [logsVolumeData]); + }, [logs, logsVolumeData?.data]); const styles = useStyles2(getStyles); diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index 61f428745a5..763f6a3a942 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -523,3 +523,17 @@ const addISODateTransformation: CustomTransformOperator = () => (source: Observa }) ); }; + +/** + * Get the start and end timestamps from series. + */ +export function getLogsVisibleRange(logs: LogRowModel[]) { + let start = 0; + let end = 0; + if (logs.length > 1) { + const values = [logs[0].timeEpochMs, logs[logs.length - 1].timeEpochMs].sort(); + start = values[0]; + end = values[values.length - 1]; + } + return { end, start }; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d9efaadc4d6..f7e833025d2 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7330,7 +7330,8 @@ "title-failed-volume-query": "Failed to load log volume for this query", "title-no-logs-volume-available": "No logs volume available", "title-showing-partial-data": "Showing partial data", - "title-unable-to-show-log-volume": "Unable to show log volume" + "title-unable-to-show-log-volume": "Unable to show log volume", + "visible-range-description": "Range from oldest to newest logs in display" }, "logs-volumne-panel-list": { "body-no-logs-volume-available": "No volume information available for the current queries and time range."