Compare commits

...

16 Commits

Author SHA1 Message Date
Gaurav Tewari
100067d8dc refactor: more changes 2026-06-01 14:24:41 +05:30
Gaurav Tewari
b883a09c78 fix: update types 2026-06-01 14:21:43 +05:30
Gaurav Tewari
96f2160119 refactor: store in local storage 2026-06-01 14:17:28 +05:30
Gaurav Tewari
1824acc860 chore: remove extra commentes 2026-06-01 14:00:02 +05:30
Gaurav Tewari
fd9bc7c42e refactor: more code 2026-06-01 13:53:35 +05:30
Gaurav Tewari
68d17dd164 chore: remove comments 2026-06-01 13:44:11 +05:30
Gaurav Tewari
7568a1a1e1 refactor : more changes 2026-06-01 13:43:39 +05:30
Gaurav Tewari
8c28035e6e feat: add time feature 2026-06-01 13:03:21 +05:30
Gaurav Tewari
3d9dd945f6 refactor: more comments 2026-06-01 12:24:20 +05:30
Gaurav Tewari
3b8bcdecaa refactor: comments 2026-06-01 12:11:15 +05:30
Gaurav Tewari
4b54affb10 refactor: css 2026-06-01 11:41:58 +05:30
Gaurav Tewari
519c5471ba refactor: self review comments 2026-06-01 11:21:40 +05:30
Gaurav Tewari
68c4b6f724 refactor: queries 2026-06-01 10:32:30 +05:30
Gaurav Tewari
e65a1dd1ce refactor: move all components into one 2026-06-01 10:22:19 +05:30
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
15 changed files with 983 additions and 4 deletions

View File

@@ -1,5 +1,10 @@
// TODO: Improve the styling of the query aggregation container and its components. - @YounixM , @H4ad
$dropdown-base-height: 250px;
$recents-header-height: 30px;
$recent-row-height: 52px;
$max-recents-shown: 5;
.code-mirror-where-clause {
width: 100%;
display: flex;
@@ -117,7 +122,16 @@
width: 100% !important;
max-width: 100% !important;
font-family: 'Space Mono', monospace !important;
min-height: 200px !important;
max-height: $dropdown-base-height !important;
overflow-y: auto !important;
@for $i from 1 through $max-recents-shown {
&:has(> li:nth-of-type(#{$i}) .cm-completionIcon-recent) {
max-height: $dropdown-base-height +
$recents-header-height +
($i * $recent-row-height) !important;
}
}
&::-webkit-scrollbar {
width: 0.3rem;
@@ -133,6 +147,24 @@
background: transparent;
}
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-of-type {
border-top: 0;
}
li {
width: 100% !important;
max-width: 100% !important;
@@ -159,11 +191,107 @@
display: none !important;
}
.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;
}
}
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: var(--bg-ink-200) !important;
color: var(--text-vanilla-400) !important;
border: 1px solid var(--l1-border) !important;
border-radius: 6px !important;
line-height: 24px !important;
.cm-completionLabel {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover,
&[aria-selected='true'] {
background: color-mix(
in srgb,
var(--bg-ink-200) 80%,
var(--text-vanilla-400)
) !important;
color: var(--text-vanilla-400) !important;
}
&:hover .cm-recent-delete,
&[aria-selected='true'] .cm-recent-delete {
opacity: 1;
}
}
li:not(:has(.cm-completionIcon-recent)) .cm-completionLabel {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cm-recent-delete {
margin-left: 8px;
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);
}
}
}
&::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

