From ab81696be5b93df5a8506c1ddf1fda92316989d7 Mon Sep 17 00:00:00 2001 From: Asheesh Kumar Date: Sat, 7 Nov 2026 15:24:44 +0530 Subject: [PATCH 1/2] feat: open saved searches with a saved default time range Saved searches can store a relative defaultTimeRange (e.g. past 1 day) that is applied when the search is opened without a time in the URL, instead of always landing in live tail. The save modal gets a "When opened" choice. Same shape as the relative case of dashboard savedDateRange; fixed windows are not accepted. The saved-search PATCH route now drops only undefined, so sending null clears the stored range. Refs #3567 --- .changeset/saved-search-time-range.md | 7 + packages/api/src/models/savedSearch.ts | 1 + .../api/__tests__/savedSearch.int.test.ts | 69 +++++++ packages/api/src/routers/api/savedSearch.ts | 4 +- packages/app/src/DBSearchPage.tsx | 70 ++++++- .../SavedSearches/SaveTimeRangeField.tsx | 116 +++++++++++ .../__tests__/savedSearchTimeRange.test.ts | 183 ++++++++++++++++++ .../SavedSearches/savedSearchTimeRange.ts | 133 +++++++++++++ packages/app/src/timeQuery.ts | 21 +- .../components/SavedSearchModalComponent.ts | 11 ++ .../e2e/features/search/saved-search.spec.ts | 41 ++++ .../common-utils/src/__tests__/types.test.ts | 28 +++ packages/common-utils/src/types.ts | 38 ++-- 13 files changed, 701 insertions(+), 21 deletions(-) create mode 100644 .changeset/saved-search-time-range.md create mode 100644 packages/app/src/components/SavedSearches/SaveTimeRangeField.tsx create mode 100644 packages/app/src/components/SavedSearches/__tests__/savedSearchTimeRange.test.ts create mode 100644 packages/app/src/components/SavedSearches/savedSearchTimeRange.ts diff --git a/.changeset/saved-search-time-range.md b/.changeset/saved-search-time-range.md new file mode 100644 index 0000000000..7034cb8e6f --- /dev/null +++ b/.changeset/saved-search-time-range.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/api': minor +'@hyperdx/app': minor +'@hyperdx/common-utils': minor +--- + +feat: saved searches can store a default time range (e.g. past 1 day) to open with instead of live tail, chosen in the save modal under "When opened" diff --git a/packages/api/src/models/savedSearch.ts b/packages/api/src/models/savedSearch.ts index 54e49ea657..cd64f1ae25 100644 --- a/packages/api/src/models/savedSearch.ts +++ b/packages/api/src/models/savedSearch.ts @@ -61,6 +61,7 @@ const savedSearchSchema = new Schema< }, tags: [String], filters: [{ type: mongoose.Schema.Types.Mixed }], + defaultTimeRange: { type: mongoose.Schema.Types.Mixed, required: false }, // Immutable: ownership attribution is set once at creation. createdBy: { type: mongoose.Schema.Types.ObjectId, diff --git a/packages/api/src/routers/api/__tests__/savedSearch.int.test.ts b/packages/api/src/routers/api/__tests__/savedSearch.int.test.ts index 6a51905ee5..053d4b4e7b 100644 --- a/packages/api/src/routers/api/__tests__/savedSearch.int.test.ts +++ b/packages/api/src/routers/api/__tests__/savedSearch.int.test.ts @@ -101,6 +101,75 @@ describe('savedSearch router', () => { expect(updatedSavedSearch.body.select).toBe('SELECT 1'); }); + describe('defaultTimeRange', () => { + const ONE_DAY = { type: 'relative', value: 86400 }; + + it('persists a default time range on create and returns it from GET', async () => { + await agent + .post('/saved-search') + .send({ ...MOCK_SAVED_SEARCH, defaultTimeRange: ONE_DAY }) + .expect(200); + const savedSearches = await agent.get('/saved-search').expect(200); + expect(savedSearches.body[0].defaultTimeRange).toEqual(ONE_DAY); + }); + + it('keeps the stored range when an update omits it', async () => { + const created = await agent + .post('/saved-search') + .send({ ...MOCK_SAVED_SEARCH, defaultTimeRange: ONE_DAY }) + .expect(200); + const updated = await agent + .patch(`/saved-search/${created.body._id}`) + .send({ name: 'renamed' }) + .expect(200); + expect(updated.body.defaultTimeRange).toEqual(ONE_DAY); + }); + + it('clears the stored range when an update sends null', async () => { + const created = await agent + .post('/saved-search') + .send({ ...MOCK_SAVED_SEARCH, defaultTimeRange: ONE_DAY }) + .expect(200); + await agent + .patch(`/saved-search/${created.body._id}`) + .send({ defaultTimeRange: null }) + .expect(200); + const dbRecord = await SavedSearch.findById(created.body._id); + expect(dbRecord?.defaultTimeRange ?? null).toBeNull(); + }); + + it('rejects a malformed range', async () => { + await agent + .post('/saved-search') + .send({ ...MOCK_SAVED_SEARCH, defaultTimeRange: '1d' }) + .expect(400); + }); + + it('rejects a fixed window', async () => { + await agent + .post('/saved-search') + .send({ + ...MOCK_SAVED_SEARCH, + defaultTimeRange: { + type: 'historical', + value: [1700000000000, 1700003600000], + }, + }) + .expect(400); + }); + + it('still rejects null for fields that are not nullable', async () => { + const created = await agent + .post('/saved-search') + .send(MOCK_SAVED_SEARCH) + .expect(200); + await agent + .patch(`/saved-search/${created.body._id}`) + .send({ orderBy: null }) + .expect(400); + }); + }); + it('can get saved searches', async () => { await agent.post('/saved-search').send(MOCK_SAVED_SEARCH).expect(200); const savedSearches = await agent.get('/saved-search').expect(200); diff --git a/packages/api/src/routers/api/savedSearch.ts b/packages/api/src/routers/api/savedSearch.ts index 867a0bf8be..4362212773 100644 --- a/packages/api/src/routers/api/savedSearch.ts +++ b/packages/api/src/routers/api/savedSearch.ts @@ -86,9 +86,11 @@ router.patch( // Re-parse: validateRequest validates without replacing req.body, so // server-owned keys like `team`/`createdBy` would otherwise reach the // update. Parsing drops every key the schema doesn't declare. + // Only undefined is dropped: an explicit null clears a nullable field + // (defaultTimeRange), and the schema rejects null for every other field. const updates = _.omitBy( updateSavedSearchBodySchema.parse(req.body), - _.isNil, + _.isUndefined, ); const updatedSavedSearch = await updateSavedSearch( diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 4466eadd8c..7323737263 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -153,6 +153,11 @@ import { import ChartSQLPreview, { SQLPreview } from './components/ChartSQLPreview'; import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import PatternTable from './components/PatternTable'; +import { getSavedSearchLandingRange } from './components/SavedSearches/savedSearchTimeRange'; +import { + SaveTimeRangeField, + useSaveTimeRangeField, +} from './components/SavedSearches/SaveTimeRangeField'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel'; import SourceSchemaPreview, { @@ -465,6 +470,9 @@ function SaveSearchModalComponent({ onCreate, isUpdate, savedSearchId, + isLive, + searchedTimeRange, + openedAtMs, }: { searchedConfig: SearchConfig; opened: boolean; @@ -472,6 +480,9 @@ function SaveSearchModalComponent({ onCreate: (savedSearchId: string) => Promise; isUpdate: boolean; savedSearchId: string | undefined | null; + isLive: boolean; + searchedTimeRange: [Date, Date]; + openedAtMs: number | null; }) { const { data: savedSearch } = useSavedSearch( { id: savedSearchId ?? '' }, @@ -480,6 +491,13 @@ function SaveSearchModalComponent({ }, ); + const timeRangeField = useSaveTimeRangeField({ + openedAtMs, + isLive, + searchedTimeRange, + stored: savedSearch?.defaultTimeRange, + }); + const { control, handleSubmit, @@ -528,6 +546,9 @@ function SaveSearchModalComponent({ e.preventDefault(); handleSubmit(async ({ name }) => { + const timeRange = timeRangeField.resolve(isUpdate); + const defaultTimeRangeUpdate = + timeRange === undefined ? {} : { defaultTimeRange: timeRange }; if (isUpdate) { if (savedSearchId == null) { throw new Error('savedSearchId is required for update'); @@ -545,6 +566,7 @@ function SaveSearchModalComponent({ orderBy: searchedConfig.orderBy ?? '', filters: searchedConfig.filters ?? [], tags: tags, + ...defaultTimeRangeUpdate, }, { onSuccess: () => { @@ -573,6 +595,7 @@ function SaveSearchModalComponent({ orderBy: searchedConfig.orderBy ?? '', filters: searchedConfig.filters ?? [], tags: tags, + ...defaultTimeRangeUpdate, }); await onCreate(savedSearch.id); @@ -701,6 +724,7 @@ function SaveSearchModalComponent({ +