Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/saved-search-time-range.md
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions packages/api/src/models/savedSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
76 changes: 76 additions & 0 deletions packages/api/src/routers/api/__tests__/savedSearch.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion packages/api/src/routers/api/savedSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
93 changes: 82 additions & 11 deletions packages/app/src/DBSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -465,13 +470,21 @@ function SaveSearchModalComponent({
onCreate,
isUpdate,
savedSearchId,
isLive,
liveIntervalMs,
searchedTimeRange,
openedAtMs,
}: {
searchedConfig: SearchConfig;
opened: boolean;
onClose: () => void;
onCreate: (savedSearchId: string) => Promise<void>;
isUpdate: boolean;
savedSearchId: string | undefined | null;
isLive: boolean;
liveIntervalMs: number;
searchedTimeRange: [Date, Date];
openedAtMs: number | null;
}) {
const { data: savedSearch } = useSavedSearch(
{ id: savedSearchId ?? '' },
Expand All @@ -480,6 +493,14 @@ function SaveSearchModalComponent({
},
);

const timeRangeField = useSaveTimeRangeField({
openedAtMs,
isLive,
liveIntervalMs,
searchedTimeRange,
stored: savedSearch?.defaultTimeRange,
});

const {
control,
handleSubmit,
Expand Down Expand Up @@ -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');
Expand All @@ -545,6 +569,7 @@ function SaveSearchModalComponent({
orderBy: searchedConfig.orderBy ?? '',
filters: searchedConfig.filters ?? [],
tags: tags,
...defaultTimeRangeUpdate,
},
{
onSuccess: () => {
Expand Down Expand Up @@ -573,6 +598,7 @@ function SaveSearchModalComponent({
orderBy: searchedConfig.orderBy ?? '',
filters: searchedConfig.filters ?? [],
tags: tags,
...defaultTimeRangeUpdate,
});

await onCreate(savedSearch.id);
Expand Down Expand Up @@ -701,6 +727,7 @@ function SaveSearchModalComponent({
</Tags>
</Group>
</Box>
<SaveTimeRangeField field={timeRangeField} />
<Button
data-testid="save-search-submit-button"
variant="primary"
Expand Down Expand Up @@ -1131,6 +1158,10 @@ function DBSearchPageContent() {
'isLive',
parseAsBoolean.withDefault(true),
);
const [interval, setInterval] = useQueryState(
'liveInterval',
parseAsInteger.withDefault(LIVE_TAIL_DURATION_MS),
);

useEffect(() => {
if (analysisMode === 'delta' || analysisMode === 'pattern') {
Expand Down Expand Up @@ -1291,6 +1322,28 @@ function DBSearchPageContent() {
savedSearch.id === savedSearchId && // Make sure we've loaded the correct saved search
isSearchConfigEmpty // Only populate if URL doesn't have explicit config
) {
const nowMs = performance.timeOrigin + performance.now();
const landing = getSavedSearchLanding(
savedSearch.defaultTimeRange,
window.location.search,
nowMs,
);
// Replace, not push: a pushed entry would leave the bare URL behind,
// and Back would land on it and re-apply the range forever.
if (landing?.kind === 'live') {
// Same writes as picking a live tail range in the time picker.
const [start, end] = parseRelativeTimeQuery(landing.intervalMs, nowMs);
setIsLive(true, { history: 'replace' });
setInterval(landing.intervalMs, { history: 'replace' });
onTimeRangeSelect(start, end, null, { history: 'replace' });
const label = getRelativeTimeOptionLabel(landing.intervalMs);
if (label) setDisplayedTimeInputValue(label);
} else if (landing?.kind === 'window') {
setIsLive(false, { history: 'replace' });
onTimeRangeSelect(landing.range[0], landing.range[1], undefined, {
history: 'replace',
});
}
setSearchedConfig({
source: savedSearch.source,
where: savedSearch.where,
Expand Down Expand Up @@ -1327,6 +1380,9 @@ function DBSearchPageContent() {
defaultSourceId,
directTraceId,
sources,
setIsLive,
setInterval,
onTimeRangeSelect,
]);

const [_queryErrors, setQueryErrors] = useState<{
Expand Down Expand Up @@ -1593,9 +1649,19 @@ function DBSearchPageContent() {
}, [setIsLive]);

const [modelFormExpanded, setModelFormExpanded] = useState(false); // Used in local mode
// openedAtMs is read in the click handler: the modal compares it with the
// searched window to decide whether that window reads as "the past N".
const [saveSearchModalState, setSaveSearchModalState] = useState<
'create' | 'update' | undefined
{ mode: 'create' | 'update'; openedAtMs: number } | undefined
>(undefined);
const openSaveSearchModal = useCallback(
(mode: 'create' | 'update') =>
setSaveSearchModalState({
mode,
openedAtMs: performance.timeOrigin + performance.now(),
}),
[],
);
const chartSearchConfig = useMemo(
() => ({
select: searchedConfig.select ?? '',
Expand Down Expand Up @@ -1644,7 +1710,7 @@ function DBSearchPageContent() {
const deleteSavedSearch = useDeleteSavedSearch();
const onSaveSearch = useCallback(() => {
if (savedSearch == null) {
setSaveSearchModalState('create');
openSaveSearchModal('create');
} else {
handleSubmit(s => {
updateSavedSearch.mutate(
Expand All @@ -1661,7 +1727,13 @@ function DBSearchPageContent() {
);
})();
}
}, [savedSearch, updateSavedSearch, onSubmit, handleSubmit]);
}, [
savedSearch,
updateSavedSearch,
onSubmit,
handleSubmit,
openSaveSearchModal,
]);

const handleUpdateTags = useCallback(
(newTags: string[]) => {
Expand Down Expand Up @@ -1721,11 +1793,6 @@ function DBSearchPageContent() {
// State for collapsing all expanded rows when resuming live tail
const [collapseAllRows, setCollapseAllRows] = useState(false);

const [interval, setInterval] = useQueryState(
'liveInterval',
parseAsInteger.withDefault(LIVE_TAIL_DURATION_MS),
);

const [refreshFrequency, setRefreshFrequency] = useQueryState(
'refreshFrequency',
parseAsInteger.withDefault(DEFAULT_REFRESH_FREQUENCY),
Expand Down Expand Up @@ -2482,7 +2549,7 @@ function DBSearchPageContent() {
});
}}
onClickSaveAsNew={() => {
setSaveSearchModalState('create');
openSaveSearchModal('create');
}}
/>
</Group>
Expand Down Expand Up @@ -2543,7 +2610,7 @@ function DBSearchPageContent() {
variant="secondary"
size="xs"
onClick={() => {
setSaveSearchModalState('update');
openSaveSearchModal('update');
}}
style={{ flexShrink: 0 }}
>
Expand Down Expand Up @@ -2668,8 +2735,12 @@ function DBSearchPageContent() {
onClose={clearSaveSearchModalState}
onCreate={onSavedSearchCreated}
searchedConfig={searchedConfig}
isUpdate={saveSearchModalState === 'update'}
isUpdate={saveSearchModalState?.mode === 'update'}
savedSearchId={savedSearchId}
isLive={isLive}
liveIntervalMs={interval}
searchedTimeRange={searchedTimeRange}
openedAtMs={saveSearchModalState?.openedAtMs ?? null}
/>
)}
<DirectTraceSidePanel
Expand Down
Loading
Loading