@@ -45,8 +45,13 @@ import {
import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils';
import { queryExamples } from './constants';
import { combineInitialAndUserExpression } from './utils';
import { queryExamples, SUGGESTIONS_SECTION } from './constants';
import {
combineInitialAndUserExpression,
getRecentOptions,
type RecentsSignal,
renderRecentDeleteButton,
} from './utils';
import './QuerySearch.styles.scss';
@@ -1249,6 +1254,40 @@ function QuerySearch({
};
}
const recentsSignal = dataSource as RecentsSignal;
function combinedSuggestions(
context: CompletionContext,
): CompletionResult | null {
const fullDoc = context.state.doc.toString();
const recentOptions = getRecentOptions(recentsSignal, fullDoc);
const result = autoSuggestions(context);
const sectionedSuggestions = (result?.options || []).map((opt) => ({
...opt,
section: SUGGESTIONS_SECTION,
}));
if (recentOptions.length === 0 && sectionedSuggestions.length === 0) {
return result;
}
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 +1433,14 @@ function QuerySearch({
})}
extensions={[
autocompletion({
override: [autoSuggestions],
override: [combinedSuggestions],
defaultKeymap: true,
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 50,
addToOptions: [
{ render: renderRecentDeleteButton, position: 100 },
],
}),
javascript({ jsx: false, typescript: false }),
EditorView.lineWrapping,

View File

@@ -1,3 +1,8 @@
export const RECENTS_SECTION = { name: 'Recent searches', rank: 1 } as const;
export const SUGGESTIONS_SECTION = { name: 'Suggestions', rank: 2 } as const;
export const RECENTS_DISPLAY_CAP = 5;
export const queryExamples = [
{
label: 'Basic Query',

View File

@@ -1,3 +1,15 @@
import { startCompletion } from '@codemirror/autocomplete';
import type { Completion } from '@codemirror/autocomplete';
import type { EditorView } from '@uiw/react-codemirror';
import dayjs from 'dayjs';
import { normalizeFilterExpression } from 'lib/recentQueries/normalize';
import * as recentQueriesStore from 'lib/recentQueries/store';
import 'utils/timeUtils';
import { RECENTS_DISPLAY_CAP, RECENTS_SECTION } from './constants';
export type RecentsSignal = 'logs' | 'traces' | 'metrics';
export function combineInitialAndUserExpression(
initial: string,
user: string,
@@ -38,3 +50,91 @@ export function getUserExpressionFromCombined(
}
return c;
}
export function getRecentOptions(
signal: RecentsSignal,
fullDoc: string,
): Completion[] {
const all = recentQueriesStore.list(signal);
const normalizedDoc = normalizeFilterExpression(fullDoc);
const matches = all
.filter((e) => {
if (normalizedDoc === '') {
return true;
}
return normalizeFilterExpression(e.filter.expression).includes(
normalizedDoc,
);
})
.slice(0, RECENTS_DISPLAY_CAP);
return matches.map((entry) => ({
label: entry.filter.expression,
type: 'recent' as const,
boost: -50,
section: RECENTS_SECTION,
detail: dayjs(entry.lastUsedAt).fromNow(),
recentId: entry.id,
recentSignal: entry.signal,
apply: (view: EditorView): void => {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: entry.filter.expression,
},
selection: { anchor: entry.filter.expression.length },
});
},
}));
}
export function renderRecentDeleteButton(
completion: Completion,
_state: unknown,
view: EditorView | null,
): Node {
if (completion.type !== 'recent') {
const empty = document.createElement('span');
empty.style.display = 'none';
return empty;
}
const c = completion as Completion & {
recentId?: string;
recentSignal?: RecentsSignal;
};
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 = '×';
queueMicrotask(() => {
if (btn.parentElement) {
btn.parentElement.title = completion.label;
}
});
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);
if (view) {
view.focus();
startCompletion(view);
}
});
return btn;
}

View File

