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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Changeset mentions a "When opened" control that no longer exists

The last commit, "save the selected time range without asking", removed the chooser. The modal now only shows read-only "Opens with…" text. Reword the release note to say the selected range is saved automatically.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

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
94 changes: 83 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,15 @@ function SaveSearchModalComponent({
},
);

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

const {
control,
handleSubmit,
Expand Down Expand Up @@ -528,6 +550,9 @@ function SaveSearchModalComponent({
e.preventDefault();

handleSubmit(async ({ name }) => {
const timeRange = timeRangeOutcome.value;
const defaultTimeRangeUpdate =
timeRange === undefined ? {} : { defaultTimeRange: timeRange };
if (isUpdate) {
if (savedSearchId == null) {
throw new Error('savedSearchId is required for update');
Expand All @@ -545,6 +570,7 @@ function SaveSearchModalComponent({
orderBy: searchedConfig.orderBy ?? '',
filters: searchedConfig.filters ?? [],
tags: tags,
...defaultTimeRangeUpdate,
},
{
onSuccess: () => {
Expand Down Expand Up @@ -573,6 +599,7 @@ function SaveSearchModalComponent({
orderBy: searchedConfig.orderBy ?? '',
filters: searchedConfig.filters ?? [],
tags: tags,
...defaultTimeRangeUpdate,
});

await onCreate(savedSearch.id);
Expand Down Expand Up @@ -701,6 +728,7 @@ function SaveSearchModalComponent({
</Tags>
</Group>
</Box>
<SaveTimeRangeField opensWith={timeRangeOutcome.opensWith} />
<Button
data-testid="save-search-submit-button"
variant="primary"
Expand Down Expand Up @@ -1131,6 +1159,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 +1323,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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — performance.timeOrigin + performance.now() is used to get around the Date.now() lint rule

Here and in openSaveSearchModal, both inside an effect/callback, which the rule's message says is fine. Use Date.now() with // eslint-disable-next-line no-restricted-syntax, as parseRelativeTimeQuery in timeQuery.ts already does, so the intent stays obvious.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

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 +1381,9 @@ function DBSearchPageContent() {
defaultSourceId,
directTraceId,
sources,
setIsLive,
setInterval,
onTimeRangeSelect,
]);

const [_queryErrors, setQueryErrors] = useState<{
Expand Down Expand Up @@ -1593,9 +1650,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 +1711,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 +1728,13 @@ function DBSearchPageContent() {
);
})();
}
}, [savedSearch, updateSavedSearch, onSubmit, handleSubmit]);
}, [
savedSearch,
updateSavedSearch,
onSubmit,
handleSubmit,
openSaveSearchModal,
]);

const handleUpdateTags = useCallback(
(newTags: string[]) => {
Expand Down Expand Up @@ -1721,11 +1794,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 +2550,7 @@ function DBSearchPageContent() {
});
}}
onClickSaveAsNew={() => {
setSaveSearchModalState('create');
openSaveSearchModal('create');
}}
/>
</Group>
Expand Down Expand Up @@ -2543,7 +2611,7 @@ function DBSearchPageContent() {
variant="secondary"
size="xs"
onClick={() => {
setSaveSearchModalState('update');
openSaveSearchModal('update');
}}
style={{ flexShrink: 0 }}
>
Expand Down Expand Up @@ -2668,8 +2736,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