diff --git a/deploy/docker-swarm/docker-compose.ha.yaml b/deploy/docker-swarm/docker-compose.ha.yaml index 3d7fb6a7a7bc..56537d57db39 100644 --- a/deploy/docker-swarm/docker-compose.ha.yaml +++ b/deploy/docker-swarm/docker-compose.ha.yaml @@ -176,7 +176,7 @@ services: # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml signoz: !!merge <<: *db-depend - image: signoz/signoz:v0.96.0 + image: signoz/signoz:v0.96.1 command: - --config=/root/config/prometheus.yml ports: diff --git a/deploy/docker-swarm/docker-compose.yaml b/deploy/docker-swarm/docker-compose.yaml index c29b0a93c572..46f136b7aa8f 100644 --- a/deploy/docker-swarm/docker-compose.yaml +++ b/deploy/docker-swarm/docker-compose.yaml @@ -117,7 +117,7 @@ services: # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml signoz: !!merge <<: *db-depend - image: signoz/signoz:v0.96.0 + image: signoz/signoz:v0.96.1 command: - --config=/root/config/prometheus.yml ports: diff --git a/deploy/docker/docker-compose.ha.yaml b/deploy/docker/docker-compose.ha.yaml index d24ec442dc5d..2faeed24feff 100644 --- a/deploy/docker/docker-compose.ha.yaml +++ b/deploy/docker/docker-compose.ha.yaml @@ -179,7 +179,7 @@ services: # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml signoz: !!merge <<: *db-depend - image: signoz/signoz:${VERSION:-v0.96.0} + image: signoz/signoz:${VERSION:-v0.96.1} container_name: signoz command: - --config=/root/config/prometheus.yml diff --git a/deploy/docker/docker-compose.yaml b/deploy/docker/docker-compose.yaml index 3639e312dc8a..66e7433b7688 100644 --- a/deploy/docker/docker-compose.yaml +++ b/deploy/docker/docker-compose.yaml @@ -111,7 +111,7 @@ services: # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml signoz: !!merge <<: *db-depend - image: signoz/signoz:${VERSION:-v0.96.0} + image: signoz/signoz:${VERSION:-v0.96.1} container_name: signoz command: - --config=/root/config/prometheus.yml diff --git a/frontend/src/components/ChangelogModal/ChangelogModal.tsx b/frontend/src/components/ChangelogModal/ChangelogModal.tsx index 129ca9897188..586563ccfb08 100644 --- a/frontend/src/components/ChangelogModal/ChangelogModal.tsx +++ b/frontend/src/components/ChangelogModal/ChangelogModal.tsx @@ -87,7 +87,7 @@ function ChangelogModal({ changelog, onClose }: Props): JSX.Element { const onClickUpdateWorkspace = (): void => { window.open( - 'https://github.com/SigNoz/signoz/releases', + 'https://signoz.io/upgrade-path', '_blank', 'noopener,noreferrer', ); diff --git a/frontend/src/components/ChangelogModal/__test__/ChangelogModal.test.tsx b/frontend/src/components/ChangelogModal/__test__/ChangelogModal.test.tsx index da92fd6affd6..36d43da61199 100644 --- a/frontend/src/components/ChangelogModal/__test__/ChangelogModal.test.tsx +++ b/frontend/src/components/ChangelogModal/__test__/ChangelogModal.test.tsx @@ -91,7 +91,7 @@ describe('ChangelogModal', () => { renderChangelog(); fireEvent.click(screen.getByText('Update my workspace')); expect(window.open).toHaveBeenCalledWith( - 'https://github.com/SigNoz/signoz/releases', + 'https://signoz.io/upgrade-path', '_blank', 'noopener,noreferrer', ); diff --git a/frontend/src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.tsx b/frontend/src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.tsx index f37c2be1eb9e..7d2be2066d50 100644 --- a/frontend/src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.tsx +++ b/frontend/src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.tsx @@ -26,7 +26,7 @@ interface LogsFormatOptionsMenuProps { config: OptionsMenuConfig; } -export default function LogsFormatOptionsMenu({ +function OptionsMenu({ items, selectedOptionFormat, config, @@ -49,7 +49,6 @@ export default function LogsFormatOptionsMenu({ const [selectedValue, setSelectedValue] = useState(null); const listRef = useRef(null); const initialMouseEnterRef = useRef(false); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); const onChange = useCallback( (key: LogViewMode) => { @@ -209,7 +208,7 @@ export default function LogsFormatOptionsMenu({ }; }, [selectedValue]); - const popoverContent = ( + return (
); +} + +function LogsFormatOptionsMenu({ + items, + selectedOptionFormat, + config, +}: LogsFormatOptionsMenuProps): JSX.Element { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); return ( + } trigger="click" placement="bottomRight" arrow={false} open={isPopoverOpen} onOpenChange={setIsPopoverOpen} rootClassName="format-options-popover" + destroyTooltipOnHide >
); - // 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 (
{editingMode && ( @@ -1331,7 +1293,6 @@ function QuerySearch({ theme={isDarkMode ? copilot : githubLight} onChange={handleChange} onUpdate={handleUpdate} - data-testid="query-where-clause-editor" className={cx('query-where-clause-editor', { isValid: validation.isValid === true, hasErrors: validation.errors.length > 0, @@ -1368,14 +1329,11 @@ function QuerySearch({ // and instead run a custom action // Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS run: (): boolean => { - if ( - onChange && - typeof onChange === 'function' && - query !== queryData.filter?.expression - ) { - onChange(query); + if (onRun && typeof onRun === 'function') { + onRun(query); + } else { + handleRunQuery(); } - setShouldRunQueryPostUpdate(true); return true; }, }, @@ -1394,13 +1352,8 @@ function QuerySearch({ }} onFocus={(): void => { setIsFocused(true); - setHasInteractedWithQB(true); }} onBlur={handleBlur} - onCreateEditor={(view: EditorView): EditorView => { - editorRef.current = view; - return view; - }} /> {query && validation.isValid === false && !isFocused && ( diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/__tests__/QuerySearch.test.tsx b/frontend/src/components/QueryBuilderV2/QueryV2/__tests__/QuerySearch.test.tsx index b3862283f6b3..290ef518b5ec 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/__tests__/QuerySearch.test.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryV2/__tests__/QuerySearch.test.tsx @@ -222,28 +222,6 @@ describe('QuerySearch', () => { 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( - , - ); - - 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 () => { jest.useFakeTimers(); const advance = (ms: number): void => { diff --git a/frontend/src/components/RouteTab/index.tsx b/frontend/src/components/RouteTab/index.tsx index 43d652b2e4ab..9a8aec7ae5c3 100644 --- a/frontend/src/components/RouteTab/index.tsx +++ b/frontend/src/components/RouteTab/index.tsx @@ -61,8 +61,6 @@ function RouteTab({ defaultActiveKey={currentRoute?.key || activeKey} animated items={items} - // eslint-disable-next-line react/jsx-props-no-spreading - {...rest} tabBarExtraContent={ showRightSection && ( ) } + // eslint-disable-next-line react/jsx-props-no-spreading ---- TODO: remove this once follow the linting rules + {...rest} /> ); } diff --git a/frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx b/frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx index d189edfcb728..0c2ecbf53c87 100644 --- a/frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx +++ b/frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx @@ -1,6 +1,6 @@ import './LiveLogsContainer.styles.scss'; -import { Button, Switch, Typography } from 'antd'; +import { Switch, Typography } from 'antd'; import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu'; import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail'; import { LOCALSTORAGE } from 'constants/localStorage'; @@ -8,10 +8,8 @@ import GoToTop from 'container/GoToTop'; import { useOptionsMenu } from 'container/OptionsMenu'; import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; -import useClickOutside from 'hooks/useClickOutside'; import useDebouncedFn from 'hooks/useDebouncedFunction'; import { useEventSourceEvent } from 'hooks/useEventSourceEvent'; -import { Sliders } from 'lucide-react'; import { useEventSource } from 'providers/EventSource'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; @@ -41,9 +39,6 @@ function LiveLogsContainer(): JSX.Element { const batchedEventsRef = useRef([]); - const [showFormatMenuItems, setShowFormatMenuItems] = useState(false); - const menuRef = useRef(null); - const prevFilterExpressionRef = useRef(null); 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 { handleStartOpenConnection, handleCloseConnection, @@ -231,21 +214,11 @@ function LiveLogsContainer(): JSX.Element { />
-
-
+ {showLiveLogsFrequencyChart && ( diff --git a/frontend/src/container/LogsExplorerViews/index.tsx b/frontend/src/container/LogsExplorerViews/index.tsx index aaff83ab3d42..8063bc62b630 100644 --- a/frontend/src/container/LogsExplorerViews/index.tsx +++ b/frontend/src/container/LogsExplorerViews/index.tsx @@ -59,6 +59,7 @@ import { Query, TagFilter, } from 'types/api/queryBuilder/queryBuilderData'; +import { Filter } from 'types/api/v5/queryRange'; import { QueryDataV3 } from 'types/api/widgets/getQuery'; import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder'; import { GlobalReducer } from 'types/reducer/globalTime'; @@ -171,6 +172,11 @@ function LogsExplorerViewsContainer({ return; } + let updatedFilterExpression = listQuery.filter?.expression || ''; + if (activeLogId) { + updatedFilterExpression = `${updatedFilterExpression} id <= '${activeLogId}'`.trim(); + } + const modifiedQueryData: IBuilderQuery = { ...listQuery, aggregateOperator: LogsAggregatorOperator.COUNT, @@ -183,6 +189,10 @@ function LogsExplorerViewsContainer({ }, ], legend: '{{severity_text}}', + filter: { + ...listQuery?.filter, + expression: updatedFilterExpression || '', + }, ...(activeLogId && { filters: { ...listQuery?.filters, @@ -286,6 +296,7 @@ function LogsExplorerViewsContainer({ page: number; pageSize: number; filters: TagFilter; + filter: Filter; }, ): Query | null => { if (!query) return null; @@ -297,6 +308,7 @@ function LogsExplorerViewsContainer({ // Add filter for activeLogId if present let updatedFilters = params.filters; + let updatedFilterExpression = params.filter?.expression || ''; if (activeLogId) { updatedFilters = { ...params.filters, @@ -315,6 +327,7 @@ function LogsExplorerViewsContainer({ ], op: 'AND', }; + updatedFilterExpression = `${updatedFilterExpression} id <= '${activeLogId}'`.trim(); } // Create orderBy array based on orderDirection @@ -336,6 +349,9 @@ function LogsExplorerViewsContainer({ ...(listQuery || initialQueryBuilderFormValues), ...paginateData, ...(updatedFilters ? { filters: updatedFilters } : {}), + filter: { + expression: updatedFilterExpression || '', + }, ...(selectedView === ExplorerViews.LIST ? { order: newOrderBy, orderBy: newOrderBy } : { order: [] }), @@ -368,7 +384,7 @@ function LogsExplorerViewsContainer({ if (isLimit) return; if (logs.length < pageSize) return; - const { limit, filters } = listQuery; + const { limit, filters, filter } = listQuery; const nextLogsLength = logs.length + pageSize; @@ -379,6 +395,7 @@ function LogsExplorerViewsContainer({ const newRequestData = getRequestData(stagedQuery, { filters: filters || { items: [], op: 'AND' }, + filter: filter || { expression: '' }, page: page + 1, pageSize: nextPageSize, }); @@ -526,6 +543,7 @@ function LogsExplorerViewsContainer({ const newRequestData = getRequestData(stagedQuery, { filters: listQuery?.filters || initialFilters, + filter: listQuery?.filter || { expression: '' }, page: 1, pageSize, }); diff --git a/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx b/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx index bf9690fb7651..f5a72e2ce9c8 100644 --- a/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx +++ b/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx @@ -1,3 +1,4 @@ +import { PANEL_TYPES } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; import { useCopyLogLink } from 'hooks/logs/useCopyLogLink'; import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange'; @@ -261,6 +262,68 @@ describe('LogsExplorerViews -', () => { // Verify the total number of filters (original + 1 new activeLogId filter) 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( + + + {}} + listQueryKeyRef={{ current: {} }} + chartQueryKeyRef={{ current: {} }} + setWarning={(): void => {}} + showLiveLogs={false} + /> + + , + ); + + 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'", + ); } }); }); diff --git a/pkg/query-service/app/clickhouseReader/reader.go b/pkg/query-service/app/clickhouseReader/reader.go index c4183455a080..21ecad5656f8 100644 --- a/pkg/query-service/app/clickhouseReader/reader.go +++ b/pkg/query-service/app/clickhouseReader/reader.go @@ -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`, 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)) } @@ -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`, 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)) } diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/validation.go b/pkg/types/querybuildertypes/querybuildertypesv5/validation.go index 3d1a84de61c9..e44cef4064e6 100644 --- a/pkg/types/querybuildertypes/querybuildertypesv5/validation.go +++ b/pkg/types/querybuildertypes/querybuildertypesv5/validation.go @@ -481,6 +481,69 @@ func (r *QueryRangeRequest) Validate() error { 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 } diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/validation_test.go b/pkg/types/querybuildertypes/querybuildertypesv5/validation_test.go new file mode 100644 index 000000000000..37e6f1c00640 --- /dev/null +++ b/pkg/types/querybuildertypes/querybuildertypesv5/validation_test.go @@ -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) + } + } + }) + } +}