diff --git a/frontend/src/components/CustomTimePicker/CustomTimePicker.styles.scss b/frontend/src/components/CustomTimePicker/CustomTimePicker.styles.scss
index adf6e98269..38d8456467 100644
--- a/frontend/src/components/CustomTimePicker/CustomTimePicker.styles.scss
+++ b/frontend/src/components/CustomTimePicker/CustomTimePicker.styles.scss
@@ -1,6 +1,31 @@
.custom-time-picker {
display: flex;
- flex-direction: column;
+ flex-direction: row;
+ align-items: center;
+ gap: 4px;
+
+ .zoom-out-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ color: var(--foreground);
+ border: 1px solid var(--border);
+ border-radius: 2px;
+ box-shadow: none;
+ padding: 10px;
+ height: 33px;
+
+ &:hover:not(:disabled) {
+ color: var(--bg-vanilla-100);
+ background: var(--primary);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
.timeSelection-input {
&:hover {
diff --git a/frontend/src/components/CustomTimePicker/CustomTimePicker.test.tsx b/frontend/src/components/CustomTimePicker/CustomTimePicker.test.tsx
index a752f49361..78387187ac 100644
--- a/frontend/src/components/CustomTimePicker/CustomTimePicker.test.tsx
+++ b/frontend/src/components/CustomTimePicker/CustomTimePicker.test.tsx
@@ -16,6 +16,15 @@ jest.mock('react-router-dom', () => {
};
});
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useDispatch: jest.fn(() => jest.fn()),
+ useSelector: jest.fn(() => ({
+ minTime: 0,
+ maxTime: Date.now(),
+ })),
+}));
+
jest.mock('providers/Timezone', () => {
const actual = jest.requireActual('providers/Timezone');
diff --git a/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx b/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx
index a1b2c582ec..008f63357f 100644
--- a/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx
+++ b/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx
@@ -7,9 +7,11 @@ import {
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
+import { Button } from '@signozhq/button';
import { Input, InputRef, Popover, Tooltip } from 'antd';
import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
+import { QueryParams } from 'constants/query';
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
import {
FixedDurationSuggestionOptions,
@@ -17,9 +19,11 @@ import {
RelativeDurationSuggestionOptions,
} from 'container/TopNav/DateTimeSelectionV2/constants';
import dayjs from 'dayjs';
+import { useZoomOut } from 'hooks/useZoomOut';
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
+import { isZoomOutDisabled } from 'lib/zoomOutUtils';
import { defaultTo, isFunction, noop } from 'lodash-es';
-import { ChevronDown, ChevronUp } from 'lucide-react';
+import { ChevronDown, ChevronUp, ZoomOut } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import { getTimeDifference, validateEpochRange } from 'utils/epochUtils';
import { popupContainer } from 'utils/selectPopupContainer';
@@ -66,6 +70,8 @@ interface CustomTimePickerProps {
showRecentlyUsed?: boolean;
minTime: number;
maxTime: number;
+ /** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
+ isModalTimeSelection?: boolean;
}
function CustomTimePicker({
@@ -88,6 +94,7 @@ function CustomTimePicker({
showRecentlyUsed = true,
minTime,
maxTime,
+ isModalTimeSelection = false,
}: CustomTimePickerProps): JSX.Element {
const [
selectedTimePlaceholderValue,
@@ -116,6 +123,14 @@ function CustomTimePicker({
const [isOpenedFromFooter, setIsOpenedFromFooter] = useState(false);
+ const durationMs = (maxTime - minTime) / 1e6;
+ const zoomOutDisabled = showLiveLogs || isZoomOutDisabled(durationMs);
+
+ const handleZoomOut = useZoomOut({
+ isDisabled: zoomOutDisabled,
+ urlParamsToDelete: [QueryParams.activeLogId],
+ });
+
// function to get selected time in Last 1m, Last 2h, Last 3d, Last 4w format
// 1m, 2h, 3d, 4w -> Last 1 minute, Last 2 hours, Last 3 days, Last 4 weeks
const getSelectedTimeRangeLabelInRelativeFormat = (
@@ -631,6 +646,23 @@ function CustomTimePicker({
/>
+ {!showLiveLogs && !isModalTimeSelection && (
+
+
+ }
+ />
+
+
+ )}
);
}
diff --git a/frontend/src/components/CustomTimePicker/__tests__/customTimePickerZoomOut.test.tsx b/frontend/src/components/CustomTimePicker/__tests__/customTimePickerZoomOut.test.tsx
new file mode 100644
index 0000000000..a73c0e47d6
--- /dev/null
+++ b/frontend/src/components/CustomTimePicker/__tests__/customTimePickerZoomOut.test.tsx
@@ -0,0 +1,169 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryParams } from 'constants/query';
+import { GlobalReducer } from 'types/reducer/globalTime';
+
+import CustomTimePicker from '../CustomTimePicker';
+
+const MS_PER_MIN = 60 * 1000;
+const NOW_MS = 1705312800000;
+
+const mockDispatch = jest.fn();
+const mockSafeNavigate = jest.fn();
+const mockUrlQueryDelete = jest.fn();
+const mockUrlQuerySet = jest.fn();
+
+interface MockAppState {
+ globalTime: Pick;
+}
+
+jest.mock('react-redux', () => ({
+ useDispatch: (): jest.Mock => mockDispatch,
+ useSelector: (selector: (state: MockAppState) => unknown): unknown => {
+ const mockState: MockAppState = {
+ globalTime: {
+ minTime: (NOW_MS - 15 * MS_PER_MIN) * 1e6,
+ maxTime: NOW_MS * 1e6,
+ },
+ };
+ return selector(mockState);
+ },
+}));
+
+jest.mock('hooks/useSafeNavigate', () => ({
+ useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
+ safeNavigate: mockSafeNavigate,
+ }),
+}));
+
+interface MockUrlQuery {
+ delete: typeof mockUrlQueryDelete;
+ set: typeof mockUrlQuerySet;
+ get: () => null;
+ toString: () => string;
+}
+
+jest.mock('hooks/useUrlQuery', () => ({
+ __esModule: true,
+ default: (): MockUrlQuery => ({
+ delete: mockUrlQueryDelete,
+ set: mockUrlQuerySet,
+ get: (): null => null,
+ toString: (): string => 'relativeTime=45m',
+ }),
+}));
+
+jest.mock('providers/Timezone', () => ({
+ useTimezone: (): { timezone: { value: string; offset: string } } => ({
+ timezone: { value: 'UTC', offset: 'UTC' },
+ }),
+}));
+
+jest.mock('react-router-dom', () => ({
+ useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
+}));
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+const now = Date.now();
+const defaultProps = {
+ onSelect: jest.fn(),
+ onError: jest.fn(),
+ selectedValue: '15m',
+ selectedTime: '15m',
+ onValidCustomDateChange: jest.fn(),
+ open: false,
+ setOpen: jest.fn(),
+ items: [
+ { value: '15m', label: 'Last 15 minutes' },
+ { value: '1h', label: 'Last 1 hour' },
+ ],
+ minTime: (now - 15 * 60 * 1000) * 1e6,
+ maxTime: now * 1e6,
+};
+
+describe('CustomTimePicker - zoom out button', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.spyOn(Date, 'now').mockReturnValue(NOW_MS);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('should render zoom out button when showLiveLogs is false', () => {
+ render();
+
+ expect(screen.getByTestId('zoom-out-btn')).toBeInTheDocument();
+ });
+
+ it('should not render zoom out button when showLiveLogs is true', () => {
+ render();
+
+ expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
+ });
+
+ it('should not render zoom out button when isModalTimeSelection is true', () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
+ });
+
+ it('should call handleZoomOut when zoom out button is clicked', async () => {
+ render();
+
+ const zoomOutBtn = screen.getByTestId('zoom-out-btn');
+ await userEvent.click(zoomOutBtn);
+
+ expect(mockDispatch).toHaveBeenCalled();
+ expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
+ expect(mockSafeNavigate).toHaveBeenCalledWith(
+ expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
+ );
+ });
+
+ it('should use real ladder logic: 15m range zooms to 45m preset and updates URL', async () => {
+ render();
+
+ const zoomOutBtn = screen.getByTestId('zoom-out-btn');
+ await userEvent.click(zoomOutBtn);
+
+ expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.startTime);
+ expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.endTime);
+ expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
+ expect(mockSafeNavigate).toHaveBeenCalledWith(
+ expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
+ );
+ expect(mockDispatch).toHaveBeenCalled();
+ });
+
+ it('should delete activeLogId when zoom out is clicked', async () => {
+ render();
+
+ const zoomOutBtn = screen.getByTestId('zoom-out-btn');
+ await userEvent.click(zoomOutBtn);
+
+ expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.activeLogId);
+ });
+
+ it('should disable zoom button when time range is >= 1 month', () => {
+ const now = Date.now();
+ render(
+ ,
+ );
+
+ const zoomOutBtn = screen.getByTestId('zoom-out-btn');
+ expect(zoomOutBtn).toBeDisabled();
+ });
+});
diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx
index 2072a3be0a..6daa46c658 100644
--- a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx
+++ b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx
@@ -30,6 +30,7 @@ import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { GlobalReducer } from 'types/reducer/globalTime';
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
+import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
import { normalizeTimeToMs } from 'utils/timeUtils';
import { v4 as uuid } from 'uuid';
@@ -234,20 +235,7 @@ function DateTimeSelection({
const updateLocalStorageForRoutes = useCallback(
(value: Time | string): void => {
- const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
- if (preRoutes !== null) {
- const preRoutesObject = JSON.parse(preRoutes);
-
- const preRoute = {
- ...preRoutesObject,
- };
- preRoute[location.pathname] = value;
-
- setLocalStorageKey(
- LOCALSTORAGE.METRICS_TIME_IN_DURATION,
- JSON.stringify(preRoute),
- );
- }
+ persistTimeDurationForRoute(location.pathname, String(value));
},
[location.pathname],
);
@@ -738,6 +726,7 @@ function DateTimeSelection({
showRecentlyUsed={showRecentlyUsed}
minTime={minTimeForDateTimePicker}
maxTime={maxTimeForDateTimePicker}
+ isModalTimeSelection={isModalTimeSelection}
/>
{showAutoRefresh && selectedTime !== 'custom' && (
diff --git a/frontend/src/hooks/__tests__/useZoomOut.test.ts b/frontend/src/hooks/__tests__/useZoomOut.test.ts
new file mode 100644
index 0000000000..67b61aaa9d
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useZoomOut.test.ts
@@ -0,0 +1,160 @@
+import { act, renderHook } from '@testing-library/react';
+import { QueryParams } from 'constants/query';
+import { GlobalReducer } from 'types/reducer/globalTime';
+
+import { useZoomOut } from '../useZoomOut';
+
+const mockDispatch = jest.fn();
+const mockSafeNavigate = jest.fn();
+const mockUrlQueryDelete = jest.fn();
+const mockUrlQuerySet = jest.fn();
+const mockUrlQueryToString = jest.fn(() => '');
+
+interface MockAppState {
+ globalTime: Pick;
+}
+
+jest.mock('react-redux', () => ({
+ useDispatch: (): jest.Mock => mockDispatch,
+ useSelector: (selector: (state: MockAppState) => T): T => {
+ const mockState: MockAppState = {
+ globalTime: {
+ minTime: 15 * 60 * 1000 * 1e6, // 15 min in nanoseconds
+ maxTime: 30 * 60 * 1000 * 1e6, // 30 min in nanoseconds (mock for getNextZoomOutRange)
+ },
+ };
+ return selector(mockState);
+ },
+}));
+
+jest.mock('react-router-dom', () => ({
+ useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
+}));
+
+jest.mock('hooks/useSafeNavigate', () => ({
+ useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
+ safeNavigate: mockSafeNavigate,
+ }),
+}));
+
+interface MockUrlQuery {
+ delete: typeof mockUrlQueryDelete;
+ set: typeof mockUrlQuerySet;
+ get: () => null;
+ toString: typeof mockUrlQueryToString;
+}
+
+jest.mock('hooks/useUrlQuery', () => ({
+ __esModule: true,
+ default: (): MockUrlQuery => ({
+ delete: mockUrlQueryDelete,
+ set: mockUrlQuerySet,
+ get: (): null => null,
+ toString: mockUrlQueryToString,
+ }),
+}));
+
+const mockGetNextZoomOutRange = jest.fn();
+jest.mock('lib/zoomOutUtils', () => ({
+ getNextZoomOutRange: (
+ ...args: unknown[]
+ ): ReturnType =>
+ mockGetNextZoomOutRange(...args),
+}));
+
+describe('useZoomOut', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUrlQueryToString.mockReturnValue('relativeTime=45m');
+ });
+
+ it('should do nothing when isDisabled is true', () => {
+ const { result } = renderHook(() => useZoomOut({ isDisabled: true }));
+
+ act(() => {
+ result.current();
+ });
+
+ expect(mockGetNextZoomOutRange).not.toHaveBeenCalled();
+ expect(mockDispatch).not.toHaveBeenCalled();
+ expect(mockSafeNavigate).not.toHaveBeenCalled();
+ });
+
+ it('should do nothing when getNextZoomOutRange returns null', () => {
+ mockGetNextZoomOutRange.mockReturnValue(null);
+
+ const { result } = renderHook(() => useZoomOut());
+
+ act(() => {
+ result.current();
+ });
+
+ expect(mockGetNextZoomOutRange).toHaveBeenCalled();
+ expect(mockDispatch).not.toHaveBeenCalled();
+ expect(mockSafeNavigate).not.toHaveBeenCalled();
+ });
+
+ it('should dispatch preset and update URL when result has preset', () => {
+ mockGetNextZoomOutRange.mockReturnValue({
+ range: [1000, 2000],
+ preset: '45m',
+ });
+
+ const { result } = renderHook(() => useZoomOut());
+
+ act(() => {
+ result.current();
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function));
+ expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.startTime);
+ expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.endTime);
+ expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
+ expect(mockSafeNavigate).toHaveBeenCalledWith(
+ expect.stringContaining('/logs-explorer'),
+ );
+ });
+
+ it('should dispatch custom range and update URL when result has no preset', () => {
+ mockGetNextZoomOutRange.mockReturnValue({
+ range: [1000000, 2000000],
+ preset: null,
+ });
+
+ const { result } = renderHook(() => useZoomOut());
+
+ act(() => {
+ result.current();
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function));
+ expect(mockUrlQuerySet).toHaveBeenCalledWith(
+ QueryParams.startTime,
+ '1000000',
+ );
+ expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.endTime, '2000000');
+ expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.relativeTime);
+ expect(mockSafeNavigate).toHaveBeenCalledWith(
+ expect.stringContaining('/logs-explorer'),
+ );
+ });
+
+ it('should delete urlParamsToDelete when provided', () => {
+ mockGetNextZoomOutRange.mockReturnValue({
+ range: [1000, 2000],
+ preset: '45m',
+ });
+
+ const { result } = renderHook(() =>
+ useZoomOut({
+ urlParamsToDelete: [QueryParams.activeLogId],
+ }),
+ );
+
+ act(() => {
+ result.current();
+ });
+
+ expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.activeLogId);
+ });
+});
diff --git a/frontend/src/hooks/useZoomOut.ts b/frontend/src/hooks/useZoomOut.ts
new file mode 100644
index 0000000000..03432b88b6
--- /dev/null
+++ b/frontend/src/hooks/useZoomOut.ts
@@ -0,0 +1,79 @@
+import { useCallback, useRef } from 'react';
+// eslint-disable-next-line no-restricted-imports
+import { useDispatch, useSelector } from 'react-redux';
+import { useLocation } from 'react-router-dom';
+import { QueryParams } from 'constants/query';
+import { useSafeNavigate } from 'hooks/useSafeNavigate';
+import useUrlQuery from 'hooks/useUrlQuery';
+import { getNextZoomOutRange } from 'lib/zoomOutUtils';
+import { UpdateTimeInterval } from 'store/actions';
+import { AppState } from 'store/reducers';
+import { GlobalReducer } from 'types/reducer/globalTime';
+import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
+
+export interface UseZoomOutOptions {
+ /** When true, the zoom out handler does nothing (e.g. when live logs are enabled) */
+ isDisabled?: boolean;
+ /** URL params to delete when zooming out (e.g. [QueryParams.activeLogId] for logs) */
+ urlParamsToDelete?: string[];
+}
+
+/**
+ * Reusable hook for zoom-out functionality in explorers (logs, traces, etc.).
+ * Computes the next time range using the zoom-out ladder, updates Redux global time,
+ * and navigates with the new URL params.
+ */
+const EMPTY_PARAMS: string[] = [];
+
+export function useZoomOut(options: UseZoomOutOptions = {}): () => void {
+ const { isDisabled = false, urlParamsToDelete = EMPTY_PARAMS } = options;
+ const urlParamsToDeleteRef = useRef(urlParamsToDelete);
+ urlParamsToDeleteRef.current = urlParamsToDelete;
+
+ const dispatch = useDispatch();
+ const { minTime, maxTime } = useSelector(
+ (state) => state.globalTime,
+ );
+ const urlQuery = useUrlQuery();
+ const location = useLocation();
+ const { safeNavigate } = useSafeNavigate();
+
+ return useCallback((): void => {
+ if (isDisabled) {
+ return;
+ }
+ const minMs = Math.floor((minTime ?? 0) / 1e6);
+ const maxMs = Math.floor((maxTime ?? 0) / 1e6);
+ const result = getNextZoomOutRange(minMs, maxMs);
+ if (!result) {
+ return;
+ }
+ const [newStartMs, newEndMs] = result.range;
+ const { preset } = result;
+
+ if (preset) {
+ dispatch(UpdateTimeInterval(preset));
+ urlQuery.delete(QueryParams.startTime);
+ urlQuery.delete(QueryParams.endTime);
+ urlQuery.set(QueryParams.relativeTime, preset);
+ persistTimeDurationForRoute(location.pathname, preset);
+ } else {
+ dispatch(UpdateTimeInterval('custom', [newStartMs, newEndMs]));
+ urlQuery.set(QueryParams.startTime, String(newStartMs));
+ urlQuery.set(QueryParams.endTime, String(newEndMs));
+ urlQuery.delete(QueryParams.relativeTime);
+ }
+ for (const param of urlParamsToDeleteRef.current) {
+ urlQuery.delete(param);
+ }
+ safeNavigate(`${location.pathname}?${urlQuery.toString()}`);
+ }, [
+ dispatch,
+ isDisabled,
+ location.pathname,
+ maxTime,
+ minTime,
+ safeNavigate,
+ urlQuery,
+ ]);
+}
diff --git a/frontend/src/lib/__tests__/zoomOutUtils.test.ts b/frontend/src/lib/__tests__/zoomOutUtils.test.ts
new file mode 100644
index 0000000000..d459174d05
--- /dev/null
+++ b/frontend/src/lib/__tests__/zoomOutUtils.test.ts
@@ -0,0 +1,147 @@
+import {
+ getNextDurationInLadder,
+ getNextZoomOutRange,
+ isZoomOutDisabled,
+ ZoomOutResult,
+} from '../zoomOutUtils';
+
+const MS_PER_MIN = 60 * 1000;
+const MS_PER_HOUR = 60 * MS_PER_MIN;
+const MS_PER_DAY = 24 * MS_PER_HOUR;
+const MS_PER_WEEK = 7 * MS_PER_DAY;
+
+// Fixed "now" for deterministic tests: 2024-01-15 12:00:00 UTC
+const NOW_MS = 1705312800000;
+
+describe('zoomOutUtils', () => {
+ beforeEach(() => {
+ jest.spyOn(Date, 'now').mockReturnValue(NOW_MS);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('getNextDurationInLadder', () => {
+ it('should use 3x zoom out below 15m until reaching 15m', () => {
+ expect(getNextDurationInLadder(1 * MS_PER_MIN)).toBe(3 * MS_PER_MIN);
+ expect(getNextDurationInLadder(2 * MS_PER_MIN)).toBe(6 * MS_PER_MIN);
+ expect(getNextDurationInLadder(3 * MS_PER_MIN)).toBe(9 * MS_PER_MIN);
+ expect(getNextDurationInLadder(4 * MS_PER_MIN)).toBe(12 * MS_PER_MIN);
+ expect(getNextDurationInLadder(5 * MS_PER_MIN)).toBe(15 * MS_PER_MIN); // cap at 15m
+ expect(getNextDurationInLadder(6 * MS_PER_MIN)).toBe(15 * MS_PER_MIN); // 18m capped
+ });
+
+ it('should return next step for each ladder rung from 15m onward', () => {
+ expect(getNextDurationInLadder(10 * MS_PER_MIN)).toBe(15 * MS_PER_MIN);
+ expect(getNextDurationInLadder(15 * MS_PER_MIN)).toBe(45 * MS_PER_MIN);
+ expect(getNextDurationInLadder(45 * MS_PER_MIN)).toBe(2 * MS_PER_HOUR);
+ expect(getNextDurationInLadder(2 * MS_PER_HOUR)).toBe(7 * MS_PER_HOUR);
+ expect(getNextDurationInLadder(7 * MS_PER_HOUR)).toBe(21 * MS_PER_HOUR);
+ expect(getNextDurationInLadder(21 * MS_PER_HOUR)).toBe(1 * MS_PER_DAY);
+ expect(getNextDurationInLadder(1 * MS_PER_DAY)).toBe(2 * MS_PER_DAY);
+ expect(getNextDurationInLadder(2 * MS_PER_DAY)).toBe(3 * MS_PER_DAY);
+ expect(getNextDurationInLadder(3 * MS_PER_DAY)).toBe(1 * MS_PER_WEEK);
+ expect(getNextDurationInLadder(1 * MS_PER_WEEK)).toBe(2 * MS_PER_WEEK);
+ expect(getNextDurationInLadder(2 * MS_PER_WEEK)).toBe(30 * MS_PER_DAY);
+ });
+
+ it('should return MAX when at or past 1 month (no wrap)', () => {
+ expect(getNextDurationInLadder(30 * MS_PER_DAY)).toBe(30 * MS_PER_DAY);
+ expect(getNextDurationInLadder(31 * MS_PER_DAY)).toBe(30 * MS_PER_DAY);
+ });
+
+ it('should return next step for duration between ladder rungs', () => {
+ expect(getNextDurationInLadder(1 * MS_PER_HOUR)).toBe(2 * MS_PER_HOUR);
+ expect(getNextDurationInLadder(5 * MS_PER_HOUR)).toBe(7 * MS_PER_HOUR);
+ expect(getNextDurationInLadder(12 * MS_PER_HOUR)).toBe(21 * MS_PER_HOUR);
+ });
+ });
+
+ describe('getNextZoomOutRange', () => {
+ it('should return null when duration is zero or negative', () => {
+ expect(getNextZoomOutRange(NOW_MS, NOW_MS)).toBeNull();
+ expect(getNextZoomOutRange(NOW_MS, NOW_MS - 1000)).toBeNull();
+ });
+
+ it('should return center-anchored range and preset=null when new end does not exceed now (Phase 1)', () => {
+ // 15m range centered well before now so zoom to 45m keeps end <= now
+ // Center at now-30m: end = center + 22.5m = now - 7.5m <= now
+ const centerMs = NOW_MS - 30 * MS_PER_MIN;
+ const start15m = centerMs - 7.5 * MS_PER_MIN;
+ const end15m = centerMs + 7.5 * MS_PER_MIN;
+ const result = getNextZoomOutRange(start15m, end15m) as ZoomOutResult;
+
+ expect(result).not.toBeNull();
+ expect(result.preset).toBeNull(); // Phase 1: preserve center-anchored range, avoid GetMinMax "last X from now"
+ const [newStart, newEnd] = result.range;
+ expect(newEnd - newStart).toBe(45 * MS_PER_MIN);
+ const newCenter = (newStart + newEnd) / 2;
+ expect(Math.abs(newCenter - centerMs)).toBeLessThan(2000);
+ expect(newEnd).toBeLessThanOrEqual(NOW_MS + 1000);
+ });
+
+ it('should return end-anchored range when new end would exceed now (Phase 2)', () => {
+ // 22hr range ending at now - zoom to 1d (24hr) would push end past now
+ // Next ladder step from 22hr is 1d
+ const start22h = NOW_MS - 22 * MS_PER_HOUR;
+ const end22h = NOW_MS;
+ const result = getNextZoomOutRange(start22h, end22h) as ZoomOutResult;
+
+ expect(result).not.toBeNull();
+ expect(result.preset).toBe('1d');
+ const [newStart, newEnd] = result.range;
+ expect(newEnd).toBe(NOW_MS); // End anchored at now
+ expect(newStart).toBe(NOW_MS - 1 * MS_PER_DAY);
+ });
+
+ it('should return correct preset for each ladder step', () => {
+ const presets: [number, number, string][] = [
+ [15 * MS_PER_MIN, 0, '45m'],
+ [45 * MS_PER_MIN, 0, '2h'],
+ [2 * MS_PER_HOUR, 0, '7h'],
+ [7 * MS_PER_HOUR, 0, '21h'],
+ [21 * MS_PER_HOUR, 0, '1d'],
+ [1 * MS_PER_DAY, 0, '2d'],
+ [2 * MS_PER_DAY, 0, '3d'],
+ [3 * MS_PER_DAY, 0, '1w'],
+ [1 * MS_PER_WEEK, 0, '2w'],
+ [2 * MS_PER_WEEK, 0, '1month'],
+ ];
+
+ presets.forEach(([durationMs, offset, expectedPreset]) => {
+ const end = NOW_MS - offset;
+ const start = end - durationMs;
+ const result = getNextZoomOutRange(start, end);
+ expect(result?.preset).toBe(expectedPreset);
+ });
+ });
+
+ it('isZoomOutDisabled returns true when duration >= 1 month', () => {
+ expect(isZoomOutDisabled(30 * MS_PER_DAY)).toBe(true);
+ expect(isZoomOutDisabled(31 * MS_PER_DAY)).toBe(true);
+ expect(isZoomOutDisabled(29 * MS_PER_DAY)).toBe(false);
+ expect(isZoomOutDisabled(15 * MS_PER_MIN)).toBe(false);
+ });
+
+ it('should return null when at 1 month (no zoom out beyond max)', () => {
+ const start1m = NOW_MS - 30 * MS_PER_DAY;
+ const end1m = NOW_MS;
+ const result = getNextZoomOutRange(start1m, end1m);
+
+ expect(result).toBeNull();
+ });
+
+ it('should zoom out 3x from 5m range to 15m then continue with ladder', () => {
+ // 5m range ending at now → 3x = 15m
+ const start5m = NOW_MS - 5 * MS_PER_MIN;
+ const end5m = NOW_MS;
+ const result = getNextZoomOutRange(start5m, end5m) as ZoomOutResult;
+
+ expect(result).not.toBeNull();
+ expect(result.preset).toBe('15m');
+ const [newStart, newEnd] = result.range;
+ expect(newEnd - newStart).toBe(15 * MS_PER_MIN);
+ });
+ });
+});
diff --git a/frontend/src/lib/zoomOutUtils.ts b/frontend/src/lib/zoomOutUtils.ts
new file mode 100644
index 0000000000..77da376c79
--- /dev/null
+++ b/frontend/src/lib/zoomOutUtils.ts
@@ -0,0 +1,139 @@
+/**
+ * Custom Time Picker zoom-out ladder:
+ * - Until 1 day: 15m → 45m → 2hr → 7hr → 21hr
+ * - Then fixed: 1d → 2d → 3d → 1w → 2w → 1m
+ * - At 1 month: zoom out is disabled (max range)
+ */
+
+import type {
+ CustomTimeType,
+ Time,
+} from 'container/TopNav/DateTimeSelectionV2/types';
+
+const MS_PER_MIN = 60 * 1000;
+const MS_PER_HOUR = 60 * MS_PER_MIN;
+const MS_PER_DAY = 24 * MS_PER_HOUR;
+const MS_PER_WEEK = 7 * MS_PER_DAY;
+
+const ZOOM_OUT_LADDER_MS: number[] = [
+ 15 * MS_PER_MIN, // 15m
+ 45 * MS_PER_MIN, // 45m
+ 2 * MS_PER_HOUR, // 2hr
+ 7 * MS_PER_HOUR, // 7hr
+ 21 * MS_PER_HOUR, // 21hr
+ 1 * MS_PER_DAY, // 1d
+ 2 * MS_PER_DAY, // 2d
+ 3 * MS_PER_DAY, // 3d
+ 1 * MS_PER_WEEK, // 1w
+ 2 * MS_PER_WEEK, // 2w
+ 30 * MS_PER_DAY, // 1m
+];
+
+const LADDER_LAST_INDEX = ZOOM_OUT_LADDER_MS.length - 1;
+const MAX_DURATION = ZOOM_OUT_LADDER_MS[LADDER_LAST_INDEX];
+const MIN_LADDER_DURATION_MS = ZOOM_OUT_LADDER_MS[0]; // 15m - below this we use 3x
+
+export const MAX_ZOOM_OUT_DURATION_MS = MAX_DURATION;
+
+/** Returns true when zoom out should be disabled (range at or beyond 1 month) */
+export function isZoomOutDisabled(durationMs: number): boolean {
+ return durationMs >= MAX_ZOOM_OUT_DURATION_MS;
+}
+
+/** Preset labels for ladder steps supported by GetMinMax (shows "Last 15 minutes" etc. instead of "Custom") */
+const PRESET_FOR_DURATION_MS: Record = {
+ [15 * MS_PER_MIN]: '15m',
+ [45 * MS_PER_MIN]: '45m',
+ [2 * MS_PER_HOUR]: '2h',
+ [7 * MS_PER_HOUR]: '7h',
+ [21 * MS_PER_HOUR]: '21h',
+ [1 * MS_PER_DAY]: '1d',
+ [2 * MS_PER_DAY]: '2d',
+ [3 * MS_PER_DAY]: '3d',
+ [1 * MS_PER_WEEK]: '1w',
+ [2 * MS_PER_WEEK]: '2w',
+ [30 * MS_PER_DAY]: '1month',
+};
+
+/**
+ * Returns the next duration in the zoom-out ladder for the given current duration.
+ * Below 15m: zoom out 3x until we reach 15m, then continue with the ladder.
+ * If at or past 1 month, returns MAX_DURATION (no zoom out - button is disabled).
+ */
+export function getNextDurationInLadder(durationMs: number): number {
+ if (durationMs >= MAX_DURATION) {
+ return MAX_DURATION; // No zoom out beyond 1 month
+ }
+
+ // Below 15m: zoom out 3x until we reach 15m
+ if (durationMs < MIN_LADDER_DURATION_MS) {
+ const next = durationMs * 3;
+ return Math.min(next, MIN_LADDER_DURATION_MS);
+ }
+
+ // At or above 15m: use the fixed ladder
+ for (let i = 0; i < ZOOM_OUT_LADDER_MS.length; i++) {
+ if (ZOOM_OUT_LADDER_MS[i] > durationMs) {
+ return ZOOM_OUT_LADDER_MS[i];
+ }
+ }
+
+ return MAX_DURATION;
+}
+
+export interface ZoomOutResult {
+ range: [number, number];
+ /** Preset key (e.g. '15m') when range matches a preset - use for display instead of "Custom Date Range" */
+ preset: Time | CustomTimeType | null;
+}
+
+/**
+ * Computes the next zoomed-out time range.
+ * Phase 1 (center-anchored): While new end <= now, expand from center.
+ * Phase 2 (end-anchored at now): When new end would exceed now, anchor end at now and move start backward.
+ *
+ * @returns ZoomOutResult with range and preset (or null if no change)
+ */
+export function getNextZoomOutRange(
+ startMs: number,
+ endMs: number,
+): ZoomOutResult | null {
+ const nowMs = Date.now();
+ const durationMs = endMs - startMs;
+
+ if (durationMs <= 0) {
+ return null;
+ }
+
+ const newDurationMs = getNextDurationInLadder(durationMs);
+
+ // No zoom out when already at max (1 month)
+ if (newDurationMs <= durationMs) {
+ return null;
+ }
+ const centerMs = startMs + durationMs / 2;
+ const computedEndMs = centerMs + newDurationMs / 2;
+
+ let newStartMs: number;
+ let newEndMs: number;
+
+ const isPhase1 = computedEndMs <= nowMs;
+ if (isPhase1) {
+ // Phase 1: center-anchored (historical range not ending at now)
+ newStartMs = centerMs - newDurationMs / 2;
+ newEndMs = computedEndMs;
+ } else {
+ // Phase 2: end-anchored at now
+ newStartMs = nowMs - newDurationMs;
+ newEndMs = nowMs;
+ }
+
+ // Phase 2 only: use preset so GetMinMax produces "last X from now".
+ // Phase 1: preset=null so the center-anchored range is preserved (GetMinMax would discard it).
+ const preset = isPhase1 ? null : PRESET_FOR_DURATION_MS[newDurationMs] ?? null;
+
+ return {
+ range: [Math.round(newStartMs), Math.round(newEndMs)],
+ preset,
+ };
+}
diff --git a/frontend/src/utils/metricsTimeStorageUtils.ts b/frontend/src/utils/metricsTimeStorageUtils.ts
new file mode 100644
index 0000000000..77579a7c0c
--- /dev/null
+++ b/frontend/src/utils/metricsTimeStorageUtils.ts
@@ -0,0 +1,28 @@
+import getLocalStorageKey from 'api/browser/localstorage/get';
+import setLocalStorageKey from 'api/browser/localstorage/set';
+import { LOCALSTORAGE } from 'constants/localStorage';
+
+/**
+ * Updates the stored time duration for a route in localStorage.
+ * Used by both DateTimeSelectionV2 (manual time pick) and useZoomOut (zoom out button).
+ *
+ * @param pathname - The route path (e.g. /infrastructure-monitoring/hosts)
+ * @param value - The time value to store (preset string like '1w' or JSON string for custom range)
+ */
+export function persistTimeDurationForRoute(
+ pathname: string,
+ value: string,
+): void {
+ const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
+ let preRoutesObject: Record = {};
+ try {
+ preRoutesObject = preRoutes ? JSON.parse(preRoutes) : {};
+ } catch {
+ preRoutesObject = {};
+ }
+ const preRoute = { ...preRoutesObject, [pathname]: value };
+ setLocalStorageKey(
+ LOCALSTORAGE.METRICS_TIME_IN_DURATION,
+ JSON.stringify(preRoute),
+ );
+}