diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index fc81be6de9b..d883367602f 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -392,6 +392,7 @@ class UnthemedLogs extends PureComponent { onLoadLogsVolume={loadLogsVolumeData} onHiddenSeriesChanged={this.onToggleLogLevel} eventBus={this.logsVolumeEventBus} + onClose={() => this.onToggleLogsVolumeCollapse(false)} /> )} diff --git a/public/app/features/explore/LogsVolumePanelList.test.tsx b/public/app/features/explore/LogsVolumePanelList.test.tsx index a8f2670f839..d9a0473747a 100644 --- a/public/app/features/explore/LogsVolumePanelList.test.tsx +++ b/public/app/features/explore/LogsVolumePanelList.test.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { DataQueryResponse, LoadingState, EventBusSrv } from '@grafana/data'; @@ -12,7 +13,7 @@ jest.mock('./Graph/ExploreGraph', () => { }; }); -function renderPanel(logsVolumeData?: DataQueryResponse) { +function renderPanel(logsVolumeData?: DataQueryResponse, onLoadLogsVolume = () => {}) { render( {}} logsVolumeData={logsVolumeData} - onLoadLogsVolume={() => {}} + onLoadLogsVolume={onLoadLogsVolume} onHiddenSeriesChanged={() => null} eventBus={new EventBusSrv()} /> @@ -40,7 +41,7 @@ describe('LogsVolumePanelList', () => { expect(screen.getByText('Test error message')).toBeInTheDocument(); }); - it('shows long warning message', () => { + it('shows long warning message', async () => { // we make a long message const messagePart = 'One two three four five six seven eight nine ten.'; const message = messagePart + ' ' + messagePart + ' ' + messagePart; @@ -48,7 +49,22 @@ describe('LogsVolumePanelList', () => { renderPanel({ state: LoadingState.Error, error: { data: { message } }, data: [] }); expect(screen.getByText('Failed to load log volume for this query')).toBeInTheDocument(); expect(screen.queryByText(message)).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Show details' })); + await userEvent.click(screen.getByRole('button', { name: 'Show details' })); expect(screen.getByText(message)).toBeInTheDocument(); }); + + it('a custom message for timeout errors', async () => { + const onLoadCallback = jest.fn(); + renderPanel( + { + state: LoadingState.Error, + error: { data: { message: '{"status":"error","errorType":"timeout","error":"context deadline exceeded"}' } }, + data: [], + }, + onLoadCallback + ); + expect(screen.getByText('The logs volume query is taking too long and has timed out')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(onLoadCallback).toHaveBeenCalled(); + }); }); diff --git a/public/app/features/explore/LogsVolumePanelList.tsx b/public/app/features/explore/LogsVolumePanelList.tsx index a07683d612f..c3295896ef6 100644 --- a/public/app/features/explore/LogsVolumePanelList.tsx +++ b/public/app/features/explore/LogsVolumePanelList.tsx @@ -19,6 +19,7 @@ import { mergeLogsVolumeDataFrames } from '../logs/utils'; import { LogsVolumePanel } from './LogsVolumePanel'; import { SupplementaryResultError } from './SupplementaryResultError'; +import { isTimeoutErrorResponse } from './utils/logsVolumeResponse'; type Props = { logsVolumeData: DataQueryResponse | undefined; @@ -30,6 +31,7 @@ type Props = { onLoadLogsVolume: () => void; onHiddenSeriesChanged: (hiddenSeries: string[]) => void; eventBus: EventBus; + onClose?(): void; }; export const LogsVolumePanelList = ({ @@ -42,6 +44,7 @@ export const LogsVolumePanelList = ({ eventBus, splitOpen, timeZone, + onClose, }: Props) => { const logVolumes: Record = useMemo(() => { const grouped = groupBy(logsVolumeData?.data || [], 'meta.custom.datasourceName'); @@ -59,10 +62,22 @@ export const LogsVolumePanelList = ({ return !isLogsVolumeLimited(data) && zoomRatio && zoomRatio < 1; }); + const timeoutError = isTimeoutErrorResponse(logsVolumeData); + if (logsVolumeData?.state === LoadingState.Loading) { return Loading...; - } - if (logsVolumeData?.error !== undefined) { + } else if (timeoutError) { + return ( + + ); + } else if (logsVolumeData?.error !== undefined) { return ; } return ( diff --git a/public/app/features/explore/SupplementaryResultError.tsx b/public/app/features/explore/SupplementaryResultError.tsx index 237c2072db5..018af1ed7fc 100644 --- a/public/app/features/explore/SupplementaryResultError.tsx +++ b/public/app/features/explore/SupplementaryResultError.tsx @@ -1,23 +1,27 @@ import React, { useState } from 'react'; import { DataQueryError } from '@grafana/data'; -import { Alert, Button } from '@grafana/ui'; +import { Alert, AlertVariant, Button } from '@grafana/ui'; type Props = { - error: DataQueryError; + error?: DataQueryError; title: string; + severity?: AlertVariant; + suggestedAction?: string; + onSuggestedAction?(): void; + onRemove?(): void; }; export function SupplementaryResultError(props: Props) { const [isOpen, setIsOpen] = useState(false); const SHORT_ERROR_MESSAGE_LIMIT = 100; - const { error, title } = props; + const { error, title, suggestedAction, onSuggestedAction, onRemove, severity = 'warning' } = props; // generic get-error-message-logic, taken from // /public/app/features/explore/ErrorContainer.tsx - const message = error.message || error.data?.message || ''; + const message = error?.message || error?.data?.message || ''; const showButton = !isOpen && message.length > SHORT_ERROR_MESSAGE_LIMIT; return ( - + {showButton ? ( + )} ); } diff --git a/public/app/features/explore/utils/logsVolumeResponse.test.ts b/public/app/features/explore/utils/logsVolumeResponse.test.ts new file mode 100644 index 00000000000..b29f1798767 --- /dev/null +++ b/public/app/features/explore/utils/logsVolumeResponse.test.ts @@ -0,0 +1,66 @@ +import { DataQueryResponse } from '@grafana/data'; + +import { isTimeoutErrorResponse } from './logsVolumeResponse'; + +const errorA = + 'Get "http://localhost:3100/loki/api/v1/query_range?direction=backward&end=1680001200000000000&limit=1000&query=sum+by+%28level%29+%28count_over_time%28%7Bcontainer_name%3D%22docker-compose-app-1%22%7D%5B1h%5D%29%29&start=1679914800000000000&step=3600000ms": net/http: request canceled (Client.Timeout exceeded while awaiting headers)'; +const errorB = '{"status":"error","errorType":"timeout","error":"context deadline exceeded"}'; + +describe('isTimeoutErrorResponse', () => { + test.each([errorA, errorB])( + 'identifies timeout errors in the error.message attribute when the message is `%s`', + (timeoutError: string) => { + const response: DataQueryResponse = { + data: [], + error: { + message: timeoutError, + }, + }; + expect(isTimeoutErrorResponse(response)).toBe(true); + } + ); + test.each([errorA, errorB])( + 'identifies timeout errors in the errors.message attribute when the message is `%s`', + (timeoutError: string) => { + const response: DataQueryResponse = { + data: [], + errors: [ + { + message: 'Something else', + }, + { + message: timeoutError, + }, + ], + }; + expect(isTimeoutErrorResponse(response)).toBe(true); + } + ); + test.each([errorA, errorB])( + 'identifies timeout errors in the errors.data.message attribute when the message is `%s`', + (timeoutError: string) => { + const response: DataQueryResponse = { + data: [], + errors: [ + { + data: { + message: 'Something else', + }, + }, + { + data: { + message: timeoutError, + }, + }, + ], + }; + expect(isTimeoutErrorResponse(response)).toBe(true); + } + ); + test('does not report false positives', () => { + const response: DataQueryResponse = { + data: [], + }; + expect(isTimeoutErrorResponse(response)).toBe(false); + }); +}); diff --git a/public/app/features/explore/utils/logsVolumeResponse.ts b/public/app/features/explore/utils/logsVolumeResponse.ts new file mode 100644 index 00000000000..2dcb5221331 --- /dev/null +++ b/public/app/features/explore/utils/logsVolumeResponse.ts @@ -0,0 +1,18 @@ +import { DataQueryError, DataQueryResponse } from '@grafana/data'; + +// Currently we can only infer if an error response is a timeout or not. +export function isTimeoutErrorResponse(response: DataQueryResponse | undefined): boolean { + if (!response) { + return false; + } + if (!response.error && !response.errors) { + return false; + } + + const errors = response.error ? [response.error] : response.errors || []; + + return errors.some((error: DataQueryError) => { + const message = `${error.message || error.data?.message}`?.toLowerCase(); + return message.includes('timeout'); + }); +}