mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-18 01:10:41 +01:00
Compare commits
5 Commits
feat/query
...
issue-2946
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83402ebea8 | ||
|
|
1285e0a5f2 | ||
|
|
f82a9ee528 | ||
|
|
9f03fea0f3 | ||
|
|
d1a382945c |
@@ -11012,9 +11012,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration:list
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration:list
|
||||
summary: List accounts
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11069,9 +11069,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration:create
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration:create
|
||||
summary: Create account
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11114,9 +11114,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration:delete
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration:delete
|
||||
summary: Disconnect account
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11182,9 +11182,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration:read
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration:read
|
||||
summary: Get account
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11231,9 +11231,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration:update
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration:update
|
||||
summary: Update account
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11289,9 +11289,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration-service:list
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration-service:list
|
||||
summary: List account services metadata
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11364,9 +11364,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration-service:read
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration-service:read
|
||||
summary: Get service for account
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11418,9 +11418,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration-service:update
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration-service:update
|
||||
summary: Update service
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11528,9 +11528,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration:create
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration:create
|
||||
summary: Get connection credentials
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11580,10 +11580,8 @@ paths:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- api_key: []
|
||||
- tokenizer: []
|
||||
summary: List services metadata
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11638,10 +11636,8 @@ paths:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- api_key: []
|
||||
- tokenizer: []
|
||||
summary: Get service
|
||||
tags:
|
||||
- cloudintegration
|
||||
|
||||
@@ -17,7 +17,6 @@ function InputWithLabel({
|
||||
onChange,
|
||||
className,
|
||||
closeIcon,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
initialValue?: string | number | null;
|
||||
@@ -28,7 +27,6 @@ function InputWithLabel({
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
closeIcon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState<string>(
|
||||
initialValue ? initialValue.toString() : '',
|
||||
@@ -55,7 +53,6 @@ function InputWithLabel({
|
||||
type={type}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
name={label.toLowerCase()}
|
||||
data-testid={`input-${label}`}
|
||||
/>
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { OPERATORS, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Formula } from 'container/QueryBuilder/components/Formula';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderField } from './queryBuilderFields.types';
|
||||
import {
|
||||
mergeQueryBuilderFieldsConfig,
|
||||
RAW_QUERY_FIELDS,
|
||||
resolveQueryBuilderField,
|
||||
} from './queryBuilderFields.utils';
|
||||
import { QueryBuilderV2Provider } from './QueryBuilderV2Context';
|
||||
import { clearPreviousQuery } from './QueryV2/previousQuery.utils';
|
||||
import QueryFooter from './QueryV2/QueryFooter/QueryFooter';
|
||||
@@ -21,18 +14,12 @@ import TraceOperator from './QueryV2/TraceOperator/TraceOperator';
|
||||
|
||||
import './QueryBuilderV2.styles.scss';
|
||||
|
||||
// Raw rows come from logs or spans; metrics only exist aggregated.
|
||||
const RAW_QUERY_SIGNALS = [
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
];
|
||||
|
||||
export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
config,
|
||||
panelType: newPanelType,
|
||||
fieldsConfig,
|
||||
allowedDataSources,
|
||||
isRawQuery = false,
|
||||
filterConfigs = {},
|
||||
queryComponents,
|
||||
isListViewPanel = false,
|
||||
showOnlyWhereClause = false,
|
||||
showTraceOperator = false,
|
||||
version,
|
||||
@@ -84,48 +71,55 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resolvedConfig = useMemo(
|
||||
() =>
|
||||
mergeQueryBuilderFieldsConfig(
|
||||
isRawQuery ? RAW_QUERY_FIELDS : undefined,
|
||||
fieldsConfig,
|
||||
),
|
||||
[isRawQuery, fieldsConfig],
|
||||
);
|
||||
|
||||
const additionalQueries = useMemo(
|
||||
() =>
|
||||
resolveQueryBuilderField(
|
||||
QueryBuilderField.AdditionalQueries,
|
||||
resolvedConfig,
|
||||
),
|
||||
[resolvedConfig],
|
||||
);
|
||||
|
||||
const formula = useMemo(
|
||||
() => resolveQueryBuilderField(QueryBuilderField.Formula, resolvedConfig),
|
||||
[resolvedConfig],
|
||||
);
|
||||
|
||||
const isMultiQueryAllowed = useMemo(
|
||||
() => !additionalQueries.hidden && (!isRawQuery || showTraceOperator),
|
||||
[additionalQueries.hidden, showTraceOperator, isRawQuery],
|
||||
() => !isListViewPanel || showTraceOperator,
|
||||
[showTraceOperator, isListViewPanel],
|
||||
);
|
||||
|
||||
const queryDataSources = useMemo(
|
||||
() => allowedDataSources ?? (isRawQuery ? RAW_QUERY_SIGNALS : undefined),
|
||||
[allowedDataSources, isRawQuery],
|
||||
);
|
||||
const listViewLogFilterConfigs: QueryBuilderProps['filterConfigs'] =
|
||||
useMemo(() => {
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
filters: {
|
||||
customKey: 'body',
|
||||
customOp: OPERATORS.CONTAINS,
|
||||
},
|
||||
};
|
||||
|
||||
// What the editor renders. A single-query builder edits the first query alone, so
|
||||
// the query list beside it must not advertise ones there is no way to reach.
|
||||
const renderedQueries = useMemo(
|
||||
() =>
|
||||
isMultiQueryAllowed
|
||||
? currentQuery.builder.queryData
|
||||
: currentQuery.builder.queryData.slice(0, 1),
|
||||
[isMultiQueryAllowed, currentQuery.builder.queryData],
|
||||
);
|
||||
return config;
|
||||
}, []);
|
||||
|
||||
const listViewTracesFilterConfigs: QueryBuilderProps['filterConfigs'] =
|
||||
useMemo(() => {
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
limit: { isHidden: true, isDisabled: true },
|
||||
filters: {
|
||||
customKey: 'body',
|
||||
customOp: OPERATORS.CONTAINS,
|
||||
},
|
||||
};
|
||||
|
||||
return config;
|
||||
}, []);
|
||||
|
||||
const queryFilterConfigs = useMemo(() => {
|
||||
if (isListViewPanel) {
|
||||
return currentQuery.builder.queryData[0].dataSource === DataSource.TRACES
|
||||
? listViewTracesFilterConfigs
|
||||
: listViewLogFilterConfigs;
|
||||
}
|
||||
|
||||
return filterConfigs;
|
||||
}, [
|
||||
isListViewPanel,
|
||||
filterConfigs,
|
||||
currentQuery.builder.queryData,
|
||||
listViewLogFilterConfigs,
|
||||
listViewTracesFilterConfigs,
|
||||
]);
|
||||
|
||||
const traceOperator = useMemo((): IBuilderTraceOperator | undefined => {
|
||||
if (
|
||||
@@ -151,46 +145,31 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
|
||||
);
|
||||
|
||||
const shouldShowFooter = useMemo(
|
||||
() =>
|
||||
(!showOnlyWhereClause && !isListViewPanel) ||
|
||||
(currentDataSource === DataSource.TRACES && showTraceOperator),
|
||||
[isListViewPanel, showTraceOperator, showOnlyWhereClause, currentDataSource],
|
||||
);
|
||||
|
||||
const showQueryList = useMemo(
|
||||
() => (!showOnlyWhereClause && !isRawQuery) || showTraceOperator,
|
||||
[isRawQuery, showOnlyWhereClause, showTraceOperator],
|
||||
() => (!showOnlyWhereClause && !isListViewPanel) || showTraceOperator,
|
||||
[isListViewPanel, showOnlyWhereClause, showTraceOperator],
|
||||
);
|
||||
|
||||
const showFormula = useMemo(() => {
|
||||
if (formula.hidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentDataSource === DataSource.TRACES) {
|
||||
return !isRawQuery;
|
||||
return !isListViewPanel;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [formula.hidden, isRawQuery, currentDataSource]);
|
||||
}, [isListViewPanel, currentDataSource]);
|
||||
|
||||
const showAddTraceOperator = useMemo(
|
||||
() => showTraceOperator && !traceOperator && hasAtLeastOneTraceQuery,
|
||||
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
|
||||
);
|
||||
|
||||
// Nothing left to add means no footer at all, rather than an empty bar under the
|
||||
// last query.
|
||||
const shouldShowFooter = useMemo(
|
||||
() =>
|
||||
(!additionalQueries.hidden || showFormula || showAddTraceOperator) &&
|
||||
((!showOnlyWhereClause && !isRawQuery) ||
|
||||
(currentDataSource === DataSource.TRACES && showTraceOperator)),
|
||||
[
|
||||
additionalQueries.hidden,
|
||||
showFormula,
|
||||
showAddTraceOperator,
|
||||
isRawQuery,
|
||||
showTraceOperator,
|
||||
showOnlyWhereClause,
|
||||
currentDataSource,
|
||||
],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
@@ -220,8 +199,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
key={currentQuery.builder.queryData[0].queryName}
|
||||
index={0}
|
||||
query={currentQuery.builder.queryData[0]}
|
||||
fieldsConfig={fieldsConfig}
|
||||
allowedDataSources={queryDataSources}
|
||||
filterConfigs={queryFilterConfigs}
|
||||
queryComponents={queryComponents}
|
||||
isMultiQueryAllowed={isMultiQueryAllowed}
|
||||
showTraceOperator={showTraceOperator}
|
||||
hasTraceOperator={hasTraceOperator}
|
||||
@@ -229,7 +208,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
isAvailableToDisable={false}
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isRawQuery={isRawQuery}
|
||||
isListViewPanel={isListViewPanel}
|
||||
signalSource={currentQuery.builder.queryData[0].source as 'meter' | ''}
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
@@ -237,14 +216,14 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
savePreviousQuery={savePreviousQuery}
|
||||
/>
|
||||
) : (
|
||||
renderedQueries.map((query, index) => (
|
||||
currentQuery.builder.queryData.map((query, index) => (
|
||||
<QueryV2
|
||||
ref={containerRef}
|
||||
key={query.queryName}
|
||||
index={index}
|
||||
query={query}
|
||||
fieldsConfig={fieldsConfig}
|
||||
allowedDataSources={queryDataSources}
|
||||
filterConfigs={queryFilterConfigs}
|
||||
queryComponents={queryComponents}
|
||||
version={version}
|
||||
isMultiQueryAllowed={isMultiQueryAllowed}
|
||||
isAvailableToDisable={false}
|
||||
@@ -252,7 +231,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
hasTraceOperator={hasTraceOperator}
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isRawQuery={isRawQuery}
|
||||
isListViewPanel={isListViewPanel}
|
||||
signalSource={query.source as 'meter' | ''}
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
@@ -272,7 +251,14 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
|
||||
return (
|
||||
<div key={formula.queryName} className="qb-formula">
|
||||
<Formula query={query} formula={formula} index={index} isQBV2 />
|
||||
<Formula
|
||||
filterConfigs={filterConfigs}
|
||||
query={query}
|
||||
formula={formula}
|
||||
index={index}
|
||||
isAdditionalFilterEnable={false}
|
||||
isQBV2
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -281,13 +267,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
|
||||
{shouldShowFooter && (
|
||||
<QueryFooter
|
||||
showAddQuery={!additionalQueries.hidden}
|
||||
showAddFormula={showFormula}
|
||||
isAddFormulaDisabled={formula.disabled}
|
||||
addFormulaDisabledReason={formula.reason}
|
||||
addNewBuilderQuery={addNewBuilderQuery}
|
||||
isAddQueryDisabled={additionalQueries.disabled}
|
||||
addQueryDisabledReason={additionalQueries.reason}
|
||||
addNewFormula={addNewFormula}
|
||||
addTraceOperator={addTraceOperator}
|
||||
showAddTraceOperator={showAddTraceOperator}
|
||||
@@ -296,8 +277,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
|
||||
{hasTraceOperator && (
|
||||
<TraceOperator
|
||||
isRawQuery={isRawQuery}
|
||||
fieldsConfig={resolvedConfig}
|
||||
isListViewPanel={isListViewPanel}
|
||||
traceOperator={traceOperator as IBuilderTraceOperator}
|
||||
/>
|
||||
)}
|
||||
@@ -305,7 +285,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
|
||||
{showQueryList && (
|
||||
<div className="query-names-section">
|
||||
{renderedQueries.map((query) => (
|
||||
{currentQuery.builder.queryData.map((query) => (
|
||||
<div key={query.queryName} className="query-name">
|
||||
{query.queryName}
|
||||
</div>
|
||||
|
||||
@@ -23,11 +23,6 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--margin-2);
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -15,16 +14,6 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
QueryBuilderField,
|
||||
QueryBuilderFieldsConfig,
|
||||
} from '../../queryBuilderFields.types';
|
||||
import {
|
||||
mergeQueryBuilderFieldsConfig,
|
||||
RAW_QUERY_FIELDS,
|
||||
resolveQueryBuilderFields,
|
||||
} from '../../queryBuilderFields.utils';
|
||||
|
||||
import HavingFilter from './HavingFilter/HavingFilter';
|
||||
import { buildDefaultLegendFromGroupBy } from './utils';
|
||||
|
||||
@@ -33,37 +22,34 @@ import './QueryAddOns.styles.scss';
|
||||
interface AddOn {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
key: QueryBuilderField;
|
||||
key: string;
|
||||
description?: string;
|
||||
docLink?: string;
|
||||
}
|
||||
|
||||
/** Fields the add-on bar does not own: each has its own control elsewhere in the query. */
|
||||
type NonAddOnField =
|
||||
| QueryBuilderField.Aggregation
|
||||
| QueryBuilderField.StepInterval
|
||||
| QueryBuilderField.Functions
|
||||
| QueryBuilderField.Formula
|
||||
| QueryBuilderField.AdditionalQueries;
|
||||
|
||||
// Omit rather than Partial, so a field added to the enum has to be placed on one side.
|
||||
const ADD_ONS_KEYS_TO_QUERY_PATH: Omit<
|
||||
Record<QueryBuilderField, string>,
|
||||
NonAddOnField
|
||||
> = {
|
||||
[QueryBuilderField.GroupBy]: 'groupBy',
|
||||
[QueryBuilderField.Having]: 'having.expression',
|
||||
[QueryBuilderField.OrderBy]: 'orderBy',
|
||||
[QueryBuilderField.Limit]: 'limit',
|
||||
[QueryBuilderField.Legend]: 'legend',
|
||||
[QueryBuilderField.ReduceTo]: 'reduceTo',
|
||||
const ADD_ONS_KEYS = {
|
||||
GROUP_BY: 'group_by',
|
||||
HAVING: 'having',
|
||||
ORDER_BY: 'order_by',
|
||||
LIMIT: 'limit',
|
||||
LEGEND_FORMAT: 'legend_format',
|
||||
REDUCE_TO: 'reduce_to',
|
||||
};
|
||||
|
||||
const ADD_ONS: AddOn[] = [
|
||||
const ADD_ONS_KEYS_TO_QUERY_PATH = {
|
||||
[ADD_ONS_KEYS.GROUP_BY]: 'groupBy',
|
||||
[ADD_ONS_KEYS.HAVING]: 'having.expression',
|
||||
[ADD_ONS_KEYS.ORDER_BY]: 'orderBy',
|
||||
[ADD_ONS_KEYS.LIMIT]: 'limit',
|
||||
[ADD_ONS_KEYS.LEGEND_FORMAT]: 'legend',
|
||||
[ADD_ONS_KEYS.REDUCE_TO]: 'reduceTo',
|
||||
};
|
||||
|
||||
const ADD_ONS = [
|
||||
{
|
||||
icon: <BarChart size={14} />,
|
||||
label: 'Group By',
|
||||
key: QueryBuilderField.GroupBy,
|
||||
key: ADD_ONS_KEYS.GROUP_BY,
|
||||
description:
|
||||
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
|
||||
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
|
||||
@@ -71,7 +57,7 @@ const ADD_ONS: AddOn[] = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Having',
|
||||
key: QueryBuilderField.Having,
|
||||
key: ADD_ONS_KEYS.HAVING,
|
||||
description:
|
||||
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
|
||||
docLink:
|
||||
@@ -80,7 +66,7 @@ const ADD_ONS: AddOn[] = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Order By',
|
||||
key: QueryBuilderField.OrderBy,
|
||||
key: ADD_ONS_KEYS.ORDER_BY,
|
||||
description:
|
||||
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
|
||||
docLink:
|
||||
@@ -89,7 +75,7 @@ const ADD_ONS: AddOn[] = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Limit',
|
||||
key: QueryBuilderField.Limit,
|
||||
key: ADD_ONS_KEYS.LIMIT,
|
||||
description:
|
||||
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
|
||||
docLink:
|
||||
@@ -98,7 +84,7 @@ const ADD_ONS: AddOn[] = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Legend format',
|
||||
key: QueryBuilderField.Legend,
|
||||
key: ADD_ONS_KEYS.LEGEND_FORMAT,
|
||||
description:
|
||||
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
|
||||
docLink:
|
||||
@@ -106,10 +92,10 @@ const ADD_ONS: AddOn[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const REDUCE_TO: AddOn = {
|
||||
const REDUCE_TO = {
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Reduce to',
|
||||
key: QueryBuilderField.ReduceTo,
|
||||
key: ADD_ONS_KEYS.REDUCE_TO,
|
||||
description:
|
||||
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
|
||||
docLink:
|
||||
@@ -168,26 +154,26 @@ function TooltipContent({
|
||||
function QueryAddOns({
|
||||
query,
|
||||
version,
|
||||
isRawQuery,
|
||||
isListViewPanel,
|
||||
showReduceTo,
|
||||
panelType,
|
||||
index,
|
||||
fieldsConfig,
|
||||
isForTraceOperator = false,
|
||||
}: {
|
||||
query: IBuilderQuery;
|
||||
version: string;
|
||||
isRawQuery: boolean;
|
||||
isListViewPanel: boolean;
|
||||
showReduceTo: boolean;
|
||||
panelType: PANEL_TYPES | null;
|
||||
index: number;
|
||||
fieldsConfig?: QueryBuilderFieldsConfig;
|
||||
isForTraceOperator?: boolean;
|
||||
}): JSX.Element {
|
||||
const [addOns, setAddOns] = useState<AddOn[]>(ADD_ONS);
|
||||
|
||||
const [selectedViews, setSelectedViews] = useState<AddOn[]>([]);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
const prevAvailableKeysRef = useRef<Set<QueryBuilderField> | null>(null);
|
||||
const prevAvailableKeysRef = useRef<Set<string> | null>(null);
|
||||
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
index,
|
||||
@@ -198,62 +184,40 @@ function QueryAddOns({
|
||||
|
||||
const { handleSetQueryData } = useQueryBuilder();
|
||||
|
||||
const supportedAddOns = useMemo((): AddOn[] => {
|
||||
let addOns: AddOn[];
|
||||
useEffect(() => {
|
||||
if (isListViewPanel) {
|
||||
setAddOns([]);
|
||||
|
||||
if (panelType === PANEL_TYPES.VALUE) {
|
||||
addOns = ADD_ONS.filter((addOn) => addOn.key === QueryBuilderField.Legend);
|
||||
} else if (query.dataSource === DataSource.METRICS) {
|
||||
// Group by for metrics is offered by MetricsAggregateSection instead.
|
||||
addOns = ADD_ONS.filter((addOn) => addOn.key !== QueryBuilderField.GroupBy);
|
||||
} else {
|
||||
addOns = [...ADD_ONS];
|
||||
setSelectedViews([
|
||||
ADD_ONS.find((addOn) => addOn.key === ADD_ONS_KEYS.ORDER_BY) as AddOn,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return showReduceTo ? [...addOns, REDUCE_TO] : addOns;
|
||||
}, [panelType, query.dataSource, showReduceTo]);
|
||||
let filteredAddOns: AddOn[];
|
||||
if (panelType === PANEL_TYPES.VALUE) {
|
||||
// Filter out all add-ons except legend format
|
||||
filteredAddOns = ADD_ONS.filter(
|
||||
(addOn) => addOn.key === ADD_ONS_KEYS.LEGEND_FORMAT,
|
||||
);
|
||||
} else {
|
||||
filteredAddOns = Object.values(ADD_ONS);
|
||||
|
||||
const resolvedFields = useMemo(
|
||||
() =>
|
||||
resolveQueryBuilderFields(
|
||||
supportedAddOns.map((addOn) => addOn.key),
|
||||
mergeQueryBuilderFieldsConfig(
|
||||
isRawQuery ? RAW_QUERY_FIELDS : undefined,
|
||||
fieldsConfig,
|
||||
),
|
||||
),
|
||||
[supportedAddOns, fieldsConfig, isRawQuery],
|
||||
);
|
||||
if (query.dataSource === DataSource.METRICS) {
|
||||
// Filter out group_by for metrics data source (handled in MetricsAggregateSection)
|
||||
filteredAddOns = filteredAddOns.filter(
|
||||
(addOn) => addOn.key !== ADD_ONS_KEYS.GROUP_BY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const offeredAddOns = useMemo(
|
||||
() =>
|
||||
supportedAddOns.filter((addOn) => !resolvedFields.get(addOn.key)?.hidden),
|
||||
[supportedAddOns, resolvedFields],
|
||||
);
|
||||
if (showReduceTo) {
|
||||
filteredAddOns = [...filteredAddOns, REDUCE_TO];
|
||||
}
|
||||
setAddOns(filteredAddOns);
|
||||
|
||||
const pinnedAddOns = useMemo(
|
||||
() => offeredAddOns.filter((addOn) => resolvedFields.get(addOn.key)?.pinned),
|
||||
[offeredAddOns, resolvedFields],
|
||||
);
|
||||
|
||||
const togglableAddOns = useMemo(
|
||||
() => offeredAddOns.filter((addOn) => !resolvedFields.get(addOn.key)?.pinned),
|
||||
[offeredAddOns, resolvedFields],
|
||||
);
|
||||
|
||||
const isPinned = useCallback(
|
||||
(key: QueryBuilderField): boolean => Boolean(resolvedFields.get(key)?.pinned),
|
||||
[resolvedFields],
|
||||
);
|
||||
|
||||
const isDisabled = useCallback(
|
||||
(key: QueryBuilderField): boolean =>
|
||||
Boolean(resolvedFields.get(key)?.disabled),
|
||||
[resolvedFields],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const availableAddOnKeys = new Set(offeredAddOns.map((a) => a.key));
|
||||
const availableAddOnKeys = new Set(filteredAddOns.map((a) => a.key));
|
||||
const previousKeys = prevAvailableKeysRef.current;
|
||||
const hasAvailabilityItemsChanged =
|
||||
previousKeys !== null &&
|
||||
@@ -267,39 +231,27 @@ function QueryAddOns({
|
||||
const activeAddOnKeys = new Set(
|
||||
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
|
||||
.filter(([, path]) => hasValue(get(query, path)))
|
||||
.map(([key]) => key as QueryBuilderField),
|
||||
.map(([key]) => key),
|
||||
);
|
||||
|
||||
// Initial seeding from query values on mount. A disabled field never opens.
|
||||
// Initial seeding from query values on mount
|
||||
setSelectedViews(
|
||||
offeredAddOns.filter((addOn) => {
|
||||
const resolved = resolvedFields.get(addOn.key);
|
||||
|
||||
return (
|
||||
resolved?.pinned ||
|
||||
(activeAddOnKeys.has(addOn.key) && !resolved?.disabled)
|
||||
);
|
||||
}),
|
||||
filteredAddOns.filter(
|
||||
(addOn) =>
|
||||
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedViews((prev) => {
|
||||
const kept = prev.filter((view) => availableAddOnKeys.has(view.key));
|
||||
|
||||
const reopenedPinned = pinnedAddOns.filter(
|
||||
(addOn) => !kept.some((view) => view.key === addOn.key),
|
||||
);
|
||||
|
||||
return [...kept, ...reopenedPinned];
|
||||
});
|
||||
}, [offeredAddOns, pinnedAddOns, query]);
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) =>
|
||||
filteredAddOns.some((addOn) => addOn.key === view.key),
|
||||
),
|
||||
);
|
||||
}, [panelType, isListViewPanel, query, showReduceTo]);
|
||||
|
||||
const handleOptionClick = (clickedAddOn: AddOn): void => {
|
||||
if (isDisabled(clickedAddOn.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isAlreadySelected = selectedViews.some(
|
||||
(view) => view.key === clickedAddOn.key,
|
||||
);
|
||||
@@ -313,7 +265,7 @@ function QueryAddOns({
|
||||
// and existing group-by keys, prefill the legend using all group-by keys.
|
||||
// This keeps existing custom legends intact and only helps seed a sensible default.
|
||||
if (
|
||||
clickedAddOn.key === QueryBuilderField.Legend &&
|
||||
clickedAddOn.key === ADD_ONS_KEYS.LEGEND_FORMAT &&
|
||||
isEmpty(query?.legend) &&
|
||||
Array.isArray(query.groupBy) &&
|
||||
query.groupBy.length > 0
|
||||
@@ -358,16 +310,9 @@ function QueryAddOns({
|
||||
[handleSetQueryData, index, query],
|
||||
);
|
||||
|
||||
const handleRemoveView = useCallback(
|
||||
(key: QueryBuilderField): void => {
|
||||
if (isPinned(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedViews((prev) => prev.filter((view) => view.key !== key));
|
||||
},
|
||||
[isPinned],
|
||||
);
|
||||
const handleRemoveView = useCallback((key: string): void => {
|
||||
setSelectedViews((prev) => prev.filter((view) => view.key !== key));
|
||||
}, []);
|
||||
|
||||
const handleChangeQueryLegend = useCallback(
|
||||
(value: string) => {
|
||||
@@ -396,7 +341,7 @@ function QueryAddOns({
|
||||
<div className="query-add-ons" data-testid="query-add-ons">
|
||||
{selectedViews.length > 0 && (
|
||||
<div className="selected-add-ons-content">
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.GroupBy) && (
|
||||
{selectedViews.find((view) => view.key === 'group_by') && (
|
||||
<div className="add-on-content" data-testid="group-by-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<Tooltip
|
||||
@@ -424,17 +369,15 @@ function QueryAddOns({
|
||||
onChange={handleChangeGroupByKeys}
|
||||
/>
|
||||
</div>
|
||||
{!isPinned(QueryBuilderField.GroupBy) && (
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView(QueryBuilderField.GroupBy)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView('group_by')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.Having) && (
|
||||
{selectedViews.find((view) => view.key === 'having') && (
|
||||
<div className="add-on-content" data-testid="having-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<Tooltip
|
||||
@@ -454,7 +397,11 @@ function QueryAddOns({
|
||||
</Tooltip>
|
||||
<div className="input">
|
||||
<HavingFilter
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Having)}
|
||||
onClose={(): void => {
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) => view.key !== 'having'),
|
||||
);
|
||||
}}
|
||||
onChange={handleChangeHaving}
|
||||
queryData={query}
|
||||
/>
|
||||
@@ -462,7 +409,7 @@ function QueryAddOns({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.Limit) && (
|
||||
{selectedViews.find((view) => view.key === 'limit') && (
|
||||
<div className="add-on-content" data-testid="limit-content">
|
||||
<InputWithLabel
|
||||
label="Limit"
|
||||
@@ -470,12 +417,16 @@ function QueryAddOns({
|
||||
onChange={handleChangeLimit}
|
||||
initialValue={query?.limit ?? undefined}
|
||||
placeholder="Enter limit"
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
|
||||
onClose={(): void => {
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) => view.key !== 'limit'),
|
||||
);
|
||||
}}
|
||||
closeIcon={<ChevronUp size={16} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.OrderBy) && (
|
||||
{selectedViews.find((view) => view.key === 'order_by') && (
|
||||
<div className="add-on-content" data-testid="order-by-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<Tooltip
|
||||
@@ -498,22 +449,22 @@ function QueryAddOns({
|
||||
entityVersion={version}
|
||||
query={query}
|
||||
onChange={handleChangeOrderByKeys}
|
||||
isRawQuery={isRawQuery}
|
||||
isListViewPanel={isListViewPanel}
|
||||
isNewQueryV2
|
||||
/>
|
||||
</div>
|
||||
{!isPinned(QueryBuilderField.OrderBy) && (
|
||||
{!isListViewPanel && (
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView(QueryBuilderField.OrderBy)}
|
||||
onClick={(): void => handleRemoveView('order_by')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.ReduceTo) &&
|
||||
{selectedViews.find((view) => view.key === 'reduce_to') &&
|
||||
showReduceTo && (
|
||||
<div className="add-on-content" data-testid="reduce-to-content">
|
||||
<div className="periscope-input-with-label">
|
||||
@@ -536,25 +487,27 @@ function QueryAddOns({
|
||||
<ReduceToFilter query={query} onChange={handleChangeReduceToV5} />
|
||||
</div>
|
||||
|
||||
{!isPinned(QueryBuilderField.ReduceTo) && (
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView(QueryBuilderField.ReduceTo)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView('reduce_to')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.Legend) && (
|
||||
{selectedViews.find((view) => view.key === 'legend_format') && (
|
||||
<div className="add-on-content" data-testid="legend-format-content">
|
||||
<InputWithLabel
|
||||
label="Legend format"
|
||||
placeholder="Write legend format"
|
||||
onChange={handleChangeQueryLegend}
|
||||
initialValue={isEmpty(query?.legend) ? undefined : query?.legend}
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Legend)}
|
||||
onClose={(): void => {
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) => view.key !== 'legend_format'),
|
||||
);
|
||||
}}
|
||||
closeIcon={<ChevronUp size={16} />}
|
||||
/>
|
||||
</div>
|
||||
@@ -567,49 +520,42 @@ function QueryAddOns({
|
||||
className="add-ons-tabs"
|
||||
value={selectedViews.map((view) => view.key)}
|
||||
onChange={(newKeys: string[]): void => {
|
||||
const oldKeys: string[] = selectedViews.map((view) => view.key);
|
||||
const oldKeys = selectedViews.map((view) => view.key);
|
||||
const toggledKey =
|
||||
newKeys.find((key) => !oldKeys.includes(key)) ??
|
||||
oldKeys.find((key) => !newKeys.includes(key));
|
||||
newKeys.find((k) => !oldKeys.includes(k)) ??
|
||||
oldKeys.find((k) => !newKeys.includes(k));
|
||||
if (!toggledKey) {
|
||||
return;
|
||||
}
|
||||
const clickedAddOn = togglableAddOns.find((a) => a.key === toggledKey);
|
||||
const clickedAddOn = addOns.find((a) => a.key === toggledKey);
|
||||
if (clickedAddOn) {
|
||||
handleOptionClick(clickedAddOn);
|
||||
}
|
||||
}}
|
||||
items={togglableAddOns.map((addOn) => {
|
||||
const resolved = resolvedFields.get(addOn.key);
|
||||
|
||||
return {
|
||||
value: addOn.key,
|
||||
label: (
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label={addOn.label}
|
||||
description={resolved?.reason ?? addOn.description}
|
||||
docLink={resolved?.disabled ? undefined : addOn.docLink}
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
items={addOns.map((addOn) => ({
|
||||
value: addOn.key,
|
||||
label: (
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label={addOn.label}
|
||||
description={addOn.description}
|
||||
docLink={addOn.docLink}
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<span
|
||||
className="add-on-tab-title"
|
||||
data-testid={`query-add-on-${addOn.key}`}
|
||||
>
|
||||
<span
|
||||
className={cx('add-on-tab-title', {
|
||||
'add-on-tab-title--disabled': resolved?.disabled,
|
||||
})}
|
||||
aria-disabled={resolved?.disabled}
|
||||
data-testid={`query-add-on-${addOn.key}`}
|
||||
>
|
||||
{addOn.icon}
|
||||
{addOn.label}
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
};
|
||||
})}
|
||||
{addOn.icon}
|
||||
{addOn.label}
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,12 +8,6 @@ import {
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
QueryBuilderField,
|
||||
QueryBuilderFieldsConfig,
|
||||
} from '../../queryBuilderFields.types';
|
||||
import { resolveQueryBuilderField } from '../../queryBuilderFields.utils';
|
||||
|
||||
import QueryAggregationSelect from './QueryAggregationSelect';
|
||||
|
||||
import './QueryAggregation.styles.scss';
|
||||
@@ -24,32 +18,24 @@ function QueryAggregationOptions({
|
||||
onAggregationIntervalChange,
|
||||
onChange,
|
||||
queryData,
|
||||
fieldsConfig,
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
panelType?: string;
|
||||
onAggregationIntervalChange: (value: number) => void;
|
||||
onChange?: (value: string) => void;
|
||||
queryData: IBuilderQuery | IBuilderTraceOperator;
|
||||
fieldsConfig?: QueryBuilderFieldsConfig;
|
||||
}): JSX.Element {
|
||||
const stepInterval = useMemo(() => {
|
||||
const showAggregationInterval = useMemo(() => {
|
||||
if (panelType === PANEL_TYPES.VALUE) {
|
||||
return { hidden: true, disabled: false, reason: undefined };
|
||||
return false;
|
||||
}
|
||||
|
||||
const isNonMetricSource =
|
||||
dataSource === DataSource.TRACES || dataSource === DataSource.LOGS;
|
||||
|
||||
if (
|
||||
isNonMetricSource &&
|
||||
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
|
||||
) {
|
||||
return { hidden: true, disabled: false, reason: undefined };
|
||||
if (dataSource === DataSource.TRACES || dataSource === DataSource.LOGS) {
|
||||
return !(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE);
|
||||
}
|
||||
|
||||
return resolveQueryBuilderField(QueryBuilderField.StepInterval, fieldsConfig);
|
||||
}, [dataSource, panelType, fieldsConfig]);
|
||||
return true;
|
||||
}, [dataSource, panelType]);
|
||||
|
||||
const handleAggregationIntervalChange = (value: string): void => {
|
||||
onAggregationIntervalChange(Number(value));
|
||||
@@ -71,24 +57,22 @@ function QueryAggregationOptions({
|
||||
}
|
||||
/>
|
||||
|
||||
{!stepInterval.hidden && (
|
||||
{showAggregationInterval && (
|
||||
<div className="query-aggregation-interval">
|
||||
<Tooltip
|
||||
title={
|
||||
stepInterval.reason ?? (
|
||||
<div>
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
>
|
||||
Learn about step intervals
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
<div>
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
>
|
||||
Learn about step intervals
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
@@ -108,7 +92,6 @@ function QueryAggregationOptions({
|
||||
placeholder="Auto"
|
||||
type="number"
|
||||
onChange={handleAggregationIntervalChange}
|
||||
disabled={stepInterval.disabled}
|
||||
labelAfter
|
||||
/>
|
||||
</div>
|
||||
@@ -122,7 +105,6 @@ function QueryAggregationOptions({
|
||||
QueryAggregationOptions.defaultProps = {
|
||||
panelType: null,
|
||||
onChange: undefined,
|
||||
fieldsConfig: undefined,
|
||||
};
|
||||
|
||||
export default QueryAggregationOptions;
|
||||
|
||||
@@ -17,13 +17,13 @@ function TraceOperatorSection({
|
||||
const { currentQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const showTraceOperatorWarning = useMemo(() => {
|
||||
const isRawQueryPanel =
|
||||
const isListViewPanel =
|
||||
panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE;
|
||||
const hasMultipleQueries = currentQuery.builder.queryData.length > 1;
|
||||
const hasTraceOperator =
|
||||
currentQuery.builder.queryTraceOperator &&
|
||||
currentQuery.builder.queryTraceOperator.length > 0;
|
||||
return isRawQueryPanel && hasMultipleQueries && !hasTraceOperator;
|
||||
return isListViewPanel && hasMultipleQueries && !hasTraceOperator;
|
||||
}, [
|
||||
currentQuery?.builder?.queryData,
|
||||
currentQuery?.builder?.queryTraceOperator,
|
||||
@@ -77,74 +77,50 @@ export default function QueryFooter({
|
||||
addNewBuilderQuery,
|
||||
addNewFormula,
|
||||
addTraceOperator,
|
||||
showAddQuery = true,
|
||||
showAddFormula = true,
|
||||
showAddTraceOperator = false,
|
||||
isAddQueryDisabled = false,
|
||||
addQueryDisabledReason,
|
||||
isAddFormulaDisabled = false,
|
||||
addFormulaDisabledReason,
|
||||
}: {
|
||||
addNewBuilderQuery: () => void;
|
||||
addNewFormula: () => void;
|
||||
addTraceOperator?: () => void;
|
||||
showAddTraceOperator: boolean;
|
||||
showAddQuery?: boolean;
|
||||
showAddFormula?: boolean;
|
||||
isAddQueryDisabled?: boolean;
|
||||
addQueryDisabledReason?: string;
|
||||
isAddFormulaDisabled?: boolean;
|
||||
addFormulaDisabledReason?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="qb-footer">
|
||||
<div className="qb-footer-container">
|
||||
{showAddQuery && (
|
||||
<div className="qb-add-new-query">
|
||||
<Tooltip
|
||||
title={
|
||||
addQueryDisabledReason ?? (
|
||||
<div style={{ textAlign: 'center' }}>Add New Query</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="add-new-query-button periscope-btn "
|
||||
data-testid="add-new-query-button"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={addNewBuilderQuery}
|
||||
disabled={isAddQueryDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="qb-add-new-query">
|
||||
<Tooltip title={<div style={{ textAlign: 'center' }}>Add New Query</div>}>
|
||||
<Button
|
||||
className="add-new-query-button periscope-btn "
|
||||
icon={<Plus size={16} />}
|
||||
onClick={addNewBuilderQuery}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{showAddFormula && (
|
||||
<div className="qb-add-formula">
|
||||
<Tooltip
|
||||
title={
|
||||
addFormulaDisabledReason ?? (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
Add New Formula
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/querying/multi-query-analysis/#advanced-comparisons"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{' '}
|
||||
<br />
|
||||
Learn more
|
||||
</Typography.Link>
|
||||
</div>
|
||||
)
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
Add New Formula
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/querying/multi-query-analysis/#advanced-comparisons"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{' '}
|
||||
<br />
|
||||
Learn more
|
||||
</Typography.Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="add-formula-button periscope-btn "
|
||||
data-testid="add-formula-button"
|
||||
icon={<Sigma size={16} />}
|
||||
onClick={addNewFormula}
|
||||
disabled={isAddFormulaDisabled}
|
||||
>
|
||||
Add Formula
|
||||
</Button>
|
||||
|
||||
@@ -20,13 +20,6 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderField } from '../queryBuilderFields.types';
|
||||
import {
|
||||
mergeQueryBuilderFieldsConfig,
|
||||
RAW_QUERY_FIELDS,
|
||||
resolveQueryBuilderField,
|
||||
} from '../queryBuilderFields.utils';
|
||||
|
||||
import MetricsAggregateSection from './MerticsAggregateSection/MetricsAggregateSection';
|
||||
import { MetricsSelect } from './MetricsSelect/MetricsSelect';
|
||||
import QueryAddOns from './QueryAddOns/QueryAddOns';
|
||||
@@ -38,7 +31,8 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
index,
|
||||
queryVariant,
|
||||
query,
|
||||
isRawQuery = false,
|
||||
filterConfigs,
|
||||
isListViewPanel = false,
|
||||
showTraceOperator = false,
|
||||
hasTraceOperator = false,
|
||||
version,
|
||||
@@ -49,8 +43,6 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
signalSourceChangeEnabled = false,
|
||||
queriesCount = 1,
|
||||
savePreviousQuery = false,
|
||||
fieldsConfig,
|
||||
allowedDataSources,
|
||||
}: QueryProps & {
|
||||
onSignalSourceChange: (value: string) => void;
|
||||
signalSourceChangeEnabled: boolean;
|
||||
@@ -61,7 +53,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
): JSX.Element {
|
||||
const { cloneQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const hasQueryFunctions = query?.functions?.length > 0;
|
||||
const showFunctions = query?.functions?.length > 0;
|
||||
const { dataSource, builderQueryType } = query;
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
@@ -74,7 +66,8 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
} = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
isRawQuery,
|
||||
filterConfigs,
|
||||
isListViewPanel,
|
||||
entityVersion: version,
|
||||
savePreviousQuery,
|
||||
});
|
||||
@@ -106,31 +99,14 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
[dataSource, builderQueryType],
|
||||
);
|
||||
|
||||
const resolvedConfig = useMemo(
|
||||
() =>
|
||||
mergeQueryBuilderFieldsConfig(
|
||||
isRawQuery ? RAW_QUERY_FIELDS : undefined,
|
||||
fieldsConfig,
|
||||
),
|
||||
[isRawQuery, fieldsConfig],
|
||||
);
|
||||
|
||||
const aggregation = useMemo(
|
||||
() => resolveQueryBuilderField(QueryBuilderField.Aggregation, resolvedConfig),
|
||||
[resolvedConfig],
|
||||
);
|
||||
|
||||
const functions = useMemo(
|
||||
() => resolveQueryBuilderField(QueryBuilderField.Functions, resolvedConfig),
|
||||
[resolvedConfig],
|
||||
);
|
||||
|
||||
const showInlineQuerySearch = useMemo(() => {
|
||||
if (!showTraceOperator) {
|
||||
return false;
|
||||
}
|
||||
return dataSource === DataSource.TRACES && (hasTraceOperator || isRawQuery);
|
||||
}, [hasTraceOperator, isRawQuery, showTraceOperator, dataSource]);
|
||||
return (
|
||||
dataSource === DataSource.TRACES && (hasTraceOperator || isListViewPanel)
|
||||
);
|
||||
}, [hasTraceOperator, isListViewPanel, showTraceOperator, dataSource]);
|
||||
|
||||
const handleChangeAggregateEvery = useCallback(
|
||||
(value: IBuilderQuery['stepInterval']) => {
|
||||
@@ -173,15 +149,12 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
hasTraceOperator={hasTraceOperator}
|
||||
isMetricsDataSource={dataSource === DataSource.METRICS}
|
||||
showFunctions={
|
||||
!functions.hidden &&
|
||||
((version && version === ENTITY_VERSION_V4) ||
|
||||
query.dataSource === DataSource.LOGS ||
|
||||
query.dataSource === DataSource.METRICS ||
|
||||
hasQueryFunctions ||
|
||||
false)
|
||||
(version && version === ENTITY_VERSION_V4) ||
|
||||
query.dataSource === DataSource.LOGS ||
|
||||
query.dataSource === DataSource.METRICS ||
|
||||
showFunctions ||
|
||||
false
|
||||
}
|
||||
areFunctionsDisabled={functions.disabled}
|
||||
functionsDisabledReason={functions.reason}
|
||||
isCollapsed={isCollapsed}
|
||||
showTraceOperator={showTraceOperator}
|
||||
entityType="query"
|
||||
@@ -194,8 +167,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
onQueryFunctionsUpdates={handleQueryFunctionsUpdates}
|
||||
showDeleteButton={false}
|
||||
showCloneOption={false}
|
||||
isRawQuery={isRawQuery}
|
||||
allowedDataSources={allowedDataSources}
|
||||
isListViewPanel={isListViewPanel}
|
||||
index={index}
|
||||
queryVariant={queryVariant}
|
||||
onChangeDataSource={handleChangeDataSource}
|
||||
@@ -295,7 +267,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
</div>
|
||||
|
||||
{!showOnlyWhereClause &&
|
||||
!aggregation.hidden &&
|
||||
!isListViewPanel &&
|
||||
!(hasTraceOperator && dataSource === DataSource.TRACES) &&
|
||||
dataSource !== DataSource.METRICS && (
|
||||
<QueryAggregation
|
||||
@@ -305,7 +277,6 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
onAggregationIntervalChange={handleChangeAggregateEvery}
|
||||
onChange={handleChangeAggregation}
|
||||
queryData={query}
|
||||
fieldsConfig={fieldsConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -326,10 +297,9 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
index={index}
|
||||
query={query}
|
||||
version="v3"
|
||||
isRawQuery={isRawQuery}
|
||||
isListViewPanel={isListViewPanel}
|
||||
showReduceTo={showReduceTo}
|
||||
panelType={panelType}
|
||||
fieldsConfig={fieldsConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderFieldsConfig } from '../../queryBuilderFields.types';
|
||||
import QueryAddOns from '../QueryAddOns/QueryAddOns';
|
||||
import QueryAggregation from '../QueryAggregation/QueryAggregation';
|
||||
import TraceOperatorEditor from './TraceOperatorEditor';
|
||||
@@ -20,12 +19,10 @@ import './TraceOperator.styles.scss';
|
||||
|
||||
export default function TraceOperator({
|
||||
traceOperator,
|
||||
isRawQuery = false,
|
||||
fieldsConfig,
|
||||
isListViewPanel = false,
|
||||
}: {
|
||||
traceOperator: IBuilderTraceOperator;
|
||||
isRawQuery?: boolean;
|
||||
fieldsConfig?: QueryBuilderFieldsConfig;
|
||||
isListViewPanel?: boolean;
|
||||
}): JSX.Element {
|
||||
const { panelType, removeTraceOperator } = useQueryBuilder();
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
@@ -61,12 +58,12 @@ export default function TraceOperator({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx('qb-trace-operator', !isRawQuery && 'non-list-view')}>
|
||||
<div className={cx('qb-trace-operator', !isListViewPanel && 'non-list-view')}>
|
||||
<div className="qb-trace-operator-container">
|
||||
<div
|
||||
className={cx(
|
||||
'qb-trace-operator-label-with-input',
|
||||
!isRawQuery && 'qb-trace-operator-arrow',
|
||||
!isListViewPanel && 'qb-trace-operator-arrow',
|
||||
)}
|
||||
>
|
||||
<Typography.Text className="label">Trace Operator</Typography.Text>
|
||||
@@ -79,9 +76,9 @@ export default function TraceOperator({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isRawQuery && (
|
||||
{!isListViewPanel && (
|
||||
<div className="qb-trace-operator-aggregation-container">
|
||||
<div className={cx(!isRawQuery && 'qb-trace-operator-arrow')}>
|
||||
<div className={cx(!isListViewPanel && 'qb-trace-operator-arrow')}>
|
||||
<QueryAggregation
|
||||
dataSource={DataSource.TRACES}
|
||||
key={`query-search-${traceOperator.queryName}`}
|
||||
@@ -89,13 +86,12 @@ export default function TraceOperator({
|
||||
onAggregationIntervalChange={handleChangeAggregateEvery}
|
||||
onChange={handleChangeAggregation}
|
||||
queryData={traceOperator}
|
||||
fieldsConfig={fieldsConfig}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
'qb-trace-operator-add-ons-container',
|
||||
!isRawQuery && 'qb-trace-operator-arrow',
|
||||
!isListViewPanel && 'qb-trace-operator-arrow',
|
||||
)}
|
||||
>
|
||||
<QueryAddOns
|
||||
@@ -103,10 +99,9 @@ export default function TraceOperator({
|
||||
query={traceOperator}
|
||||
version="v3"
|
||||
isForTraceOperator
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo={false}
|
||||
panelType={panelType}
|
||||
fieldsConfig={fieldsConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -142,6 +142,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
isMetricsDataSource: false,
|
||||
operators: [],
|
||||
spaceAggregationOptions: [],
|
||||
listOfAdditionalFilters: [],
|
||||
handleChangeOperator: jest.fn(),
|
||||
handleSpaceAggregationChange: jest.fn(),
|
||||
handleChangeAggregatorAttribute: jest.fn(),
|
||||
@@ -151,6 +152,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
jest.fn() as unknown as ReturnType<UseQueryOperations>['handleChangeQueryData'],
|
||||
handleChangeFormulaData: jest.fn(),
|
||||
handleQueryFunctionsUpdates: handleQueryFunctionsUpdatesMock,
|
||||
listOfAdditionalFormulaFilters: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.VALUE}
|
||||
index={0}
|
||||
@@ -119,7 +119,7 @@ describe('QueryAddOns', () => {
|
||||
groupBy: ['service.name'],
|
||||
})}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -135,7 +135,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isRawQuery
|
||||
isListViewPanel
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.LIST}
|
||||
index={0}
|
||||
@@ -151,7 +151,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={baseQuery({ limit: 5 })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -176,7 +176,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -195,7 +195,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -211,7 +211,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={baseQuery({ reduceTo: ReduceOperators.SUM })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -234,7 +234,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -286,7 +286,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -314,7 +314,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import QueryFooter from '../QueryV2/QueryFooter/QueryFooter';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): {
|
||||
currentQuery: { builder: { queryData: unknown[] } };
|
||||
panelType: string;
|
||||
} => ({
|
||||
currentQuery: { builder: { queryData: [] } },
|
||||
panelType: 'time_series',
|
||||
}),
|
||||
}));
|
||||
|
||||
const noop = (): void => {};
|
||||
|
||||
describe('QueryFooter', () => {
|
||||
it('offers both buttons by default', () => {
|
||||
render(
|
||||
<QueryFooter
|
||||
addNewBuilderQuery={noop}
|
||||
addNewFormula={noop}
|
||||
showAddTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('add-new-query-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('add-formula-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// A kind whose request takes a single query (Heatmap) hides the button outright
|
||||
// rather than disabling it — a query it adds is one the builder cannot render.
|
||||
it('drops the Add New Query button when the caller withholds it', () => {
|
||||
render(
|
||||
<QueryFooter
|
||||
addNewBuilderQuery={noop}
|
||||
addNewFormula={noop}
|
||||
showAddQuery={false}
|
||||
showAddTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('add-new-query-button')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('add-formula-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
import { QueryBuilderField } from '../queryBuilderFields.types';
|
||||
import {
|
||||
mergeQueryBuilderFieldsConfig,
|
||||
RAW_QUERY_FIELDS,
|
||||
resolveQueryBuilderField,
|
||||
resolveQueryBuilderFields,
|
||||
} from '../queryBuilderFields.utils';
|
||||
|
||||
const SUPPORTED = [
|
||||
QueryBuilderField.GroupBy,
|
||||
QueryBuilderField.Having,
|
||||
QueryBuilderField.OrderBy,
|
||||
QueryBuilderField.Limit,
|
||||
QueryBuilderField.Legend,
|
||||
];
|
||||
|
||||
describe('resolveQueryBuilderField', () => {
|
||||
it('leaves an unconfigured field available', () => {
|
||||
expect(resolveQueryBuilderField(QueryBuilderField.Having)).toStrictEqual({
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
pinned: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides a field configured hidden', () => {
|
||||
const resolved = resolveQueryBuilderField(QueryBuilderField.Having, {
|
||||
[QueryBuilderField.Having]: { state: 'hidden' },
|
||||
});
|
||||
|
||||
expect(resolved.hidden).toBe(true);
|
||||
expect(resolved.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('carries the reason through on a disabled field', () => {
|
||||
const resolved = resolveQueryBuilderField(QueryBuilderField.Having, {
|
||||
[QueryBuilderField.Having]: {
|
||||
state: 'disabled',
|
||||
reason: 'Having filters aggregated results.',
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).toStrictEqual({
|
||||
hidden: false,
|
||||
disabled: true,
|
||||
reason: 'Having filters aggregated results.',
|
||||
pinned: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('pins a field configured pinned', () => {
|
||||
const resolved = resolveQueryBuilderField(QueryBuilderField.OrderBy, {
|
||||
[QueryBuilderField.OrderBy]: { state: 'pinned' },
|
||||
});
|
||||
|
||||
expect(resolved.pinned).toBe(true);
|
||||
expect(resolved.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('only ever resolves one state at a time', () => {
|
||||
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
|
||||
[QueryBuilderField.Limit]: { state: 'disabled', reason: 'why' },
|
||||
});
|
||||
|
||||
expect([resolved.hidden, resolved.disabled, resolved.pinned]).toStrictEqual([
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveQueryBuilderFields', () => {
|
||||
it('resolves every supported field and nothing else', () => {
|
||||
const resolved = resolveQueryBuilderFields(SUPPORTED);
|
||||
|
||||
expect([...resolved.keys()]).toStrictEqual(SUPPORTED);
|
||||
});
|
||||
|
||||
it('cannot widen beyond what the builder supports', () => {
|
||||
const resolved = resolveQueryBuilderFields([QueryBuilderField.Legend], {
|
||||
[QueryBuilderField.ReduceTo]: { state: 'pinned' },
|
||||
});
|
||||
|
||||
expect(resolved.has(QueryBuilderField.ReduceTo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeQueryBuilderFieldsConfig', () => {
|
||||
it('returns the override when there is no baseline', () => {
|
||||
const override = { [QueryBuilderField.Limit]: { state: 'hidden' } } as const;
|
||||
|
||||
expect(mergeQueryBuilderFieldsConfig(undefined, override)).toBe(override);
|
||||
});
|
||||
|
||||
it('returns the baseline when there is no override', () => {
|
||||
expect(mergeQueryBuilderFieldsConfig(RAW_QUERY_FIELDS, undefined)).toBe(
|
||||
RAW_QUERY_FIELDS,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the override win per field, leaving the rest of the baseline intact', () => {
|
||||
const merged = mergeQueryBuilderFieldsConfig(RAW_QUERY_FIELDS, {
|
||||
[QueryBuilderField.Having]: { state: 'disabled', reason: 'no aggregation' },
|
||||
});
|
||||
|
||||
expect(merged?.[QueryBuilderField.Having]).toStrictEqual({
|
||||
state: 'disabled',
|
||||
reason: 'no aggregation',
|
||||
});
|
||||
expect(merged?.[QueryBuilderField.GroupBy]).toStrictEqual({
|
||||
state: 'hidden',
|
||||
});
|
||||
expect(merged?.[QueryBuilderField.OrderBy]).toStrictEqual({
|
||||
state: 'pinned',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RAW_QUERY_FIELDS', () => {
|
||||
it('reduces an aggregate surface to a pinned order by', () => {
|
||||
const resolved = resolveQueryBuilderFields(SUPPORTED, RAW_QUERY_FIELDS);
|
||||
|
||||
const visible = [...resolved.entries()]
|
||||
.filter(([, field]) => !field.hidden)
|
||||
.map(([key]) => key);
|
||||
|
||||
expect(visible).toStrictEqual([QueryBuilderField.OrderBy]);
|
||||
expect(resolved.get(QueryBuilderField.OrderBy)?.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves additional queries alone, so trace matching still allows several', () => {
|
||||
expect(
|
||||
resolveQueryBuilderField(
|
||||
QueryBuilderField.AdditionalQueries,
|
||||
RAW_QUERY_FIELDS,
|
||||
).hidden,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Everything the query builder can surface.
|
||||
*
|
||||
* The per-query values double as the add-on identities the builder renders
|
||||
* (`data-testid="query-add-on-<value>"`), so they are part of the DOM contract and must
|
||||
* not be renamed to match the member names.
|
||||
*/
|
||||
export enum QueryBuilderField {
|
||||
// Per query
|
||||
Aggregation = 'aggregation',
|
||||
StepInterval = 'step_interval',
|
||||
Functions = 'functions',
|
||||
GroupBy = 'group_by',
|
||||
Having = 'having',
|
||||
OrderBy = 'order_by',
|
||||
Limit = 'limit',
|
||||
Legend = 'legend_format',
|
||||
ReduceTo = 'reduce_to',
|
||||
// Builder level
|
||||
Formula = 'formula',
|
||||
AdditionalQueries = 'additional_queries',
|
||||
}
|
||||
|
||||
/** `reason` is required on `disabled`: an inert control the user can see has to explain itself. */
|
||||
export type QueryBuilderFieldRule =
|
||||
| { state: 'hidden' }
|
||||
| { state: 'disabled'; reason: string }
|
||||
| { state: 'pinned' };
|
||||
|
||||
/**
|
||||
* A caller's narrowing of the builder's surface. The builder works out which fields suit
|
||||
* the current data source and panel type first; this can only take away from that set.
|
||||
*/
|
||||
export type QueryBuilderFieldsConfig = Partial<
|
||||
Record<QueryBuilderField, QueryBuilderFieldRule>
|
||||
>;
|
||||
@@ -1,92 +0,0 @@
|
||||
import {
|
||||
QueryBuilderField,
|
||||
QueryBuilderFieldRule,
|
||||
QueryBuilderFieldsConfig,
|
||||
} from './queryBuilderFields.types';
|
||||
|
||||
export interface ResolvedQueryBuilderField {
|
||||
hidden: boolean;
|
||||
disabled: boolean;
|
||||
reason?: string;
|
||||
/** Rendered open, not dismissable, and kept out of the add-on toggle bar. */
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
const AVAILABLE: ResolvedQueryBuilderField = {
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
pinned: false,
|
||||
};
|
||||
|
||||
function fromRule(rule: QueryBuilderFieldRule): ResolvedQueryBuilderField {
|
||||
switch (rule.state) {
|
||||
case 'hidden':
|
||||
return { hidden: true, disabled: false, pinned: false };
|
||||
case 'disabled':
|
||||
return {
|
||||
hidden: false,
|
||||
disabled: true,
|
||||
reason: rule.reason,
|
||||
pinned: false,
|
||||
};
|
||||
case 'pinned':
|
||||
return { hidden: false, disabled: false, pinned: true };
|
||||
default:
|
||||
return AVAILABLE;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveQueryBuilderField(
|
||||
field: QueryBuilderField,
|
||||
config?: QueryBuilderFieldsConfig,
|
||||
): ResolvedQueryBuilderField {
|
||||
const rule = config?.[field];
|
||||
|
||||
return rule ? fromRule(rule) : AVAILABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields absent from `supported` are hidden whatever the config says, so a config can
|
||||
* only ever take away.
|
||||
*/
|
||||
export function resolveQueryBuilderFields(
|
||||
supported: readonly QueryBuilderField[],
|
||||
config?: QueryBuilderFieldsConfig,
|
||||
): Map<QueryBuilderField, ResolvedQueryBuilderField> {
|
||||
return new Map(
|
||||
supported.map((field) => [field, resolveQueryBuilderField(field, config)]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The surface a raw-row builder starts from, layered under a caller's own config.
|
||||
* `AdditionalQueries` is deliberately absent — a raw trace builder still takes several
|
||||
* queries when trace matching is on. Omit rather than Partial, so a field added to the
|
||||
* enum has to be placed on one side.
|
||||
*/
|
||||
export const RAW_QUERY_FIELDS: Omit<
|
||||
Record<QueryBuilderField, QueryBuilderFieldRule>,
|
||||
QueryBuilderField.AdditionalQueries
|
||||
> = {
|
||||
[QueryBuilderField.Aggregation]: { state: 'hidden' },
|
||||
[QueryBuilderField.StepInterval]: { state: 'hidden' },
|
||||
[QueryBuilderField.Functions]: { state: 'hidden' },
|
||||
[QueryBuilderField.GroupBy]: { state: 'hidden' },
|
||||
[QueryBuilderField.Having]: { state: 'hidden' },
|
||||
[QueryBuilderField.Limit]: { state: 'hidden' },
|
||||
[QueryBuilderField.Legend]: { state: 'hidden' },
|
||||
[QueryBuilderField.ReduceTo]: { state: 'hidden' },
|
||||
[QueryBuilderField.Formula]: { state: 'hidden' },
|
||||
[QueryBuilderField.OrderBy]: { state: 'pinned' },
|
||||
};
|
||||
|
||||
export function mergeQueryBuilderFieldsConfig(
|
||||
baseline: QueryBuilderFieldsConfig | undefined,
|
||||
override: QueryBuilderFieldsConfig | undefined,
|
||||
): QueryBuilderFieldsConfig | undefined {
|
||||
if (!baseline) {
|
||||
return override;
|
||||
}
|
||||
|
||||
return override ? { ...baseline, ...override } : baseline;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
MeterAggregateOperator,
|
||||
MetricAggregateOperator,
|
||||
NumberOperators,
|
||||
QueryAdditionalFilter,
|
||||
QueryBuilderData,
|
||||
ReduceOperators,
|
||||
StringOperators,
|
||||
@@ -103,6 +104,43 @@ export const metricsSpaceAggregationOperatorsByType = {
|
||||
ExponentialHistogram: metricsHistogramSpaceAggregateOperatorOptions,
|
||||
};
|
||||
|
||||
export const mapOfQueryFilters: Record<DataSource, QueryAdditionalFilter[]> = {
|
||||
metrics: [
|
||||
{ text: 'Aggregation interval', field: 'stepInterval' },
|
||||
{ text: 'Having', field: 'having' },
|
||||
],
|
||||
logs: [
|
||||
{ text: 'Order by', field: 'orderBy' },
|
||||
{ text: 'Limit', field: 'limit' },
|
||||
{ text: 'Having', field: 'having' },
|
||||
{ text: 'Aggregation interval', field: 'stepInterval' },
|
||||
],
|
||||
traces: [
|
||||
{ text: 'Order by', field: 'orderBy' },
|
||||
{ text: 'Limit', field: 'limit' },
|
||||
{ text: 'Having', field: 'having' },
|
||||
{ text: 'Aggregation interval', field: 'stepInterval' },
|
||||
],
|
||||
};
|
||||
|
||||
const commonFormulaFilters: QueryAdditionalFilter[] = [
|
||||
{
|
||||
text: 'Having',
|
||||
field: 'having',
|
||||
},
|
||||
{ text: 'Order by', field: 'orderBy' },
|
||||
{ text: 'Limit', field: 'limit' },
|
||||
];
|
||||
|
||||
export const mapOfFormulaToFilters: Record<
|
||||
DataSource,
|
||||
QueryAdditionalFilter[]
|
||||
> = {
|
||||
metrics: commonFormulaFilters,
|
||||
logs: commonFormulaFilters,
|
||||
traces: commonFormulaFilters,
|
||||
};
|
||||
|
||||
export const REDUCE_TO_VALUES: SelectOption<ReduceOperators, string>[] = [
|
||||
{ value: ReduceOperators.LAST, label: 'Latest of values in timeframe' },
|
||||
{ value: ReduceOperators.SUM, label: 'Sum of values in timeframe' },
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const isRawQuery = useMemo(
|
||||
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
() => ({
|
||||
stepInterval: { isHidden: false, isDisabled: false },
|
||||
limit: { isHidden: false, isDisabled: true },
|
||||
having: { isHidden: false, isDisabled: true },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const isListViewPanel = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
[panelTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isRawQuery={isRawQuery}
|
||||
isListViewPanel={isListViewPanel}
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
panelType={panelTypes}
|
||||
showOnlyWhereClause={isRawQuery}
|
||||
filterConfigs={filterConfigs}
|
||||
showOnlyWhereClause={isListViewPanel}
|
||||
version="v3" // setting this to v3 as we this is rendered in logs explorer
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
OPERATORS,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ExplorerOrderBy from 'container/ExplorerOrderBy';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
@@ -29,11 +36,42 @@ function LogExplorerQuerySection({
|
||||
|
||||
useShareBuilderUrl({ defaultValue });
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
const isTable = panelTypes === PANEL_TYPES.TABLE;
|
||||
const isList = panelTypes === PANEL_TYPES.LIST;
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: isTable, isDisabled: false },
|
||||
having: { isHidden: isList, isDisabled: true },
|
||||
filters: {
|
||||
customKey: 'body',
|
||||
customOp: OPERATORS.CONTAINS,
|
||||
},
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [panelTypes]);
|
||||
|
||||
const renderOrderBy = useCallback(
|
||||
({ query, onChange }: OrderByFilterProps): JSX.Element => (
|
||||
<ExplorerOrderBy query={query} onChange={onChange} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const queryComponents = useMemo(
|
||||
(): QueryBuilderProps['queryComponents'] => ({
|
||||
...(panelTypes === PANEL_TYPES.LIST ? { renderOrderBy } : {}),
|
||||
}),
|
||||
[panelTypes, renderOrderBy],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isRawQuery={panelTypes === PANEL_TYPES.LIST}
|
||||
isListViewPanel={panelTypes === PANEL_TYPES.LIST}
|
||||
config={{ initialDataSource: DataSource.LOGS, queryVariant: 'static' }}
|
||||
panelType={panelTypes}
|
||||
filterConfigs={filterConfigs}
|
||||
queryComponents={queryComponents}
|
||||
showOnlyWhereClause={selectedView === ExplorerViews.LIST}
|
||||
version="v3" // setting this to v3 as we this is rendered in logs explorer
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
@@ -119,6 +120,11 @@ function Explorer(): JSX.Element {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const queryComponents = useMemo(
|
||||
(): QueryBuilderProps['queryComponents'] => ({}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
@@ -175,6 +181,7 @@ function Explorer(): JSX.Element {
|
||||
signalSource: 'meter',
|
||||
}}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
queryComponents={queryComponents}
|
||||
showFunctions={false}
|
||||
version="v3"
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
@@ -322,6 +323,11 @@ function Explorer(): JSX.Element {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const queryComponents = useMemo(
|
||||
(): QueryBuilderProps['queryComponents'] => ({}),
|
||||
[],
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
|
||||
const oneChartPerQueryDisabledTooltip = useMemo(() => {
|
||||
@@ -375,6 +381,7 @@ function Explorer(): JSX.Element {
|
||||
<QueryBuilderV2
|
||||
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
queryComponents={queryComponents}
|
||||
showFunctions={false}
|
||||
version="v3"
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryBuilderFieldsConfig } from 'components/QueryBuilderV2/queryBuilderFields.types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { OrderByFilterProps } from './filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
|
||||
export type WhereClauseConfig = {
|
||||
customKey: string;
|
||||
customOp: string;
|
||||
};
|
||||
|
||||
type FilterConfigs = {
|
||||
[Key in keyof Omit<IBuilderQuery, 'filters'>]: {
|
||||
isHidden: boolean;
|
||||
isDisabled: boolean;
|
||||
};
|
||||
} & { filters: WhereClauseConfig };
|
||||
|
||||
export type QueryBuilderConfig =
|
||||
| {
|
||||
queryVariant: 'static';
|
||||
@@ -16,16 +29,9 @@ export type QueryBuilderProps = {
|
||||
config?: QueryBuilderConfig;
|
||||
panelType: PANEL_TYPES;
|
||||
actions?: ReactNode;
|
||||
fieldsConfig?: QueryBuilderFieldsConfig;
|
||||
/**
|
||||
* The builder edits raw rows rather than an aggregation: a single query unless trace
|
||||
* matching is on, no formulas, data-source switches reset to the raw-query template,
|
||||
* and order by resolves keys without an aggregate attribute. Supplies the defaults for
|
||||
* `fieldsConfig` and `allowedDataSources`, which override it per field.
|
||||
*/
|
||||
isRawQuery?: boolean;
|
||||
/** Defaults to every signal. */
|
||||
allowedDataSources?: TelemetrytypesSignalDTO[];
|
||||
filterConfigs?: Partial<FilterConfigs>;
|
||||
queryComponents?: { renderOrderBy?: (props: OrderByFilterProps) => ReactNode };
|
||||
isListViewPanel?: boolean;
|
||||
showFunctions?: boolean;
|
||||
showOnlyWhereClause?: boolean;
|
||||
showOnlyTraceOperator?: boolean;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type AdditionalFiltersProps = {
|
||||
listOfAdditionalFilter: string[];
|
||||
children: ReactNode;
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { SquareMinus, SquarePlus } from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Col } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
const IconCss = css`
|
||||
margin-right: 0.6875rem;
|
||||
transition: all 0.2s ease;
|
||||
`;
|
||||
|
||||
export const StyledIconOpen = styled(SquarePlus)`
|
||||
${IconCss}
|
||||
`;
|
||||
|
||||
export const StyledIconClose = styled(SquareMinus)`
|
||||
${IconCss}
|
||||
`;
|
||||
|
||||
export const StyledInner = styled(Col)`
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 0.875rem;
|
||||
min-height: 1.375rem;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
${StyledIconOpen}, ${StyledIconClose} {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledLink = styled(Typography.Link)`
|
||||
pointer-events: none;
|
||||
color: ${Color.BG_ROBIN_400} !important;
|
||||
`;
|
||||
@@ -0,0 +1,15 @@
|
||||
.filter-toggler {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.additinal-filters-container {
|
||||
.action-btn {
|
||||
background: var(--primary-background);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Fragment, memo, ReactNode, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Col, Row } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
|
||||
// ** Types
|
||||
import { AdditionalFiltersProps } from './AdditionalFiltersToggler.interfaces';
|
||||
// ** Styles
|
||||
import { StyledInner, StyledLink } from './AdditionalFiltersToggler.styled';
|
||||
|
||||
import './AdditionalFiltersToggler.styles.scss';
|
||||
|
||||
export const AdditionalFiltersToggler = memo(function AdditionalFiltersToggler({
|
||||
children,
|
||||
listOfAdditionalFilter,
|
||||
}: AdditionalFiltersProps): JSX.Element {
|
||||
const [isOpenedFilters, setIsOpenedFilters] = useState<boolean>(false);
|
||||
|
||||
const handleToggleOpenFilters = (): void => {
|
||||
setIsOpenedFilters((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const filtersTexts: ReactNode = listOfAdditionalFilter?.map((str, index) => {
|
||||
const isNextLast = index + 1 === listOfAdditionalFilter.length - 1;
|
||||
|
||||
if (index === listOfAdditionalFilter.length - 1) {
|
||||
return (
|
||||
<Fragment key={str}>
|
||||
{listOfAdditionalFilter?.length > 1 && 'and'}{' '}
|
||||
<StyledLink>{str.toUpperCase()}</StyledLink>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={str}>
|
||||
<StyledLink>{str.toUpperCase()}</StyledLink>
|
||||
{isNextLast ? ' ' : ', '}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Row className="additinal-filters-container">
|
||||
<Col span={24}>
|
||||
<StyledInner onClick={handleToggleOpenFilters} style={{ marginBottom: 0 }}>
|
||||
{isOpenedFilters ? (
|
||||
<span className="action-btn">
|
||||
<Minus size={14} color={Color.BG_INK_500} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="action-btn">
|
||||
<Plus size={14} color={Color.BG_INK_500} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isOpenedFilters && (
|
||||
<Typography>Add conditions for {filtersTexts}</Typography>
|
||||
)}
|
||||
</StyledInner>
|
||||
</Col>
|
||||
{isOpenedFilters && <Col span={24}>{children}</Col>}
|
||||
</Row>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { AdditionalFiltersToggler } from './AdditionalFiltersToggler';
|
||||
@@ -1,10 +1,8 @@
|
||||
import { SelectProps } from 'antd';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export type QueryLabelProps = {
|
||||
onChange: (value: DataSource) => void;
|
||||
/** Defaults to every signal. */
|
||||
allowedDataSources?: TelemetrytypesSignalDTO[];
|
||||
isListViewPanel?: boolean;
|
||||
'data-testid'?: string;
|
||||
} & Omit<SelectProps, 'onChange'>;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
// ** Helpers
|
||||
@@ -8,24 +7,25 @@ import { transformToUpperCase } from 'utils/transformToUpperCase';
|
||||
|
||||
// ** Types
|
||||
import { QueryLabelProps } from './DataSourceDropdown.interfaces';
|
||||
import { signalsToDataSources } from './DataSourceDropdown.utils';
|
||||
|
||||
const ALL_SIGNALS = [
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
];
|
||||
const dataSourceMap = [DataSource.LOGS, DataSource.METRICS, DataSource.TRACES];
|
||||
|
||||
const exploreDataSourceMap = [DataSource.LOGS, DataSource.TRACES];
|
||||
|
||||
export const DataSourceDropdown = memo(function DataSourceDropdown(
|
||||
props: QueryLabelProps,
|
||||
): JSX.Element {
|
||||
const { onChange, value, style, allowedDataSources = ALL_SIGNALS } = props;
|
||||
const { onChange, value, style, isListViewPanel = false } = props;
|
||||
|
||||
const dataSourceOptions: SelectOption<DataSource, string>[] =
|
||||
signalsToDataSources(allowedDataSources).map((source) => ({
|
||||
label: transformToUpperCase(source),
|
||||
value: source,
|
||||
}));
|
||||
const dataSourceOptions: SelectOption<DataSource, string>[] = isListViewPanel
|
||||
? exploreDataSourceMap.map((source) => ({
|
||||
label: transformToUpperCase(source),
|
||||
value: source,
|
||||
}))
|
||||
: dataSourceMap.map((source) => ({
|
||||
label: transformToUpperCase(source),
|
||||
value: source,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
// Total, not Partial, so a signal added to the generated enum has to be mapped here
|
||||
// before it compiles.
|
||||
const SIGNAL_TO_DATA_SOURCE: Record<
|
||||
TelemetrytypesSignalDTO,
|
||||
DataSource | undefined
|
||||
> = {
|
||||
[TelemetrytypesSignalDTO.logs]: DataSource.LOGS,
|
||||
[TelemetrytypesSignalDTO.metrics]: DataSource.METRICS,
|
||||
[TelemetrytypesSignalDTO.traces]: DataSource.TRACES,
|
||||
// The "unset" member: not a data source a query can be built against.
|
||||
[TelemetrytypesSignalDTO['']]: undefined,
|
||||
};
|
||||
|
||||
export function signalsToDataSources(
|
||||
signals: readonly TelemetrytypesSignalDTO[],
|
||||
): DataSource[] {
|
||||
return signals
|
||||
.map((signal) => SIGNAL_TO_DATA_SOURCE[signal])
|
||||
.filter((dataSource): dataSource is DataSource => Boolean(dataSource));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
export type FilterLabelProps = {
|
||||
label: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface Props {
|
||||
isDarkMode: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const StyledLabel = styled.div<Props>`
|
||||
padding: 0 0.6875rem;
|
||||
min-height: 2rem;
|
||||
min-width: 5.625rem;
|
||||
display: inline-flex;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
border-radius: 0.125rem;
|
||||
`;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { memo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
// ** Types
|
||||
import { FilterLabelProps } from './FilterLabel.interfaces';
|
||||
// ** Styles
|
||||
import { StyledLabel } from './FilterLabel.styled';
|
||||
|
||||
export const FilterLabel = memo(function FilterLabel({
|
||||
label,
|
||||
}: FilterLabelProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return (
|
||||
<StyledLabel isDarkMode={isDarkMode}>
|
||||
<Typography
|
||||
style={{
|
||||
color: 'var(--bg-vanilla-400)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</StyledLabel>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { FilterLabel } from './FilterLabel';
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
@@ -7,5 +8,7 @@ export type FormulaProps = {
|
||||
formula: IBuilderFormula;
|
||||
index: number;
|
||||
query: IBuilderQuery;
|
||||
filterConfigs: Partial<QueryBuilderProps['filterConfigs']>;
|
||||
isAdditionalFilterEnable: boolean;
|
||||
isQBV2?: boolean;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,11 @@ import { ChangeEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { Col, Input, Row, Select } from 'antd';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { LEGEND } from 'constants/global';
|
||||
// ** Components
|
||||
import { FilterLabel } from 'container/QueryBuilder/components';
|
||||
import HavingFilter from 'container/QueryBuilder/filters/Formula/Having/HavingFilter';
|
||||
import LimitFilter from 'container/QueryBuilder/filters/Formula/Limit/Limit';
|
||||
import OrderByFilter from 'container/QueryBuilder/filters/Formula/OrderBy/OrderByFilter';
|
||||
// ** Hooks
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
@@ -12,6 +17,7 @@ import {
|
||||
import { getFormatedLegend } from 'utils/getFormatedLegend';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { AdditionalFiltersToggler } from '../AdditionalFiltersToggler';
|
||||
import QBEntityOptions from '../QBEntityOptions/QBEntityOptions';
|
||||
// ** Types
|
||||
import { FormulaProps } from './Formula.interfaces';
|
||||
@@ -21,18 +27,22 @@ import './Formula.styles.scss';
|
||||
export function Formula({
|
||||
index,
|
||||
formula,
|
||||
filterConfigs,
|
||||
query,
|
||||
isAdditionalFilterEnable,
|
||||
isQBV2,
|
||||
}: FormulaProps): JSX.Element {
|
||||
const { removeQueryBuilderEntityByIndex, handleSetFormulaData } =
|
||||
useQueryBuilder();
|
||||
|
||||
const { handleChangeFormulaData } = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
formula,
|
||||
entityVersion: '',
|
||||
});
|
||||
const { listOfAdditionalFormulaFilters, handleChangeFormulaData } =
|
||||
useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
filterConfigs,
|
||||
formula,
|
||||
entityVersion: '',
|
||||
});
|
||||
|
||||
const [isCollapse, setIsCollapsed] = useState(false);
|
||||
|
||||
@@ -73,6 +83,20 @@ export function Formula({
|
||||
[handleChangeFormulaData],
|
||||
);
|
||||
|
||||
const handleChangeHavingFilter = useCallback(
|
||||
(value: IBuilderFormula['having']) => {
|
||||
handleChangeFormulaData('having', value);
|
||||
},
|
||||
[handleChangeFormulaData],
|
||||
);
|
||||
|
||||
const handleChangeOrderByFilter = useCallback(
|
||||
(value: IBuilderFormula['orderBy']) => {
|
||||
handleChangeFormulaData('orderBy', value);
|
||||
},
|
||||
[handleChangeFormulaData],
|
||||
);
|
||||
|
||||
const handleQBV2OrderByChange = useCallback(
|
||||
(value: string) => {
|
||||
const [columnName, order] = value.split(' ');
|
||||
@@ -98,6 +122,54 @@ export function Formula({
|
||||
[formula.orderBy],
|
||||
);
|
||||
|
||||
const renderAdditionalFilters = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Col span={11}>
|
||||
<Row gutter={[11, 5]}>
|
||||
<Col flex="5.93rem">
|
||||
<FilterLabel label="Limit" />
|
||||
</Col>
|
||||
<Col flex="1 1 12.5rem">
|
||||
<LimitFilter formula={formula} onChange={handleChangeLimit} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col span={11}>
|
||||
<Row gutter={[11, 5]}>
|
||||
<Col flex="5.93rem">
|
||||
<FilterLabel label="HAVING" />
|
||||
</Col>
|
||||
<Col flex="1 1 12.5rem">
|
||||
<HavingFilter formula={formula} onChange={handleChangeHavingFilter} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col span={11}>
|
||||
<Row gutter={[11, 5]}>
|
||||
<Col flex="5.93rem">
|
||||
<FilterLabel label="Order by" />
|
||||
</Col>
|
||||
<Col flex="1 1 12.5rem">
|
||||
<OrderByFilter
|
||||
query={query}
|
||||
formula={formula}
|
||||
onChange={handleChangeOrderByFilter}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</>
|
||||
),
|
||||
[
|
||||
formula,
|
||||
handleChangeHavingFilter,
|
||||
handleChangeLimit,
|
||||
handleChangeOrderByFilter,
|
||||
query,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Row gutter={[0, 15]}>
|
||||
<QBEntityOptions
|
||||
@@ -134,6 +206,17 @@ export function Formula({
|
||||
addonBefore="Legend Format"
|
||||
/>
|
||||
</Col>
|
||||
{isAdditionalFilterEnable && (
|
||||
<Col span={24}>
|
||||
<AdditionalFiltersToggler
|
||||
listOfAdditionalFilter={listOfAdditionalFormulaFilters}
|
||||
>
|
||||
<Row gutter={[0, 11]} justify="space-between">
|
||||
{renderAdditionalFilters}
|
||||
</Row>
|
||||
</AdditionalFiltersToggler>
|
||||
</Col>
|
||||
)}
|
||||
{isQBV2 && (
|
||||
<Col span={24}>
|
||||
<div className="formula-qbv2-container">
|
||||
|
||||
@@ -84,14 +84,5 @@
|
||||
.options-group {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.query-functions-container--disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button, Col, Tooltip } from 'antd';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -27,8 +26,6 @@ interface QBEntityOptionsProps {
|
||||
query?: IBuilderQuery;
|
||||
isMetricsDataSource?: boolean;
|
||||
showFunctions?: boolean;
|
||||
areFunctionsDisabled?: boolean;
|
||||
functionsDisabledReason?: string;
|
||||
isCollapsed: boolean;
|
||||
entityType: string;
|
||||
entityData: any;
|
||||
@@ -39,8 +36,7 @@ interface QBEntityOptionsProps {
|
||||
onQueryFunctionsUpdates?: (functions: QueryFunction[]) => void;
|
||||
showDeleteButton?: boolean;
|
||||
showCloneOption?: boolean;
|
||||
isRawQuery?: boolean;
|
||||
allowedDataSources?: TelemetrytypesSignalDTO[];
|
||||
isListViewPanel?: boolean;
|
||||
index?: number;
|
||||
showTraceOperator?: boolean;
|
||||
hasTraceOperator?: boolean;
|
||||
@@ -54,15 +50,12 @@ export default function QBEntityOptions({
|
||||
isMetricsDataSource,
|
||||
isCollapsed,
|
||||
showFunctions,
|
||||
areFunctionsDisabled,
|
||||
functionsDisabledReason,
|
||||
entityType,
|
||||
entityData,
|
||||
onToggleVisibility,
|
||||
onCollapseEntity,
|
||||
onQueryFunctionsUpdates,
|
||||
isRawQuery,
|
||||
allowedDataSources,
|
||||
isListViewPanel,
|
||||
onDelete,
|
||||
showDeleteButton,
|
||||
showCloneOption,
|
||||
@@ -107,7 +100,7 @@ export default function QBEntityOptions({
|
||||
value="query-builder"
|
||||
className="periscope-btn visibility-toggle"
|
||||
onClick={onToggleVisibility}
|
||||
disabled={isRawQuery && !showTraceOperator}
|
||||
disabled={isListViewPanel && !showTraceOperator}
|
||||
>
|
||||
{entityData.disabled ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</Button>
|
||||
@@ -126,7 +119,7 @@ export default function QBEntityOptions({
|
||||
'periscope-btn',
|
||||
entityType === 'query' ? 'query-name' : 'formula-name',
|
||||
query?.dataSource === DataSource.TRACES &&
|
||||
(hasTraceOperator || (showTraceOperator && isRawQuery))
|
||||
(hasTraceOperator || (showTraceOperator && isListViewPanel))
|
||||
? 'has-trace-operator'
|
||||
: '',
|
||||
isLogsExplorerPage && lastUsedQuery === index ? 'sync-btn' : '',
|
||||
@@ -145,33 +138,24 @@ export default function QBEntityOptions({
|
||||
}}
|
||||
data-testid={`query-data-source-selector-${index}`}
|
||||
value={query?.dataSource || DataSource.METRICS}
|
||||
allowedDataSources={allowedDataSources}
|
||||
isListViewPanel={isListViewPanel}
|
||||
className="query-data-source-dropdown"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFunctions &&
|
||||
!isRawQuery &&
|
||||
!isListViewPanel &&
|
||||
(isMetricsDataSource || isLogsDataSource) &&
|
||||
query &&
|
||||
onQueryFunctionsUpdates && (
|
||||
<Tooltip title={functionsDisabledReason}>
|
||||
<div
|
||||
className={cx('query-functions-container', {
|
||||
'query-functions-container--disabled': areFunctionsDisabled,
|
||||
})}
|
||||
aria-disabled={areFunctionsDisabled}
|
||||
>
|
||||
<QueryFunctions
|
||||
query={query}
|
||||
queryFunctions={query.functions || []}
|
||||
key={query.functions?.toString()}
|
||||
onChange={onQueryFunctionsUpdates}
|
||||
maxFunctions={isLogsDataSource ? 1 : 3}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<QueryFunctions
|
||||
query={query}
|
||||
queryFunctions={query.functions || []}
|
||||
key={query.functions?.toString()}
|
||||
onChange={onQueryFunctionsUpdates}
|
||||
maxFunctions={isLogsDataSource ? 1 : 3}
|
||||
/>
|
||||
)}
|
||||
</Button.Group>
|
||||
</div>
|
||||
@@ -184,7 +168,7 @@ export default function QBEntityOptions({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDeleteButton && !isRawQuery && (
|
||||
{showDeleteButton && !isListViewPanel && (
|
||||
<Button className="periscope-btn ghost" onClick={onDelete}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
@@ -195,14 +179,11 @@ export default function QBEntityOptions({
|
||||
}
|
||||
|
||||
QBEntityOptions.defaultProps = {
|
||||
isRawQuery: false,
|
||||
allowedDataSources: undefined,
|
||||
isListViewPanel: false,
|
||||
query: undefined,
|
||||
isMetricsDataSource: false,
|
||||
onQueryFunctionsUpdates: undefined,
|
||||
showFunctions: false,
|
||||
areFunctionsDisabled: false,
|
||||
functionsDisabledReason: undefined,
|
||||
onCloneQuery: noop,
|
||||
index: 0,
|
||||
onDelete: noop,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export { AdditionalFiltersToggler } from './AdditionalFiltersToggler';
|
||||
export { DataSourceDropdown } from './DataSourceDropdown';
|
||||
export { FilterLabel } from './FilterLabel';
|
||||
export { Formula } from './Formula';
|
||||
export { HavingFilterTag } from './HavingFilterTag';
|
||||
export { ListItemWrapper } from './ListItemWrapper';
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { HAVING_OPERATORS, initialHavingValues } from 'constants/queryBuilder';
|
||||
import { HavingFilterTag } from 'container/QueryBuilder/components';
|
||||
import { useTagValidation } from 'hooks/queryBuilder/useTagValidation';
|
||||
import {
|
||||
transformFromStringToHaving,
|
||||
transformHavingToStringValue,
|
||||
} from 'lib/query/transformQueryBuilderData';
|
||||
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { getHavingObject, isValidHavingValue } from '../../utils';
|
||||
import { HavingFilterProps, HavingTagRenderProps } from './types';
|
||||
|
||||
function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const { having } = formula;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [localValues, setLocalValues] = useState<string[]>([]);
|
||||
const [currentFormValue, setCurrentFormValue] =
|
||||
useState<HavingForm>(initialHavingValues);
|
||||
const [options, setOptions] = useState<SelectOption<string, string>[]>([]);
|
||||
|
||||
const { isMulti } = useTagValidation(
|
||||
currentFormValue.op,
|
||||
currentFormValue.value,
|
||||
);
|
||||
|
||||
const columnName = formula.expression.replace(/ /g, '').toUpperCase();
|
||||
|
||||
const aggregatorOptions: SelectOption<string, string>[] = useMemo(
|
||||
() => [{ label: columnName, value: columnName }],
|
||||
[columnName],
|
||||
);
|
||||
|
||||
const handleUpdateTag = useCallback(
|
||||
(value: string) => {
|
||||
const filteredValues = localValues.filter(
|
||||
(currentValue) => currentValue !== value,
|
||||
);
|
||||
const having: Having[] = filteredValues.map(transformFromStringToHaving);
|
||||
|
||||
onChange(having);
|
||||
setSearchText(value);
|
||||
},
|
||||
[localValues, onChange],
|
||||
);
|
||||
|
||||
const generateOptions = useCallback(
|
||||
(currentString: string) => {
|
||||
const [aggregator = '', op = '', ...restValue] = currentString.split(' ');
|
||||
let newOptions: SelectOption<string, string>[] = [];
|
||||
|
||||
const isAggregatorExist = columnName
|
||||
.toLowerCase()
|
||||
.includes(currentString.toLowerCase());
|
||||
|
||||
const isAggregatorChosen = aggregator === columnName;
|
||||
|
||||
if (isAggregatorExist || aggregator === '') {
|
||||
newOptions = aggregatorOptions;
|
||||
}
|
||||
|
||||
if ((isAggregatorChosen && op === '') || op) {
|
||||
const filteredOperators = HAVING_OPERATORS.filter((num) =>
|
||||
num.toLowerCase().includes(op.toLowerCase()),
|
||||
);
|
||||
|
||||
newOptions = filteredOperators.map((opt) => ({
|
||||
label: `${columnName} ${opt} ${restValue && restValue.join(' ')}`,
|
||||
value: `${columnName} ${opt} ${restValue && restValue.join(' ')}`,
|
||||
}));
|
||||
}
|
||||
|
||||
setOptions(newOptions);
|
||||
},
|
||||
[aggregatorOptions, columnName],
|
||||
);
|
||||
|
||||
const parseSearchText = useCallback(
|
||||
(text: string) => {
|
||||
const { columnName, op, value } = getHavingObject(text);
|
||||
setCurrentFormValue({ columnName, op, value });
|
||||
|
||||
generateOptions(text);
|
||||
},
|
||||
[generateOptions],
|
||||
);
|
||||
|
||||
const tagRender = ({
|
||||
label,
|
||||
value,
|
||||
closable,
|
||||
disabled,
|
||||
onClose,
|
||||
}: HavingTagRenderProps): JSX.Element => {
|
||||
const handleClose = (): void => {
|
||||
onClose();
|
||||
setSearchText('');
|
||||
};
|
||||
return (
|
||||
<HavingFilterTag
|
||||
label={label}
|
||||
value={value}
|
||||
closable={closable}
|
||||
disabled={disabled}
|
||||
onClose={handleClose}
|
||||
onUpdate={handleUpdateTag}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const handleSearch = (search: string): void => {
|
||||
const trimmedSearch = search.replace(/\s\s+/g, ' ').trimStart();
|
||||
|
||||
const currentSearch = isMulti
|
||||
? trimmedSearch
|
||||
: trimmedSearch.split(' ').slice(0, 3).join(' ');
|
||||
|
||||
const isValidSearch = isValidHavingValue(currentSearch);
|
||||
|
||||
if (isValidSearch) {
|
||||
setSearchText(currentSearch);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValues(transformHavingToStringValue(having || []));
|
||||
}, [having]);
|
||||
|
||||
useEffect(() => {
|
||||
parseSearchText(searchText);
|
||||
}, [searchText, parseSearchText]);
|
||||
|
||||
const resetChanges = (): void => {
|
||||
setSearchText('');
|
||||
setCurrentFormValue(initialHavingValues);
|
||||
setOptions(aggregatorOptions);
|
||||
};
|
||||
|
||||
const handleDeselect = (value: string): void => {
|
||||
const result = localValues.filter((item) => item !== value);
|
||||
const having: Having[] = result.map(transformFromStringToHaving);
|
||||
onChange(having);
|
||||
resetChanges();
|
||||
};
|
||||
|
||||
const handleSelect = (currentValue: string): void => {
|
||||
const { columnName, op, value } = getHavingObject(currentValue);
|
||||
|
||||
const isCompletedValue = value.every((item) => !!item);
|
||||
|
||||
const isClearSearch = isCompletedValue && columnName && op;
|
||||
|
||||
setSearchText(isClearSearch ? '' : currentValue);
|
||||
};
|
||||
|
||||
const handleChange = (values: string[]): void => {
|
||||
const having: Having[] = values.map(transformFromStringToHaving);
|
||||
|
||||
const isSelectable =
|
||||
currentFormValue.value.length > 0 &&
|
||||
currentFormValue.value.every((value) => !!value);
|
||||
|
||||
if (isSelectable) {
|
||||
onChange(having);
|
||||
resetChanges();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={getPopupContainer}
|
||||
autoClearSearchValue={false}
|
||||
mode="multiple"
|
||||
onSearch={handleSearch}
|
||||
searchValue={searchText}
|
||||
data-testid="havingSelectFormula"
|
||||
placeholder="Count(operation) > 5"
|
||||
style={{ width: '100%' }}
|
||||
tagRender={tagRender}
|
||||
onDeselect={handleDeselect}
|
||||
onSelect={handleSelect}
|
||||
onChange={handleChange}
|
||||
value={localValues}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<Select.Option key={opt.value} value={opt.value} title="havingOption">
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export default HavingFilter;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { HavingFilterTagProps } from 'container/QueryBuilder/components/HavingFilterTag/HavingFilterTag.interfaces';
|
||||
import {
|
||||
Having,
|
||||
IBuilderFormula,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export type HavingFilterProps = {
|
||||
formula: IBuilderFormula;
|
||||
onChange: (having: Having[]) => void;
|
||||
};
|
||||
|
||||
export type HavingTagRenderProps = Omit<HavingFilterTagProps, 'onUpdate'>;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { InputNumber } from 'antd';
|
||||
|
||||
import { selectStyle } from '../../QueryBuilderSearchV2/config';
|
||||
import { handleKeyDownLimitFilter } from '../../utils';
|
||||
import { LimitFilterProps } from './types';
|
||||
|
||||
function LimitFilter({ onChange, formula }: LimitFilterProps): JSX.Element {
|
||||
return (
|
||||
<InputNumber
|
||||
min={1}
|
||||
type="number"
|
||||
value={formula.limit}
|
||||
style={selectStyle}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDownLimitFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default LimitFilter;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface LimitFilterProps {
|
||||
onChange: (values: number | null) => void;
|
||||
formula: IBuilderFormula;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../../QueryBuilderSearchV2/config';
|
||||
import { OrderByProps } from './types';
|
||||
import { useOrderByFormulaFilter } from './useOrderByFormulaFilter';
|
||||
|
||||
function OrderByFilter({
|
||||
formula,
|
||||
onChange,
|
||||
query,
|
||||
}: OrderByProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const {
|
||||
debouncedSearchText,
|
||||
createOptions,
|
||||
aggregationOptions,
|
||||
handleChange,
|
||||
handleSearchKeys,
|
||||
selectedValue,
|
||||
generateOptions,
|
||||
} = useOrderByFormulaFilter({
|
||||
query,
|
||||
onChange,
|
||||
formula,
|
||||
});
|
||||
|
||||
const { data, isFetching } = useGetAggregateKeys(
|
||||
{
|
||||
aggregateAttribute: query.aggregateAttribute?.key || '',
|
||||
dataSource: query.dataSource,
|
||||
aggregateOperator: query.aggregateOperator || '',
|
||||
searchText: debouncedSearchText,
|
||||
},
|
||||
{
|
||||
enabled: !!query.aggregateAttribute?.key,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const optionsData = useMemo(() => {
|
||||
const keyOptions = createOptions(data?.payload?.attributeKeys || []);
|
||||
const groupByOptions = createOptions(query.groupBy);
|
||||
const options =
|
||||
query.aggregateOperator === MetricAggregateOperator.NOOP
|
||||
? keyOptions
|
||||
: [...groupByOptions, ...aggregationOptions];
|
||||
|
||||
return generateOptions(options);
|
||||
}, [
|
||||
aggregationOptions,
|
||||
createOptions,
|
||||
data?.payload?.attributeKeys,
|
||||
generateOptions,
|
||||
query.aggregateOperator,
|
||||
query.groupBy,
|
||||
]);
|
||||
|
||||
const isDisabledSelect =
|
||||
!query.aggregateAttribute?.key ||
|
||||
query.aggregateOperator === MetricAggregateOperator.NOOP;
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={getPopupContainer}
|
||||
mode="tags"
|
||||
style={selectStyle}
|
||||
onSearch={handleSearchKeys}
|
||||
showSearch
|
||||
disabled={isDisabledSelect}
|
||||
showArrow={false}
|
||||
value={selectedValue}
|
||||
labelInValue
|
||||
filterOption={false}
|
||||
options={optionsData}
|
||||
notFoundContent={isFetching ? <Spin size="small" /> : null}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrderByFilter;
|
||||
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface OrderByProps {
|
||||
formula: IBuilderFormula;
|
||||
query: IBuilderQuery;
|
||||
onChange: (value: IBuilderFormula['orderBy']) => void;
|
||||
}
|
||||
|
||||
export type IOrderByFormulaFilterProps = OrderByProps;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { IOption } from 'hooks/useResourceAttribute/types';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import uniqWith from 'lodash-es/uniqWith';
|
||||
import { parse } from 'papaparse';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { ORDERBY_FILTERS } from '../../OrderByFilter/config';
|
||||
import { SIGNOZ_VALUE } from '../../OrderByFilter/constants';
|
||||
import { UseOrderByFilterResult } from '../../OrderByFilter/useOrderByFilter';
|
||||
import {
|
||||
getLabelFromValue,
|
||||
mapLabelValuePairs,
|
||||
orderByValueDelimiter,
|
||||
} from '../../OrderByFilter/utils';
|
||||
import { getRemoveOrderFromValue } from '../../QueryBuilderSearchV2/utils';
|
||||
import { getUniqueOrderByValues, getValidOrderByResult } from '../../utils';
|
||||
import { IOrderByFormulaFilterProps } from './types';
|
||||
import { transformToOrderByStringValuesByFormula } from './utils';
|
||||
|
||||
export const useOrderByFormulaFilter = ({
|
||||
onChange,
|
||||
formula,
|
||||
}: IOrderByFormulaFilterProps): UseOrderByFilterResult => {
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
|
||||
const debouncedSearchText = useDebounce(searchText, DEBOUNCE_DELAY);
|
||||
|
||||
const handleSearchKeys = (searchText: string): void =>
|
||||
setSearchText(searchText);
|
||||
|
||||
const handleChange = (values: IOption[]): void => {
|
||||
const validResult = getValidOrderByResult(values);
|
||||
const result = getUniqueOrderByValues(validResult);
|
||||
|
||||
const orderByValues: OrderByPayload[] = result.map((item) => {
|
||||
const match = parse(item.value, { delimiter: orderByValueDelimiter });
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
columnName: item.value,
|
||||
order: ORDERBY_FILTERS.ASC,
|
||||
};
|
||||
}
|
||||
|
||||
const [columnName, order] = match.data.flat() as string[];
|
||||
|
||||
const columnNameValue =
|
||||
columnName === SIGNOZ_VALUE ? SIGNOZ_VALUE : columnName;
|
||||
|
||||
const orderValue = order ?? ORDERBY_FILTERS.ASC;
|
||||
|
||||
return {
|
||||
columnName: columnNameValue,
|
||||
order: orderValue,
|
||||
};
|
||||
});
|
||||
|
||||
setSearchText('');
|
||||
onChange(orderByValues);
|
||||
};
|
||||
|
||||
const aggregationOptions = [
|
||||
{
|
||||
label: `${formula.expression} ${ORDERBY_FILTERS.ASC}`,
|
||||
value: `${SIGNOZ_VALUE}${orderByValueDelimiter}${ORDERBY_FILTERS.ASC}`,
|
||||
},
|
||||
{
|
||||
label: `${formula.expression} ${ORDERBY_FILTERS.DESC}`,
|
||||
value: `${SIGNOZ_VALUE}${orderByValueDelimiter}${ORDERBY_FILTERS.DESC}`,
|
||||
},
|
||||
];
|
||||
|
||||
const selectedValue = transformToOrderByStringValuesByFormula(formula);
|
||||
|
||||
const createOptions = (data: BaseAutocompleteData[]): IOption[] =>
|
||||
mapLabelValuePairs(data).flat();
|
||||
|
||||
const customValue: IOption[] = useMemo(() => {
|
||||
if (!searchText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: `${searchText} ${ORDERBY_FILTERS.ASC}`,
|
||||
value: `${searchText}${orderByValueDelimiter}${ORDERBY_FILTERS.ASC}`,
|
||||
},
|
||||
{
|
||||
label: `${searchText} ${ORDERBY_FILTERS.DESC}`,
|
||||
value: `${searchText}${orderByValueDelimiter}${ORDERBY_FILTERS.DESC}`,
|
||||
},
|
||||
];
|
||||
}, [searchText]);
|
||||
|
||||
const generateOptions = (options: IOption[]): IOption[] => {
|
||||
const currentCustomValue = options.find(
|
||||
(keyOption) =>
|
||||
getRemoveOrderFromValue(keyOption.value) === debouncedSearchText,
|
||||
)
|
||||
? []
|
||||
: customValue;
|
||||
|
||||
const result = [...currentCustomValue, ...options];
|
||||
|
||||
const uniqResult = uniqWith(result, isEqual);
|
||||
|
||||
return uniqResult.filter(
|
||||
(option) =>
|
||||
!getLabelFromValue(selectedValue).includes(
|
||||
getRemoveOrderFromValue(option.value),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
searchText,
|
||||
debouncedSearchText,
|
||||
selectedValue,
|
||||
aggregationOptions,
|
||||
createOptions,
|
||||
handleChange,
|
||||
handleSearchKeys,
|
||||
generateOptions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IOption } from 'hooks/useResourceAttribute/types';
|
||||
import { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { SIGNOZ_VALUE } from '../../OrderByFilter/constants';
|
||||
import { orderByValueDelimiter } from '../../OrderByFilter/utils';
|
||||
|
||||
export const transformToOrderByStringValuesByFormula = (
|
||||
formula: IBuilderFormula,
|
||||
): IOption[] => {
|
||||
const prepareSelectedValue: IOption[] =
|
||||
formula?.orderBy?.map((item) => {
|
||||
if (item.columnName === SIGNOZ_VALUE) {
|
||||
return {
|
||||
label: `${formula.expression} ${item.order}`,
|
||||
value: `${item.columnName}${orderByValueDelimiter}${item.order}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: `${item.columnName} ${item.order}`,
|
||||
value: `${item.columnName}${orderByValueDelimiter}${item.order}`,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return prepareSelectedValue;
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
export type OrderByFilterProps = {
|
||||
query: IBuilderQuery;
|
||||
onChange: (values: OrderByPayload[]) => void;
|
||||
isRawQuery?: boolean;
|
||||
isListViewPanel?: boolean;
|
||||
entityVersion?: string;
|
||||
isNewQueryV2?: boolean;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useOrderByFilter } from './useOrderByFilter';
|
||||
export function OrderByFilter({
|
||||
query,
|
||||
onChange,
|
||||
isRawQuery = false,
|
||||
isListViewPanel = false,
|
||||
entityVersion,
|
||||
isNewQueryV2 = false,
|
||||
}: OrderByFilterProps): JSX.Element {
|
||||
@@ -35,7 +35,7 @@ export function OrderByFilter({
|
||||
searchText: debouncedSearchText,
|
||||
},
|
||||
{
|
||||
enabled: !!query.aggregateAttribute?.key || isRawQuery,
|
||||
enabled: !!query.aggregateAttribute?.key || isListViewPanel,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
QUERY_BUILDER_SEARCH_VALUES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import type { WhereClauseConfig } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
|
||||
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
|
||||
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
@@ -87,6 +88,7 @@ interface CustomTagProps {
|
||||
interface QueryBuilderSearchV2Props {
|
||||
query: IBuilderQuery;
|
||||
onChange: (value: TagFilter) => void;
|
||||
whereClauseConfig?: WhereClauseConfig;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
suffixIcon?: React.ReactNode;
|
||||
@@ -143,6 +145,7 @@ function QueryBuilderSearchV2(
|
||||
placeholder,
|
||||
className,
|
||||
suffixIcon,
|
||||
whereClauseConfig,
|
||||
hardcodedAttributeKeys,
|
||||
hasPopupContainer,
|
||||
rootClassName,
|
||||
@@ -474,7 +477,31 @@ function QueryBuilderSearchV2(
|
||||
if (searchValue) {
|
||||
const operatorType =
|
||||
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
|
||||
// if key is added and operator is not present then convert to body CONTAINS key
|
||||
if (
|
||||
currentFilterItem?.key &&
|
||||
isEmpty(currentFilterItem?.op) &&
|
||||
whereClauseConfig?.customKey === 'body' &&
|
||||
whereClauseConfig?.customOp === OPERATORS.CONTAINS
|
||||
) {
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: {
|
||||
key: 'body',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
id: 'body--string----true',
|
||||
},
|
||||
op: OPERATORS.CONTAINS,
|
||||
value: currentFilterItem?.key?.key,
|
||||
},
|
||||
]);
|
||||
setCurrentFilterItem(undefined);
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
} else if (
|
||||
currentFilterItem?.op === OPERATORS.EXISTS ||
|
||||
currentFilterItem?.op === OPERATORS.NOT_EXISTS
|
||||
) {
|
||||
@@ -516,6 +543,8 @@ function QueryBuilderSearchV2(
|
||||
currentFilterItem?.op,
|
||||
currentFilterItem?.value,
|
||||
searchValue,
|
||||
whereClauseConfig?.customKey,
|
||||
whereClauseConfig?.customOp,
|
||||
]);
|
||||
|
||||
// this useEffect takes care of tokenisation based on the search state
|
||||
@@ -1056,6 +1085,7 @@ QueryBuilderSearchV2.defaultProps = {
|
||||
placeholder: PLACEHOLDER,
|
||||
className: '',
|
||||
suffixIcon: null,
|
||||
whereClauseConfig: {},
|
||||
hasPopupContainer: true,
|
||||
rootClassName: '',
|
||||
hardcodedAttributeKeys: undefined,
|
||||
|
||||
@@ -26,7 +26,7 @@ export type QueryProps = {
|
||||
isAvailableToDisable: boolean;
|
||||
query: IBuilderQuery;
|
||||
queryVariant?: 'static' | 'dropdown';
|
||||
isRawQuery?: boolean;
|
||||
isListViewPanel?: boolean;
|
||||
showFunctions?: boolean;
|
||||
version: string;
|
||||
showSpanScopeSelector?: boolean;
|
||||
@@ -35,4 +35,4 @@ export type QueryProps = {
|
||||
hasTraceOperator?: boolean;
|
||||
signalSource?: string;
|
||||
isMultiQueryAllowed?: boolean;
|
||||
} & Pick<QueryBuilderProps, 'fieldsConfig' | 'allowedDataSources'>;
|
||||
} & Pick<QueryBuilderProps, 'filterConfigs' | 'queryComponents'>;
|
||||
|
||||
@@ -1,23 +1,55 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExplorerOrderBy from 'container/ExplorerOrderBy';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const isRawQuery = useMemo(
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
const isList = panelTypes === PANEL_TYPES.LIST;
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: false, isDisabled: false },
|
||||
limit: { isHidden: isList, isDisabled: true },
|
||||
having: { isHidden: isList, isDisabled: true },
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [panelTypes]);
|
||||
|
||||
const renderOrderBy = useCallback(
|
||||
({ query, onChange }: OrderByFilterProps) => (
|
||||
<ExplorerOrderBy query={query} onChange={onChange} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
|
||||
const shouldRenderCustomOrderBy =
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
|
||||
|
||||
return {
|
||||
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
|
||||
};
|
||||
}, [panelTypes, renderOrderBy]);
|
||||
|
||||
const isListViewPanel = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
[panelTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isRawQuery={isRawQuery}
|
||||
isListViewPanel={isListViewPanel}
|
||||
showTraceOperator
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
queryComponents={queryComponents}
|
||||
panelType={panelTypes}
|
||||
filterConfigs={filterConfigs}
|
||||
showOnlyWhereClause={
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ function FullView({
|
||||
<QueryBuilderV2
|
||||
panelType={selectedPanelType}
|
||||
version="v3"
|
||||
isRawQuery={selectedPanelType === PANEL_TYPES.LIST}
|
||||
isListViewPanel={selectedPanelType === PANEL_TYPES.LIST}
|
||||
signalSourceChangeEnabled
|
||||
// filterConfigs={filterConfigs}
|
||||
// queryComponents={queryComponents}
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
listViewInitialLogQuery,
|
||||
listViewInitialTraceQuery,
|
||||
mapOfFormulaToFilters,
|
||||
mapOfQueryFilters,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
@@ -57,8 +59,9 @@ import { getFormatedLegend } from 'utils/getFormatedLegend';
|
||||
export const useQueryOperations: UseQueryOperations = ({
|
||||
query,
|
||||
index,
|
||||
filterConfigs,
|
||||
formula,
|
||||
isRawQuery = false,
|
||||
isListViewPanel = false,
|
||||
entityVersion,
|
||||
isForTraceOperator = false,
|
||||
savePreviousQuery = false,
|
||||
@@ -102,7 +105,46 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
const { dataSource } = query;
|
||||
const { dataSource, aggregateOperator } = query;
|
||||
|
||||
const getNewListOfAdditionalFilters = useCallback(
|
||||
(dataSource: DataSource, isQuery: boolean): string[] => {
|
||||
const additionalFiltersKeys: (keyof Pick<
|
||||
IBuilderQuery,
|
||||
'orderBy' | 'limit' | 'having' | 'stepInterval'
|
||||
>)[] = ['having', 'limit', 'orderBy', 'stepInterval'];
|
||||
|
||||
const mapsOfFilters = isQuery ? mapOfQueryFilters : mapOfFormulaToFilters;
|
||||
|
||||
const result: string[] = mapsOfFilters[dataSource]?.reduce<string[]>(
|
||||
(acc, item) => {
|
||||
if (
|
||||
filterConfigs &&
|
||||
filterConfigs[item.field as (typeof additionalFiltersKeys)[number]]
|
||||
?.isHidden
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push(item.text);
|
||||
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
[filterConfigs],
|
||||
);
|
||||
|
||||
const [listOfAdditionalFilters, setListOfAdditionalFilters] = useState<
|
||||
string[]
|
||||
>(getNewListOfAdditionalFilters(dataSource, true));
|
||||
|
||||
const [listOfAdditionalFormulaFilters, setListOfAdditionalFormulaFilters] =
|
||||
useState<string[]>(getNewListOfAdditionalFilters(dataSource, false));
|
||||
|
||||
const handleChangeOperator = useCallback(
|
||||
(value: string): void => {
|
||||
@@ -418,7 +460,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
removeKeyFromPreviousQuery(newKey);
|
||||
}
|
||||
|
||||
if (isRawQuery) {
|
||||
if (isListViewPanel) {
|
||||
let listPanelQuery: Query | null = null;
|
||||
|
||||
if (nextSource === DataSource.LOGS) {
|
||||
@@ -464,7 +506,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
handleSetQueryData(index, newQueryData);
|
||||
},
|
||||
[
|
||||
isRawQuery,
|
||||
isListViewPanel,
|
||||
panelType,
|
||||
query,
|
||||
handleSetQueryData,
|
||||
@@ -583,18 +625,32 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
handleMetricAggregateAtributeTypes,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const additionalFilters = getNewListOfAdditionalFilters(dataSource, true);
|
||||
|
||||
setListOfAdditionalFilters(additionalFilters);
|
||||
}, [dataSource, aggregateOperator, getNewListOfAdditionalFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
const additionalFilters = getNewListOfAdditionalFilters(dataSource, false);
|
||||
|
||||
setListOfAdditionalFormulaFilters(additionalFilters);
|
||||
}, [dataSource, aggregateOperator, getNewListOfAdditionalFilters]);
|
||||
|
||||
return {
|
||||
isTracePanelType,
|
||||
isMetricsDataSource,
|
||||
isLogsDataSource,
|
||||
operators,
|
||||
spaceAggregationOptions,
|
||||
listOfAdditionalFilters,
|
||||
handleChangeOperator,
|
||||
handleSpaceAggregationChange,
|
||||
handleChangeAggregatorAttribute,
|
||||
handleChangeDataSource,
|
||||
handleDeleteQuery,
|
||||
handleChangeQueryData,
|
||||
listOfAdditionalFormulaFilters,
|
||||
handleChangeFormulaData,
|
||||
handleQueryFunctionsUpdates,
|
||||
};
|
||||
|
||||
@@ -9,17 +9,19 @@ import { Atom, Terminal } from '@signozhq/icons';
|
||||
import { Tabs } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import ClickHouseQueryContainer from 'container/QueryBuilder/rawQueryEditors/ClickHouse';
|
||||
import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { isRawRequest } from '../../Panels/types/panelCapabilities';
|
||||
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
|
||||
import { toPanelType } from '../../Panels/types/panelKind';
|
||||
|
||||
@@ -28,6 +30,8 @@ import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
interface PanelEditorQueryBuilderProps {
|
||||
/** The edited kind's definition — drives supported query types + field visibility. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** The panel's current signal; selects per-signal query-builder field rules. */
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
|
||||
isLoadingQueries: boolean;
|
||||
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
|
||||
@@ -47,6 +51,7 @@ interface PanelEditorQueryBuilderProps {
|
||||
*/
|
||||
function PanelEditorQueryBuilder({
|
||||
panelDefinition,
|
||||
signal,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
@@ -58,7 +63,7 @@ function PanelEditorQueryBuilder({
|
||||
const panelType = toPanelType(panelDefinition.kind);
|
||||
// Raw rows: the builder drops its aggregation controls, and with them the trace
|
||||
// operator that combines aggregated trace queries (V1 parity).
|
||||
const isRawQuery = isRawRequest(panelDefinition.queryCapabilities);
|
||||
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -86,6 +91,13 @@ function PanelEditorQueryBuilder({
|
||||
[onStageRunQuery],
|
||||
);
|
||||
|
||||
// Per-kind query-builder field rules from the guard (e.g. List hides step interval
|
||||
// and having), passed to QueryBuilderV2 as its `filterConfigs`.
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
() => mergeQueryBuilderFieldRule(panelDefinition.queryBuilderFields, signal),
|
||||
[panelDefinition.queryBuilderFields, signal],
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const { supportedQueryTypes } = panelDefinition;
|
||||
|
||||
@@ -97,10 +109,11 @@ function PanelEditorQueryBuilder({
|
||||
<div className="query-builder-v2-container">
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
fieldsConfig={panelDefinition.queryBuilderFields}
|
||||
showTraceOperator={!isRawQuery}
|
||||
filterConfigs={filterConfigs}
|
||||
showTraceOperator={!isListViewPanel}
|
||||
version="v3"
|
||||
isRawQuery={isRawQuery}
|
||||
isListViewPanel={isListViewPanel}
|
||||
queryComponents={{}}
|
||||
signalSourceChangeEnabled
|
||||
savePreviousQuery
|
||||
/>
|
||||
@@ -133,7 +146,7 @@ function PanelEditorQueryBuilder({
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
}));
|
||||
}, [panelDefinition, panelType, isDarkMode, isRawQuery]);
|
||||
}, [panelDefinition, panelType, filterConfigs, isDarkMode, isListViewPanel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -7,6 +7,7 @@ import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
|
||||
*/
|
||||
function QueryBuilderEditorPane({
|
||||
panelDefinition,
|
||||
signal,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
@@ -15,6 +16,7 @@ function QueryBuilderEditorPane({
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={panelDefinition}
|
||||
signal={signal}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
onCancelQuery={onCancelQuery}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
@@ -43,10 +45,14 @@ jest.mock('assets/Dashboard/PromQl', () => ({
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
|
||||
function renderBuilder(panelKind: string): void {
|
||||
function renderBuilder(
|
||||
panelKind: string,
|
||||
signal: TelemetrytypesSignalDTO = TelemetrytypesSignalDTO.logs,
|
||||
): void {
|
||||
render(
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
|
||||
signal={signal}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={jest.fn()}
|
||||
onCancelQuery={jest.fn()}
|
||||
@@ -56,9 +62,9 @@ function renderBuilder(panelKind: string): void {
|
||||
|
||||
function lastQueryBuilderProps(): {
|
||||
panelType: string;
|
||||
isRawQuery: boolean;
|
||||
isListViewPanel: boolean;
|
||||
showTraceOperator: boolean;
|
||||
fieldsConfig: unknown;
|
||||
filterConfigs: unknown;
|
||||
} {
|
||||
const calls = mockQueryBuilderV2.mock.calls;
|
||||
return calls[calls.length - 1][0];
|
||||
@@ -74,7 +80,7 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
|
||||
});
|
||||
|
||||
it('shows only the Query Builder tab for the List kind', () => {
|
||||
renderBuilder('signoz/ListPanel');
|
||||
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
|
||||
|
||||
expect(screen.getByText('Query Builder')).toBeInTheDocument();
|
||||
expect(screen.queryByText('ClickHouse Query')).not.toBeInTheDocument();
|
||||
@@ -108,24 +114,40 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
|
||||
});
|
||||
|
||||
it('passes empty field config + non-list flag for a non-list kind', () => {
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
renderBuilder('signoz/TimeSeriesPanel', TelemetrytypesSignalDTO.metrics);
|
||||
|
||||
const props = lastQueryBuilderProps();
|
||||
expect(props.panelType).toBe('graph');
|
||||
expect(props.isRawQuery).toBe(false);
|
||||
expect(props.isListViewPanel).toBe(false);
|
||||
// The trace operator combines aggregated trace queries, so it rides along with
|
||||
// the aggregation controls.
|
||||
expect(props.showTraceOperator).toBe(true);
|
||||
expect(props.fieldsConfig).toStrictEqual({});
|
||||
expect(props.filterConfigs).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('marks List raw and leaves the field surface to the raw baseline', () => {
|
||||
renderBuilder('signoz/ListPanel');
|
||||
it('hides step interval / having and sets body-contains for List + logs', () => {
|
||||
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
|
||||
|
||||
const props = lastQueryBuilderProps();
|
||||
expect(props.panelType).toBe('list');
|
||||
expect(props.isRawQuery).toBe(true);
|
||||
expect(props.isListViewPanel).toBe(true);
|
||||
expect(props.showTraceOperator).toBe(false);
|
||||
expect(props.fieldsConfig).toStrictEqual({});
|
||||
expect(props.filterConfigs).toStrictEqual({
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
});
|
||||
});
|
||||
|
||||
it('additionally hides limit for List + traces', () => {
|
||||
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.traces);
|
||||
|
||||
const props = lastQueryBuilderProps();
|
||||
expect(props.filterConfigs).toStrictEqual({
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
limit: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,6 +86,7 @@ function EditorRoute(): JSX.Element {
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={noop}
|
||||
onCancelQuery={noop}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { UNSUPPORTED_PANEL } from '../kinds/UnsupportedPanel/definition';
|
||||
@@ -9,6 +10,7 @@ import { getPanelDefinition, isPanelKindSupported } from '../registry';
|
||||
import type { PanelQueryCapabilities } from '../types/panelCapabilities';
|
||||
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
|
||||
import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getQueryPanelDefinition,
|
||||
requireQueryPanelDefinition,
|
||||
getSupportedQueryTypes,
|
||||
@@ -142,6 +144,7 @@ describe('panel capabilities guard', () => {
|
||||
expect(
|
||||
isPanelCombinationValid({ kind: unknownKind, queryType: QUERY_BUILDER }),
|
||||
).toBe(false);
|
||||
expect(getHiddenQueryBuilderFields(unknownKind, logs)).toStrictEqual({});
|
||||
expect(getPanelDefinition(unknownKind).sections).toStrictEqual([]);
|
||||
});
|
||||
|
||||
@@ -264,4 +267,37 @@ describe('panel capabilities guard', () => {
|
||||
expect(resolveQueryType('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHiddenQueryBuilderFields', () => {
|
||||
it('returns {} for kinds that declare no field rules', () => {
|
||||
expect(
|
||||
getHiddenQueryBuilderFields('signoz/TimeSeriesPanel', logs),
|
||||
).toStrictEqual({});
|
||||
expect(getHiddenQueryBuilderFields('signoz/TablePanel', logs)).toStrictEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
// Mirrors QueryBuilderV2's internal listViewLogFilterConfigs — the guard is the
|
||||
// single source of truth for these values.
|
||||
it('hides step interval / having and sets body-contains for List + logs', () => {
|
||||
expect(getHiddenQueryBuilderFields('signoz/ListPanel', logs)).toStrictEqual({
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
});
|
||||
});
|
||||
|
||||
// Mirrors listViewTracesFilterConfigs — traces additionally hide `limit`.
|
||||
it('additionally hides limit for List + traces', () => {
|
||||
expect(
|
||||
getHiddenQueryBuilderFields('signoz/ListPanel', traces),
|
||||
).toStrictEqual({
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
limit: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { type TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { getPanelDefinition } from './registry';
|
||||
import {
|
||||
mergeQueryBuilderFieldRule,
|
||||
type FilterConfigsPartial,
|
||||
} from './types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
|
||||
import type { PanelKind } from './types/panelKind';
|
||||
|
||||
/**
|
||||
* The single deterministic guard for V2 dashboards. Every "what works with what"
|
||||
* question — panel kind × query type × signal — is answered here by reading each kind's
|
||||
* declared capabilities from the panel registry. Adding a new kind means declaring its
|
||||
* capabilities once in its definition;
|
||||
* question — panel kind × query type × signal, and which query-builder fields a kind
|
||||
* hides — is answered here by reading each kind's declared capabilities from the panel
|
||||
* registry. Adding a new kind means declaring its capabilities once in its definition;
|
||||
* these functions then cover it automatically. Pure and side-effect free.
|
||||
*/
|
||||
|
||||
@@ -118,3 +122,16 @@ export function resolveQueryType(
|
||||
// A query-less kind has no supported types; the builder is the neutral answer.
|
||||
return supported[0] ?? EQueryType.QUERY_BUILDER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-builder field visibility for a kind + signal: the kind's `default` rule with
|
||||
* its per-signal overrides merged over it (signal wins). `{}` when the kind hides
|
||||
* nothing, i.e. the builder shows every field.
|
||||
*/
|
||||
export function getHiddenQueryBuilderFields(
|
||||
kind: PanelKind,
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): FilterConfigsPartial {
|
||||
const rule = getQueryPanelDefinition(kind)?.queryBuilderFields ?? {};
|
||||
return mergeQueryBuilderFieldRule(rule, signal);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ function ListEditorPane({
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={panelDefinition}
|
||||
signal={signal}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
onCancelQuery={onCancelQuery}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
@@ -22,9 +23,21 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
// Raw rows have no aggregation, so step interval / having never apply, and the
|
||||
// Where clause searches the log/span body via `body CONTAINS`. Traces additionally
|
||||
// hide `limit` (the server paginates raw spans). Mirrors QueryBuilderV2's internal
|
||||
// list configs — the capabilities guard is the single source for both.
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER],
|
||||
// No deviation from the baseline the raw request type below already implies.
|
||||
queryBuilderFields: {},
|
||||
queryBuilderFields: {
|
||||
default: {
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
},
|
||||
[TelemetrytypesSignalDTO.traces]: {
|
||||
limit: { isHidden: true, isDisabled: true },
|
||||
},
|
||||
},
|
||||
sections,
|
||||
// The only kind reading raw rows: they page server-side, and the sort needs a
|
||||
// tiebreaker so a duplicated sort key can't repeat or skip a row across pages.
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
type TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
|
||||
export type { QueryBuilderFieldsConfig } from 'components/QueryBuilderV2/queryBuilderFields.types';
|
||||
/**
|
||||
* Query-builder field-visibility config a panel kind can declare, mirroring the
|
||||
* shape `QueryBuilderV2` consumes via its `filterConfigs` prop. Derived from that
|
||||
* prop type (the underlying `FilterConfigs` isn't exported) so the two never drift.
|
||||
*/
|
||||
export type FilterConfigsPartial = NonNullable<
|
||||
QueryBuilderProps['filterConfigs']
|
||||
>;
|
||||
|
||||
/**
|
||||
* Per-signal query-builder field rules for a panel kind. `default` applies to every
|
||||
* signal; a per-signal entry is merged over it (signal wins). The capabilities guard
|
||||
* resolves this into a single `FilterConfigsPartial` via `getHiddenQueryBuilderFields`.
|
||||
*/
|
||||
export type QueryBuilderFieldRule = {
|
||||
default?: FilterConfigsPartial;
|
||||
} & Partial<Record<TelemetrytypesSignalDTO, FilterConfigsPartial>>;
|
||||
|
||||
/** The kind's `default` rule with its per-signal overrides merged over it (signal wins). */
|
||||
export function mergeQueryBuilderFieldRule(
|
||||
rule: QueryBuilderFieldRule,
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): FilterConfigsPartial {
|
||||
const perSignal = signal ? rule[signal] : undefined;
|
||||
return { ...rule.default, ...perSignal };
|
||||
}
|
||||
|
||||
/**
|
||||
* How a kind's query-range request is shaped. Declared per-kind in
|
||||
@@ -28,8 +57,3 @@ export interface PanelQueryCapabilities {
|
||||
*/
|
||||
serverPaginated: boolean;
|
||||
}
|
||||
|
||||
/** Raw rows rather than an aggregated result — the single source for "is this raw?". */
|
||||
export function isRawRequest(capabilities: PanelQueryCapabilities): boolean {
|
||||
return capabilities.requestType === Querybuildertypesv5RequestTypeDTO.raw;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { AnyPanelInteractionProps } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
import type {
|
||||
PanelQueryCapabilities,
|
||||
QueryBuilderFieldsConfig,
|
||||
QueryBuilderFieldRule,
|
||||
} from './panelCapabilities';
|
||||
import type {
|
||||
BaseRendererProps,
|
||||
@@ -118,7 +118,8 @@ export interface QueryPanelDefinition<
|
||||
supportedSignals: TelemetrytypesSignalDTO[];
|
||||
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
|
||||
supportedQueryTypes: EQueryType[];
|
||||
queryBuilderFields: QueryBuilderFieldsConfig;
|
||||
/** Query-builder fields this kind hides/disables, optionally per signal (`{}` hides none). */
|
||||
queryBuilderFields: QueryBuilderFieldRule;
|
||||
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
|
||||
queryCapabilities: PanelQueryCapabilities;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { QueryProps } from 'container/QueryBuilder/type';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderFormula,
|
||||
@@ -16,13 +17,14 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SelectOption } from './select';
|
||||
|
||||
type UseQueryOperationsParams = Pick<QueryProps, 'index' | 'query'> & {
|
||||
isForTraceOperator?: boolean;
|
||||
formula?: IBuilderFormula;
|
||||
isRawQuery?: boolean;
|
||||
entityVersion: string;
|
||||
savePreviousQuery?: boolean;
|
||||
};
|
||||
type UseQueryOperationsParams = Pick<QueryProps, 'index' | 'query'> &
|
||||
Pick<QueryBuilderProps, 'filterConfigs'> & {
|
||||
isForTraceOperator?: boolean;
|
||||
formula?: IBuilderFormula;
|
||||
isListViewPanel?: boolean;
|
||||
entityVersion: string;
|
||||
savePreviousQuery?: boolean;
|
||||
};
|
||||
|
||||
// Generic type that can work with both legacy and V5 query types
|
||||
export type HandleChangeQueryData<T = IBuilderQuery> = <
|
||||
@@ -62,6 +64,7 @@ export type UseQueryOperations = (params: UseQueryOperationsParams) => {
|
||||
isMetricsDataSource: boolean;
|
||||
operators: SelectOption<string, string>[];
|
||||
spaceAggregationOptions: SelectOption<string, string>[];
|
||||
listOfAdditionalFilters: string[];
|
||||
handleChangeOperator: (value: string) => void;
|
||||
handleSpaceAggregationChange: (value: string) => void;
|
||||
handleChangeAggregatorAttribute: (
|
||||
@@ -73,4 +76,5 @@ export type UseQueryOperations = (params: UseQueryOperationsParams) => {
|
||||
handleChangeQueryData: HandleChangeQueryData;
|
||||
handleChangeFormulaData: HandleChangeFormulaData;
|
||||
handleQueryFunctionsUpdates: (functions: QueryFunction[]) => void;
|
||||
listOfAdditionalFormulaFilters: string[];
|
||||
};
|
||||
|
||||
@@ -314,6 +314,11 @@ export type QueryBuilderContextType = {
|
||||
isDefaultQuery: (props: IsDefaultQueryProps) => boolean;
|
||||
};
|
||||
|
||||
export type QueryAdditionalFilter = {
|
||||
field: keyof IBuilderQuery;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type IsDefaultQueryProps = {
|
||||
currentQuery: Query;
|
||||
sourcePage: DataSource;
|
||||
|
||||
@@ -5,13 +5,15 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
citypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/credentials", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.GetConnectionCredentials),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.GetConnectionCredentials, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetConnectionCredentials",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -24,14 +26,20 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegration.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbCreate, // get or create the credentials, so we use create verb here
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.CreateAccount),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.CreateAccount, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateAccount",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -44,14 +52,21 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegration.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.ListAccounts),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.ListAccounts, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListAccounts",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -64,14 +79,20 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegration.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.GetAccount),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.GetAccount, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetAccount",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -84,14 +105,21 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegration.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.UpdateAccount),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.UpdateAccount, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateAccount",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -104,14 +132,21 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegration.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.DisconnectAccount),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.DisconnectAccount, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DisconnectAccount",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -124,14 +159,21 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegration.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.ListServicesMetadata),
|
||||
provider.authzMiddleware.OpenAccess(provider.cloudIntegrationHandler.ListServicesMetadata),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListServicesMetadata",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -144,14 +186,14 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes(nil),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.ListAccountServicesMetadata),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.ListAccountServicesMetadata, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListAccountServicesMetadata",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -164,14 +206,20 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegrationService.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegrationService,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.GetService),
|
||||
provider.authzMiddleware.OpenAccess(provider.cloudIntegrationHandler.GetService),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetService",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -184,14 +232,14 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes(nil),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.UpdateService),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.UpdateService, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateService",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -204,14 +252,21 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegrationService.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegrationService,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("service_id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.GetAccountService),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.GetAccountService, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetAccountService",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -224,8 +279,15 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegrationService.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegrationService,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("service_id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -252,6 +314,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: figure out authz permission model for this endppoint without breaking existing deployed agents.
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/check_in", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.cloudIntegrationHandler.AgentCheckIn),
|
||||
handler.OpenAPIDef{
|
||||
|
||||
@@ -25,8 +25,9 @@ const (
|
||||
// ResolveLogicalFields picks which logical fields a filter term builds conditions
|
||||
// for. With 0 or 1 field it returns the input unchanged and no warning. When a
|
||||
// name is ambiguous (several logical fields — a family is one field and never
|
||||
// ambiguous with itself) it returns a warning; a resource+attribute mix defaults
|
||||
// to the resource fields (the common intent), noted in the warning.
|
||||
// ambiguous with itself) it returns a warning; a resource + other-context mix
|
||||
// (attribute, body, scope, …) defaults to the resource fields (the common
|
||||
// intent), noted in the warning.
|
||||
func ResolveLogicalFields(field *telemetrytypes.TelemetryFieldKey, logicalFields []*telemetrytypes.LogicalField) ([]*telemetrytypes.LogicalField, string) {
|
||||
if len(logicalFields) <= 1 {
|
||||
return logicalFields, ""
|
||||
@@ -39,18 +40,17 @@ func ResolveLogicalFields(field *telemetrytypes.TelemetryFieldKey, logicalFields
|
||||
logicalFields,
|
||||
)
|
||||
|
||||
hasResource, hasAttribute := false, false
|
||||
hasResource, hasOther := false, false
|
||||
for _, item := range logicalFields {
|
||||
switch item.FieldContext {
|
||||
case telemetrytypes.FieldContextResource:
|
||||
if item.FieldContext == telemetrytypes.FieldContextResource {
|
||||
hasResource = true
|
||||
case telemetrytypes.FieldContextAttribute:
|
||||
hasAttribute = true
|
||||
} else {
|
||||
hasOther = true
|
||||
}
|
||||
}
|
||||
|
||||
// when there is both resource and attribute context, default to resource only
|
||||
if hasResource && hasAttribute {
|
||||
// with resource and any other context, default to resource only
|
||||
if hasResource && hasOther {
|
||||
filtered := make([]*telemetrytypes.LogicalField, 0, len(logicalFields))
|
||||
for _, item := range logicalFields {
|
||||
if item.FieldContext == telemetrytypes.FieldContextResource {
|
||||
@@ -58,8 +58,8 @@ func ResolveLogicalFields(field *telemetrytypes.TelemetryFieldKey, logicalFields
|
||||
}
|
||||
}
|
||||
logicalFields = filtered
|
||||
warning += " " + "Using `resource` context by default. To query attributes explicitly, " +
|
||||
fmt.Sprintf("use the fully qualified name (e.g., 'attribute.%s')", field.Name)
|
||||
warning += " " + "Using `resource` context by default. To query another context explicitly, " +
|
||||
fmt.Sprintf("use the fully qualified name (e.g., 'attribute.%s' or 'body.%s')", field.Name, field.Name)
|
||||
}
|
||||
|
||||
return logicalFields, warning
|
||||
|
||||
@@ -175,6 +175,42 @@ func TestResolveLogicalFieldsKeepsFamilyThroughAmbiguity(t *testing.T) {
|
||||
assert.Equal(t, []string{"deployment.environment.name", "deployment.environment"}, memberNames(resolved[0]))
|
||||
}
|
||||
|
||||
// Resource wins over every other context, not just attribute: a bare key that
|
||||
// also lives in body or scope must collapse to resource alone, so the surviving
|
||||
// candidate does not AND against the resource fingerprint CTE.
|
||||
func TestResolveLogicalFieldsResourceWinsOverOtherContexts(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
other telemetrytypes.FieldContext
|
||||
}{
|
||||
{name: "ResourceOverBody", other: telemetrytypes.FieldContextBody},
|
||||
{name: "ResourceOverScope", other: telemetrytypes.FieldContextScope},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
requested := &telemetrytypes.TelemetryFieldKey{Name: "service.name"}
|
||||
fields := []*telemetrytypes.LogicalField{
|
||||
telemetrytypes.SingleLogicalField("service.name", &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}),
|
||||
telemetrytypes.SingleLogicalField("service.name", &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
FieldContext: testCase.other,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}),
|
||||
}
|
||||
|
||||
resolved, warning := ResolveLogicalFields(requested, fields)
|
||||
assert.NotEmpty(t, warning)
|
||||
require.Len(t, resolved, 1)
|
||||
assert.Equal(t, telemetrytypes.FieldContextResource, resolved[0].FieldContext)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Members of a family with different data types never merge: the identity
|
||||
// (signal, context, data type) separates them into distinct logical fields.
|
||||
func TestMatchingLogicalFieldsNeverMergesAcrossDataTypes(t *testing.T) {
|
||||
|
||||
@@ -254,6 +254,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddIngestionTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddSubscriptionTuplesFactory(sqlstore),
|
||||
sqlmigration.NewNormalizeQuickFilterFieldsFactory(sqlstore),
|
||||
sqlmigration.NewAddCloudIntegrationTuplesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
175
pkg/sqlmigration/127_add_cloud_integration_tuples.go
Normal file
175
pkg/sqlmigration/127_add_cloud_integration_tuples.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addCloudIntegrationTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddCloudIntegrationTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_cloud_integration_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addCloudIntegrationTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addCloudIntegrationTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addCloudIntegrationTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
err = tx.NewSelect().
|
||||
Table("organizations").
|
||||
Column("id").
|
||||
Scan(ctx, &orgIDs)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
// cloud-integration and cloud-integration-service moved from legacy role
|
||||
// gates to CheckResources. Existing organizations need the same tuples that
|
||||
// new organizations receive from the managed-role registry at bootstrap.
|
||||
tuples := []migrationTuple{
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration", "create"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration", "read"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration", "update"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration", "delete"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration", "list"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration-service", "read"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration-service", "update"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration-service", "list"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "cloud-integration", "read"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "cloud-integration", "update"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "cloud-integration", "list"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "cloud-integration-service", "read"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "cloud-integration-service", "update"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "cloud-integration-service", "list"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "cloud-integration", "read"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "cloud-integration", "list"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "cloud-integration-service", "read"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "cloud-integration-service", "list"},
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for _, tuple := range tuples {
|
||||
entropy := ulid.DefaultEntropy()
|
||||
now := time.Now().UTC()
|
||||
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
|
||||
|
||||
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
|
||||
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
|
||||
|
||||
if isPG {
|
||||
user := "role:" + roleSubject + "#assignee"
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
|
||||
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
|
||||
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
managedRoleGroups[roleName] = string(data)
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for roleName, data := range managedRoleGroups {
|
||||
if _, err := tx.NewUpdate().
|
||||
Model(new(roles)).
|
||||
Set("transaction_groups = ?", data).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
|
||||
Where("name = ?", roleName).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addCloudIntegrationTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package logsstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A key present in both resource and body contexts must filter on resource only.
|
||||
// The resource condition builds the fingerprint CTE, so a surviving body condition
|
||||
// would AND against it and match almost nothing (engineering-pod#6086).
|
||||
func TestStatementBuilderResourceBodyConflict(t *testing.T) {
|
||||
store := telemetrytypestest.NewMockMetadataStore()
|
||||
store.SetStaticFields(logstelemetryschema.IntrinsicFields)
|
||||
store.SetKey(&telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
})
|
||||
bodyKey := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
require.NoError(t, bodyKey.SetJSONAccessPlan(telemetrytypes.JSONColumnMetadata{
|
||||
BaseColumn: logstelemetryschema.LogsV2BodyV2Column,
|
||||
PromotedColumn: logstelemetryschema.LogsV2BodyPromotedColumn,
|
||||
}, map[string][]telemetrytypes.FieldDataType{"service.name": {telemetrytypes.FieldDataTypeString}}))
|
||||
store.SetKey(bodyKey)
|
||||
|
||||
fl := flaggertest.WithUseJSONBody(t, true)
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
store,
|
||||
storage,
|
||||
aggExprRewriter,
|
||||
logstelemetryschema.DefaultFullTextColumn,
|
||||
fl,
|
||||
nil,
|
||||
statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Enabled: false, Threshold: 100000}},
|
||||
)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
expected qbtypes.Statement
|
||||
}{
|
||||
{
|
||||
name: "AmbiguousKeyFiltersResourceOnly",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'webapp'"},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"webapp", "%service.name%", "%service.name\":\"webapp%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{
|
||||
"Key `service.name` is ambiguous, found 2 different combinations of field context / data type: [name=service.name,context=resource,datatype=string name=service.name,context=body,datatype=string]. Using `resource` context by default. To query another context explicitly, use the fully qualified name (e.g., 'attribute.service.name' or 'body.service.name')",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
q, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, testCase.requestType, testCase.query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testCase.expected.Query, q.Query)
|
||||
require.Equal(t, testCase.expected.Args, q.Args)
|
||||
require.Equal(t, testCase.expected.Warnings, q.Warnings)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1014,7 +1014,7 @@ func rejectHTTPBasicAuthBeyondPassword(channelName string, httpConfig *commoncfg
|
||||
|
||||
basicAuth := httpConfig.BasicAuth
|
||||
if *basicAuth != (commoncfg.BasicAuth{Username: basicAuth.Username, Password: basicAuth.Password}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth, which is not supported", channelName)
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth with fields other than username and password, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1026,8 +1026,8 @@ func rejectHTTPAuthorizationBeyondBearer(channelName string, httpConfig *commonc
|
||||
}
|
||||
|
||||
authorization := httpConfig.Authorization
|
||||
if *authorization != (commoncfg.Authorization{Type: bearerAuthorizationType, Credentials: authorization.Credentials}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization, which is not supported", channelName)
|
||||
if !strings.EqualFold(authorization.Type, bearerAuthorizationType) || *authorization != (commoncfg.Authorization{Type: authorization.Type, Credentials: authorization.Credentials}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization with fields other than a bearer token, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -542,3 +542,42 @@ func TestChannelToPostableChannelRejectsUnrepresentableChannels(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The HTTP auth scheme is case-insensitive (RFC 7235) and Alertmanager sends
|
||||
// the stored spelling verbatim, so a hand-written receiver may carry any casing.
|
||||
func TestChannelToPostableChannelReadsWebhookBearerSchemeCaseInsensitively(t *testing.T) {
|
||||
sendResolved := config.DefaultWebhookConfig.VSendResolved
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
storedChannelData string
|
||||
expectedWebhookSpec *ChannelWebhookConfig
|
||||
}{
|
||||
{
|
||||
name: "CanonicalBearer",
|
||||
storedChannelData: `{"name":"hook","webhook_configs":[{"send_resolved":true,"url":"https://a","http_config":{"authorization":{"type":"Bearer","credentials":"tok"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
expectedWebhookSpec: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://a", BearerToken: "tok"},
|
||||
},
|
||||
{
|
||||
name: "LowercaseBearer",
|
||||
storedChannelData: `{"name":"hook","webhook_configs":[{"send_resolved":true,"url":"https://b","http_config":{"authorization":{"type":"bearer","credentials":"lower"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
expectedWebhookSpec: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://b", BearerToken: "lower"},
|
||||
},
|
||||
{
|
||||
name: "UppercaseBearer",
|
||||
storedChannelData: `{"name":"hook","webhook_configs":[{"send_resolved":true,"url":"https://c","http_config":{"authorization":{"type":"BEARER","credentials":"upper"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
expectedWebhookSpec: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://c", BearerToken: "upper"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
channel := Channel{DisplayName: "hook", Data: testCase.storedChannelData}
|
||||
|
||||
postable, err := channel.toPostableNotificationChannel()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ChannelKindWebhook, postable.Config.Kind)
|
||||
assert.Equal(t, testCase.expectedWebhookSpec, postable.Config.Spec)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,17 +35,15 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)},
|
||||
// cloud-integration — admin only
|
||||
// cloud-integration — admin can fully manage accounts
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
// cloud-integration-service — admin only
|
||||
// cloud-integration-service — admin can read and update account services
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
// integration — viewer/editor/admin (install/uninstall via ViewAccess)
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
@@ -216,6 +214,14 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
|
||||
},
|
||||
SigNozEditorRoleName: {
|
||||
// cloud-integration — editor can read and update existing accounts
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
// cloud-integration-service — editor can read and update account services
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
// dashboard — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
@@ -308,6 +314,12 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
|
||||
},
|
||||
SigNozViewerRoleName: {
|
||||
// cloud-integration — viewer can read accounts
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
// cloud-integration-service — viewer can read account services
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
// dashboard — read only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
|
||||
@@ -52,8 +52,8 @@ var (
|
||||
ResourceMetaResourceApdexSetting = NewResourceMetaResource(KindApdexSetting)
|
||||
ResourceMetaResourceAuthDomain = NewResourceMetaResource(KindAuthDomain)
|
||||
ResourceMetaResourceSession = NewResourceMetaResource(KindSession)
|
||||
ResourceMetaResourceCloudIntegration = NewResourceMetaResource(KindCloudIntegration)
|
||||
ResourceMetaResourceCloudIntegrationService = NewResourceMetaResource(KindCloudIntegrationService)
|
||||
ResourceMetaResourceCloudIntegration = NewResourceMetaResource(KindCloudIntegration, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
|
||||
ResourceMetaResourceCloudIntegrationService = NewResourceMetaResource(KindCloudIntegrationService, VerbList, VerbRead, VerbUpdate)
|
||||
ResourceMetaResourceIntegration = NewResourceMetaResource(KindIntegration)
|
||||
ResourceMetaResourceDashboard = NewResourceMetaResource(KindDashboard, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
|
||||
ResourceMetaResourcePublicDashboard = NewResourceMetaResource(KindPublicDashboard)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import (
|
||||
build_raw_query,
|
||||
get_rows,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
|
||||
def test_resource_body_conflict(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
export_json_types: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
start_ms = int((now - timedelta(seconds=10)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
|
||||
# python's body carries service.name, making the bare key ambiguous across
|
||||
# resource and body; java's body omits it, so ANDing body in would drop it.
|
||||
logs_list = [
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=2),
|
||||
resources={"service.name": "java"},
|
||||
body_v2=json.dumps({"msg": "hello"}),
|
||||
body_promoted="",
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
resources={"service.name": "python"},
|
||||
body_v2=json.dumps({"service.name": "python"}),
|
||||
body_promoted="",
|
||||
),
|
||||
]
|
||||
export_json_types(logs_list)
|
||||
insert_logs(logs_list)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
cases = [
|
||||
{
|
||||
"name": "bare_key_resolves_to_resource",
|
||||
"filter": "service.name = 'java'",
|
||||
"expected_service_names": ["java"],
|
||||
"expect_resource_warning": True,
|
||||
},
|
||||
{
|
||||
"name": "qualified_body_key_targets_body",
|
||||
"filter": "body.service.name = 'python'",
|
||||
"expected_service_names": ["python"],
|
||||
"expect_resource_warning": False,
|
||||
},
|
||||
]
|
||||
|
||||
for case in cases:
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
request_type="raw",
|
||||
queries=[
|
||||
build_raw_query(
|
||||
name="A",
|
||||
signal="logs",
|
||||
filter_expression=case["filter"],
|
||||
limit=100,
|
||||
step_interval=60,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, f"{case['name']}: {response.text}"
|
||||
rows = get_rows(response)
|
||||
assert [row["data"]["resources_string"].get("service.name") for row in rows] == case["expected_service_names"], f"{case['name']}: {response.json()}"
|
||||
|
||||
warning = response.json()["data"].get("warning")
|
||||
if case["expect_resource_warning"]:
|
||||
assert warning is not None and "Using `resource` context by default" in warning["warnings"][0]["message"], f"{case['name']}: {warning}"
|
||||
else:
|
||||
assert warning is None, f"{case['name']}: {warning}"
|
||||
@@ -64,8 +64,8 @@ def test_resource_default_warning(
|
||||
"Key `service.name` is ambiguous, found 2 different combinations of "
|
||||
"field context / data type: [name=service.name,context=resource,datatype=string "
|
||||
"name=service.name,context=attribute,datatype=string]. Using `resource` context "
|
||||
"by default. To query attributes explicitly, use the fully qualified name "
|
||||
"(e.g., 'attribute.service.name')"
|
||||
"by default. To query another context explicitly, use the fully qualified name "
|
||||
"(e.g., 'attribute.service.name' or 'body.service.name')"
|
||||
)
|
||||
assert warning["warnings"] == [
|
||||
{"message": expected_service_name_warning},
|
||||
@@ -237,8 +237,8 @@ def test_deduped_warnings_for_single_query(
|
||||
"Key `service.name` is ambiguous, found 2 different combinations of "
|
||||
"field context / data type: [name=service.name,context=resource,datatype=string "
|
||||
"name=service.name,context=attribute,datatype=string]. Using `resource` context "
|
||||
"by default. To query attributes explicitly, use the fully qualified name "
|
||||
"(e.g., 'attribute.service.name')"
|
||||
"by default. To query another context explicitly, use the fully qualified name "
|
||||
"(e.g., 'attribute.service.name' or 'body.service.name')"
|
||||
)
|
||||
expected_status_code_warning = "Key `http.status_code` is ambiguous, found 2 different combinations of field context / data type: [name=http.status_code,context=attribute,datatype=number name=http.status_code,context=attribute,datatype=string]."
|
||||
assert warning["warnings"] == [
|
||||
@@ -328,8 +328,8 @@ def test_deduped_warnings_for_multiple_queries(
|
||||
"Key `service.name` is ambiguous, found 2 different combinations of "
|
||||
"field context / data type: [name=service.name,context=resource,datatype=string "
|
||||
"name=service.name,context=attribute,datatype=string]. Using `resource` context "
|
||||
"by default. To query attributes explicitly, use the fully qualified name "
|
||||
"(e.g., 'attribute.service.name')"
|
||||
"by default. To query another context explicitly, use the fully qualified name "
|
||||
"(e.g., 'attribute.service.name' or 'body.service.name')"
|
||||
)
|
||||
expected_status_code_warning = "Key `http.status_code` is ambiguous, found 2 different combinations of field context / data type: [name=http.status_code,context=attribute,datatype=number name=http.status_code,context=attribute,datatype=string]."
|
||||
assert warning["warnings"] == [
|
||||
|
||||
Reference in New Issue
Block a user