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 diff --git a/packages/api/src/models/dashboard.ts b/packages/api/src/models/dashboard.ts index c760d7ece6..6468b05d7b 100644 --- a/packages/api/src/models/dashboard.ts +++ b/packages/api/src/models/dashboard.ts @@ -34,6 +34,10 @@ export default mongoose.model( savedQuery: { type: String, required: false }, savedQueryLanguage: { type: String, required: false }, savedFilterValues: { type: mongoose.Schema.Types.Array, 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 f2d014419c..0dfbd92a39 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -78,6 +78,7 @@ import { Group, Indicator, List, + Loader, Menu, Modal, Paper, @@ -147,7 +148,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, @@ -211,6 +215,7 @@ import { } from './source'; import { dateRangeToString, + parseRelativeTimeQuery, useDefaultTimeRange, useNewTimeQuery, } from './timeQuery'; @@ -1855,15 +1860,14 @@ function DashboardContainerRow({ const DEFAULT_INTERVAL = 'Past 1h'; -function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { - const defaultTimeRange = useDefaultTimeRange(DEFAULT_INTERVAL); - 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 = DEFAULT_INTERVAL, +}: { + dashboardProps: ReturnType; + defaultTimeInput?: string; +}) { + const defaultTimeRange = useDefaultTimeRange(defaultTimeInput); const { dashboard, @@ -1872,10 +1876,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(); @@ -2033,10 +2044,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { }, [router.isReady, watchedGranularity, granularity, setGranularity]); const [displayedTimeInputValue, setDisplayedTimeInputValue] = - useState(DEFAULT_INTERVAL); + useState(defaultTimeInput); const { searchedTimeRange, onSearch, onTimeRangeSelect } = useNewTimeQuery({ - initialDisplayValue: DEFAULT_INTERVAL, + initialDisplayValue: defaultTimeInput, initialTimeRange: defaultTimeRange, setDisplayedTimeInputValue, }); @@ -2113,6 +2124,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { dashboard?.savedQuery, dashboard?.savedQueryLanguage, dashboard?.savedFilterValues, + dashboard?.savedDateRange, isLocalDashboard, isFetchingDashboard, router.isReady, @@ -2121,6 +2133,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { setWhere, setWhereLanguage, setFilterValueEntries, + onTimeRangeSelect, ]); // Sync changes to the URL params into the form @@ -2150,18 +2163,30 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ? filterValueEntries : []; + const currentRelativeDateRange = timeRangeInputToSeconds( + displayedTimeInputValue, + isUTC, + ); + setDashboard( produce(dashboard, draft => { draft.savedQuery = currentWhere; draft.savedQueryLanguage = currentWhereLanguage; draft.savedFilterValues = currentFilterValues; + // Only supporting relative date range saving ATM + if (currentRelativeDateRange) { + draft.savedDateRange = { + type: 'relative', + value: currentRelativeDateRange, + }; + } }), () => { notifications.show({ 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, }); }, @@ -2173,6 +2198,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { getValues, filterValueEntries, onSubmit, + isUTC, + displayedTimeInputValue, ]); const handleRemoveSavedQuery = useCallback(() => { if (!dashboard || isLocalDashboard) return; @@ -2182,6 +2209,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { draft.savedQuery = null; draft.savedQueryLanguage = null; draft.savedFilterValues = []; + draft.savedDateRange = null; }), () => { notifications.show({ @@ -3484,7 +3512,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 undefined; + 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/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/app/src/dashboard.ts b/packages/app/src/dashboard.ts index b3ba2484e2..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,6 +40,7 @@ export type Dashboard = { savedQuery?: string | null; savedQueryLanguage?: SearchConditionLanguage | null; savedFilterValues?: DashboardFilterValue[]; + savedDateRange?: DashboardWithoutId['savedDateRange']; containers?: DashboardContainer[]; createdAt?: string; updatedAt?: string; diff --git a/packages/app/src/timeQuery.ts b/packages/app/src/timeQuery.ts index 3734b942ee..3216bc298d 100644 --- a/packages/app/src/timeQuery.ts +++ b/packages/app/src/timeQuery.ts @@ -522,9 +522,9 @@ export function useNewTimeQuery({ }; } -function getRangeOrFallback(query: string): [Date, Date] { +function getRangeOrFallback(query: string, isUTC: boolean): [Date, Date] { return ( - parseValidTimeRange(query, false) ?? + parseValidTimeRange(query, isUTC) ?? // This fallback catches arbitrary inputs but shouldn't be reachable // for standard literal labels (e.g., 'Past 1h') since they are always valid. parseRelativeTimeQuery(60 * 60 * 1000) @@ -532,17 +532,20 @@ function getRangeOrFallback(query: string): [Date, Date] { } export function useDefaultTimeRange(query: string): [Date, Date] { + const { + userPreferences: { isUTC }, + } = useUserPreferences(); const [state, setState] = useState<{ query: string; range: [Date, Date] }>( () => ({ query, - range: getRangeOrFallback(query), + range: getRangeOrFallback(query, isUTC), }), ); if (state.query !== query) { const newState = { query, - range: getRangeOrFallback(query), + range: getRangeOrFallback(query, isUTC), }; setState(newState); return newState.range; diff --git a/packages/app/tests/e2e/features/dashboard.spec.ts b/packages/app/tests/e2e/features/dashboard.spec.ts index cef9868c3c..7d83d89fef 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 85017efcf5..5fd063aa14 100644 --- a/packages/common-utils/src/__tests__/types.test.ts +++ b/packages/common-utils/src/__tests__/types.test.ts @@ -829,6 +829,28 @@ describe('DashboardFilterValueSchema', () => { }); }); +describe('DashboardSchema.savedDateRange', () => { + const base = { id: 'd1', name: 'Dashboard', tiles: [], tags: [] }; + const parse = (savedDateRange: unknown) => + DashboardSchema.safeParse({ ...base, savedDateRange }).success; + + 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 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); + }); +}); + describe('MetricFormulaSchema', () => { it('parses an expression-only formula', () => { const result = MetricFormulaSchema.safeParse({ diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index b0373d7572..37f95e6330 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -2083,6 +2083,18 @@ export const DashboardSchema = z.object({ savedQuery: z.string().nullable().optional(), savedQueryLanguage: SearchConditionLanguageSchema.nullable().optional(), savedFilterValues: z.array(DashboardFilterValueSchema).optional(), + 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)