From d021ac1df3cdac270d3da8734b8a112313bbad23 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:37:44 -0400 Subject: [PATCH 1/7] feat: relative date ranges can be saved for dashboards --- packages/api/src/models/dashboard.ts | 1 + packages/app/src/DBDashboardPage.tsx | 18 ++++++++++++++++++ packages/app/src/dashboard.ts | 1 + packages/common-utils/src/types.ts | 1 + 4 files changed, 21 insertions(+) diff --git a/packages/api/src/models/dashboard.ts b/packages/api/src/models/dashboard.ts index c760d7ece6..d45cae6eca 100644 --- a/packages/api/src/models/dashboard.ts +++ b/packages/api/src/models/dashboard.ts @@ -34,6 +34,7 @@ export default mongoose.model( savedQuery: { type: String, required: false }, savedQueryLanguage: { type: String, required: false }, savedFilterValues: { type: mongoose.Schema.Types.Array, required: false }, + savedRelativeDateRange: { type: Number, required: false }, containers: { type: mongoose.Schema.Types.Array, required: false }, createdBy: { type: mongoose.Schema.Types.ObjectId, diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index de30e23d4f..f1eb98b89c 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -1976,6 +1976,9 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { setWhereLanguage, ]); + // eslint-disable-next-line no-restricted-syntax + const now = useMemo(() => Date.now(), []); + // Initialize query/filter state once when dashboard changes. useEffect(() => { if (!dashboard?.id || !router.isReady) return; @@ -2017,20 +2020,31 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { } } + // Initialize dashboard with the saved date range + if (dashboard.savedRelativeDateRange) { + onTimeRangeSelect( + new Date(now - dashboard.savedRelativeDateRange * 1000), + new Date(now), + ); + } + initializedDashboardRef.current = dashboard.id; }, [ dashboard?.id, dashboard?.savedQuery, dashboard?.savedQueryLanguage, dashboard?.savedFilterValues, + dashboard?.savedRelativeDateRange, isLocalDashboard, isFetchingDashboard, router.isReady, router.query, + now, setValue, setWhere, setWhereLanguage, setFilterValueEntries, + onTimeRangeSelect, ]); // Sync changes to the URL params into the form @@ -2060,11 +2074,14 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ? filterValueEntries : []; + const currentRelativeDateRange = + (searchedTimeRange[1].getTime() - searchedTimeRange[0].getTime()) / 1000; setDashboard( produce(dashboard, draft => { draft.savedQuery = currentWhere; draft.savedQueryLanguage = currentWhereLanguage; draft.savedFilterValues = currentFilterValues; + draft.savedRelativeDateRange = currentRelativeDateRange; }), () => { notifications.show({ @@ -2083,6 +2100,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { getValues, filterValueEntries, onSubmit, + searchedTimeRange, ]); const handleRemoveSavedQuery = useCallback(() => { if (!dashboard || isLocalDashboard) return; diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index b3ba2484e2..21b405a104 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -39,6 +39,7 @@ export type Dashboard = { savedQuery?: string | null; savedQueryLanguage?: SearchConditionLanguage | null; savedFilterValues?: DashboardFilterValue[]; + savedRelativeDateRange?: number; containers?: DashboardContainer[]; createdAt?: string; updatedAt?: string; diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 90063dc714..1d21a0e981 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -2054,6 +2054,7 @@ export const DashboardSchema = z.object({ savedQuery: z.string().nullable().optional(), savedQueryLanguage: SearchConditionLanguageSchema.nullable().optional(), savedFilterValues: z.array(DashboardFilterValueSchema).optional(), + savedRelativeDateRange: z.number().optional(), containers: z .array(DashboardContainerSchema) .max(DASHBOARD_MAX_CONTAINERS) From 1014344233c768abfbb3444f406b0d1ee181ea24 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:46:22 -0400 Subject: [PATCH 2/7] add changeset --- .changeset/orange-pens-accept.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/orange-pens-accept.md diff --git a/.changeset/orange-pens-accept.md b/.changeset/orange-pens-accept.md new file mode 100644 index 0000000000..e533390e3e --- /dev/null +++ b/.changeset/orange-pens-accept.md @@ -0,0 +1,7 @@ +--- +"@hyperdx/common-utils": patch +"@hyperdx/api": patch +"@hyperdx/app": patch +--- + +feat: relative date ranges for dashboards can now be saved From e557c33252c63329504c3dcab734e636aaf6109e Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:34:45 -0400 Subject: [PATCH 3/7] fix: review suggestions --- packages/app/src/DBDashboardPage.tsx | 19 ++++++++++--------- packages/app/src/dashboard.ts | 2 +- packages/common-utils/src/types.ts | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index f1eb98b89c..51ae1a3cb4 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -210,6 +210,7 @@ import { } from './source'; import { dateRangeToString, + parseRelativeTimeQuery, parseTimeQuery, useNewTimeQuery, } from './timeQuery'; @@ -1976,9 +1977,6 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { setWhereLanguage, ]); - // eslint-disable-next-line no-restricted-syntax - const now = useMemo(() => Date.now(), []); - // Initialize query/filter state once when dashboard changes. useEffect(() => { if (!dashboard?.id || !router.isReady) return; @@ -1990,6 +1988,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const hasWhereInUrl = 'where' in router.query; const hasFiltersInUrl = 'filters' in router.query; + const hasDateRangeInUrl = 'from' in router.query && 'to' in router.query; // Query defaults: URL query overrides saved defaults. If switching to a // dashboard without defaults, clear query. On first load/reload, keep current state. @@ -2021,11 +2020,13 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { } // Initialize dashboard with the saved date range - if (dashboard.savedRelativeDateRange) { - onTimeRangeSelect( - new Date(now - dashboard.savedRelativeDateRange * 1000), - new Date(now), - ); + if (!hasDateRangeInUrl) { + if (dashboard.savedRelativeDateRange) { + const [start, end] = parseRelativeTimeQuery( + dashboard.savedRelativeDateRange * 1000, + ); + onTimeRangeSelect(start, end); + } } initializedDashboardRef.current = dashboard.id; @@ -2039,7 +2040,6 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { isFetchingDashboard, router.isReady, router.query, - now, setValue, setWhere, setWhereLanguage, @@ -2110,6 +2110,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { draft.savedQuery = null; draft.savedQueryLanguage = null; draft.savedFilterValues = []; + draft.savedRelativeDateRange = null; }), () => { notifications.show({ diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index 21b405a104..5a8662eebd 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -39,7 +39,7 @@ export type Dashboard = { savedQuery?: string | null; savedQueryLanguage?: SearchConditionLanguage | null; savedFilterValues?: DashboardFilterValue[]; - savedRelativeDateRange?: number; + savedRelativeDateRange?: number | null; containers?: DashboardContainer[]; createdAt?: string; updatedAt?: string; diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 1d21a0e981..7a760a603c 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -2054,7 +2054,7 @@ export const DashboardSchema = z.object({ savedQuery: z.string().nullable().optional(), savedQueryLanguage: SearchConditionLanguageSchema.nullable().optional(), savedFilterValues: z.array(DashboardFilterValueSchema).optional(), - savedRelativeDateRange: z.number().optional(), + savedRelativeDateRange: z.number().nullish(), containers: z .array(DashboardContainerSchema) .max(DASHBOARD_MAX_CONTAINERS) From 83f263e4e583255033c3b9d2a43bef941bfce2eb Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:25:28 -0400 Subject: [PATCH 4/7] fix review comments --- packages/app/src/DBDashboardPage.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 51ae1a3cb4..8fe60a18cc 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -2058,6 +2058,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ); }, [setValue, where, whereLanguage]); + const { + userPreferences: { isUTC }, + } = useUserPreferences(); + const handleSaveQuery = useCallback(() => { if (!dashboard || isLocalDashboard) return; @@ -2074,8 +2078,11 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ? filterValueEntries : []; - const currentRelativeDateRange = - (searchedTimeRange[1].getTime() - searchedTimeRange[0].getTime()) / 1000; + const timeRange = parseTimeQuery(displayedTimeInputValue, isUTC); + const currentRelativeDateRange = timeRange.some(v => v === null) + ? null + : (timeRange[1]!.getTime() - timeRange[0]!.getTime()) / 1000; + setDashboard( produce(dashboard, draft => { draft.savedQuery = currentWhere; @@ -2100,7 +2107,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { getValues, filterValueEntries, onSubmit, - searchedTimeRange, + isUTC, + displayedTimeInputValue, ]); const handleRemoveSavedQuery = useCallback(() => { if (!dashboard || isLocalDashboard) return; From b2210e6e0f846c788ddeabc9dab29bdffa53c757 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:42:11 -0400 Subject: [PATCH 5/7] add tests --- packages/app/src/DBDashboardPage.tsx | 13 +++++--- .../TimePicker/__tests__/utils.test.ts | 33 ++++++++++++++++++- .../app/src/components/TimePicker/utils.ts | 10 ++++++ .../common-utils/src/__tests__/types.test.ts | 19 +++++++++++ 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 8fe60a18cc..bc08851a7e 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -146,7 +146,10 @@ import { PageHeader } from '@/components/PageHeader'; import { PageLayout } from '@/components/PageLayout'; import { SqlVariablesProvider } from '@/components/SQLEditor/variableCompletions'; import { TimePicker } from '@/components/TimePicker'; -import { parseTimeRangeInput } from '@/components/TimePicker/utils'; +import { + parseTimeRangeInput, + timeRangeInputToSeconds, +} from '@/components/TimePicker/utils'; import { Dashboard, type Tile, @@ -2078,10 +2081,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ? filterValueEntries : []; - const timeRange = parseTimeQuery(displayedTimeInputValue, isUTC); - const currentRelativeDateRange = timeRange.some(v => v === null) - ? null - : (timeRange[1]!.getTime() - timeRange[0]!.getTime()) / 1000; + const currentRelativeDateRange = timeRangeInputToSeconds( + displayedTimeInputValue, + isUTC, + ); setDashboard( produce(dashboard, draft => { diff --git a/packages/app/src/components/TimePicker/__tests__/utils.test.ts b/packages/app/src/components/TimePicker/__tests__/utils.test.ts index cc4968688e..de760bb57a 100644 --- a/packages/app/src/components/TimePicker/__tests__/utils.test.ts +++ b/packages/app/src/components/TimePicker/__tests__/utils.test.ts @@ -1,4 +1,8 @@ -import { dateParser, parseTimeRangeInput } from '@/components/TimePicker/utils'; +import { + dateParser, + parseTimeRangeInput, + timeRangeInputToSeconds, +} from '@/components/TimePicker/utils'; describe('dateParser', () => { let mockDate: Date; @@ -154,3 +158,30 @@ describe('parseTimeRangeInput', () => { ]); }); }); + +describe('timeRangeInputToSeconds', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-15T22:00:00')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns the relative range length in seconds', () => { + expect(timeRangeInputToSeconds('Past 1h', false)).toBe(3600); + expect(timeRangeInputToSeconds('Past 15m', false)).toBe(900); + }); + + it('returns the span of an absolute range', () => { + expect(timeRangeInputToSeconds('2024-01-15 to 2024-01-16', false)).toBe( + 86400, + ); + }); + + it('returns null for unparseable input', () => { + expect(timeRangeInputToSeconds('', false)).toBeNull(); + expect(timeRangeInputToSeconds('not a date', false)).toBeNull(); + }); +}); diff --git a/packages/app/src/components/TimePicker/utils.ts b/packages/app/src/components/TimePicker/utils.ts index 3a63b95c55..b0e5e78540 100644 --- a/packages/app/src/components/TimePicker/utils.ts +++ b/packages/app/src/components/TimePicker/utils.ts @@ -72,6 +72,16 @@ export function parseTimeRangeInput( } } +// Duration of a time range input in seconds, or null if it doesn't parse. +export function timeRangeInputToSeconds( + str: string, + isUTC: boolean, +): number | null { + const [start, end] = parseTimeRangeInput(str, isUTC); + if (start == null || end == null) return null; + return (end.getTime() - start.getTime()) / 1000; +} + export const LIVE_TAIL_TIME_QUERY = 'Live Tail' as const; export const LIVE_TAIL_DURATION_MS = ms('15m'); diff --git a/packages/common-utils/src/__tests__/types.test.ts b/packages/common-utils/src/__tests__/types.test.ts index 85017efcf5..2a07cadd32 100644 --- a/packages/common-utils/src/__tests__/types.test.ts +++ b/packages/common-utils/src/__tests__/types.test.ts @@ -829,6 +829,25 @@ describe('DashboardFilterValueSchema', () => { }); }); +describe('DashboardSchema.savedRelativeDateRange', () => { + const base = { id: 'd1', name: 'Dashboard', tiles: [], tags: [] }; + + it('accepts a number, null, or absent', () => { + for (const savedRelativeDateRange of [3600, null, undefined]) { + expect( + DashboardSchema.safeParse({ ...base, savedRelativeDateRange }).success, + ).toBe(true); + } + }); + + it('rejects non-numeric values', () => { + expect( + DashboardSchema.safeParse({ ...base, savedRelativeDateRange: '1h' }) + .success, + ).toBe(false); + }); +}); + describe('MetricFormulaSchema', () => { it('parses an expression-only formula', () => { const result = MetricFormulaSchema.safeParse({ From c693601f4a13467e164beb733872846ec58c8c13 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:52:00 -0400 Subject: [PATCH 6/7] fix: allow expansion for historical time saves in a future feature --- packages/api/src/models/dashboard.ts | 5 ++++- packages/app/src/DBDashboardPage.tsx | 25 ++++++++++++++++++------- packages/app/src/dashboard.ts | 5 ++++- packages/common-utils/src/types.ts | 13 ++++++++++++- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/packages/api/src/models/dashboard.ts b/packages/api/src/models/dashboard.ts index d45cae6eca..6468b05d7b 100644 --- a/packages/api/src/models/dashboard.ts +++ b/packages/api/src/models/dashboard.ts @@ -34,7 +34,10 @@ export default mongoose.model( savedQuery: { type: String, required: false }, savedQueryLanguage: { type: String, required: false }, savedFilterValues: { type: mongoose.Schema.Types.Array, required: false }, - savedRelativeDateRange: { type: Number, required: false }, + savedDateRange: { + type: mongoose.Schema.Types.Mixed, + required: false, + }, containers: { type: mongoose.Schema.Types.Array, required: false }, createdBy: { type: mongoose.Schema.Types.ObjectId, diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index bc08851a7e..ac5b8fa4a7 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -2024,10 +2024,15 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { // Initialize dashboard with the saved date range if (!hasDateRangeInUrl) { - if (dashboard.savedRelativeDateRange) { - const [start, end] = parseRelativeTimeQuery( - dashboard.savedRelativeDateRange * 1000, - ); + if (dashboard.savedDateRange) { + // assume only relative right now + const dateRange = dashboard.savedDateRange; + let start: Date, end: Date; + if (dateRange.type === 'relative') { + [start, end] = parseRelativeTimeQuery(dateRange.value * 1000); + } else { + [start, end] = dateRange.value; + } onTimeRangeSelect(start, end); } } @@ -2038,7 +2043,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { dashboard?.savedQuery, dashboard?.savedQueryLanguage, dashboard?.savedFilterValues, - dashboard?.savedRelativeDateRange, + dashboard?.savedDateRange, isLocalDashboard, isFetchingDashboard, router.isReady, @@ -2091,7 +2096,13 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { draft.savedQuery = currentWhere; draft.savedQueryLanguage = currentWhereLanguage; draft.savedFilterValues = currentFilterValues; - draft.savedRelativeDateRange = currentRelativeDateRange; + // Only supporting relative date range saving ATM + if (currentRelativeDateRange) { + draft.savedDateRange = { + type: 'relative', + value: currentRelativeDateRange, + }; + } }), () => { notifications.show({ @@ -2121,7 +2132,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { draft.savedQuery = null; draft.savedQueryLanguage = null; draft.savedFilterValues = []; - draft.savedRelativeDateRange = null; + draft.savedDateRange = null; }), () => { notifications.show({ diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index 5a8662eebd..48964d7080 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -39,7 +39,10 @@ export type Dashboard = { savedQuery?: string | null; savedQueryLanguage?: SearchConditionLanguage | null; savedFilterValues?: DashboardFilterValue[]; - savedRelativeDateRange?: number | null; + savedDateRange?: + | { type: 'relative'; value: number } + | { type: 'historical'; value: [Date, Date] } + | null; containers?: DashboardContainer[]; createdAt?: string; updatedAt?: string; diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 7a760a603c..7b255b152e 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -2054,7 +2054,18 @@ export const DashboardSchema = z.object({ savedQuery: z.string().nullable().optional(), savedQueryLanguage: SearchConditionLanguageSchema.nullable().optional(), savedFilterValues: z.array(DashboardFilterValueSchema).optional(), - savedRelativeDateRange: z.number().nullish(), + savedDateRange: z + .discriminatedUnion('type', [ + z.object({ + type: z.literal('relative'), + value: z.number(), + }), + z.object({ + type: z.literal('historical'), + value: z.array(z.number()).length(2), + }), + ]) + .nullish(), containers: z .array(DashboardContainerSchema) .max(DASHBOARD_MAX_CONTAINERS) From f55717991b8a549c1a38a9c845cec41fdf4545f3 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:05:17 -0400 Subject: [PATCH 7/7] fix: add dashboard guard to properly handle initialization --- packages/app/src/DBDashboardPage.tsx | 109 +++++++++++------- packages/app/src/dashboard.ts | 6 +- .../app/tests/e2e/features/dashboard.spec.ts | 8 +- .../common-utils/src/__tests__/types.test.ts | 27 +++-- 4 files changed, 90 insertions(+), 60 deletions(-) diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index ac5b8fa4a7..9acf9992f7 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -77,6 +77,7 @@ import { Group, Indicator, List, + Loader, Menu, Modal, Paper, @@ -379,9 +380,6 @@ const tileToLayoutItem = (chart: Tile): RGL.Layout => ({ minW: 1, }); -// TODO: This is a hack to set the default time range -const defaultTimeRange = parseTimeQuery('Past 1h', false) as [Date, Date]; - const whereLanguageParser = parseAsString.withDefault( typeof window !== 'undefined' ? (getStoredLanguage() ?? 'lucene') : 'lucene', ); @@ -1771,15 +1769,13 @@ function DashboardContainerRow({ ); } -function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { - const brandName = useBrandDisplayName(); - const confirm = useConfirm(); - - const router = useRouter(); - const dashboardId = router.query.dashboardId as string | undefined; - const { enterKioskMode, exitKioskMode, isKioskMode } = - useDashboardKioskMode(); - +function DBDashboardPage({ + dashboardProps, + defaultTimeInput, +}: { + dashboardProps: ReturnType; + defaultTimeInput: string; +}) { const { dashboard, setDashboard, @@ -1787,10 +1783,17 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { isLocalDashboard, isFetching: isFetchingDashboard, isSetting: isSavingDashboard, - } = useDashboard({ - dashboardId: dashboardId as string | undefined, - presetConfig, - }); + } = dashboardProps; + const brandName = useBrandDisplayName(); + const confirm = useConfirm(); + const { + userPreferences: { isUTC }, + } = useUserPreferences(); + + const router = useRouter(); + const dashboardId = router.query.dashboardId as string | undefined; + const { enterKioskMode, exitKioskMode, isKioskMode } = + useDashboardKioskMode(); const { data: sources } = useSources(); const { data: connections } = useConnections(); @@ -1947,10 +1950,17 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { }, [router.isReady, watchedGranularity, granularity, setGranularity]); const [displayedTimeInputValue, setDisplayedTimeInputValue] = - useState('Past 1h'); + useState(defaultTimeInput); + + // Must not depend on displayedTimeInputValue: useNewTimeQuery resets the + // input whenever initialTimeRange changes, which would clobber typing. + const defaultTimeRange = useMemo( + () => parseTimeQuery(defaultTimeInput, isUTC) as [Date, Date], + [defaultTimeInput, isUTC], + ); const { searchedTimeRange, onSearch, onTimeRangeSelect } = useNewTimeQuery({ - initialDisplayValue: 'Past 1h', + initialDisplayValue: defaultTimeInput, initialTimeRange: defaultTimeRange, setDisplayedTimeInputValue, }); @@ -1991,7 +2001,6 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const hasWhereInUrl = 'where' in router.query; const hasFiltersInUrl = 'filters' in router.query; - const hasDateRangeInUrl = 'from' in router.query && 'to' in router.query; // Query defaults: URL query overrides saved defaults. If switching to a // dashboard without defaults, clear query. On first load/reload, keep current state. @@ -2022,21 +2031,6 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { } } - // Initialize dashboard with the saved date range - if (!hasDateRangeInUrl) { - if (dashboard.savedDateRange) { - // assume only relative right now - const dateRange = dashboard.savedDateRange; - let start: Date, end: Date; - if (dateRange.type === 'relative') { - [start, end] = parseRelativeTimeQuery(dateRange.value * 1000); - } else { - [start, end] = dateRange.value; - } - onTimeRangeSelect(start, end); - } - } - initializedDashboardRef.current = dashboard.id; }, [ dashboard?.id, @@ -2066,10 +2060,6 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ); }, [setValue, where, whereLanguage]); - const { - userPreferences: { isUTC }, - } = useUserPreferences(); - const handleSaveQuery = useCallback(() => { if (!dashboard || isLocalDashboard) return; @@ -2109,7 +2099,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { color: 'green', title: 'Query saved and executed', message: - 'Filter query and dropdown values have been saved with the dashboard', + 'Filter query, dropdown values, and relative time range have been saved with the dashboard', autoClose: 3000, }); }, @@ -3432,7 +3422,46 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ); } -const DBDashboardPageDynamic = dynamic(async () => DBDashboardPage, { +function DBDashboardPageGuarded({ + presetConfig, +}: { + presetConfig?: Dashboard; +}) { + const router = useRouter(); + const dashboardId = router.query.dashboardId as string | undefined; + const dashboardProps = useDashboard({ + dashboardId: dashboardId as string | undefined, + presetConfig, + }); + const { + userPreferences: { isUTC }, + } = useUserPreferences(); + + const savedDateRange = dashboardProps.dashboard?.savedDateRange; + // Keyed on the saved range, not the render: a relative range re-stringifies + // to a new value every render, and useNewTimeQuery resets the input on change. + // Valid URL from/to still win: useNewTimeQuery overwrites the input from them. + const defaultTimeInput = useMemo(() => { + if (!savedDateRange) return 'Past 1h'; + const [start, end] = + savedDateRange.type === 'relative' + ? parseRelativeTimeQuery(savedDateRange.value * 1000) + : savedDateRange.value.map(v => new Date(v)); + // TODO: show relative ranges as "Past Xh" via getRelativeInterval + return dateRangeToString([start, end], isUTC); + }, [savedDateRange, isUTC]); + + if (!dashboardProps || !router.isReady) return ; + + return ( + + ); +} + +const DBDashboardPageDynamic = dynamic(async () => DBDashboardPageGuarded, { ssr: false, }); diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index 48964d7080..c1f671d13f 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -4,6 +4,7 @@ import { DashboardContainer, DashboardFilter, DashboardFilterValue, + DashboardWithoutId, resolveChartPaletteToken, SavedChartConfig, SearchConditionLanguage, @@ -39,10 +40,7 @@ export type Dashboard = { savedQuery?: string | null; savedQueryLanguage?: SearchConditionLanguage | null; savedFilterValues?: DashboardFilterValue[]; - savedDateRange?: - | { type: 'relative'; value: number } - | { type: 'historical'; value: [Date, Date] } - | null; + savedDateRange?: DashboardWithoutId['savedDateRange']; containers?: DashboardContainer[]; createdAt?: string; updatedAt?: string; diff --git a/packages/app/tests/e2e/features/dashboard.spec.ts b/packages/app/tests/e2e/features/dashboard.spec.ts index e7f1066e69..44259e3cf9 100644 --- a/packages/app/tests/e2e/features/dashboard.spec.ts +++ b/packages/app/tests/e2e/features/dashboard.spec.ts @@ -1201,7 +1201,7 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => { // Wait for success notification const notification = dashboardPage.page.locator( - 'text=/Filter query and dropdown values/i', + 'text=/Filter query, dropdown values, and relative time range/i', ); await expect(notification).toBeVisible({ timeout: 5000 }); }); @@ -1266,7 +1266,7 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => { // Wait for the save success notification rather than a blind sleep, so // we only read the URL once the save has actually landed. const notification = dashboardPage.page.locator( - 'text=/Filter query and dropdown values/i', + 'text=/Filter query, dropdown values, and relative time range/i', ); await expect(notification).toBeVisible({ timeout: 5000 }); @@ -2649,7 +2649,7 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => { // Wait for success notification const notification = dashboardPage.page.locator( - 'text=/Filter query and dropdown values/i', + 'text=/Filter query, dropdown values, and relative time range/i', ); await expect(notification).toBeVisible({ timeout: 5000 }); }); @@ -2670,7 +2670,7 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => { // Wait for success notification const notification = dashboardPage.page.locator( - 'text=/Filter query and dropdown values/i', + 'text=/Filter query, dropdown values, and relative time range/i', ); await expect(notification).toBeVisible({ timeout: 5000 }); }); diff --git a/packages/common-utils/src/__tests__/types.test.ts b/packages/common-utils/src/__tests__/types.test.ts index 2a07cadd32..5fd063aa14 100644 --- a/packages/common-utils/src/__tests__/types.test.ts +++ b/packages/common-utils/src/__tests__/types.test.ts @@ -829,22 +829,25 @@ describe('DashboardFilterValueSchema', () => { }); }); -describe('DashboardSchema.savedRelativeDateRange', () => { +describe('DashboardSchema.savedDateRange', () => { const base = { id: 'd1', name: 'Dashboard', tiles: [], tags: [] }; + const parse = (savedDateRange: unknown) => + DashboardSchema.safeParse({ ...base, savedDateRange }).success; - it('accepts a number, null, or absent', () => { - for (const savedRelativeDateRange of [3600, null, undefined]) { - expect( - DashboardSchema.safeParse({ ...base, savedRelativeDateRange }).success, - ).toBe(true); - } + it('accepts relative, historical, null, or absent', () => { + expect(parse({ type: 'relative', value: 3600 })).toBe(true); + expect(parse({ type: 'historical', value: [1700000000, 1700003600] })).toBe( + true, + ); + expect(parse(null)).toBe(true); + expect(parse(undefined)).toBe(true); }); - it('rejects non-numeric values', () => { - expect( - DashboardSchema.safeParse({ ...base, savedRelativeDateRange: '1h' }) - .success, - ).toBe(false); + it('rejects malformed values', () => { + expect(parse({ type: 'relative', value: '1h' })).toBe(false); + expect(parse({ type: 'historical', value: [1700000000] })).toBe(false); + expect(parse({ type: 'other', value: 1 })).toBe(false); + expect(parse(3600)).toBe(false); }); });