Compare commits

...

2 Commits

Author SHA1 Message Date
Gaurav Tewari
8d2e024cb2 refactor: recent query 2026-06-01 02:48:57 +05:30
Gaurav Tewari
90cb4e99f9 feat: initial commit 2026-06-01 02:00:50 +05:30
19 changed files with 1340 additions and 2 deletions

View File

@@ -117,7 +117,37 @@
width: 100% !important;
max-width: 100% !important;
font-family: 'Space Mono', monospace !important;
min-height: 200px !important;
// Default cap = ~5 suggestion rows + section header + footer.
// Anything beyond scrolls inside this container.
max-height: 250px !important;
overflow-y: auto !important;
// Grow the cap proportionally to the number of recent search
// rows present so all recents render in full while ~5
// suggestions remain visible below them. Each pill card is
// ~52px (36px row + 8px margin top/bottom + 8px padding) plus
// a one-time ~30px for the "Recent searches" section header.
//
// CodeMirror renders recents first (rank: 1), then
// `<completion-section>` between groups, then suggestions —
// so the first N `<li>` direct children are the recents.
// `:nth-of-type` counts only `<li>` (sections use a different
// tag), making the count-via-position trick reliable.
&:has(> li:nth-of-type(1) .cm-completionIcon-recent) {
max-height: 332px !important;
}
&:has(> li:nth-of-type(2) .cm-completionIcon-recent) {
max-height: 384px !important;
}
&:has(> li:nth-of-type(3) .cm-completionIcon-recent) {
max-height: 436px !important;
}
&:has(> li:nth-of-type(4) .cm-completionIcon-recent) {
max-height: 488px !important;
}
&:has(> li:nth-of-type(5) .cm-completionIcon-recent) {
max-height: 540px !important;
}
&::-webkit-scrollbar {
width: 0.3rem;
@@ -133,6 +163,31 @@
background: transparent;
}
// CodeMirror renders <completion-section> headers between
// option groups when the `section` field is set on each
// Completion. Style them as small caps labels with a top
// divider so the Suggestions and Recent searches groups are
// clearly distinct.
completion-section {
display: block;
padding: 10px 12px 6px;
font-size: 10px !important;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--l3-foreground, var(--l2-foreground));
opacity: 0.7;
border-top: 1px solid var(--l1-border);
background-color: transparent;
}
// The first section header doesn't need a divider above it.
completion-section:first-child,
> li:first-child + completion-section,
completion-section:first-of-type {
border-top: 0;
}
li {
width: 100% !important;
max-width: 100% !important;
@@ -159,11 +214,136 @@
display: none !important;
}
// Tag the "recent" detail column so it reads as a label,
// not as an inline value. CodeMirror puts the option's
// `detail` string in `.cm-completionDetail`.
.cm-completionDetail {
margin-left: auto;
font-style: normal;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.55;
}
&[aria-selected='true'] {
background: var(--l3-background) !important;
font-weight: 600 !important;
}
}
// Recent search rows render as pill-style cards: rounded
// background per row, padded inset, design-token text color.
// Targeted via the icon class CodeMirror emits — even though
// the icon itself is hidden, its element still carries the
// type class.
li:has(.cm-completionIcon-recent) {
height: auto !important;
margin: 4px 8px !important;
padding: 6px 12px !important;
width: calc(100% - 16px) !important;
max-width: calc(100% - 16px) !important;
background: #23262e !important;
color: var(--text-vanilla-400) !important;
border: 1px solid var(--l1-border) !important;
border-radius: 6px !important;
line-height: 24px !important;
// Long filter expressions truncate to a single line with
// an ellipsis instead of wrapping or pushing the inline
// delete button off-screen. `min-width: 0` on the label
// is required for ellipsis to work inside a flex parent.
.cm-completionLabel {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover,
&[aria-selected='true'] {
background: color-mix(
in srgb,
#23262e 80%,
var(--text-vanilla-400)
) !important;
color: var(--text-vanilla-400) !important;
}
// Reveal the inline delete button more strongly on hover
// or when the row is keyboard-selected.
&:hover .cm-recent-delete,
&[aria-selected='true'] .cm-recent-delete {
opacity: 1;
}
}
// Apply the same truncation to regular suggestion rows so
// long attribute names (e.g. `service.cluster.namespace.x`)
// don't push other cells off the row.
li:not(:has(.cm-completionIcon-recent)) .cm-completionLabel {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Inline per-row delete affordance for recent searches,
// injected via the autocompletion `addToOptions` config.
.cm-recent-delete {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text-vanilla-400);
font-size: 18px;
line-height: 1;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.12s ease, background-color 0.12s ease;
&:hover {
opacity: 1;
background: color-mix(
in srgb,
var(--text-vanilla-400) 18%,
transparent
);
}
}
}
// Footer hint bar: keyboard shortcuts at the bottom of the
// dropdown. Rendered via ::after so we don't need to inject DOM
// — the popover's outer container gets a pseudo-element below
// the scrollable list.
&::after {
content: '↓↑ to navigate · ↵ to apply · esc to dismiss';
display: block;
padding: 8px 12px;
border-top: 1px solid var(--l1-border);
font-family: 'Space Mono', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--l2-foreground);
opacity: 0.6;
background: color-mix(
in srgb,
var(--l1-background) 50%,
transparent
);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
}

