Merge branch 'main' into json

This commit is contained in:
Piyush Singariya 2025-09-26 17:23:23 +05:30
commit d1a27fc3cd
17 changed files with 683 additions and 128 deletions

View File

@ -176,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:v0.96.0 image: signoz/signoz:v0.96.1
command: command:
- --config=/root/config/prometheus.yml - --config=/root/config/prometheus.yml
ports: ports:

View File

@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:v0.96.0 image: signoz/signoz:v0.96.1
command: command:
- --config=/root/config/prometheus.yml - --config=/root/config/prometheus.yml
ports: ports:

View File

@ -179,7 +179,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.96.0} image: signoz/signoz:${VERSION:-v0.96.1}
container_name: signoz container_name: signoz
command: command:
- --config=/root/config/prometheus.yml - --config=/root/config/prometheus.yml

View File

@ -111,7 +111,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.96.0} image: signoz/signoz:${VERSION:-v0.96.1}
container_name: signoz container_name: signoz
command: command:
- --config=/root/config/prometheus.yml - --config=/root/config/prometheus.yml

View File

@ -87,7 +87,7 @@ function ChangelogModal({ changelog, onClose }: Props): JSX.Element {
const onClickUpdateWorkspace = (): void => { const onClickUpdateWorkspace = (): void => {
window.open( window.open(
'https://github.com/SigNoz/signoz/releases', 'https://signoz.io/upgrade-path',
'_blank', '_blank',
'noopener,noreferrer', 'noopener,noreferrer',
); );

View File

@ -91,7 +91,7 @@ describe('ChangelogModal', () => {
renderChangelog(); renderChangelog();
fireEvent.click(screen.getByText('Update my workspace')); fireEvent.click(screen.getByText('Update my workspace'));
expect(window.open).toHaveBeenCalledWith( expect(window.open).toHaveBeenCalledWith(
'https://github.com/SigNoz/signoz/releases', 'https://signoz.io/upgrade-path',
'_blank', '_blank',
'noopener,noreferrer', 'noopener,noreferrer',
); );

View File

@ -26,7 +26,7 @@ interface LogsFormatOptionsMenuProps {
config: OptionsMenuConfig; config: OptionsMenuConfig;
} }
export default function LogsFormatOptionsMenu({ function OptionsMenu({
items, items,
selectedOptionFormat, selectedOptionFormat,
config, config,
@ -49,7 +49,6 @@ export default function LogsFormatOptionsMenu({
const [selectedValue, setSelectedValue] = useState<string | null>(null); const [selectedValue, setSelectedValue] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const initialMouseEnterRef = useRef<boolean>(false); const initialMouseEnterRef = useRef<boolean>(false);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const onChange = useCallback( const onChange = useCallback(
(key: LogViewMode) => { (key: LogViewMode) => {
@ -209,7 +208,7 @@ export default function LogsFormatOptionsMenu({
}; };
}, [selectedValue]); }, [selectedValue]);
const popoverContent = ( return (
<div <div
className={cx( className={cx(
'nested-menu-container', 'nested-menu-container',
@ -447,15 +446,30 @@ export default function LogsFormatOptionsMenu({
)} )}
</div> </div>
); );
}
function LogsFormatOptionsMenu({
items,
selectedOptionFormat,
config,
}: LogsFormatOptionsMenuProps): JSX.Element {
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
return ( return (
<Popover <Popover
content={popoverContent} content={
<OptionsMenu
items={items}
selectedOptionFormat={selectedOptionFormat}
config={config}
/>
}
trigger="click" trigger="click"
placement="bottomRight" placement="bottomRight"
arrow={false} arrow={false}
open={isPopoverOpen} open={isPopoverOpen}
onOpenChange={setIsPopoverOpen} onOpenChange={setIsPopoverOpen}
rootClassName="format-options-popover" rootClassName="format-options-popover"
destroyTooltipOnHide
> >
<Button <Button
className="periscope-btn ghost" className="periscope-btn ghost"
@ -465,3 +479,5 @@ export default function LogsFormatOptionsMenu({
</Popover> </Popover>
); );
} }
export default LogsFormatOptionsMenu;

View File

@ -0,0 +1,157 @@
import { FontSize } from 'container/OptionsMenu/types';
import { fireEvent, render, waitFor } from 'tests/test-utils';
import LogsFormatOptionsMenu from '../LogsFormatOptionsMenu';
const mockUpdateFormatting = jest.fn();
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
usePreferenceSync: (): any => ({
preferences: {
columns: [],
formatting: {
maxLines: 2,
format: 'table',
fontSize: 'small',
version: 1,
},
},
loading: false,
error: null,
updateColumns: jest.fn(),
updateFormatting: mockUpdateFormatting,
}),
}));
describe('LogsFormatOptionsMenu (unit)', () => {
beforeEach(() => {
mockUpdateFormatting.mockClear();
});
function setup(): {
getByTestId: ReturnType<typeof render>['getByTestId'];
findItemByLabel: (label: string) => Element | undefined;
formatOnChange: jest.Mock<any, any>;
maxLinesOnChange: jest.Mock<any, any>;
fontSizeOnChange: jest.Mock<any, any>;
} {
const items = [
{ key: 'raw', label: 'Raw', data: { title: 'max lines per row' } },
{ key: 'list', label: 'Default' },
{ key: 'table', label: 'Column', data: { title: 'columns' } },
];
const formatOnChange = jest.fn();
const maxLinesOnChange = jest.fn();
const fontSizeOnChange = jest.fn();
const { getByTestId } = render(
<LogsFormatOptionsMenu
items={items}
selectedOptionFormat="table"
config={{
format: { value: 'table', onChange: formatOnChange },
maxLines: { value: 2, onChange: maxLinesOnChange },
fontSize: { value: FontSize.SMALL, onChange: fontSizeOnChange },
addColumn: {
isFetching: false,
value: [],
options: [],
onFocus: jest.fn(),
onBlur: jest.fn(),
onSearch: jest.fn(),
onSelect: jest.fn(),
onRemove: jest.fn(),
},
}}
/>,
);
// Open the popover menu by default for each test
const formatButton = getByTestId('periscope-btn-format-options');
fireEvent.click(formatButton);
const getMenuItems = (): Element[] =>
Array.from(document.querySelectorAll('.menu-items .item'));
const findItemByLabel = (label: string): Element | undefined =>
getMenuItems().find((el) => (el.textContent || '').includes(label));
return {
getByTestId,
findItemByLabel,
formatOnChange,
maxLinesOnChange,
fontSizeOnChange,
};
}
// Covers: opens menu, changes format selection, updates max-lines, changes font size
it('opens and toggles format selection', async () => {
const { findItemByLabel, formatOnChange } = setup();
// Assert initial selection
const columnItem = findItemByLabel('Column') as Element;
expect(document.querySelectorAll('.menu-items .item svg')).toHaveLength(1);
expect(columnItem.querySelector('svg')).toBeInTheDocument();
// Change selection to 'Raw'
const rawItem = findItemByLabel('Raw') as Element;
fireEvent.click(rawItem as HTMLElement);
await waitFor(() => {
const rawEl = findItemByLabel('Raw') as Element;
expect(document.querySelectorAll('.menu-items .item svg')).toHaveLength(1);
expect(rawEl.querySelector('svg')).toBeInTheDocument();
});
expect(formatOnChange).toHaveBeenCalledWith('raw');
});
it('increments max-lines and calls onChange', async () => {
const { maxLinesOnChange } = setup();
// Increment max lines
const input = document.querySelector(
'.max-lines-per-row-input input',
) as HTMLInputElement;
const initial = Number(input.value);
const buttons = document.querySelectorAll(
'.max-lines-per-row-input .periscope-btn',
);
const incrementBtn = buttons[1] as HTMLElement;
fireEvent.click(incrementBtn);
await waitFor(() => {
expect(Number(input.value)).toBe(initial + 1);
});
await waitFor(() => {
expect(maxLinesOnChange).toHaveBeenCalledWith(initial + 1);
});
});
it('changes font size to MEDIUM and calls onChange', async () => {
const { fontSizeOnChange } = setup();
// Open font dropdown
const fontButton = document.querySelector(
'.font-size-container .value',
) as HTMLElement;
fireEvent.click(fontButton);
// Choose MEDIUM
const optionButtons = Array.from(
document.querySelectorAll('.font-size-dropdown .option-btn'),
);
const mediumBtn = optionButtons[1] as HTMLElement;
fireEvent.click(mediumBtn);
await waitFor(() => {
expect(
document.querySelectorAll('.font-size-dropdown .option-btn .icon'),
).toHaveLength(1);
expect(
(optionButtons[1] as Element).querySelector('.icon'),
).toBeInTheDocument();
});
await waitFor(() => {
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);
});
});
});

View File

@ -97,7 +97,7 @@ function QuerySearch({
onRun?: (query: string) => void; onRun?: (query: string) => void;
}): JSX.Element { }): JSX.Element {
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const [query, setQuery] = useState<string>(''); const [query, setQuery] = useState<string>(queryData.filter?.expression || '');
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]); const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
const [activeKey, setActiveKey] = useState<string>(''); const [activeKey, setActiveKey] = useState<string>('');
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
@ -108,10 +108,6 @@ function QuerySearch({
errors: [], errors: [],
}); });
const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 });
const [isFocused, setIsFocused] = useState(false);
const [hasInteractedWithQB, setHasInteractedWithQB] = useState(false);
const handleQueryValidation = (newQuery: string): void => { const handleQueryValidation = (newQuery: string): void => {
try { try {
const validationResponse = validateQuery(newQuery); const validationResponse = validateQuery(newQuery);
@ -131,28 +127,13 @@ function QuerySearch({
useEffect(() => { useEffect(() => {
const newQuery = queryData.filter?.expression || ''; const newQuery = queryData.filter?.expression || '';
// Only update query from external source when editor is not focused // Only mark as external change if the query actually changed from external source
// When focused, just update the lastExternalQuery to track changes
if (newQuery !== lastExternalQuery) { if (newQuery !== lastExternalQuery) {
setQuery(newQuery); setQuery(newQuery);
setIsExternalQueryChange(true); setIsExternalQueryChange(true);
setLastExternalQuery(newQuery); setLastExternalQuery(newQuery);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [queryData.filter?.expression, lastExternalQuery]);
}, [queryData.filter?.expression]);
useEffect(() => {
// Update the query when the editor is blurred and the query has changed
// Only call onChange if the editor has been focused before (not on initial mount)
if (
!isFocused &&
hasInteractedWithQB &&
query !== queryData.filter?.expression
) {
onChange(query);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFocused]);
// Validate query when it changes externally (from queryData) // Validate query when it changes externally (from queryData)
useEffect(() => { useEffect(() => {
@ -168,6 +149,9 @@ function QuerySearch({
const [showExamples] = useState(false); const [showExamples] = useState(false);
const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 });
const [isFocused, setIsFocused] = useState(false);
const [ const [
isFetchingCompleteValuesList, isFetchingCompleteValuesList,
setIsFetchingCompleteValuesList, setIsFetchingCompleteValuesList,
@ -181,9 +165,6 @@ function QuerySearch({
const lastFetchedKeyRef = useRef<string>(''); const lastFetchedKeyRef = useRef<string>('');
const lastValueRef = useRef<string>(''); const lastValueRef = useRef<string>('');
const isMountedRef = useRef<boolean>(true); const isMountedRef = useRef<boolean>(true);
const [shouldRunQueryPostUpdate, setShouldRunQueryPostUpdate] = useState(
false,
);
const { handleRunQuery } = useQueryBuilder(); const { handleRunQuery } = useQueryBuilder();
@ -229,7 +210,6 @@ function QuerySearch({
return (): void => clearTimeout(timeoutId); return (): void => clearTimeout(timeoutId);
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps
[isFocused], [isFocused],
); );
@ -575,6 +555,7 @@ function QuerySearch({
const handleChange = (value: string): void => { const handleChange = (value: string): void => {
setQuery(value); setQuery(value);
onChange(value);
// Mark as internal change to avoid triggering external validation // Mark as internal change to avoid triggering external validation
setIsExternalQueryChange(false); setIsExternalQueryChange(false);
// Update lastExternalQuery to prevent external validation trigger // Update lastExternalQuery to prevent external validation trigger
@ -1238,25 +1219,6 @@ function QuerySearch({
</div> </div>
); );
// Effect to handle query run after update
useEffect(
() => {
// Only run the query post updating the filter expression.
// This runs the query in the next update cycle of react, when it's guaranteed that the query is updated.
// Because both the things are sequential and react batches the updates so it was still taking the old query.
if (shouldRunQueryPostUpdate) {
if (onRun && typeof onRun === 'function') {
onRun(query);
} else {
handleRunQuery();
}
setShouldRunQueryPostUpdate(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[shouldRunQueryPostUpdate, handleRunQuery, onRun],
);
return ( return (
<div className="code-mirror-where-clause"> <div className="code-mirror-where-clause">
{editingMode && ( {editingMode && (
@ -1331,7 +1293,6 @@ function QuerySearch({
theme={isDarkMode ? copilot : githubLight} theme={isDarkMode ? copilot : githubLight}
onChange={handleChange} onChange={handleChange}
onUpdate={handleUpdate} onUpdate={handleUpdate}
data-testid="query-where-clause-editor"
className={cx('query-where-clause-editor', { className={cx('query-where-clause-editor', {
isValid: validation.isValid === true, isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0, hasErrors: validation.errors.length > 0,
@ -1368,14 +1329,11 @@ function QuerySearch({
// and instead run a custom action // and instead run a custom action
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS // Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => { run: (): boolean => {
if ( if (onRun && typeof onRun === 'function') {
onChange && onRun(query);
typeof onChange === 'function' && } else {
query !== queryData.filter?.expression handleRunQuery();
) {
onChange(query);
} }
setShouldRunQueryPostUpdate(true);
return true; return true;
}, },
}, },
@ -1394,13 +1352,8 @@ function QuerySearch({
}} }}
onFocus={(): void => { onFocus={(): void => {
setIsFocused(true); setIsFocused(true);
setHasInteractedWithQB(true);
}} }}
onBlur={handleBlur} onBlur={handleBlur}
onCreateEditor={(view: EditorView): EditorView => {
editorRef.current = view;
return view;
}}
/> />
{query && validation.isValid === false && !isFocused && ( {query && validation.isValid === false && !isFocused && (

View File

@ -222,28 +222,6 @@ describe('QuerySearch', () => {
expect(screen.getByPlaceholderText(PLACEHOLDER_TEXT)).toBeInTheDocument(); expect(screen.getByPlaceholderText(PLACEHOLDER_TEXT)).toBeInTheDocument();
}); });
it('calls onChange on blur after user edits', async () => {
const handleChange = jest.fn() as jest.MockedFunction<(v: string) => void>;
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={handleChange}
queryData={initialQueriesMap.metrics.builder.queryData[0]}
dataSource={DataSource.METRICS}
/>,
);
const editor = screen.getByTestId(TESTID_EDITOR);
await user.click(editor);
await user.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
// Blur triggers validation + onChange (only if focused at least once and value changed)
editor.blur();
await waitFor(() => expect(handleChange).toHaveBeenCalledTimes(1));
expect(handleChange.mock.calls[0][0]).toContain("service.name = 'frontend'");
});
it('fetches key suggestions when typing a key (debounced)', async () => { it('fetches key suggestions when typing a key (debounced)', async () => {
jest.useFakeTimers(); jest.useFakeTimers();
const advance = (ms: number): void => { const advance = (ms: number): void => {

View File

@ -61,8 +61,6 @@ function RouteTab({
defaultActiveKey={currentRoute?.key || activeKey} defaultActiveKey={currentRoute?.key || activeKey}
animated animated
items={items} items={items}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
tabBarExtraContent={ tabBarExtraContent={
showRightSection && ( showRightSection && (
<HeaderRightSection <HeaderRightSection
@ -72,6 +70,8 @@ function RouteTab({
/> />
) )
} }
// eslint-disable-next-line react/jsx-props-no-spreading ---- TODO: remove this once follow the linting rules
{...rest}
/> />
); );
} }

View File

@ -1,6 +1,6 @@
import './LiveLogsContainer.styles.scss'; import './LiveLogsContainer.styles.scss';
import { Button, Switch, Typography } from 'antd'; import { Switch, Typography } from 'antd';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu'; import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail'; import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail';
import { LOCALSTORAGE } from 'constants/localStorage'; import { LOCALSTORAGE } from 'constants/localStorage';
@ -8,10 +8,8 @@ import GoToTop from 'container/GoToTop';
import { useOptionsMenu } from 'container/OptionsMenu'; import { useOptionsMenu } from 'container/OptionsMenu';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam'; import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useClickOutside from 'hooks/useClickOutside';
import useDebouncedFn from 'hooks/useDebouncedFunction'; import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useEventSourceEvent } from 'hooks/useEventSourceEvent'; import { useEventSourceEvent } from 'hooks/useEventSourceEvent';
import { Sliders } from 'lucide-react';
import { useEventSource } from 'providers/EventSource'; import { useEventSource } from 'providers/EventSource';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
@ -41,9 +39,6 @@ function LiveLogsContainer(): JSX.Element {
const batchedEventsRef = useRef<ILiveLogsLog[]>([]); const batchedEventsRef = useRef<ILiveLogsLog[]>([]);
const [showFormatMenuItems, setShowFormatMenuItems] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const prevFilterExpressionRef = useRef<string | null>(null); const prevFilterExpressionRef = useRef<string | null>(null);
const { options, config } = useOptionsMenu({ const { options, config } = useOptionsMenu({
@ -73,18 +68,6 @@ function LiveLogsContainer(): JSX.Element {
}, },
]; ];
const handleToggleShowFormatOptions = (): void =>
setShowFormatMenuItems(!showFormatMenuItems);
useClickOutside({
ref: menuRef,
onClickOutside: () => {
if (showFormatMenuItems) {
setShowFormatMenuItems(false);
}
},
});
const { const {
handleStartOpenConnection, handleStartOpenConnection,
handleCloseConnection, handleCloseConnection,
@ -231,21 +214,11 @@ function LiveLogsContainer(): JSX.Element {
/> />
</div> </div>
<div className="format-options-container" ref={menuRef}>
<Button
className="periscope-btn ghost"
onClick={handleToggleShowFormatOptions}
icon={<Sliders size={14} />}
/>
{showFormatMenuItems && (
<LogsFormatOptionsMenu <LogsFormatOptionsMenu
items={formatItems} items={formatItems}
selectedOptionFormat={options.format} selectedOptionFormat={options.format}
config={config} config={config}
/> />
)}
</div>
</div> </div>
{showLiveLogsFrequencyChart && ( {showLiveLogsFrequencyChart && (

View File

@ -59,6 +59,7 @@ import {
Query, Query,
TagFilter, TagFilter,
} from 'types/api/queryBuilder/queryBuilderData'; } from 'types/api/queryBuilder/queryBuilderData';
import { Filter } from 'types/api/v5/queryRange';
import { QueryDataV3 } from 'types/api/widgets/getQuery'; import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder'; import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime'; import { GlobalReducer } from 'types/reducer/globalTime';
@ -171,6 +172,11 @@ function LogsExplorerViewsContainer({
return; return;
} }
let updatedFilterExpression = listQuery.filter?.expression || '';
if (activeLogId) {
updatedFilterExpression = `${updatedFilterExpression} id <= '${activeLogId}'`.trim();
}
const modifiedQueryData: IBuilderQuery = { const modifiedQueryData: IBuilderQuery = {
...listQuery, ...listQuery,
aggregateOperator: LogsAggregatorOperator.COUNT, aggregateOperator: LogsAggregatorOperator.COUNT,
@ -183,6 +189,10 @@ function LogsExplorerViewsContainer({
}, },
], ],
legend: '{{severity_text}}', legend: '{{severity_text}}',
filter: {
...listQuery?.filter,
expression: updatedFilterExpression || '',
},
...(activeLogId && { ...(activeLogId && {
filters: { filters: {
...listQuery?.filters, ...listQuery?.filters,
@ -286,6 +296,7 @@ function LogsExplorerViewsContainer({
page: number; page: number;
pageSize: number; pageSize: number;
filters: TagFilter; filters: TagFilter;
filter: Filter;
}, },
): Query | null => { ): Query | null => {
if (!query) return null; if (!query) return null;
@ -297,6 +308,7 @@ function LogsExplorerViewsContainer({
// Add filter for activeLogId if present // Add filter for activeLogId if present
let updatedFilters = params.filters; let updatedFilters = params.filters;
let updatedFilterExpression = params.filter?.expression || '';
if (activeLogId) { if (activeLogId) {
updatedFilters = { updatedFilters = {
...params.filters, ...params.filters,
@ -315,6 +327,7 @@ function LogsExplorerViewsContainer({
], ],
op: 'AND', op: 'AND',
}; };
updatedFilterExpression = `${updatedFilterExpression} id <= '${activeLogId}'`.trim();
} }
// Create orderBy array based on orderDirection // Create orderBy array based on orderDirection
@ -336,6 +349,9 @@ function LogsExplorerViewsContainer({
...(listQuery || initialQueryBuilderFormValues), ...(listQuery || initialQueryBuilderFormValues),
...paginateData, ...paginateData,
...(updatedFilters ? { filters: updatedFilters } : {}), ...(updatedFilters ? { filters: updatedFilters } : {}),
filter: {
expression: updatedFilterExpression || '',
},
...(selectedView === ExplorerViews.LIST ...(selectedView === ExplorerViews.LIST
? { order: newOrderBy, orderBy: newOrderBy } ? { order: newOrderBy, orderBy: newOrderBy }
: { order: [] }), : { order: [] }),
@ -368,7 +384,7 @@ function LogsExplorerViewsContainer({
if (isLimit) return; if (isLimit) return;
if (logs.length < pageSize) return; if (logs.length < pageSize) return;
const { limit, filters } = listQuery; const { limit, filters, filter } = listQuery;
const nextLogsLength = logs.length + pageSize; const nextLogsLength = logs.length + pageSize;
@ -379,6 +395,7 @@ function LogsExplorerViewsContainer({
const newRequestData = getRequestData(stagedQuery, { const newRequestData = getRequestData(stagedQuery, {
filters: filters || { items: [], op: 'AND' }, filters: filters || { items: [], op: 'AND' },
filter: filter || { expression: '' },
page: page + 1, page: page + 1,
pageSize: nextPageSize, pageSize: nextPageSize,
}); });
@ -526,6 +543,7 @@ function LogsExplorerViewsContainer({
const newRequestData = getRequestData(stagedQuery, { const newRequestData = getRequestData(stagedQuery, {
filters: listQuery?.filters || initialFilters, filters: listQuery?.filters || initialFilters,
filter: listQuery?.filter || { expression: '' },
page: 1, page: 1,
pageSize, pageSize,
}); });

View File

@ -1,3 +1,4 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink'; import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange'; import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
@ -261,6 +262,68 @@ describe('LogsExplorerViews -', () => {
// Verify the total number of filters (original + 1 new activeLogId filter) // Verify the total number of filters (original + 1 new activeLogId filter)
expect(firstQuery.filters?.items.length).toBe(expectedFiltersLength); expect(firstQuery.filters?.items.length).toBe(expectedFiltersLength);
// Verify the filter expression
expect(firstQuery.filter?.expression).toBe(`id <= '${ACTIVE_LOG_ID}'`);
}
});
});
it('should update filter expression with activeLogId when present with existing filter expression', async () => {
// Mock useCopyLogLink to return an activeLogId
(useCopyLogLink as jest.Mock).mockReturnValue({
activeLogId: ACTIVE_LOG_ID,
});
// Create a custom QueryBuilderContext with an existing filter expression
const customContext = {
...mockQueryBuilderContextValue,
panelType: PANEL_TYPES.LIST,
stagedQuery: {
...mockQueryBuilderContextValue.stagedQuery,
builder: {
...mockQueryBuilderContextValue.stagedQuery.builder,
queryData: [
{
...mockQueryBuilderContextValue.stagedQuery.builder.queryData[0],
filter: { expression: "service = 'frontend'" },
},
],
},
},
};
lodsQueryServerRequest();
render(
<QueryBuilderContext.Provider value={customContext as any}>
<PreferenceContextProvider>
<LogsExplorerViews
selectedView={ExplorerViews.LIST}
setIsLoadingQueries={(): void => {}}
listQueryKeyRef={{ current: {} }}
chartQueryKeyRef={{ current: {} }}
setWarning={(): void => {}}
showLiveLogs={false}
/>
</PreferenceContextProvider>
</QueryBuilderContext.Provider>,
);
await waitFor(() => {
// Find the call made for LIST panel type (main logs list request)
const listCall = (useGetExplorerQueryRange as jest.Mock).mock.calls.find(
(call) => call[1] === PANEL_TYPES.LIST && call[0],
);
expect(listCall).toBeDefined();
if (listCall) {
const queryArg = listCall[0];
const firstQuery = queryArg.builder.queryData[0];
// It should append the activeLogId condition to existing expression
expect(firstQuery.filter?.expression).toBe(
"service = 'frontend' id <= 'test-log-id'",
);
} }
}); });
}); });

View File

@ -1675,7 +1675,7 @@ func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *m
queries = append(queries, fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY COLUMN _retention_days_cold UInt16 DEFAULT %d`, queries = append(queries, fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY COLUMN _retention_days_cold UInt16 DEFAULT %d`,
tableNames[0], r.cluster, coldStorageDuration)) tableNames[0], r.cluster, coldStorageDuration))
queries = append(queries, fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY TTL toDateTime(timestamp / 1000000000) + toIntervalDay(_retention_days) DELETE, toDateTime(timestamp / 1000000000) + toIntervalDay(_retention_days_cold) TO VOLUME '%s'`, queries = append(queries, fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY TTL toDateTime(timestamp / 1000000000) + toIntervalDay(_retention_days) DELETE, toDateTime(timestamp / 1000000000) + toIntervalDay(_retention_days_cold) TO VOLUME '%s' SETTINGS materialize_ttl_after_modify=0`,
tableNames[0], r.cluster, params.ColdStorageVolume)) tableNames[0], r.cluster, params.ColdStorageVolume))
} }
@ -1690,7 +1690,7 @@ func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *m
resourceQueries = append(resourceQueries, fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY COLUMN _retention_days_cold UInt16 DEFAULT %d`, resourceQueries = append(resourceQueries, fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY COLUMN _retention_days_cold UInt16 DEFAULT %d`,
tableNames[1], r.cluster, coldStorageDuration)) tableNames[1], r.cluster, coldStorageDuration))
resourceQueries = append(resourceQueries, fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY TTL toDateTime(seen_at_ts_bucket_start) + toIntervalSecond(1800) + toIntervalDay(_retention_days) DELETE, toDateTime(seen_at_ts_bucket_start) + toIntervalSecond(1800) + toIntervalDay(_retention_days_cold) TO VOLUME '%s'`, resourceQueries = append(resourceQueries, fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY TTL toDateTime(seen_at_ts_bucket_start) + toIntervalSecond(1800) + toIntervalDay(_retention_days) DELETE, toDateTime(seen_at_ts_bucket_start) + toIntervalSecond(1800) + toIntervalDay(_retention_days_cold) TO VOLUME '%s' SETTINGS materialize_ttl_after_modify=0`,
tableNames[1], r.cluster, params.ColdStorageVolume)) tableNames[1], r.cluster, params.ColdStorageVolume))
} }

View File

@ -481,6 +481,69 @@ func (r *QueryRangeRequest) Validate() error {
return err return err
} }
// Check if all queries are disabled
if err := r.validateAllQueriesNotDisabled(); err != nil {
return err
}
return nil
}
// validateAllQueriesNotDisabled validates that at least one query in the composite query is enabled
func (r *QueryRangeRequest) validateAllQueriesNotDisabled() error {
allDisabled := true
for _, envelope := range r.CompositeQuery.Queries {
switch envelope.Type {
case QueryTypeBuilder, QueryTypeSubQuery:
switch spec := envelope.Spec.(type) {
case QueryBuilderQuery[TraceAggregation]:
if !spec.Disabled {
allDisabled = false
}
case QueryBuilderQuery[LogAggregation]:
if !spec.Disabled {
allDisabled = false
}
case QueryBuilderQuery[MetricAggregation]:
if !spec.Disabled {
allDisabled = false
}
}
case QueryTypeFormula:
if spec, ok := envelope.Spec.(QueryBuilderFormula); ok && !spec.Disabled {
allDisabled = false
}
case QueryTypeTraceOperator:
if spec, ok := envelope.Spec.(QueryBuilderTraceOperator); ok && !spec.Disabled {
allDisabled = false
}
case QueryTypeJoin:
if spec, ok := envelope.Spec.(QueryBuilderJoin); ok && !spec.Disabled {
allDisabled = false
}
case QueryTypePromQL:
if spec, ok := envelope.Spec.(PromQuery); ok && !spec.Disabled {
allDisabled = false
}
case QueryTypeClickHouseSQL:
if spec, ok := envelope.Spec.(ClickHouseQuery); ok && !spec.Disabled {
allDisabled = false
}
}
// Early exit if we find at least one enabled query
if !allDisabled {
break
}
}
if allDisabled {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"all queries are disabled - at least one query must be enabled",
)
}
return nil return nil
} }

View File

@ -0,0 +1,334 @@
package querybuildertypesv5
import (
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}
func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
tests := []struct {
name string
request QueryRangeRequest
wantErr bool
errMsg string
}{
{
name: "all queries disabled should return error",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalMetrics,
},
},
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[LogAggregation]{
Name: "B",
Disabled: true,
Signal: telemetrytypes.SignalLogs,
},
},
},
},
},
wantErr: true,
errMsg: "all queries are disabled - at least one query must be enabled",
},
{
name: "mixed disabled and enabled queries should pass",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalMetrics,
},
},
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[LogAggregation]{
Name: "B",
Disabled: false,
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{
Expression: "count()",
},
},
},
},
},
},
},
wantErr: false,
},
{
name: "all queries enabled should pass",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[LogAggregation]{
Name: "A",
Disabled: false,
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{
Expression: "count()",
},
},
},
},
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[LogAggregation]{
Name: "B",
Disabled: false,
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{
Expression: "sum(duration)",
},
},
},
},
},
},
},
wantErr: false,
},
{
name: "all formula queries disabled should return error",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeFormula,
Spec: QueryBuilderFormula{
Name: "F1",
Expression: "A + B",
Disabled: true,
},
},
{
Type: QueryTypeFormula,
Spec: QueryBuilderFormula{
Name: "F2",
Expression: "A * 2",
Disabled: true,
},
},
},
},
},
wantErr: true,
errMsg: "all queries are disabled - at least one query must be enabled",
},
{
name: "all PromQL queries disabled should return error",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypePromQL,
Spec: PromQuery{
Name: "P1",
Query: "up",
Disabled: true,
},
},
{
Type: QueryTypePromQL,
Spec: PromQuery{
Name: "P2",
Query: "rate(http_requests_total[5m])",
Disabled: true,
},
},
},
},
},
wantErr: true,
errMsg: "all queries are disabled - at least one query must be enabled",
},
{
name: "mixed query types with all disabled should return error",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalMetrics,
},
},
{
Type: QueryTypeFormula,
Spec: QueryBuilderFormula{
Name: "F1",
Expression: "A + 1",
Disabled: true,
},
},
{
Type: QueryTypePromQL,
Spec: PromQuery{
Name: "P1",
Query: "up",
Disabled: true,
},
},
},
},
},
wantErr: true,
errMsg: "all queries are disabled - at least one query must be enabled",
},
{
name: "single disabled query should return error",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[LogAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalLogs,
},
},
},
},
},
wantErr: true,
errMsg: "all queries are disabled - at least one query must be enabled",
},
{
name: "single enabled query should pass",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[LogAggregation]{
Name: "A",
Disabled: false,
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{
Expression: "count()",
},
},
},
},
},
},
},
wantErr: false,
},
{
name: "all ClickHouse queries disabled should return error",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeClickHouseSQL,
Spec: ClickHouseQuery{
Name: "CH1",
Query: "SELECT count() FROM logs",
Disabled: true,
},
},
},
},
},
wantErr: true,
errMsg: "all queries are disabled - at least one query must be enabled",
},
{
name: "all trace operator queries disabled should return error",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeTraceOperator,
Spec: QueryBuilderTraceOperator{
Name: "TO1",
Expression: "count()",
Disabled: true,
},
},
},
},
},
wantErr: true,
errMsg: "all queries are disabled - at least one query must be enabled",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.request.Validate()
if tt.wantErr {
if err == nil {
t.Errorf("QueryRangeRequest.Validate() expected error but got none")
return
}
if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) {
t.Errorf("QueryRangeRequest.Validate() error = %v, want to contain %v", err.Error(), tt.errMsg)
}
} else {
if err != nil {
t.Errorf("QueryRangeRequest.Validate() unexpected error = %v", err)
}
}
})
}
}