@@ -283,6 +283,15 @@ function GridCardGraph({
},
);
/*
`widget.query` is the dashboard's saved query for this panel — it
does not flow through `useQueryBuilder().stagedQuery`, so the
provider-level save effect in `providers/QueryBuilder.tsx` doesn't
cover this surface. This explicit call ensures dashboard-viewed
queries are surfaced in the user's recents.
useSaveRecentQuery(widget?.query);
*/
const isEmptyLayout = widget?.id === PANEL_TYPES.EMPTY_WIDGET;
if (queryResponse.data && widget.panelTypes === PANEL_TYPES.BAR) {

View File

@@ -62,6 +62,7 @@ function Explorer(): JSX.Element {
handleSetQueryData,
redirectWithQueryBuilderData,
} = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const isAIAssistantEnabled = useIsAIAssistantEnabled();

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,71 @@
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';
function toSignal(dataSource: IBuilderQuery['dataSource']): SignalType | null {
if (
dataSource === 'logs' ||
dataSource === 'traces' ||
dataSource === 'metrics'
) {
return dataSource;
}
return null;
}
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 ?? '',
})),
);
}
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,29 @@
const KEYWORDS_RE =
/\b(AND|OR|NOT|IN|LIKE|ILIKE|CONTAINS|EXISTS|BETWEEN|IS|NULL|TRUE|FALSE)\b/gi;
const QUOTED_RE = /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g;
function processOutsideQuotes(s: string): string {
return s.replace(KEYWORDS_RE, (m) => m.toLowerCase()).replace(/\s+/g, '');
}
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,161 @@
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 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 [];
}
}
function writeToStorage(signal: SignalType, entries: RecentQueryEntry[]): void {
if (!hasLocalStorage()) {
return;
}
try {
window.localStorage.setItem(
storageKey(signal),
JSON.stringify({ version: STORAGE_VERSION, entries }),
);
} catch {
console.warn('Failed to persist recent queries to localStorage');
// Persistence failed; cache still reflects the user's intent in-session.
}
}
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) => normalizeFilterExpression(e.filter.expression) !== normalized,
);
const newEntry: RecentQueryEntry = {
...entry,
id,
lastUsedAt: Date.now(),
};
const next = [newEntry, ...filtered].slice(0, MAX_ENTRIES);
cache.set(entry.signal, next);
writeToStorage(entry.signal, next);
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;
}
cache.set(signal, next);
writeToStorage(signal, next);
notify();
}
export function subscribe(cb: () => void): () => void {
subscribers.add(cb);
return (): void => {
subscribers.delete(cb);
};
}
function parseSignalFromStorageKey(key: string): SignalType | null {
return SIGNALS.find((s) => storageKey(s) === key) ?? null;
}
function handleCrossTabStorageEvent(event: StorageEvent): void {
if (!event.key) {
return;
}
const signal = parseSignalFromStorageKey(event.key);
if (!signal) {
return;
}
cache.set(signal, readFromStorage(signal));
notify();
}
if (
typeof window !== 'undefined' &&
typeof window.addEventListener === 'function'
) {
window.addEventListener('storage', handleCrossTabStorageEvent);
}
// Test-only escape hatch.
export function __resetForTests(): void {
cache.clear();
subscribers.clear();
if (hasLocalStorage()) {
SIGNALS.forEach((s) => window.localStorage.removeItem(storageKey(s)));
}
}
// Test-only escape hatch.
export function __dropCacheForTests(): void {
cache.clear();
}

View File

@@ -0,0 +1,13 @@
import type { Filter, SignalType } from 'types/api/v5/queryRange';
export interface RecentQueryEntry {
id: string;
signal: SignalType;
filter: Filter;
lastUsedAt: number;
}
export interface RecentQueriesStoreShape {
version: 1;
entries: RecentQueryEntry[];
}

View File

@@ -36,6 +36,7 @@ import {
import { OptionsQuery } from 'container/OptionsMenu/types';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
@@ -134,6 +135,8 @@ export function QueryBuilderProvider({
const [lastUsedQuery, setLastUsedQuery] = useState<number | null>(0);
const [stagedQuery, setStagedQuery] = useState<Query | null>(null);
useSaveRecentQuery(stagedQuery);
const [queryType, setQueryType] = useState<EQueryType>(queryTypeParam);
const getElementWithActualOperator = useCallback(

View File

@@ -2,6 +2,7 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
@@ -9,6 +10,7 @@ dayjs.extend(utc);
dayjs.extend(customParseFormat);
dayjs.extend(duration);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
export function toUTCEpoch(time: number): number {
const x = new Date();