Add time range warning in query editor for large time ranges (#2150)
## Summary Adds a non-intrusive warning banner in the query editor that alerts users when the selected time range exceeds 1 year (365 days). This helps users understand that their query may return a large amount of data and could take longer to execute, without blocking or interrupting their workflow. Part of https://github.com/grafana/oss-big-tent-squad/issues/127 ## Changes - Added `TIME_RANGE_WARNING_THRESHOLD_DAYS` constant (365 days) in `src/datasource/constants.ts` - Created new `TimeRangeWarning` component in `src/datasource/components/TimeRangeWarning.tsx` - Integrated the warning component into the main `QueryEditor` component ## How it works - When the dashboard time range is >= 365 days, a warning banner appears at the top of the query editor - The warning displays the formatted duration (e.g., "1 year and 30 days") - The warning is purely informational - queries still execute normally - Uses Grafana theme colors for consistent styling in both light and dark modes ## Screenshot The warning appears as a subtle banner with a warning icon: <img width="705" height="374" alt="grafik" src="https://github.com/user-attachments/assets/eb0ace4b-524a-488e-8f88-b7e9523660b0" /> ## Why Queries spanning years of data can return millions of data points and potentially overload the Zabbix server. This proactive warning helps users make informed decisions about their query scope without adding friction to the normal query flow.
This commit is contained in:
@@ -17,6 +17,7 @@ import { TriggersQueryEditor } from './QueryEditor/TriggersQueryEditor';
|
|||||||
import { UserMacrosQueryEditor } from './QueryEditor/UserMacrosQueryEditor';
|
import { UserMacrosQueryEditor } from './QueryEditor/UserMacrosQueryEditor';
|
||||||
import { QueryEditorRow } from './QueryEditor/QueryEditorRow';
|
import { QueryEditorRow } from './QueryEditor/QueryEditorRow';
|
||||||
import { ItemCountWarning } from './ItemCountWarning';
|
import { ItemCountWarning } from './ItemCountWarning';
|
||||||
|
import { TimeRangeWarning } from './TimeRangeWarning';
|
||||||
|
|
||||||
const zabbixQueryTypeOptions: Array<ComboboxOption<QueryType>> = [
|
const zabbixQueryTypeOptions: Array<ComboboxOption<QueryType>> = [
|
||||||
{
|
{
|
||||||
@@ -113,7 +114,7 @@ function getProblemsQueryDefaults(): Partial<ZabbixMetricsQuery> {
|
|||||||
export interface ZabbixQueryEditorProps
|
export interface ZabbixQueryEditorProps
|
||||||
extends QueryEditorProps<ZabbixDatasource, ZabbixMetricsQuery, ZabbixDSOptions> {}
|
extends QueryEditorProps<ZabbixDatasource, ZabbixMetricsQuery, ZabbixDSOptions> {}
|
||||||
|
|
||||||
export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: ZabbixQueryEditorProps) => {
|
export const QueryEditor = ({ query, datasource, onChange, onRunQuery, range }: ZabbixQueryEditorProps) => {
|
||||||
const [itemCount, setItemCount] = useState(0);
|
const [itemCount, setItemCount] = useState(0);
|
||||||
const queryDefaults = getDefaultQuery();
|
const queryDefaults = getDefaultQuery();
|
||||||
query = { ...queryDefaults, ...query };
|
query = { ...queryDefaults, ...query };
|
||||||
@@ -206,6 +207,7 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: ZabbixQ
|
|||||||
return (
|
return (
|
||||||
<Stack direction="column">
|
<Stack direction="column">
|
||||||
{queryType === c.MODE_METRICS && <ItemCountWarning itemCount={itemCount} />}
|
{queryType === c.MODE_METRICS && <ItemCountWarning itemCount={itemCount} />}
|
||||||
|
<TimeRangeWarning timeRange={range} />
|
||||||
<QueryEditorRow>
|
<QueryEditorRow>
|
||||||
<InlineField label="Query type" labelWidth={12}>
|
<InlineField label="Query type" labelWidth={12}>
|
||||||
<Combobox<QueryType>
|
<Combobox<QueryType>
|
||||||
|
|||||||
28
src/datasource/components/TimeRangeWarning.tsx
Normal file
28
src/datasource/components/TimeRangeWarning.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Alert } from '@grafana/ui';
|
||||||
|
import { formatDuration, getTimeRangeDurationDays, isTimeRangeLarge } from './timeRangeDuration';
|
||||||
|
import { TimeRange } from '@grafana/data';
|
||||||
|
|
||||||
|
interface TimeRangeWarningProps {
|
||||||
|
timeRange?: TimeRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A warning banner that displays when the selected time range is very large.
|
||||||
|
* This is a non-intrusive warning that doesn't block the query flow.
|
||||||
|
*/
|
||||||
|
export const TimeRangeWarning: React.FC<TimeRangeWarningProps> = ({ timeRange }) => {
|
||||||
|
if (!timeRange || !isTimeRangeLarge(timeRange)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationDays = getTimeRangeDurationDays(timeRange);
|
||||||
|
const formattedDuration = formatDuration(durationDays);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert title="Large time range" severity="warning">
|
||||||
|
Selected time range is {formattedDuration}. This query may return a large amount of data and could take longer to
|
||||||
|
execute.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
135
src/datasource/components/timeRangeDuration.test.ts
Normal file
135
src/datasource/components/timeRangeDuration.test.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { TimeRange, dateTime } from '@grafana/data';
|
||||||
|
import { formatDuration, getTimeRangeDurationDays, isTimeRangeLarge } from './timeRangeDuration';
|
||||||
|
|
||||||
|
function createTimeRange(from: string, to: string): TimeRange {
|
||||||
|
const fromDate = dateTime(from);
|
||||||
|
const toDate = dateTime(to);
|
||||||
|
return {
|
||||||
|
from: fromDate,
|
||||||
|
to: toDate,
|
||||||
|
raw: {
|
||||||
|
from: fromDate,
|
||||||
|
to: toDate,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('timeRangeDuration', () => {
|
||||||
|
describe('getTimeRangeDurationDays', () => {
|
||||||
|
it('should calculate duration correctly for 1 day', () => {
|
||||||
|
const timeRange = createTimeRange('2024-01-01T00:00:00Z', '2024-01-02T00:00:00Z');
|
||||||
|
|
||||||
|
const result = getTimeRangeDurationDays(timeRange);
|
||||||
|
expect(result).toBeCloseTo(1, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate duration correctly for 7 days', () => {
|
||||||
|
const timeRange = createTimeRange('2024-01-01T00:00:00Z', '2024-01-08T00:00:00Z');
|
||||||
|
|
||||||
|
const result = getTimeRangeDurationDays(timeRange);
|
||||||
|
expect(result).toBeCloseTo(7, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate duration correctly for 365 days', () => {
|
||||||
|
const timeRange = createTimeRange('2023-01-01T00:00:00Z', '2024-01-01T00:00:00Z');
|
||||||
|
|
||||||
|
const result = getTimeRangeDurationDays(timeRange);
|
||||||
|
expect(result).toBeCloseTo(365, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate duration correctly for partial days', () => {
|
||||||
|
const timeRange = createTimeRange('2024-01-01T00:00:00Z', '2024-01-01T12:00:00Z'); // 12 hours = 0.5 days
|
||||||
|
|
||||||
|
const result = getTimeRangeDurationDays(timeRange);
|
||||||
|
expect(result).toBeCloseTo(0.5, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero duration', () => {
|
||||||
|
const timeRange = createTimeRange('2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z');
|
||||||
|
|
||||||
|
const result = getTimeRangeDurationDays(timeRange);
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isTimeRangeLarge', () => {
|
||||||
|
it('should return true when duration equals threshold', () => {
|
||||||
|
const timeRange = createTimeRange('2024-01-01T00:00:00Z', '2025-01-01T00:00:00Z'); // 365 days
|
||||||
|
|
||||||
|
const result = isTimeRangeLarge(timeRange, 365);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when duration exceeds threshold', () => {
|
||||||
|
const timeRange = createTimeRange('2024-01-01T00:00:00Z', '2025-06-01T00:00:00Z'); // ~516 days
|
||||||
|
|
||||||
|
const result = isTimeRangeLarge(timeRange, 365);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when duration is below threshold', () => {
|
||||||
|
const timeRange = createTimeRange('2024-01-01T00:00:00Z', '2024-12-30T00:00:00Z'); // 364 days
|
||||||
|
|
||||||
|
const result = isTimeRangeLarge(timeRange, 365);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default threshold when not specified', () => {
|
||||||
|
const timeRange = createTimeRange('2023-01-01T00:00:00Z', '2024-01-01T00:00:00Z'); // 365 days
|
||||||
|
|
||||||
|
const result = isTimeRangeLarge(timeRange);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom threshold when specified', () => {
|
||||||
|
const timeRange = createTimeRange('2024-01-01T00:00:00Z', '2024-01-08T00:00:00Z'); // 7 days
|
||||||
|
|
||||||
|
const result = isTimeRangeLarge(timeRange, 7);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDuration', () => {
|
||||||
|
it('should format single day correctly', () => {
|
||||||
|
expect(formatDuration(1)).toBe('1 days');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format multiple days correctly', () => {
|
||||||
|
expect(formatDuration(7)).toBe('7 days');
|
||||||
|
expect(formatDuration(30)).toBe('30 days');
|
||||||
|
expect(formatDuration(364)).toBe('364 days');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format exactly 365 days as 1 year', () => {
|
||||||
|
expect(formatDuration(365)).toBe('1 year');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format multiple years correctly', () => {
|
||||||
|
expect(formatDuration(730)).toBe('2 years');
|
||||||
|
expect(formatDuration(1095)).toBe('3 years');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format years with remaining days correctly', () => {
|
||||||
|
expect(formatDuration(366)).toBe('1 year and 1 day');
|
||||||
|
expect(formatDuration(370)).toBe('1 year and 5 days');
|
||||||
|
expect(formatDuration(730)).toBe('2 years');
|
||||||
|
expect(formatDuration(731)).toBe('2 years and 1 day');
|
||||||
|
expect(formatDuration(800)).toBe('2 years and 70 days');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle fractional days by flooring', () => {
|
||||||
|
expect(formatDuration(1.9)).toBe('1 days');
|
||||||
|
expect(formatDuration(365.9)).toBe('1 year');
|
||||||
|
expect(formatDuration(366.5)).toBe('1 year and 1 day');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero days', () => {
|
||||||
|
expect(formatDuration(0)).toBe('0 days');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very large durations', () => {
|
||||||
|
expect(formatDuration(3650)).toBe('10 years');
|
||||||
|
expect(formatDuration(3651)).toBe('10 years and 1 day');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/datasource/components/timeRangeDuration.ts
Normal file
36
src/datasource/components/timeRangeDuration.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { TimeRange } from '@grafana/data';
|
||||||
|
import { TIME_RANGE_WARNING_THRESHOLD_DAYS } from '../constants';
|
||||||
|
|
||||||
|
export function getTimeRangeDurationDays(timeRange: TimeRange): number {
|
||||||
|
const from = timeRange.from.valueOf();
|
||||||
|
const to = timeRange.to.valueOf();
|
||||||
|
const durationMs = to - from;
|
||||||
|
return durationMs / (1000 * 60 * 60 * 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTimeRangeLarge(
|
||||||
|
timeRange: TimeRange,
|
||||||
|
thresholdDays: number = TIME_RANGE_WARNING_THRESHOLD_DAYS
|
||||||
|
): boolean {
|
||||||
|
return getTimeRangeDurationDays(timeRange) >= thresholdDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a duration in days as a human-readable string.
|
||||||
|
* Formats durations >= 365 days as years and days, otherwise as days only.
|
||||||
|
* Based on Zabbix best practices:
|
||||||
|
* - Zabbix "Max period for time selector" defaults to 2 years (range: 1-10 years)
|
||||||
|
* See: https://www.zabbix.com/documentation/current/en/manual/web_interface/frontend_sections/administration/general
|
||||||
|
* Our limit are conservative to ensure good performance
|
||||||
|
*/
|
||||||
|
export function formatDuration(days: number): string {
|
||||||
|
if (days >= TIME_RANGE_WARNING_THRESHOLD_DAYS) {
|
||||||
|
const years = Math.floor(days / TIME_RANGE_WARNING_THRESHOLD_DAYS);
|
||||||
|
const remainingDays = Math.floor(days % TIME_RANGE_WARNING_THRESHOLD_DAYS);
|
||||||
|
if (remainingDays > 0) {
|
||||||
|
return `${years} year${years > 1 ? 's' : ''} and ${remainingDays} day${remainingDays > 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
return `${years} year${years > 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
return `${Math.floor(days)} days`;
|
||||||
|
}
|
||||||
@@ -51,3 +51,5 @@ export const RANGE_VARIABLE_VALUE = 'range_series';
|
|||||||
export const DEFAULT_ZABBIX_PROBLEMS_LIMIT = 1001;
|
export const DEFAULT_ZABBIX_PROBLEMS_LIMIT = 1001;
|
||||||
|
|
||||||
export const ITEM_COUNT_WARNING_THRESHOLD = 500;
|
export const ITEM_COUNT_WARNING_THRESHOLD = 500;
|
||||||
|
|
||||||
|
export const TIME_RANGE_WARNING_THRESHOLD_DAYS = 365;
|
||||||
|
|||||||
Reference in New Issue
Block a user