mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-01 14:50:29 +01:00
Compare commits
16 Commits
mute-rules
...
feat/react
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
100067d8dc | ||
|
|
b883a09c78 | ||
|
|
96f2160119 | ||
|
|
1824acc860 | ||
|
|
fd9bc7c42e | ||
|
|
68d17dd164 | ||
|
|
7568a1a1e1 | ||
|
|
8c28035e6e | ||
|
|
3d9dd945f6 | ||
|
|
3b8bcdecaa | ||
|
|
4b54affb10 | ||
|
|
519c5471ba | ||
|
|
68c4b6f724 | ||
|
|
e65a1dd1ce | ||
|
|
8d2e024cb2 | ||
|
|
90cb4e99f9 |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -62,6 +62,7 @@ function Explorer(): JSX.Element {
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
144
frontend/src/hooks/recentQueries/useSaveRecentQuery.test.tsx
Normal file
144
frontend/src/hooks/recentQueries/useSaveRecentQuery.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
71
frontend/src/hooks/recentQueries/useSaveRecentQuery.ts
Normal file
71
frontend/src/hooks/recentQueries/useSaveRecentQuery.ts
Normal 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]);
|
||||
}
|
||||
65
frontend/src/lib/recentQueries/normalize.test.ts
Normal file
65
frontend/src/lib/recentQueries/normalize.test.ts
Normal 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"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
29
frontend/src/lib/recentQueries/normalize.ts
Normal file
29
frontend/src/lib/recentQueries/normalize.ts
Normal 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();
|
||||
}
|
||||
206
frontend/src/lib/recentQueries/store.test.ts
Normal file
206
frontend/src/lib/recentQueries/store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
161
frontend/src/lib/recentQueries/store.ts
Normal file
161
frontend/src/lib/recentQueries/store.ts
Normal 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();
|
||||
}
|
||||
13
frontend/src/lib/recentQueries/types.ts
Normal file
13
frontend/src/lib/recentQueries/types.ts
Normal 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[];
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user