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..7bc6744f93 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,82 @@ 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); + await agent + .post('/saved-search') + .send({ + ...MOCK_SAVED_SEARCH, + defaultTimeRange: { type: 'relative', value: 0 }, + }) + .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..f51d509fab 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 { getSavedSearchLanding } 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,10 @@ function SaveSearchModalComponent({ onCreate, isUpdate, savedSearchId, + isLive, + liveIntervalMs, + searchedTimeRange, + openedAtMs, }: { searchedConfig: SearchConfig; opened: boolean; @@ -472,6 +481,10 @@ function SaveSearchModalComponent({ onCreate: (savedSearchId: string) => Promise; isUpdate: boolean; savedSearchId: string | undefined | null; + isLive: boolean; + liveIntervalMs: number; + searchedTimeRange: [Date, Date]; + openedAtMs: number | null; }) { const { data: savedSearch } = useSavedSearch( { id: savedSearchId ?? '' }, @@ -480,6 +493,14 @@ function SaveSearchModalComponent({ }, ); + const timeRangeField = useSaveTimeRangeField({ + openedAtMs, + isLive, + liveIntervalMs, + searchedTimeRange, + stored: savedSearch?.defaultTimeRange, + }); + const { control, handleSubmit, @@ -528,6 +549,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 +569,7 @@ function SaveSearchModalComponent({ orderBy: searchedConfig.orderBy ?? '', filters: searchedConfig.filters ?? [], tags: tags, + ...defaultTimeRangeUpdate, }, { onSuccess: () => { @@ -573,6 +598,7 @@ function SaveSearchModalComponent({ orderBy: searchedConfig.orderBy ?? '', filters: searchedConfig.filters ?? [], tags: tags, + ...defaultTimeRangeUpdate, }); await onCreate(savedSearch.id); @@ -701,6 +727,7 @@ function SaveSearchModalComponent({ +