View File

@@ -29,6 +29,8 @@ import {
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import * as recentQueriesStore from 'lib/recentQueries/store';
import { normalizeFilterExpression } from 'lib/recentQueries/normalize';
import { debounce, isNull } from 'lodash-es';
import {
IDetailedError,
@@ -89,6 +91,24 @@ interface QuerySearchProps {
initialExpression?: string;
}
// Maps the legacy `DataSource` enum to the V5 `SignalType` union. The string
// values match (`'logs'` / `'traces'` / `'metrics'`); narrow with a runtime
// check rather than blindly casting. Return type is inlined to keep this
// module's import surface minimal (avoids a transitive dep cycle through
// the v5 queryRange types).
function dataSourceToSignal(
dataSource: DataSource,
): 'logs' | 'traces' | 'metrics' | null {
if (
dataSource === DataSource.LOGS ||
dataSource === DataSource.TRACES ||
dataSource === DataSource.METRICS
) {
return dataSource;
}
return null;
}
function QuerySearch({
placeholder,
onChange,
@@ -1249,6 +1269,144 @@ function QuerySearch({
};
}
// Whole-expression recent-search completions. Folded into the same
// `CompletionResult` as `autoSuggestions` (see `combinedSuggestions` below)
// rather than registered as a separate completion source — CodeMirror picks
// the source with the rightmost `from`, so a parallel source with
// `from: 0` would be silently dropped in favour of the positional one.
const recentsSignal = useMemo(() => dataSourceToSignal(dataSource), [dataSource]);
// Stable section objects so CodeMirror can group options under labelled
// headers in the dropdown. `rank` orders the sections — Recent searches
// render first (top) so users can scan their own history before falling
// through to the larger Suggestions list.
const recentsSection = useMemo(
() => ({ name: 'Recent searches', rank: 1 }),
[],
);
const suggestionsSection = useMemo(() => ({ name: 'Suggestions', rank: 2 }), []);
function getRecentOptions(
fullDoc: string,
): {
label: string;
type: string;
boost: number;
section: typeof recentsSection;
recentId: string;
recentSignal: 'logs' | 'traces' | 'metrics';
apply: (
view: EditorView,
completion: unknown,
from: number,
to: number,
) => void;
}[] {
// Display gates only on signal. Recents are shown across all panel
// types for the same signal so a user can replay a query they built
// anywhere.
if (!recentsSignal) {
return [];
}
const all = recentQueriesStore.list(recentsSignal);
// Normalize both sides before substring matching so formatting
// variations (leading/trailing whitespace, spaces around operators,
// keyword casing) don't accidentally exclude a recent. `attempt != 1`
// stored, ` attempt != 1 ` typed → both normalize to `attempt!=1` and
// substring-match.
const normalizedDoc = normalizeFilterExpression(fullDoc);
const seen = new Set<string>();
const matches = all
.filter((e) => {
if (normalizedDoc === '') {
return true;
}
return normalizeFilterExpression(e.filter.expression).includes(
normalizedDoc,
);
})
// Dedupe by raw expression in case old (pre-strip) entries with
// different ID formats still live in localStorage alongside the
// new ones — most-recently-used wins.
.filter((e) => {
const key = e.filter.expression.toLowerCase().trim();
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
})
.slice(0, 5);
return matches.map((entry) => ({
label: entry.filter.expression,
type: 'recent',
boost: -50,
section: recentsSection,
// Custom fields read by the addToOptions delete-button renderer
// so it can call store.remove on click.
recentId: entry.id,
recentSignal: entry.signal,
apply: (view: EditorView): void => {
// Replace the whole document with the recent's filter
// expression. The editor's onChange fires synchronously and
// pushes the new expression up to the parent via
// `handleSearchChange`, so non-filter fields on the current
// builder query stay as-is.
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: entry.filter.expression,
},
selection: { anchor: entry.filter.expression.length },
});
},
}));
}
function combinedSuggestions(
context: CompletionContext,
): CompletionResult | null {
const fullDoc = context.state.doc.toString();
const recentOptions = getRecentOptions(fullDoc);
const result = autoSuggestions(context);
// Tag positional suggestions with the "Suggestions" section so
// CodeMirror renders a section header above them. No cap — the full
// list scrolls inside the dropdown's max-height container so users
// can always reach any key/value, with or without recents present.
const sectionedSuggestions = (result?.options || []).map((opt) => ({
...opt,
section: suggestionsSection,
}));
if (recentOptions.length === 0 && sectionedSuggestions.length === 0) {
return result;
}
// `filter: false` tells CodeMirror to skip its built-in prefix scorer.
// Both option groups are pre-filtered in their respective sources
// (autoSuggestions filters positional options by the current token;
// getRecentOptions filters recents by full-doc prefix), so disabling
// CM's scorer keeps both visible when our filters allowed them through.
if (!result) {
return {
from: 0,
to: fullDoc.length,
options: recentOptions,
filter: false,
};
}
return {
...result,
options: [...recentOptions, ...sectionedSuggestions],
filter: false,
};
}
// Effect to handle focus state and trigger suggestions
useEffect(() => {
const clearTimeout = toggleSuggestions(10);
@@ -1394,11 +1552,94 @@ function QuerySearch({
})}
extensions={[
autocompletion({
override: [autoSuggestions],
override: [combinedSuggestions],
defaultKeymap: true,
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 50,
// Inject a delete (×) button into recent-search
// rows so users can remove individual entries
// without opening any other UI. Non-recent rows
// render an empty placeholder.
addToOptions: [
{
// Attach a native `title` tooltip to the row for
// long recent-search expressions. The pill cell
// truncates with ellipsis (see CSS); the tooltip
// reveals the full text on hover. We can't set
// attributes on the `<li>` directly from this
// render — CodeMirror appends our returned Node
// as a cell — so we defer the title-set to a
// microtask once the parent linkage is in place.
render: (completion): Node => {
const cell = document.createElement('span');
cell.style.display = 'none';
if (completion.type === 'recent') {
queueMicrotask(() => {
if (cell.parentElement) {
cell.parentElement.title = completion.label;
}
});
}
return cell;
},
position: 5,
},
{
render: (completion, _state, view): Node => {
if (completion.type !== 'recent') {
const empty = document.createElement('span');
empty.style.display = 'none';
return empty;
}
const c = completion as typeof completion & {
recentId?: string;
recentSignal?: 'logs' | 'traces' | 'metrics';
};
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'cm-recent-delete';
btn.setAttribute(
'aria-label',
'Remove from recent searches',
);
btn.title = 'Remove from recent searches';
btn.textContent = '×';
// Stop pointerdown / mousedown / click so the
// delete doesn't also fire the apply-completion
// path. CodeMirror uses pointerdown to commit
// the selected option, so we must intercept it.
const stop = (e: Event): void => {
e.preventDefault();
e.stopPropagation();
};
btn.addEventListener('pointerdown', stop);
btn.addEventListener('mousedown', stop);
btn.addEventListener('click', (e) => {
stop(e);
if (!c.recentId || !c.recentSignal) {
return;
}
recentQueriesStore.remove(
c.recentId,
c.recentSignal,
);
// The store mutation alone doesn't tell
// CodeMirror to re-render the open
// dropdown. Re-trigger the completion
// source so the deleted row disappears
// immediately, and re-focus the editor
// so the dropdown stays interactive.
if (view) {
view.focus();
startCompletion(view);
}
});
return btn;
},
position: 100,
},
],
}),
javascript({ jsx: false, typescript: false }),
EditorView.lineWrapping,

View File

@@ -24,6 +24,7 @@ import {
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -195,6 +196,8 @@ function ChartPreview({
},
);
useSaveRecentQuery(query);
useEffect(() => {
onFetchingStateChange?.(queryResponse.isFetching);
}, [queryResponse.isFetching, onFetchingStateChange]);

View File

@@ -11,6 +11,7 @@ import { populateMultipleResults } from 'container/NewWidget/LeftContainer/Widge
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsPanelWaitingOnVariable } from 'hooks/dashboard/useVariableFetchState';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
@@ -283,6 +284,8 @@ function GridCardGraph({
},
);
useSaveRecentQuery(widget?.query);
const isEmptyLayout = widget?.id === PANEL_TYPES.EMPTY_WIDGET;
if (queryResponse.data && widget.panelTypes === PANEL_TYPES.BAR) {

View File

@@ -37,6 +37,7 @@ import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQueryData from 'hooks/useUrlQueryData';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
@@ -188,6 +189,8 @@ function LogsExplorerViewsContainer({
'custom',
);
useSaveRecentQuery(stagedQuery);
const getRequestData = useCallback(
(
query: Query | null,

View File

@@ -16,6 +16,7 @@ import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interface
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import {
ICurrentQueryData,
useHandleExplorerTabChange,
@@ -62,6 +63,9 @@ function Explorer(): JSX.Element {
handleSetQueryData,
redirectWithQueryBuilderData,
} = useQueryBuilder();
useSaveRecentQuery(stagedQuery);
const { safeNavigate } = useSafeNavigate();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const isAIAssistantEnabled = useIsAIAssistantEnabled();

View File

@@ -25,6 +25,7 @@ import { usePageSize } from 'container/InfraMonitoringK8s/utils';
import NoLogs from 'container/NoLogs/NoLogs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { AppState } from 'store/reducers';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -69,6 +70,8 @@ function Summary(): JSX.Element {
const { currentQuery, stagedQuery, redirectWithQueryBuilderData } =
useQueryBuilder();
useSaveRecentQuery(stagedQuery);
useShareBuilderUrl({ defaultValue: initialQueriesMap[DataSource.METRICS] });
const query = useMemo(

View File

@@ -7,6 +7,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -64,6 +65,8 @@ function LeftContainer({
keepPreviousData: true,
});
useSaveRecentQuery(stagedQuery);
useEffect(() => {
if (queryResponse.isFetching) {
setIsCancelled(false);

View File

@@ -16,6 +16,7 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { QueryTable } from 'container/QueryTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
@@ -71,6 +72,8 @@ function TableView({
},
);
useSaveRecentQuery(stagedQuery);
useEffect(() => {
if (isLoading || isFetching) {
setIsLoadingQueries(true);

View File

@@ -0,0 +1,88 @@
import { act, renderHook } from '@testing-library/react';
import * as store from 'lib/recentQueries/store';
import type { RecentQueryInput } from 'lib/recentQueries/store';
import { useRecentQueries } from './useRecentQueries';
const baseInput = (
overrides: Partial<RecentQueryInput> = {},
): RecentQueryInput => ({
signal: 'logs',
filter: { expression: 'a = 1' },
...overrides,
});
describe('useRecentQueries', () => {
beforeEach(() => {
store.__resetForTests();
});
it('returns the entries for the given signal', () => {
act(() => {
store.save(baseInput({ filter: { expression: 'a = 1' } }));
store.save(baseInput({ filter: { expression: 'b = 2' } }));
});
const { result } = renderHook(() => useRecentQueries('logs'));
expect(result.current.entries).toHaveLength(2);
expect(result.current.entries.map((e) => e.filter.expression)).toStrictEqual([
'b = 2',
'a = 1',
]);
});
it('does not return entries from other signals', () => {
act(() => {
store.save(baseInput({ signal: 'logs' }));
store.save(baseInput({ signal: 'traces' }));
});
const { result } = renderHook(() => useRecentQueries('logs'));
expect(result.current.entries).toHaveLength(1);
expect(result.current.entries[0].signal).toBe('logs');
});
it('re-renders when a new entry is saved', () => {
const { result } = renderHook(() => useRecentQueries('logs'));
expect(result.current.entries).toHaveLength(0);
act(() => {
store.save(baseInput());
});
expect(result.current.entries).toHaveLength(1);
});
it('re-renders when an entry is removed via the returned remove fn', () => {
const { result } = renderHook(() => useRecentQueries('logs'));
act(() => {
store.save(baseInput());
});
expect(result.current.entries).toHaveLength(1);
const id = result.current.entries[0].id;
act(() => {
result.current.remove(id);
});
expect(result.current.entries).toHaveLength(0);
});
it('keeps a stable entries reference when nothing changed', () => {
act(() => {
store.save(baseInput());
});
const { result, rerender } = renderHook(() => useRecentQueries('logs'));
const first = result.current.entries;
rerender();
const second = result.current.entries;
expect(second).toBe(first);
});
});

View File

@@ -0,0 +1,39 @@
import { useCallback, useSyncExternalStore } from 'react';
import type { SignalType } from 'types/api/v5/queryRange';
import * as store from 'lib/recentQueries/store';
import type { RecentQueryEntry } from 'lib/recentQueries/types';
export interface UseRecentQueriesReturn {
entries: RecentQueryEntry[];
remove: (id: string) => void;
}
/**
* Returns the recent-query entries for a given signal, sorted by
* most-recently-used first (the underlying store already maintains that
* order). Subscribes to the store so saves and removes — including those
* driven by another tab via the `storage` event — re-render the consumer.
*/
export function useRecentQueries(
signal: SignalType,
): UseRecentQueriesReturn {
const getSnapshot = useCallback((): RecentQueryEntry[] => store.list(signal), [
signal,
]);
const entries = useSyncExternalStore(
store.subscribe,
getSnapshot,
getSnapshot,
);
const remove = useCallback(
(id: string): void => {
store.remove(id, signal);
},
[signal],
);
return { entries, remove };
}

View File

@@ -0,0 +1,144 @@
import { renderHook } from '@testing-library/react';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { validateQuery } from 'utils/queryValidationUtils';
import * as store from 'lib/recentQueries/store';
import { useSaveRecentQuery } from './useSaveRecentQuery';
jest.mock('utils/queryValidationUtils', () => ({
validateQuery: jest.fn(),
}));
const mockedValidateQuery = validateQuery as jest.MockedFunction<
typeof validateQuery
>;
const buildQuery = (overrides: Partial<IBuilderQuery>[] = [{}]): Query => ({
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
id: 'q1',
builder: {
queryFormulas: [],
queryTraceOperator: [],
queryData: overrides.map((o, i) => ({
queryName: `Q${i}`,
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
aggregateAttribute: undefined as never,
functions: [],
filter: { expression: 'service.name = "frontend"' },
groupBy: [],
expression: `Q${i}`,
disabled: false,
having: [],
limit: null,
stepInterval: null,
orderBy: [],
legend: '',
...o,
})) as IBuilderQuery[],
},
});
describe('useSaveRecentQuery', () => {
beforeEach(() => {
store.__resetForTests();
mockedValidateQuery.mockReturnValue({
isValid: true,
message: '',
errors: [],
});
});
it('saves the staged query when validation passes', () => {
const stagedQuery = buildQuery();
renderHook(() => useSaveRecentQuery(stagedQuery));
const entries = store.list('logs');
expect(entries).toHaveLength(1);
expect(entries[0].filter.expression).toBe('service.name = "frontend"');
});
it('does not save when validateQuery rejects the expression', () => {
mockedValidateQuery.mockReturnValue({
isValid: false,
message: 'bad',
errors: [],
});
const stagedQuery = buildQuery();
renderHook(() => useSaveRecentQuery(stagedQuery));
expect(store.list('logs')).toHaveLength(0);
});
it('does not save a builder query with an empty filter expression', () => {
const stagedQuery = buildQuery([{ filter: { expression: '' } }]);
renderHook(() => useSaveRecentQuery(stagedQuery));
expect(store.list('logs')).toHaveLength(0);
});
it('saves each builder query in the composite separately', () => {
const stagedQuery = buildQuery([
{
dataSource: DataSource.LOGS,
filter: { expression: 'service.name = "a"' },
},
{
dataSource: DataSource.TRACES,
filter: { expression: 'service.name = "b"' },
},
]);
renderHook(() => useSaveRecentQuery(stagedQuery));
expect(store.list('logs')).toHaveLength(1);
expect(store.list('traces')).toHaveLength(1);
});
it('does not re-save when the staged query has not changed', () => {
const stagedQuery = buildQuery();
const { rerender } = renderHook(
({ q }: { q: Query }) => useSaveRecentQuery(q),
{ initialProps: { q: stagedQuery } },
);
const firstTimestamp = store.list('logs')[0].lastUsedAt;
rerender({ q: stagedQuery });
const second = store.list('logs');
expect(second).toHaveLength(1);
expect(second[0].lastUsedAt).toBe(firstTimestamp);
});
it('re-saves when the staged query filter changes', () => {
const initial = buildQuery([{ filter: { expression: 'a = 1' } }]);
const changed = buildQuery([{ filter: { expression: 'b = 2' } }]);
const { rerender } = renderHook(
({ q }: { q: Query }) => useSaveRecentQuery(q),
{ initialProps: { q: initial } },
);
expect(store.list('logs')).toHaveLength(1);
rerender({ q: changed });
expect(store.list('logs')).toHaveLength(2);
});
it('is a no-op when stagedQuery is null', () => {
renderHook(() => useSaveRecentQuery(null));
expect(store.list('logs')).toHaveLength(0);
});
});

View File

@@ -0,0 +1,94 @@
import { useEffect, useRef } from 'react';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import type { SignalType } from 'types/api/v5/queryRange';
import { validateQuery } from 'utils/queryValidationUtils';
import * as store from 'lib/recentQueries/store';
// Legacy `IBuilderQuery.dataSource` uses the `DataSource` enum, whose string
// values match `SignalType`. Narrow with a runtime check rather than blindly
// casting — guards against any future drift.
function toSignal(dataSource: IBuilderQuery['dataSource']): SignalType | null {
if (
dataSource === 'logs' ||
dataSource === 'traces' ||
dataSource === 'metrics'
) {
return dataSource;
}
return null;
}
// Build a stable signature of the staged filter expressions so the effect
// knows when a re-save is genuinely warranted vs. when we're seeing the same
// state across renders.
function buildSignature(stagedQuery: Query | null | undefined): string | null {
if (!stagedQuery) {
return null;
}
const queryData = stagedQuery.builder?.queryData;
if (!Array.isArray(queryData) || queryData.length === 0) {
return null;
}
return JSON.stringify(
queryData.map((q) => ({
ds: q.dataSource,
f: q.filter?.expression ?? '',
})),
);
}
/**
* Save each builder query's filter expression in
* `stagedQuery.builder.queryData[]` to the recent-queries store.
*
* Gate: frontend grammar — `validateQuery(expression)` must accept it.
*
* We intentionally do NOT gate on a backend `isSuccess` flag. An
* investigation often runs queries that return zero results (e.g. searching
* for a rare error condition that doesn't currently exist in the data),
* and the user still wants to replay that query later. The frontend
* grammar check rejects expressions that don't parse; everything else is
* the user's intent worth preserving.
*
* The hook saves only the filter expression. Selecting a recent restores
* only the filter; the rest of the user's builder query (groupBy/orderBy/
* having/limit) is left untouched.
*/
export function useSaveRecentQuery(
stagedQuery: Query | null | undefined,
): void {
const lastSavedSignatureRef = useRef<string | null>(null);
useEffect(() => {
const signature = buildSignature(stagedQuery);
if (!signature || signature === lastSavedSignatureRef.current) {
return;
}
const queryData = stagedQuery?.builder?.queryData ?? [];
queryData.forEach((q) => {
const expression = q.filter?.expression?.trim();
if (!expression) {
return;
}
const validation = validateQuery(expression);
if (!validation.isValid) {
return;
}
const signal = toSignal(q.dataSource);
if (!signal) {
return;
}
store.save({
signal,
filter: q.filter ?? { expression: '' },
});
});
lastSavedSignatureRef.current = signature;
}, [stagedQuery]);
}

View File

@@ -0,0 +1,65 @@
import { normalizeFilterExpression } from './normalize';
describe('normalizeFilterExpression', () => {
it('returns empty string for empty input', () => {
expect(normalizeFilterExpression('')).toBe('');
});
it('returns empty string for whitespace-only input', () => {
expect(normalizeFilterExpression(' \t ')).toBe('');
});
it('strips whitespace around operators', () => {
expect(normalizeFilterExpression(' a = 1 ')).toBe('a=1');
expect(normalizeFilterExpression('a = 1')).toBe('a=1');
expect(normalizeFilterExpression('a=1')).toBe('a=1');
});
it('lowercases AND / OR / NOT outside quotes', () => {
expect(normalizeFilterExpression('A AND B OR NOT C')).toBe('AandBornotC');
});
it('lowercases IN / LIKE / ILIKE / CONTAINS', () => {
expect(normalizeFilterExpression('host IN [1, 2] AND name LIKE "foo"')).toBe(
'hostin[1,2]andnamelike"foo"',
);
});
it('preserves whitespace and casing inside single-quoted strings', () => {
expect(normalizeFilterExpression("a = 'X Y'")).toBe("a='X Y'");
});
it('preserves whitespace and casing inside double-quoted strings', () => {
expect(normalizeFilterExpression('a = "X Y"')).toBe('a="X Y"');
});
it('does not lowercase keyword-looking substrings inside quotes', () => {
expect(normalizeFilterExpression("msg = 'AND ERROR'")).toBe("msg='AND ERROR'");
});
it('handles escaped quotes inside strings', () => {
expect(normalizeFilterExpression("msg = 'a\\'b' AND x = 1")).toBe(
"msg='a\\'b'andx=1",
);
});
it('treats two formattings of the same expression as identical', () => {
const a = normalizeFilterExpression(
'service.name = "frontend" AND severity = error',
);
const b = normalizeFilterExpression(
'service.name="frontend" and severity=error',
);
expect(a).toBe(b);
});
it('preserves unquoted value casing (treats them as identifiers)', () => {
expect(normalizeFilterExpression('status = OK')).toBe('status=OK');
});
it('handles mixed quotes in one expression', () => {
expect(normalizeFilterExpression(`a = 'X' AND b = "Y"`)).toBe(
`a='X'andb="Y"`,
);
});
});

View File

@@ -0,0 +1,53 @@
// Lowercase only the standalone keywords/operators that have case-insensitive
// semantics in the query grammar. Identifiers and unquoted values are left
// alone — case may be meaningful there.
const KEYWORDS_RE = /\b(AND|OR|NOT|IN|LIKE|ILIKE|CONTAINS|EXISTS|BETWEEN|IS|NULL|TRUE|FALSE)\b/gi;
// Match a quoted string literal (single- or double-quoted) with simple
// backslash escapes. Used to skip over value regions so we don't touch their
// casing or whitespace.
const QUOTED_RE = /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g;
function processOutsideQuotes(s: string): string {
// 1. Lowercase keywords while spaces are still in place so word-boundary
// matching works (`\bAND\b`).
// 2. Strip all remaining whitespace. Glued identifiers are acceptable —
// this string is only used as an internal dedup key, never displayed.
return s.replace(KEYWORDS_RE, (m) => m.toLowerCase()).replace(/\s+/g, '');
}
/**
* Normalize a filter expression for dedup purposes only.
*
* Pipeline: lowercase known keywords/operators (AND, OR, IN, NOT, LIKE, …)
* outside quoted strings, then strip all whitespace outside quoted strings.
* Casing and whitespace inside quoted string values are preserved.
*
* The result is intentionally not human-readable — it's an internal dedup key.
* The original raw expression text stays on the entry for display and prefix
* matching.
*
* This is not a parser — it catches the common formatting drift
* (`a = "x"` vs `a="x"` vs `a = 'x'` vs `A AND B` vs `a and b`) without
* trying to canonicalize the AST.
*/
export function normalizeFilterExpression(input: string): string {
if (!input) {
return '';
}
let result = '';
let lastIndex = 0;
QUOTED_RE.lastIndex = 0;
let match = QUOTED_RE.exec(input);
while (match !== null) {
result += processOutsideQuotes(input.slice(lastIndex, match.index));
result += match[0];
lastIndex = QUOTED_RE.lastIndex;
match = QUOTED_RE.exec(input);
}
result += processOutsideQuotes(input.slice(lastIndex));
return result.trim();
}

View File

@@ -0,0 +1,206 @@
import * as store from './store';
import type { RecentQueryInput } from './store';
import type { RecentQueryEntry } from './types';
const baseInput = (
overrides: Partial<RecentQueryInput> = {},
): RecentQueryInput => ({
signal: 'logs',
filter: { expression: 'service.name = "frontend"' },
...overrides,
});
function saveOrThrow(input: RecentQueryInput): RecentQueryEntry {
const saved = store.save(input);
if (!saved) {
throw new Error('expected save to return an entry');
}
return saved;
}
describe('recentQueries store', () => {
beforeEach(() => {
store.__resetForTests();
});
describe('save + list', () => {
it('saves an entry and lists it', () => {
store.save(baseInput());
const entries = store.list('logs');
expect(entries).toHaveLength(1);
expect(entries[0].filter.expression).toBe('service.name = "frontend"');
expect(entries[0].id).toBeTruthy();
expect(entries[0].lastUsedAt).toBeGreaterThan(0);
});
it('does not save when the filter expression is empty', () => {
const result = store.save(baseInput({ filter: { expression: '' } }));
expect(result).toBeNull();
expect(store.list('logs')).toHaveLength(0);
});
it('does not save when the filter expression is whitespace only', () => {
const result = store.save(baseInput({ filter: { expression: ' ' } }));
expect(result).toBeNull();
expect(store.list('logs')).toHaveLength(0);
});
});
describe('LRU ordering', () => {
it('places the most recently saved entry at the front', () => {
store.save(baseInput({ filter: { expression: 'a = 1' } }));
store.save(baseInput({ filter: { expression: 'b = 2' } }));
store.save(baseInput({ filter: { expression: 'c = 3' } }));
const entries = store.list('logs');
expect(entries.map((e) => e.filter.expression)).toStrictEqual([
'c = 3',
'b = 2',
'a = 1',
]);
});
it('re-saving an existing filter bumps it to the front', () => {
store.save(baseInput({ filter: { expression: 'a = 1' } }));
store.save(baseInput({ filter: { expression: 'b = 2' } }));
store.save(baseInput({ filter: { expression: 'a = 1' } }));
const entries = store.list('logs');
expect(entries).toHaveLength(2);
expect(entries.map((e) => e.filter.expression)).toStrictEqual([
'a = 1',
'b = 2',
]);
});
});
describe('dedup', () => {
it('treats formatting variations of the same filter as one entry', () => {
store.save(baseInput({ filter: { expression: 'a = 1 AND b = 2' } }));
store.save(baseInput({ filter: { expression: 'a=1 and b=2' } }));
expect(store.list('logs')).toHaveLength(1);
});
});
describe('signal partitioning', () => {
it('saves to the right bucket per signal', () => {
store.save(baseInput({ signal: 'logs', filter: { expression: 'a = 1' } }));
store.save(
baseInput({ signal: 'traces', filter: { expression: 'b = 2' } }),
);
store.save(
baseInput({ signal: 'metrics', filter: { expression: 'c = 3' } }),
);
expect(store.list('logs')).toHaveLength(1);
expect(store.list('traces')).toHaveLength(1);
expect(store.list('metrics')).toHaveLength(1);
expect(store.list('logs')[0].filter.expression).toBe('a = 1');
expect(store.list('traces')[0].filter.expression).toBe('b = 2');
expect(store.list('metrics')[0].filter.expression).toBe('c = 3');
});
it('does not leak between signals on dedup', () => {
store.save(baseInput({ signal: 'logs', filter: { expression: 'a = 1' } }));
store.save(
baseInput({ signal: 'traces', filter: { expression: 'a = 1' } }),
);
expect(store.list('logs')).toHaveLength(1);
expect(store.list('traces')).toHaveLength(1);
});
});
describe('LRU cap', () => {
it('caps the bucket at 10 entries and evicts the oldest', () => {
for (let i = 0; i < 11; i += 1) {
store.save(baseInput({ filter: { expression: `attr_${i} = 1` } }));
}
const entries = store.list('logs');
expect(entries).toHaveLength(10);
expect(entries[0].filter.expression).toBe('attr_10 = 1');
expect(
entries.some((e) => e.filter.expression === 'attr_0 = 1'),
).toBe(false);
});
});
describe('remove', () => {
it('removes an entry by id', () => {
store.save(baseInput({ filter: { expression: 'a = 1' } }));
const saved = saveOrThrow(baseInput({ filter: { expression: 'b = 2' } }));
store.remove(saved.id, 'logs');
const entries = store.list('logs');
expect(entries).toHaveLength(1);
expect(entries[0].filter.expression).toBe('a = 1');
});
it('is a no-op when the id does not exist', () => {
store.save(baseInput({ filter: { expression: 'a = 1' } }));
store.remove('does-not-exist', 'logs');
expect(store.list('logs')).toHaveLength(1);
});
it('does not touch other signals', () => {
const logsEntry = saveOrThrow(
baseInput({ signal: 'logs', filter: { expression: 'a = 1' } }),
);
store.save(baseInput({ signal: 'traces', filter: { expression: 'a = 1' } }));
store.remove(logsEntry.id, 'logs');
expect(store.list('logs')).toHaveLength(0);
expect(store.list('traces')).toHaveLength(1);
});
});
describe('persistence', () => {
it('reads back the same entries after the in-memory cache is dropped', () => {
store.save(baseInput({ filter: { expression: 'a = 1' } }));
store.save(baseInput({ filter: { expression: 'b = 2' } }));
store.__dropCacheForTests();
const entries = store.list('logs');
expect(entries.map((e) => e.filter.expression)).toStrictEqual([
'b = 2',
'a = 1',
]);
});
});
describe('subscribe', () => {
it('notifies subscribers on save', () => {
const cb = jest.fn();
store.subscribe(cb);
store.save(baseInput({ filter: { expression: 'a = 1' } }));
expect(cb).toHaveBeenCalledTimes(1);
});
it('notifies subscribers on remove', () => {
const saved = saveOrThrow(baseInput({ filter: { expression: 'a = 1' } }));
const cb = jest.fn();
store.subscribe(cb);
store.remove(saved.id, 'logs');
expect(cb).toHaveBeenCalledTimes(1);
});
it('stops notifying after unsubscribe', () => {
const cb = jest.fn();
const unsubscribe = store.subscribe(cb);
unsubscribe();
store.save(baseInput({ filter: { expression: 'a = 1' } }));
expect(cb).not.toHaveBeenCalled();
});
it('does not notify for no-op removes', () => {
const cb = jest.fn();
store.subscribe(cb);
store.remove('does-not-exist', 'logs');
expect(cb).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,187 @@
import type { SignalType } from 'types/api/v5/queryRange';
import { normalizeFilterExpression } from './normalize';
import type {
RecentQueriesStoreShape,
RecentQueryEntry,
} from './types';
const STORAGE_KEY_PREFIX = 'qb_recent_v1';
const STORAGE_VERSION = 1;
const MAX_ENTRIES = 10;
const QUOTA_RETRY_FRACTION = 0.8;
const SIGNALS: readonly SignalType[] = ['logs', 'traces', 'metrics'];
const storageKey = (signal: SignalType): string =>
`${STORAGE_KEY_PREFIX}:${signal}`;
const cache = new Map<SignalType, RecentQueryEntry[]>();
const subscribers = new Set<() => void>();
function hasLocalStorage(): boolean {
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}
function readFromStorage(signal: SignalType): RecentQueryEntry[] {
if (!hasLocalStorage()) {
return [];
}
try {
const raw = window.localStorage.getItem(storageKey(signal));
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as RecentQueriesStoreShape;
if (parsed?.version !== STORAGE_VERSION || !Array.isArray(parsed.entries)) {
return [];
}
return parsed.entries;
} catch {
return [];
}
}
// Attempts to persist; on write failure (typically QuotaExceededError), drops
// the oldest fraction and retries once. Returns the entries that were actually
// written (caller should mirror this into the in-memory cache so reads stay
// consistent).
function writeToStorage(
signal: SignalType,
entries: RecentQueryEntry[],
): RecentQueryEntry[] {
if (!hasLocalStorage()) {
return entries;
}
const persist = (toWrite: RecentQueryEntry[]): boolean => {
try {
window.localStorage.setItem(
storageKey(signal),
JSON.stringify({ version: STORAGE_VERSION, entries: toWrite }),
);
return true;
} catch {
// Swallow quota and other write errors. Caller retries with a trimmed
// list when this returns false.
return false;
}
};
if (persist(entries)) {
return entries;
}
const trimmed = entries.slice(
0,
Math.max(1, Math.floor(entries.length * QUOTA_RETRY_FRACTION)),
);
if (persist(trimmed)) {
return trimmed;
}
return entries;
}
function getCache(signal: SignalType): RecentQueryEntry[] {
const existing = cache.get(signal);
if (existing) {
return existing;
}
const fresh = readFromStorage(signal);
cache.set(signal, fresh);
return fresh;
}
function notify(): void {
subscribers.forEach((cb) => cb());
}
// Deterministic id derived from the dedup key. Two saves with the same
// (signal, normalized filter) produce the same id and upsert.
function makeId(signal: SignalType, normalizedFilter: string): string {
return `${signal}|${normalizedFilter}`;
}
export type RecentQueryInput = Omit<RecentQueryEntry, 'id' | 'lastUsedAt'>;
export function list(signal: SignalType): RecentQueryEntry[] {
return getCache(signal);
}
export function save(entry: RecentQueryInput): RecentQueryEntry | null {
const normalized = normalizeFilterExpression(entry.filter.expression);
if (!normalized) {
return null;
}
const id = makeId(entry.signal, normalized);
const current = getCache(entry.signal);
const filtered = current.filter((e) => e.id !== id);
const newEntry: RecentQueryEntry = {
...entry,
id,
lastUsedAt: Date.now(),
};
const next = [newEntry, ...filtered].slice(0, MAX_ENTRIES);
const persisted = writeToStorage(entry.signal, next);
cache.set(entry.signal, persisted);
notify();
return newEntry;
}
export function remove(id: string, signal: SignalType): void {
const current = getCache(signal);
const next = current.filter((e) => e.id !== id);
if (next.length === current.length) {
return;
}
const persisted = writeToStorage(signal, next);
cache.set(signal, persisted);
notify();
}
export function subscribe(cb: () => void): () => void {
subscribers.add(cb);
return (): void => {
subscribers.delete(cb);
};
}
function handleCrossTabStorageEvent(event: StorageEvent): void {
if (!event.key || !event.key.startsWith(`${STORAGE_KEY_PREFIX}:`)) {
return;
}
const signal = event.key.slice(STORAGE_KEY_PREFIX.length + 1) as SignalType;
if (!SIGNALS.includes(signal)) {
return;
}
cache.set(signal, readFromStorage(signal));
notify();
}
if (
typeof window !== 'undefined' &&
typeof window.addEventListener === 'function'
) {
window.addEventListener('storage', handleCrossTabStorageEvent);
}
// Test-only escape hatch. Clears in-memory cache, subscribers, and the
// localStorage keys we own. Should not be called from production code.
export function __resetForTests(): void {
cache.clear();
subscribers.clear();
if (hasLocalStorage()) {
SIGNALS.forEach((s) => window.localStorage.removeItem(storageKey(s)));
}
}
// Test-only escape hatch. Drops the in-memory cache without touching
// localStorage, letting tests simulate a fresh page load that rehydrates from
// persisted data. Should not be called from production code.
export function __dropCacheForTests(): void {
cache.clear();
}

View File

@@ -0,0 +1,16 @@
import type { Filter, SignalType } from 'types/api/v5/queryRange';
// A stored recent search. We persist only the where-clause expression — the
// rest of the query bundle (groupBy/orderBy/having/limit) lives on the
// current builder query and is left untouched when a recent is selected.
export interface RecentQueryEntry {
id: string;
signal: SignalType;
filter: Filter;
lastUsedAt: number;
}
export interface RecentQueriesStoreShape {
version: 1;
entries: RecentQueryEntry[];
}

View File

@@ -15,6 +15,7 @@ import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
@@ -94,6 +95,8 @@ function TimeSeriesViewContainer({
},
);
useSaveRecentQuery(stagedQuery);
useEffect(() => {
if (data?.payload) {
setWarning(data?.warning);