Compare commits

...

4 Commits

Author SHA1 Message Date
Naman Verma
e637bbba02 feat: add plugin schema for area chart panel 2026-08-20 11:55:22 +05:30
Vikrant Gupta
dca6aa497d chore(serviceaccount): remove deprecated nested role endpoints (#12591)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- Removes the deprecated `POST /api/v1/service_accounts/{id}/roles` and
`DELETE /api/v1/service_accounts/{id}/roles/{rid}` routes, their HTTP
handlers, and the `DeprecatedPostableServiceAccountRole` type, now that
all consumers use `/api/v1/service_account_roles`.
- Keeps the `GET /api/v1/service_accounts/{id}/roles` listing endpoint.
- Regenerates `docs/api/openapi.yml` and the frontend client.

#### Issues closed by this PR

Closes SigNoz/platform-pod#2919

#### Additional Information

- Final step of the migration; the frontend (#12589) and
integration-test (#12590) consumer moves are already merged.
2026-08-19 20:03:16 +00:00
Vikrant Gupta
bb47550c01 feat(authz): enable FGA for auth domains (#12588)
#### Description

- Auth domain routes (`/api/v2/auth_domains`) now use `CheckResources` +
`ResourceDef` instead of the coarse `AdminAccess` gate — per-resource
FGA checks on enterprise, admin role gate on community.
- Create and update also check `attach` on the roles the request's
`roleMapping` will grant at SSO login (mapped roles + default role,
`signoz-viewer` when unset, `role:*` when `useRoleAttribute` is on);
update additionally checks `detach` on the roles the stored mapping was
granting, since a `PUT` replaces the mapping.
- Migration `117_add_auth_domain_tuples` backfills the admin
`auth-domain` tuples for existing organizations and re-syncs the stored
managed-role transaction groups; new organizations get both from the
registry at bootstrap.
- Regenerated OpenAPI spec: the auth-domain operations advertise
`auth-domain:*` and `role:attach`/`role:detach` scopes instead of
`ADMIN`.
- Added `callbackauthn/05_authz.py` covering managed-role gating,
custom-role wildcard/instance grants, and the role-mapping attach/detach
checks.

#### Issues closed by this PR

Closes SigNoz/platform-pod#2649
2026-08-19 19:16:14 +00:00
Vinicius Lourenço
ecf510ed67 refactor(query-builder): drop ClientSideQBSearch & QueryBuilderSearch (#12427)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Pull Request

---

### 📄 Summary
> Why does this change exist?  
> What problem does it solve, and why is this the right approach?

These are pending code that was supposed to be deleted after
Infrastructure Monitoring & Alert History adopt the QBv5.

#### Issues closed by this PR
> Reference issues using `Closes #issue-number` to enable automatic
closure on merge.

Closes https://github.com/SigNoz/engineering-pod/issues/5117

Closes https://github.com/SigNoz/engineering-pod/issues/5116

---

###  Change Type
_Select all that apply_

- [ ]  Feature
- [ ] 🐛 Bug fix
- [x] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [ ] 🧪 Test-only

---

### 🧪 Testing Strategy
> How was this change validated?

- Tests added/updated: Yes
- Manual verification: -
- Edge cases covered: -

---

### ⚠️ Risk & Impact Assessment
> What could break? How do we recover?

- Blast radius: Query Builder
- Potential regressions: Deleting more code than needed
- Rollback plan: Revert the deletion.

---

### 📝 Changelog
> Fill only if this affects users, APIs, UI, or documented behavior  
> Use **N/A** for internal or non-user-facing changes

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Maintenance |
| Description | N/A |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-19 18:00:15 +00:00
69 changed files with 1627 additions and 3181 deletions

View File

@@ -2646,6 +2646,54 @@ components:
repeatVariable:
type: string
type: object
DashboardtypesAreaChartAppearance:
properties:
fillMode:
$ref: '#/components/schemas/DashboardtypesAreaFillMode'
fillOpacity:
$ref: '#/components/schemas/DashboardtypesFillOpacity'
lineInterpolation:
$ref: '#/components/schemas/DashboardtypesLineInterpolation'
lineStyle:
$ref: '#/components/schemas/DashboardtypesLineStyle'
showPoints:
type: boolean
spanGaps:
$ref: '#/components/schemas/DashboardtypesSpanGaps'
type: object
DashboardtypesAreaChartPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesAreaChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesAreaChartVisualization'
type: object
DashboardtypesAreaChartVisualization:
properties:
fillSpans:
type: boolean
stack:
$ref: '#/components/schemas/DashboardtypesStackMode'
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesAreaFillMode:
enum:
- solid
- gradient
- none
type: string
DashboardtypesAxes:
properties:
isLogScale:
@@ -2877,6 +2925,11 @@ components:
- gradient
- none
type: string
DashboardtypesFillOpacity:
maximum: 1
minimum: 0
nullable: true
type: number
DashboardtypesGettableDashboardV2:
properties:
createdAt:
@@ -3294,6 +3347,7 @@ components:
DashboardtypesPanelPlugin:
discriminator:
mapping:
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
@@ -3305,6 +3359,7 @@ components:
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
@@ -3315,12 +3370,25 @@ components:
enum:
- signoz/TimeSeriesPanel
- signoz/BarChartPanel
- signoz/AreaChartPanel
- signoz/NumberPanel
- signoz/PieChartPanel
- signoz/TablePanel
- signoz/HistogramPanel
- signoz/ListPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
kind:
enum:
- signoz/AreaChartPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesAreaChartPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
kind:
@@ -3639,6 +3707,12 @@ components:
are connected.
type: boolean
type: object
DashboardtypesStackMode:
enum:
- none
- normal
- percent
type: string
DashboardtypesStorableDashboardData:
additionalProperties: {}
type: object
@@ -8024,13 +8098,6 @@ components:
- schemaVersion
- spec
type: object
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
properties:
id:
type: string
required:
- id
type: object
ServiceaccounttypesGettableFactorAPIKey:
properties:
createdAt:
@@ -13262,117 +13329,6 @@ paths:
summary: Gets service account roles
tags:
- serviceaccount
post:
deprecated: true
description: This endpoint assigns a role to a service account
operationId: CreateServiceAccountRoleDeprecated
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:attach
- role:attach
- tokenizer:
- serviceaccount:attach
- role:attach
summary: Create service account role
tags:
- serviceaccount
/api/v1/service_accounts/{id}/roles/{rid}:
delete:
deprecated: true
description: This endpoint revokes a role from service account
operationId: DeleteServiceAccountRoleDeprecated
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: rid
required: true
schema:
type: string
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:detach
- role:detach
- tokenizer:
- serviceaccount:detach
- role:detach
summary: Delete service account role
tags:
- serviceaccount
/api/v1/service_accounts/me:
get:
deprecated: false
@@ -14376,9 +14332,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- auth-domain:list
- tokenizer:
- ADMIN
- auth-domain:list
summary: List all auth domains
tags:
- authdomains
@@ -14438,9 +14394,13 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- auth-domain:create
- auth-domain:attach
- role:attach
- tokenizer:
- ADMIN
- auth-domain:create
- auth-domain:attach
- role:attach
summary: Create auth domain
tags:
- authdomains
@@ -14484,9 +14444,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- auth-domain:delete
- tokenizer:
- ADMIN
- auth-domain:delete
summary: Delete auth domain
tags:
- authdomains
@@ -14541,9 +14501,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- auth-domain:read
- tokenizer:
- ADMIN
- auth-domain:read
summary: Get auth domain by ID
tags:
- authdomains
@@ -14597,9 +14557,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- auth-domain:update
- auth-domain:attach
- auth-domain:detach
- role:attach
- role:detach
- tokenizer:
- ADMIN
- auth-domain:update
- auth-domain:attach
- auth-domain:detach
- role:attach
- role:detach
summary: Update auth domain
tags:
- authdomains

View File

@@ -22,10 +22,7 @@ import type {
CreateServiceAccountKey201,
CreateServiceAccountKeyPathParameters,
CreateServiceAccountRole201,
CreateServiceAccountRoleDeprecated201,
CreateServiceAccountRoleDeprecatedPathParameters,
DeleteServiceAccountPathParameters,
DeleteServiceAccountRoleDeprecatedPathParameters,
DeleteServiceAccountRolePathParameters,
GetMyServiceAccount200,
GetServiceAccount200,
@@ -39,7 +36,6 @@ import type {
ListServiceAccounts200,
RenderErrorResponseDTO,
RevokeServiceAccountKeyPathParameters,
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
ServiceaccounttypesPostableFactorAPIKeyDTO,
ServiceaccounttypesPostableServiceAccountDTO,
ServiceaccounttypesPostableServiceAccountRoleDTO,
@@ -1253,194 +1249,6 @@ export const invalidateGetServiceAccountRoles = async (
return queryClient;
};
/**
* This endpoint assigns a role to a service account
* @deprecated
* @summary Create service account role
*/
export const createServiceAccountRoleDeprecated = (
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
url: `/api/v1/service_accounts/${id}/roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
signal,
});
};
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
> => {
const mutationKey = ['createServiceAccountRoleDeprecated'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return createServiceAccountRoleDeprecated(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
>;
export type CreateServiceAccountRoleDeprecatedMutationBody =
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
| undefined;
export type CreateServiceAccountRoleDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create service account role
*/
export const useCreateServiceAccountRoleDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
> => {
return useMutation(
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
);
};
/**
* This endpoint revokes a role from service account
* @deprecated
* @summary Delete service account role
*/
export const deleteServiceAccountRoleDeprecated = (
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/${id}/roles/${rid}`,
method: 'DELETE',
signal,
});
};
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
> => {
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteServiceAccountRoleDeprecated(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
>;
export type DeleteServiceAccountRoleDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Delete service account role
*/
export const useDeleteServiceAccountRoleDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
> => {
return useMutation(
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
);
};
/**
* This endpoint gets my service account
* @summary Gets my service account

View File

@@ -9092,13 +9092,6 @@ export interface SavedviewtypesUpdatableSavedViewDTO {
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
/**
* @type string
*/
id: string;
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -11020,21 +11013,6 @@ export type GetServiceAccountRoles200 = {
status: string;
};
export type CreateServiceAccountRoleDeprecatedPathParameters = {
id: string;
};
export type CreateServiceAccountRoleDeprecated201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
id: string;
rid: string;
};
export type GetMyServiceAccount200 = {
data: ServiceaccounttypesServiceAccountWithRolesDTO;
/**

View File

@@ -1,5 +0,0 @@
.client-side-qb-search {
.ant-select-selection-search {
width: max-content !important;
}
}

View File

@@ -1,661 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Select, Tooltip } from 'antd';
import {
OPERATORS,
QUERY_BUILDER_OPERATORS_BY_TYPES,
QUERY_BUILDER_SEARCH_VALUES,
} from 'constants/queryBuilder';
import { CustomTagProps } from 'container/QueryBuilder/filters/QueryBuilderSearch';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config';
import { PLACEHOLDER } from 'container/QueryBuilder/filters/QueryBuilderSearch/constant';
import { TypographyText } from 'container/QueryBuilder/filters/QueryBuilderSearch/style';
import {
checkCommaInValue,
getOperatorFromValue,
getOperatorValue,
getTagToken,
isInNInOperator,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import {
DropdownState,
ITag,
Option,
} from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import Suggestions from 'container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { validationMapper } from 'hooks/queryBuilder/useIsValidTag';
import { operatorTypeMapper } from 'hooks/queryBuilder/useOperatorType';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { isArray, isEmpty, isEqual, isObject } from 'lodash-es';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import type { BaseSelectRef } from 'rc-select';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import './ClientSideQBSearch.styles.scss';
import { Badge } from '@signozhq/ui/badge';
export interface AttributeKey {
key: string;
}
export interface AttributeValuesMap {
[key: string]: AttributeValue;
}
interface ClientSideQBSearchProps {
filters: TagFilter;
onChange: (value: TagFilter) => void;
whereClauseConfig?: WhereClauseConfig;
placeholder?: string;
className?: string;
suffixIcon?: React.ReactNode;
attributeValuesMap?: AttributeValuesMap;
attributeKeys: AttributeKey[];
}
interface AttributeValue {
stringAttributeValues: string[] | [];
numberAttributeValues: number[] | [];
boolAttributeValues: boolean[] | [];
}
function ClientSideQBSearch(
props: ClientSideQBSearchProps,
): React.ReactElement {
const {
onChange,
placeholder,
className,
suffixIcon,
whereClauseConfig,
attributeValuesMap,
attributeKeys,
filters,
} = props;
const isDarkMode = useIsDarkMode();
const selectRef = useRef<BaseSelectRef>(null);
const [isOpen, setIsOpen] = useState<boolean>(false);
// create the tags from the initial query here, this should only be computed on the first load as post that tags and query will be always in sync.
const [tags, setTags] = useState<ITag[]>(filters.items as ITag[]);
// this will maintain the current state of in process filter item
const [currentFilterItem, setCurrentFilterItem] = useState<ITag | undefined>();
const [currentState, setCurrentState] = useState<DropdownState>(
DropdownState.ATTRIBUTE_KEY,
);
// to maintain the current running state until the tokenization happens for the tag
const [searchValue, setSearchValue] = useState<string>('');
const [dropdownOptions, setDropdownOptions] = useState<Option[]>([]);
const attributeValues = useMemo(() => {
if (currentFilterItem?.key?.key) {
return attributeValuesMap?.[currentFilterItem.key.key];
}
return {
stringAttributeValues: [],
numberAttributeValues: [],
boolAttributeValues: [],
};
}, [attributeValuesMap, currentFilterItem?.key?.key]);
const handleDropdownSelect = useCallback(
(value: string) => {
let parsedValue: BaseAutocompleteData | string;
try {
parsedValue = JSON.parse(value);
} catch {
parsedValue = value;
}
if (currentState === DropdownState.ATTRIBUTE_KEY) {
setCurrentFilterItem((prev) => ({
...prev,
key: parsedValue as BaseAutocompleteData,
op: '',
value: '',
}));
setCurrentState(DropdownState.OPERATOR);
setSearchValue((parsedValue as BaseAutocompleteData)?.key);
} else if (currentState === DropdownState.OPERATOR) {
if (value === OPERATORS.EXISTS || value === OPERATORS.NOT_EXISTS) {
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: value,
value: '',
} as ITag,
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: value as string,
value: '',
}));
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
setSearchValue(`${currentFilterItem?.key?.key} ${value}`);
}
} else if (currentState === DropdownState.ATTRIBUTE_VALUE) {
const operatorType =
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
const isMulti = operatorType === QUERY_BUILDER_SEARCH_VALUES.MULTIPLY;
if (isMulti) {
const { tagKey, tagOperator, tagValue } = getTagToken(searchValue);
// this condition takes care of adding the IN/NIN multi values when pressed enter on an already existing value.
// not the best interaction but in sync with what we have today!
if (tagValue.includes(String(value))) {
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
setCurrentFilterItem(undefined);
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: currentFilterItem?.op,
value: tagValue,
} as ITag,
]);
return;
}
// this is for adding subsequent comma seperated values
const newSearch = [...tagValue];
newSearch[newSearch.length === 0 ? 0 : newSearch.length - 1] = value;
const newSearchValue = newSearch.join(',');
setSearchValue(`${tagKey} ${tagOperator} ${newSearchValue},`);
} else {
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
setCurrentFilterItem(undefined);
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: currentFilterItem?.op,
value,
} as ITag,
]);
}
}
},
[currentFilterItem?.key, currentFilterItem?.op, currentState, searchValue],
);
const handleSearch = useCallback((value: string) => {
setSearchValue(value);
}, []);
const onInputKeyDownHandler = useCallback(
(event: KeyboardEvent<Element>): void => {
if (event.key === 'Backspace' && !searchValue) {
event.stopPropagation();
setTags((prev) => prev.slice(0, -1));
}
},
[searchValue],
);
const handleOnBlur = useCallback((): void => {
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
) {
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
) {
// is exists and not exists operator is present then convert directly to tag! no need of value here
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: currentFilterItem?.op,
value: '',
},
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else if (
// if the current state is in sync with the kind of operator used then convert into a tag
validationMapper[operatorType]?.(
isArray(currentFilterItem?.value)
? currentFilterItem?.value.length || 0
: 1,
)
) {
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key as BaseAutocompleteData,
op: currentFilterItem?.op as string,
value: currentFilterItem?.value || '',
},
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
}
}
}, [
currentFilterItem?.key,
currentFilterItem?.op,
currentFilterItem?.value,
searchValue,
whereClauseConfig?.customKey,
whereClauseConfig?.customOp,
]);
// this useEffect takes care of tokenisation based on the search state
useEffect(() => {
// if there is no search value reset to the default state
if (!searchValue) {
setCurrentFilterItem(undefined);
setCurrentState(DropdownState.ATTRIBUTE_KEY);
}
// split the current search value based on delimiters
const { tagKey, tagOperator, tagValue } = getTagToken(searchValue);
if (
// Case 1 - if key is defined but the search text doesn't match with the set key,
// can happen when user selects from dropdown and then deletes a few characters
currentFilterItem?.key &&
currentFilterItem?.key?.key !== tagKey.split(' ')[0]
) {
setCurrentFilterItem(undefined);
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else if (tagOperator && isEmpty(currentFilterItem?.op)) {
// Case 2 -> key is set and now typing for the operator
if (
tagOperator === OPERATORS.EXISTS ||
tagOperator === OPERATORS.NOT_EXISTS
) {
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: tagOperator,
value: '',
} as ITag,
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: tagOperator,
value: '',
}));
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
}
} else if (
// Case 3 -> selected operator from dropdown and then erased a part of it
!isEmpty(currentFilterItem?.op) &&
tagOperator !== currentFilterItem?.op
) {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: '',
value: '',
}));
setCurrentState(DropdownState.OPERATOR);
} else if (currentState === DropdownState.ATTRIBUTE_VALUE) {
// Case 4 -> the final value state where we set the current filter values and the tokenisation happens on either
// dropdown click or blur event
const currentValue = {
key: currentFilterItem?.key as BaseAutocompleteData,
op: currentFilterItem?.op as string,
value: tagValue,
};
if (!isEqual(currentValue, currentFilterItem)) {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: prev?.op as string,
value: tagValue,
}));
}
}
}, [
currentFilterItem,
currentFilterItem?.key,
currentFilterItem?.op,
searchValue,
currentState,
]);
// the useEffect takes care of setting the dropdown values correctly on change of the current state
useEffect(() => {
if (currentState === DropdownState.ATTRIBUTE_KEY) {
const filteredAttributeKeys = attributeKeys.filter((key) =>
key.key.startsWith(searchValue),
);
setDropdownOptions(
filteredAttributeKeys?.map(
(key) =>
({
label: key.key,
value: key,
}) as Option,
) || [],
);
}
if (currentState === DropdownState.OPERATOR) {
const keyOperator = searchValue.split(' ');
const partialOperator = keyOperator?.[1];
const strippedKey = keyOperator?.[0];
let operatorOptions;
if (currentFilterItem?.key?.dataType) {
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES[
currentFilterItem.key
.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
].map((operator) => ({
label: operator,
value: operator,
}));
if (partialOperator) {
operatorOptions = operatorOptions.filter((op) =>
op.label.startsWith(partialOperator.toLocaleUpperCase()),
);
}
setDropdownOptions(operatorOptions);
} else if (strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')) {
operatorOptions = [OPERATORS.HAS, OPERATORS.NHAS].map((operator) => ({
label: operator,
value: operator,
}));
setDropdownOptions(operatorOptions);
} else {
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES.universal.map(
(operator) => ({
label: operator,
value: operator,
}),
);
if (partialOperator) {
operatorOptions = operatorOptions.filter((op) =>
op.label.startsWith(partialOperator.toLocaleUpperCase()),
);
}
setDropdownOptions(operatorOptions);
}
}
if (currentState === DropdownState.ATTRIBUTE_VALUE) {
const values: Array<string | number | boolean> = [];
const { tagValue } = getTagToken(searchValue);
if (isArray(tagValue)) {
if (!isEmpty(tagValue[tagValue.length - 1])) {
values.push(tagValue[tagValue.length - 1]);
}
} else if (!isEmpty(tagValue)) {
values.push(tagValue);
}
const currentAttributeValues =
attributeValues?.stringAttributeValues ||
attributeValues?.numberAttributeValues ||
attributeValues?.boolAttributeValues ||
[];
values.push(...currentAttributeValues);
if (attributeValuesMap) {
setDropdownOptions(
values.map(
(val) =>
({
label: checkCommaInValue(String(val)),
value: val,
}) as Option,
),
);
} else {
// If attributeValuesMap is not provided, don't set dropdown options
setDropdownOptions([]);
}
}
}, [
attributeValues,
currentFilterItem?.key?.dataType,
currentState,
attributeKeys,
searchValue,
attributeValuesMap,
]);
useEffect(() => {
const filterTags: IBuilderQuery['filters'] = {
op: 'AND',
items: [],
};
tags.forEach((tag) => {
const computedTagValue =
tag.value &&
Array.isArray(tag.value) &&
tag.value[tag.value.length - 1] === ''
? tag.value?.slice(0, -1)
: (tag.value ?? '');
filterTags.items.push({
id: tag.id || uuid().slice(0, 8),
key: tag.key,
op: getOperatorValue(tag.op),
value: computedTagValue,
});
});
if (!isEqual(filters, filterTags)) {
onChange(filterTags);
setTags(
filterTags.items.map((tag) => ({
...tag,
op: getOperatorFromValue(tag.op),
})) as ITag[],
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tags]);
const queryTags = useMemo(
() => tags.map((tag) => `${tag.key.key} ${tag.op} ${tag.value}`),
[tags],
);
const onTagRender = ({
value,
closable,
onClose,
}: CustomTagProps): React.ReactElement => {
const { tagOperator } = getTagToken(value);
const isInNin = isInNInOperator(tagOperator);
const chipValue = isInNin
? value?.trim()?.replace(/,\s*$/, '')
: value?.trim();
const indexInQueryTags = queryTags.findIndex((qTag) => isEqual(qTag, value));
const tagDetails = tags[indexInQueryTags];
const onCloseHandler = (): void => {
onClose();
setSearchValue('');
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
};
const tagEditHandler = (value: string): void => {
setCurrentFilterItem(tagDetails);
setSearchValue(value);
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
};
const isDisabled = !!searchValue;
return (
<span className="qb-search-bar-tokenised-tags">
<Badge
color="vanilla"
className={tagDetails?.key?.type || ''}
closable={!searchValue && closable}
onClose={(e): void => {
e.preventDefault();
onCloseHandler();
}}
>
<Tooltip title={chipValue}>
<TypographyText
$isInNin={isInNin}
$isEnabled={!!searchValue}
$disabled={isDisabled}
onClick={(): void => {
if (!isDisabled) {
tagEditHandler(value);
}
}}
>
{chipValue}
</TypographyText>
</Tooltip>
</Badge>
</span>
);
};
const suffixIconContent = useMemo(() => {
if (suffixIcon) {
return suffixIcon;
}
return isOpen ? (
<ChevronUp
size={14}
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
/>
) : (
<ChevronDown
size={14}
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
/>
);
}, [isDarkMode, isOpen, suffixIcon]);
return (
<div className="query-builder-search-v2 ">
<Select
ref={selectRef}
getPopupContainer={popupContainer}
virtual={false}
showSearch
tagRender={onTagRender}
transitionName=""
choiceTransitionName=""
filterOption={false}
open={isOpen}
suffixIcon={suffixIconContent}
onDropdownVisibleChange={setIsOpen}
autoClearSearchValue={false}
mode="multiple"
placeholder={placeholder}
value={queryTags}
searchValue={searchValue}
className={className}
rootClassName="query-builder-search client-side-qb-search"
disabled={!attributeKeys.length}
style={selectStyle}
onSearch={handleSearch}
onSelect={handleDropdownSelect}
onInputKeyDown={onInputKeyDownHandler}
notFoundContent={null}
showAction={['focus']}
onBlur={handleOnBlur}
>
{dropdownOptions.map((option) => {
let val = option.value;
try {
if (isObject(option.value)) {
val = JSON.stringify(option.value);
} else {
val = option.value;
}
} catch {
val = option.value;
}
return (
<Select.Option key={isObject(val) ? `select-option` : val} value={val}>
<Suggestions
label={option.label}
value={option.value}
option={currentState}
searchValue={searchValue}
/>
</Select.Option>
);
})}
</Select>
</div>
);
}
ClientSideQBSearch.defaultProps = {
placeholder: PLACEHOLDER,
className: '',
suffixIcon: null,
whereClauseConfig: {},
attributeValuesMap: {},
};
export default ClientSideQBSearch;

View File

@@ -8,7 +8,7 @@ import {
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import FilterQueryLexer from 'parser/FilterQueryLexer';
import FilterQueryParser, {
AndExpressionContext,

View File

@@ -5,7 +5,7 @@ import {
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { cloneDeep, isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';

View File

@@ -0,0 +1,91 @@
import { renderHook } from '@testing-library/react';
import {
useStableTotalCount,
UseStableTotalCountParams,
} from '../useStableTotalCount';
function renderStableTotalCount(initial: UseStableTotalCountParams): {
result: { current: number };
rerender: (next: UseStableTotalCountParams) => void;
} {
const { result, rerender } = renderHook(
(params: UseStableTotalCountParams) => useStableTotalCount(params),
{ initialProps: initial },
);
return { result, rerender };
}
describe('useStableTotalCount', () => {
it('reports the total once the load settles', () => {
const { result } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
expect(result.current).toBe(100);
});
it('keeps the last count while the same list refetches', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
rerender({ total: 0, isLoading: true, resetKey: 'pods' });
expect(result.current).toBe(100);
});
it('forgets the count when the list changes', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
rerender({ total: 0, isLoading: true, resetKey: 'nodes' });
expect(result.current).toBe(0);
});
it('reports the new list total once it arrives', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
rerender({ total: 0, isLoading: true, resetKey: 'nodes' });
rerender({ total: 25, isLoading: false, resetKey: 'nodes' });
expect(result.current).toBe(25);
});
it('reports zero for a list that is genuinely empty', () => {
const { result } = renderStableTotalCount({
total: 0,
isLoading: false,
resetKey: 'pods',
});
expect(result.current).toBe(0);
});
it('holds nothing back when no reset key is given', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: undefined,
});
// Without a key there is no context to compare, so the cache behaves as it
// always did: it survives the refetch.
rerender({ total: 0, isLoading: true, resetKey: undefined });
expect(result.current).toBe(100);
});
});

View File

@@ -0,0 +1,35 @@
import { useRef } from 'react';
export type UseStableTotalCountParams = {
total: number | undefined;
isLoading: boolean;
/**
* Identifies the list being counted. When it changes, the cached count is
* dropped so the previous list's page count cannot outlive it.
*/
resetKey: string | undefined;
};
/**
* Holds on to the last non-zero total so the pagination does not flash while the
* same list refetches, and forgets it as soon as `resetKey` moves to another list.
*/
export function useStableTotalCount({
total,
isLoading,
resetKey,
}: UseStableTotalCountParams): number {
const prevTotalRef = useRef(total || 0);
const prevResetKeyRef = useRef(resetKey);
if (prevResetKeyRef.current !== resetKey) {
prevResetKeyRef.current = resetKey;
prevTotalRef.current = 0;
}
if (total && total > 0) {
prevTotalRef.current = total;
}
return isLoading ? prevTotalRef.current : total || 0;
}

View File

@@ -2,7 +2,7 @@ import { memo, useMemo } from 'react';
import { Select, Spin } from 'antd';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { useOrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/useOrderByFilter';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/config';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { StringOperators } from 'types/common/queryBuilder';

View File

@@ -10,7 +10,7 @@ import {
RESTRICTED_SELECTED_FIELDS,
} from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import {
BaseAutocompleteData,

View File

@@ -42,7 +42,7 @@ jest.mock('hooks/useDebounce', () => ({
}));
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer',
'container/QueryBuilder/filters/OptionRenderer/OptionRenderer',
() => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,

View File

@@ -1,11 +1,15 @@
import { ReactNode } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
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;

View File

@@ -1,45 +0,0 @@
import { useMemo } from 'react';
import { InputNumber, InputNumberProps } from 'antd';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { selectStyle } from '../QueryBuilderSearch/config';
function AggregateEveryFilter({
onChange,
query,
disabled,
}: AggregateEveryFilterProps): JSX.Element {
const isMetricsDataSource = useMemo(
() => query.dataSource === DataSource.METRICS,
[query.dataSource],
);
const onChangeHandler: InputNumberProps<number>['onChange'] = (event) => {
if (event && event >= 0) {
onChange(event);
}
};
const isDisabled =
(isMetricsDataSource && !query.aggregateAttribute?.key) || disabled;
return (
<InputNumber
placeholder="Enter in seconds"
disabled={isDisabled}
style={selectStyle}
value={query?.stepInterval}
onChange={onChangeHandler}
min={0}
/>
);
}
interface AggregateEveryFilterProps {
onChange: (values: number) => void;
query: IBuilderQuery;
disabled: boolean;
}
export default AggregateEveryFilter;

View File

@@ -24,8 +24,8 @@ import { DataSource } from 'types/common/queryBuilder';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
// ** Types
import { AgregatorFilterProps } from './AggregatorFilter.intefaces';

View File

@@ -1,6 +1,6 @@
import { InputNumber } from 'antd';
import { selectStyle } from '../../QueryBuilderSearch/config';
import { selectStyle } from '../../QueryBuilderSearchV2/config';
import { handleKeyDownLimitFilter } from '../../utils';
import { LimitFilterProps } from './types';

View File

@@ -4,7 +4,7 @@ import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { MetricAggregateOperator } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../../QueryBuilderSearch/config';
import { selectStyle } from '../../QueryBuilderSearchV2/config';
import { OrderByProps } from './types';
import { useOrderByFormulaFilter } from './useOrderByFormulaFilter';

View File

@@ -16,7 +16,7 @@ import {
mapLabelValuePairs,
orderByValueDelimiter,
} from '../../OrderByFilter/utils';
import { getRemoveOrderFromValue } from '../../QueryBuilderSearch/utils';
import { getRemoveOrderFromValue } from '../../QueryBuilderSearchV2/utils';
import { getUniqueOrderByValues, getValidOrderByResult } from '../../utils';
import { IOrderByFormulaFilterProps } from './types';
import { transformToOrderByStringValuesByFormula } from './utils';

View File

@@ -23,8 +23,8 @@ import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import { GroupByFilterProps } from './GroupByFilter.interfaces';
export const GroupByFilter = memo(function GroupByFilter({

View File

@@ -1,31 +0,0 @@
import { InputNumber } from 'antd';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { selectStyle } from '../QueryBuilderSearch/config';
import { handleKeyDownLimitFilter } from '../utils';
function LimitFilter({ onChange, query }: LimitFilterProps): JSX.Element {
const isMetricsDataSource = query.dataSource === DataSource.METRICS;
const isDisabled = isMetricsDataSource && !query.aggregateAttribute?.key;
return (
<InputNumber
min={1}
type="number"
value={query.limit}
style={selectStyle}
disabled={isDisabled}
onChange={onChange}
onKeyDown={handleKeyDownLimitFilter}
/>
);
}
interface LimitFilterProps {
onChange: (values: number | null) => void;
query: IBuilderQuery;
}
export default LimitFilter;

View File

@@ -31,7 +31,7 @@ jest.mock('hooks/useDebounce', () => ({
default: <T,>(value: T): T => value,
}));
jest.mock('../QueryBuilderSearch/OptionRenderer', () => ({
jest.mock('../OptionRenderer/OptionRenderer', () => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
}));

View File

@@ -16,8 +16,8 @@ import { MetricAggregation } from 'types/api/v5/queryRange';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import './MetricNameSelector.styles.scss';

View File

@@ -2,7 +2,7 @@ import { memo } from 'react';
import { Select } from 'antd';
// ** Types
import { selectStyle } from '../QueryBuilderSearch/config';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import { OperatorsSelectProps } from './OperatorsSelect.interfaces';
export const OperatorsSelect = memo(function OperatorsSelect({

View File

@@ -0,0 +1,22 @@
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
export const TagContainer = styled(Badge).attrs({
color: 'secondary',
variant: 'outline',
})`
&&& {
display: flex;
font-weight: 300;
font-size: 0.6rem;
}
`;
export const TagLabel = styled.span`
font-weight: 400;
`;
export const TagValue = styled.span`
text-transform: capitalize;
font-weight: 400;
`;

View File

@@ -5,7 +5,7 @@ import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import { OrderByFilterProps } from './OrderByFilter.interfaces';
import { useOrderByFilter } from './useOrderByFilter';

View File

@@ -7,7 +7,7 @@ import { parse } from 'papaparse';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
import { getRemoveOrderFromValue } from '../QueryBuilderSearch/utils';
import { getRemoveOrderFromValue } from '../QueryBuilderSearchV2/utils';
import { getUniqueOrderByValues, getValidOrderByResult } from '../utils';
import { ORDERBY_FILTERS } from './config';
import { SIGNOZ_VALUE } from './constants';

View File

@@ -1,70 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { Zap } from '@signozhq/icons';
import { getOptionType } from './utils';
import './QueryBuilderSearch.styles.scss';
function OptionRendererForLogs({
label,
value,
dataType,
isIndexed,
setDynamicPlaceholder,
}: OptionRendererProps): JSX.Element {
const optionType = getOptionType(label);
return (
<span
className="option"
onMouseEnter={(): void => setDynamicPlaceholder(value)}
onFocus={(): void => setDynamicPlaceholder(value)}
>
{optionType ? (
<Tooltip title={value} placement="topLeft">
<div className="logs-options-select">
<section className="left-section">
{isIndexed ? (
<Zap size={12} fill={Color.BG_AMBER_500} />
) : (
<div className="dot" />
)}
<Typography.Text className="text value" truncate={1}>
{value}
</Typography.Text>
</section>
<section className="right-section">
<div className="text tags data-type-tag">{dataType}</div>
<div className={cx('text tags option-type-tag', optionType)}>
<div className="dot" />
{optionType}
</div>
</section>
</div>
</Tooltip>
) : (
<Tooltip title={label} placement="topLeft">
<div className="without-option-type">
<div className="dot" />
<Typography.Text className="text" truncate={1}>
{label}
</Typography.Text>
</div>
</Tooltip>
)}
</span>
);
}
interface OptionRendererProps {
label: string;
value: string;
dataType: string;
isIndexed: boolean;
setDynamicPlaceholder: React.Dispatch<React.SetStateAction<string>>;
}
export default OptionRendererForLogs;

View File

@@ -1,298 +0,0 @@
.query-builder-search-container {
position: relative;
display: flex;
align-items: center;
gap: 12px;
}
.logs-popup {
&.hide-scroll {
.rc-virtual-list-holder {
height: 100px;
}
}
}
.logs-explorer-popup {
padding: 0px;
.ant-select-item-group {
padding: 12px 14px 8px 14px;
color: var(--muted-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 163.636% */
letter-spacing: 0.88px;
text-transform: uppercase;
}
.show-all-filter-props {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 13px;
width: 100%;
cursor: pointer;
.content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.left-section {
display: flex;
align-items: center;
gap: 4px;
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.text:hover {
color: var(--l1-foreground);
}
}
.right-section {
display: flex;
align-items: center;
gap: 4px;
.keyboard-shortcut-slash {
width: 16px;
height: 16px;
flex-shrink: 0;
border-radius: 2.286px;
border-top: 1.143px solid var(--l1-border);
border-right: 1.143px solid var(--l1-border);
border-bottom: 2.286px solid var(--l1-border);
border-left: 1.143px solid var(--l1-border);
background: var(--l2-background);
}
}
}
}
.show-all-filter-props:hover {
background: color-mix(
in srgb,
var(--l1-foreground) 4%,
transparent
) !important;
}
.example-queries {
cursor: default;
.heading {
padding: 12px 14px 8px 14px;
color: var(--muted-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 163.636% */
letter-spacing: 0.88px;
text-transform: uppercase;
}
.query-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0px 12px 12px 12px;
cursor: pointer;
.example-query {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 10px;
border-radius: 2px;
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: -0.07px;
width: fit-content;
}
.example-query:hover {
color: var(--l1-foreground);
}
}
}
.ant-select-item-option-grouped {
padding-inline-start: 0px;
padding: 7px 13px;
}
.keyboard-shortcuts {
display: flex;
align-items: center;
border-radius: 0px 0px 4px 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
padding: 11px 16px;
cursor: default;
.icons {
width: 16px;
height: 16px;
flex-shrink: 0;
border-radius: 2.286px;
border-top: 1.143px solid var(--l3-background);
border-right: 1.143px solid var(--l3-background);
border-bottom: 2.286px solid var(--l3-background);
border-left: 1.143px solid var(--l3-background);
background: var(--l2-background);
}
.keyboard-text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 142.857% */
letter-spacing: -0.07px;
}
.navigate {
display: flex;
align-items: center;
padding-right: 12px;
gap: 4px;
border-right: 1px solid var(--l1-border);
}
.update-query {
display: flex;
align-items: center;
margin-left: 12px;
gap: 4px;
}
}
.without-option-type {
display: flex;
gap: 8px;
align-items: center;
.dot {
height: 5px;
width: 5px;
border-radius: 50%;
background-color: var(--l3-background);
}
}
.logs-options-select {
display: flex;
align-items: center;
justify-content: space-between;
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.tags {
display: flex;
height: 20px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 20px;
}
.dot {
height: 5px;
width: 5px;
border-radius: 50%;
flex-shrink: 0;
}
.left-section {
display: flex;
align-items: center;
gap: 8px;
width: 90%;
.dot {
background-color: var(--l3-background);
}
.value {
width: 100%;
}
}
.right-section {
display: flex;
align-items: center;
gap: 4px;
.data-type-tag {
background: color-mix(in srgb, var(--l1-foreground) 8%, transparent);
}
.option-type-tag {
display: flex;
gap: 4px;
align-items: center;
padding: 0px 6px;
text-transform: capitalize;
}
.tag {
border-radius: 50px;
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
color: var(--bg-sienna-400);
.dot {
background-color: var(--bg-sienna-400);
}
}
.resource {
border-radius: 50px;
background: color-mix(in srgb, var(--bg-sakura-400) 10%, transparent);
color: var(--bg-sakura-400);
.dot {
background-color: var(--bg-sakura-400);
}
}
}
}
.ant-select-item-option-active {
.logs-options-select {
.left-section {
.value {
color: var(--l1-foreground);
}
}
}
}
}
.span-scope-selector {
width: 160px;
}

View File

@@ -1,558 +0,0 @@
import {
KeyboardEvent,
ReactElement,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import { Button, Select, Spin, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import {
INFRA_LONG_TO_SHORT_OPERATOR_MAP,
OPERATORS,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { getDataTypes } from 'container/LogDetailedView/utils';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import {
useAutoComplete,
WhereClauseConfig,
} from 'hooks/queryBuilder/useAutoComplete';
import { useFetchKeysAndValues } from 'hooks/queryBuilder/useFetchKeysAndValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isEqual, isUndefined } from 'lodash-es';
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronUp,
Command,
CornerDownLeft,
Filter,
Slash,
} from '@signozhq/icons';
import type { BaseSelectRef } from 'rc-select';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { selectStyle } from './config';
import { PLACEHOLDER } from './constant';
import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs';
import OptionRenderer from './OptionRenderer';
import OptionRendererForLogs from './OptionRendererForLogs';
import { StyledCheckOutlined, TypographyText } from './style';
import {
convertExampleQueriesToOptions,
getOperatorValue,
getRemovePrefixFromKey,
getTagToken,
isExistsNotExistsOperator,
isInNInOperator,
} from './utils';
import './QueryBuilderSearch.styles.scss';
function getOperatorValueForContext(
op: string,
isInfraMonitoring?: boolean,
): string {
const mappedOp =
isInfraMonitoring && INFRA_LONG_TO_SHORT_OPERATOR_MAP[op]
? INFRA_LONG_TO_SHORT_OPERATOR_MAP[op]
: op;
return getOperatorValue(mappedOp);
}
function QueryBuilderSearch({
query,
onChange,
whereClauseConfig,
className,
placeholder,
suffixIcon,
isInfraMonitoring,
isMetricsExplorer,
disableNavigationShortcuts,
entity,
}: QueryBuilderSearchProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { pathname } = useLocation();
const isLogsExplorerPage = useMemo(
() => pathname === ROUTES.LOGS_EXPLORER,
[pathname],
);
const [isEditingTag, setIsEditingTag] = useState(false);
const {
updateTag,
handleClearTag,
handleKeyDown,
handleOnBlur,
handleSearch,
handleSelect,
tags,
options,
searchValue,
isMulti,
isFetching,
setSearchKey,
setSearchValue,
searchKey,
key,
exampleQueries,
} = useAutoComplete(
query,
whereClauseConfig,
isLogsExplorerPage,
isInfraMonitoring,
entity,
isMetricsExplorer,
);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [showAllFilters, setShowAllFilters] = useState<boolean>(false);
const [dynamicPlacholder, setDynamicPlaceholder] = useState<string>(
placeholder || '',
);
const selectRef = useRef<BaseSelectRef>(null);
const { sourceKeys, handleRemoveSourceKey } = useFetchKeysAndValues(
searchValue,
query,
searchKey,
isLogsExplorerPage,
isInfraMonitoring,
entity,
isMetricsExplorer,
);
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
const { handleRunQuery, currentQuery } = useQueryBuilder();
const toggleEditMode = useCallback(
(value: boolean) => {
// Editing mode is required only in infra monitoring or metrics explorer
if (isInfraMonitoring || isMetricsExplorer) {
setIsEditingTag(value);
}
},
[isInfraMonitoring, isMetricsExplorer],
);
const onTagRender = ({
value,
closable,
onClose,
}: CustomTagProps): ReactElement => {
const { tagOperator } = getTagToken(value);
const isInNin = isInNInOperator(tagOperator);
const chipValue = isInNin
? value?.trim()?.replace(/,\s*$/, '')
: value?.trim();
const onCloseHandler = (): void => {
onClose();
// Editing is done after closing a tag
toggleEditMode(false);
handleSearch('');
setSearchKey('');
};
const tagEditHandler = (value: string): void => {
updateTag(value);
// Editing starts
toggleEditMode(true);
if (isInfraMonitoring || isMetricsExplorer) {
setSearchValue(value);
} else {
handleSearch(value);
}
};
const isDisabled = !!searchValue;
return (
<Badge
color="vanilla"
closable={!searchValue && closable}
onClose={(e): void => {
e.preventDefault();
onCloseHandler();
}}
>
<Tooltip title={chipValue}>
<TypographyText
$isInNin={isInNin}
$isEnabled={!!searchValue}
onClick={(): void => {
if (!isDisabled) {
tagEditHandler(value);
}
}}
>
{chipValue}
</TypographyText>
</Tooltip>
</Badge>
);
};
const onChangeHandler = (value: string[]): void => {
if (!isMulti) {
handleSearch(value[value.length - 1]);
}
};
const onInputKeyDownHandler = (event: KeyboardEvent<Element>): void => {
if (isMulti || event.key === 'Backspace') {
handleKeyDown(event);
}
if (isExistsNotExistsOperator(searchValue)) {
handleKeyDown(event);
}
// Editing is done after enter key press
if (event.key === 'Enter') {
toggleEditMode(false);
}
if (
!disableNavigationShortcuts &&
(event.ctrlKey || event.metaKey) &&
event.key === 'Enter'
) {
event.preventDefault();
event.stopPropagation();
handleRunQuery();
setIsOpen(false);
}
if (
!disableNavigationShortcuts &&
(event.ctrlKey || event.metaKey) &&
event.key === '/'
) {
event.preventDefault();
event.stopPropagation();
setShowAllFilters((prev) => !prev);
}
};
const handleDeselect = useCallback(
(deselectedItem: string) => {
handleClearTag(deselectedItem);
handleRemoveSourceKey(deselectedItem);
},
[handleClearTag, handleRemoveSourceKey],
);
const isMetricsDataSource = useMemo(
() =>
query.dataSource === DataSource.METRICS &&
!isInfraMonitoring &&
!isMetricsExplorer,
[query.dataSource, isInfraMonitoring, isMetricsExplorer],
);
const fetchValueDataType = (value: unknown, operator: string): DataTypes => {
if (operator === OPERATORS.HAS || operator === OPERATORS.NHAS) {
return getDataTypes([value]);
}
return DataTypes.EMPTY;
};
const queryTags = useMemo(() => {
if (!query.aggregateAttribute?.key && isMetricsDataSource) {
return [];
}
return tags;
}, [isMetricsDataSource, query.aggregateAttribute?.key, tags]);
useEffect(() => {
const initialTagFilters: TagFilter = { items: [], op: 'AND' };
const initialSourceKeys = query.filters?.items?.map(
(item) => item.key as BaseAutocompleteData,
);
initialTagFilters.items = tags.map((tag) => {
const { tagKey, tagOperator, tagValue } = getTagToken(tag);
const filterAttribute = [
...(initialSourceKeys || []),
...(sourceKeys || []),
].find((key) => key?.key === getRemovePrefixFromKey(tagKey));
const computedTagValue =
tagValue && Array.isArray(tagValue) && tagValue[tagValue.length - 1] === ''
? tagValue?.slice(0, -1)
: (tagValue ?? '');
return {
id: uuid().slice(0, 8),
key: filterAttribute ?? {
key: tagKey,
dataType: fetchValueDataType(computedTagValue, tagOperator),
type: '',
},
op: getOperatorValueForContext(tagOperator, isInfraMonitoring),
value: computedTagValue,
};
});
// If in infra monitoring or metrics explorer, only run the onChange query when editing is finsished.
if (isInfraMonitoring || isMetricsExplorer) {
if (!isEditingTag) {
onChange(initialTagFilters);
}
} else {
onChange(initialTagFilters);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceKeys]);
const isLastQuery = useMemo(
() =>
isEqual(
currentQuery.builder.queryData[currentQuery.builder.queryData.length - 1],
query,
),
[currentQuery, query],
);
useEffect(() => {
if (isLastQuery && !disableNavigationShortcuts) {
registerShortcut(LogsExplorerShortcuts.FocusTheSearchBar, () => {
// set timeout is needed here else the select treats the hotkey as input value
setTimeout(() => {
selectRef.current?.focus();
}, 0);
});
}
return (): void =>
deregisterShortcut(LogsExplorerShortcuts.FocusTheSearchBar);
}, [
deregisterShortcut,
disableNavigationShortcuts,
isLastQuery,
registerShortcut,
]);
useEffect(() => {
if (!isOpen) {
setDynamicPlaceholder(placeholder || '');
}
}, [isOpen, placeholder]);
const userOs = getUserOperatingSystem();
// conditional changes here to use a seperate component to render the example queries based on the option group label
const customRendererForLogsExplorer = options.map((option) => (
<Select.Option
key={`${option.label}-${option.type || ''}-${option.dataType || ''}`}
value={option.value}
>
<OptionRendererForLogs
label={option.label}
value={option.value}
dataType={option.dataType || ''}
isIndexed={option.isIndexed || false}
setDynamicPlaceholder={setDynamicPlaceholder}
/>
{option.selected && <StyledCheckOutlined />}
</Select.Option>
));
return (
<div className="query-builder-search-container">
<Select
data-testid={'qb-search-select'}
ref={selectRef}
getPopupContainer={getPopupContainer}
transitionName=""
choiceTransitionName=""
virtual={false}
showSearch
tagRender={onTagRender}
filterOption={false}
open={isOpen}
onDropdownVisibleChange={setIsOpen}
autoClearSearchValue={false}
mode="multiple"
placeholder={dynamicPlacholder}
value={queryTags}
searchValue={searchValue}
className={cx(
className,
isLogsExplorerPage ? 'logs-popup' : '',
!showAllFilters && options.length > 3 && !key ? 'hide-scroll' : '',
)}
rootClassName="query-builder-search"
disabled={isMetricsDataSource && !query.aggregateAttribute?.key}
style={selectStyle}
onSearch={handleSearch}
onChange={onChangeHandler}
onSelect={handleSelect}
onDeselect={handleDeselect}
onInputKeyDown={onInputKeyDownHandler}
notFoundContent={isFetching ? <Spin size="small" /> : null}
suffixIcon={
!isUndefined(suffixIcon) ? (
suffixIcon
) : isOpen ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)
}
showAction={['focus']}
onBlur={(e: React.FocusEvent<HTMLInputElement>): void => {
handleOnBlur(e);
// Editing is done after tapping out of the input
toggleEditMode(false);
}}
popupClassName={isLogsExplorerPage ? 'logs-explorer-popup' : ''}
dropdownRender={(menu): ReactElement => (
<div>
{!searchKey && isLogsExplorerPage && (
<div className="ant-select-item-group ">Suggested Filters</div>
)}
{menu}
{isLogsExplorerPage && (
<div>
{!searchKey && tags.length === 0 && (
<div className="example-queries">
<div className="heading"> Example Queries </div>
<div className="query-container">
{convertExampleQueriesToOptions(exampleQueries).map((query) => (
<ExampleQueriesRendererForLogs
key={query.label}
label={query.label}
value={query.value}
handleAddTag={onChange}
/>
))}
</div>
</div>
)}
{!key && !isFetching && !showAllFilters && options.length > 3 && (
<Button
type="text"
className="show-all-filter-props"
onClick={(): void => {
setShowAllFilters(true);
// when clicking on the button the search bar looses the focus
selectRef?.current?.focus();
}}
>
<div className="content">
<section className="left-section">
<Filter size={14} />
<Typography.Text className="text">
Show all filters properties
</Typography.Text>
</section>
<section className="right-section">
{userOs === UserOperatingSystem.MACOS ? (
<Command size={14} className="keyboard-shortcut-slash" />
) : (
<ChevronUp size={14} className="keyboard-shortcut-slash" />
)}
+
<Slash size={14} className="keyboard-shortcut-slash" />
</section>
</div>
</Button>
)}
<div className="keyboard-shortcuts">
<section className="navigate">
<ArrowDown size={10} className="icons" />
<ArrowUp size={10} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
<section className="update-query">
<CornerDownLeft size={10} className="icons" />
<span className="keyboard-text">to update query</span>
</section>
</div>
</div>
)}
</div>
)}
>
{isLogsExplorerPage
? customRendererForLogsExplorer
: options.map((option) => (
<Select.Option
key={`${option.label}-${option.type || ''}-${option.dataType || ''}`}
value={option.value}
>
<OptionRenderer
label={option.label}
value={option.value}
dataType={option.dataType || ''}
type={option.type || ''}
/>
{option.selected && <StyledCheckOutlined />}
</Select.Option>
))}
</Select>
</div>
);
}
interface QueryBuilderSearchProps {
query: IBuilderQuery;
onChange: (value: TagFilter) => void;
whereClauseConfig?: WhereClauseConfig;
className?: string;
placeholder?: string;
suffixIcon?: React.ReactNode;
isInfraMonitoring?: boolean;
disableNavigationShortcuts?: boolean;
// TODO: Remove the dependency of InfraMonitoring from this code
entity?: InfraMonitoringEntity | null;
isMetricsExplorer?: boolean;
}
QueryBuilderSearch.defaultProps = {
whereClauseConfig: undefined,
className: '',
placeholder: PLACEHOLDER,
suffixIcon: undefined,
isInfraMonitoring: false,
disableNavigationShortcuts: false,
entity: null,
isMetricsExplorer: false,
};
export interface CustomTagProps {
label: ReactNode;
value: string;
disabled: boolean;
onClose: () => void;
closable: boolean;
}
export default QueryBuilderSearch;

View File

@@ -1,7 +1,5 @@
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import './QueryBuilderSearch.styles.scss';
function ExampleQueriesRendererForLogs({
label,
value,

View File

@@ -11,8 +11,8 @@ import {
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import ExampleQueriesRendererForLogs from '../QueryBuilderSearch/ExampleQueriesRendererForLogs';
import { convertExampleQueriesToOptions } from '../QueryBuilderSearch/utils';
import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs';
import { convertExampleQueriesToOptions } from './utils';
import { ITag, Option } from './QueryBuilderSearchV2';
import './QueryBuilderSearchV2.styles.scss';

View File

@@ -19,10 +19,10 @@ 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 { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
@@ -53,16 +53,16 @@ import { DataSource } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { selectStyle } from '../QueryBuilderSearch/config';
import { PLACEHOLDER } from '../QueryBuilderSearch/constant';
import { TypographyText } from '../QueryBuilderSearch/style';
import { selectStyle } from './config';
import { PLACEHOLDER } from './constant';
import { TypographyText } from './style';
import {
checkCommaInValue,
getOperatorFromValue,
getOperatorValue,
getTagToken,
isInNInOperator,
} from '../QueryBuilderSearch/utils';
} from './utils';
import { filterByOperatorConfig } from '../utils';
import QueryBuilderSearchDropdown from './QueryBuilderSearchDropdown';
import SpanScopeSelector from './SpanScopeSelector';

View File

@@ -7,7 +7,7 @@ import { isEmpty, isObject } from 'lodash-es';
import { Check, Zap } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { getTagToken } from '../QueryBuilderSearch/utils';
import { getTagToken } from './utils';
import { DropdownState } from './QueryBuilderSearchV2';
import './Suggestions.styles.scss';

View File

@@ -1,5 +1,3 @@
import { Check } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
export const TypographyText = styled.span<{
@@ -17,27 +15,3 @@ export const TypographyText = styled.span<{
padding-left: 8px;
${({ $disabled }): string => ($disabled ? 'opacity: 0.6' : '')}
`;
export const StyledCheckOutlined = styled(Check)`
float: right;
`;
export const TagContainer = styled(Badge).attrs({
color: 'secondary',
variant: 'outline',
})`
&&& {
display: flex;
font-weight: 300;
font-size: 0.6rem;
}
`;
export const TagLabel = styled.span`
font-weight: 400;
`;
export const TagValue = styled.span`
text-transform: capitalize;
font-weight: 400;
`;

View File

@@ -1,8 +1,7 @@
import { OPERATORS } from 'constants/queryBuilder';
import { MetricsType } from 'container/MetricsApplication/constant';
import { queryFilterTags } from 'hooks/queryBuilder/useTag';
import { parse } from 'papaparse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import type { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { orderByValueDelimiter } from '../OrderByFilter/utils';
@@ -191,18 +190,6 @@ export function getRemoveOrderFromValue(tag: string): string {
return tag;
}
export function getOptionType(label: string): MetricsType | undefined {
let optionType;
if (label.startsWith('tag_')) {
optionType = MetricsType.Tag;
} else if (label.startsWith('resource_')) {
optionType = MetricsType.Resource;
}
return optionType;
}
/**
*
* @param exampleQueries the example queries based on recommendation engine

View File

@@ -1,4 +1,3 @@
import { AttributeValuesMap } from 'components/ClientSideQBSearch/ClientSideQBSearch';
import { OperatorConfigKeys, OPERATORS_CONFIG } from 'constants/queryBuilder';
import { HAVING_FILTER_REGEXP } from 'constants/regExp';
import { IOption } from 'hooks/useResourceAttribute/types';
@@ -11,7 +10,7 @@ import {
orderByValueDelimiter,
splitOrderByFromString,
} from './OrderByFilter/utils';
import { getRemoveOrderFromValue } from './QueryBuilderSearch/utils';
import { getRemoveOrderFromValue } from './QueryBuilderSearchV2/utils';
export const handleKeyDownLimitFilter: React.KeyboardEventHandler<
HTMLInputElement
@@ -101,23 +100,6 @@ export const getValidOrderByResult = (result: IOption[]): IOption[] =>
return acc;
}, []);
export const transformKeyValuesToAttributeValuesMap = (
attributeValuesMap: Record<string, string[] | number[] | boolean[]>,
): AttributeValuesMap =>
Object.fromEntries(
Object.entries(attributeValuesMap || {}).map(([key, values]) => [
key,
{
stringAttributeValues:
typeof values[0] === 'string' ? (values as string[]) : [],
numberAttributeValues:
typeof values[0] === 'number' ? (values as number[]) : [],
boolAttributeValues:
typeof values[0] === 'boolean' ? (values as boolean[]) : [],
},
]),
);
export const filterByOperatorConfig = (
options: IOption[],
key?: OperatorConfigKeys,

View File

@@ -3,7 +3,7 @@ import {
formatValueForExpression,
removeKeysFromExpression,
} from 'components/QueryBuilderV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { IQueryPair } from 'types/antlrQueryTypes';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isQuoted, unquote } from 'utils/stringUtils';

View File

@@ -9,7 +9,7 @@ import { QueryParams } from 'constants/query';
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';

View File

@@ -1,185 +0,0 @@
import { KeyboardEvent, useCallback, useState } from 'react';
import { OPERATORS } from 'constants/queryBuilder';
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
getRemovePrefixFromKey,
getTagToken,
replaceStringWithMaxLength,
tagRegexp,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { Option } from 'container/QueryBuilder/type';
import { parse } from 'papaparse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { useFetchKeysAndValues } from './useFetchKeysAndValues';
import { useOptions, WHERE_CLAUSE_CUSTOM_SUFFIX } from './useOptions';
import { useSetCurrentKeyAndOperator } from './useSetCurrentKeyAndOperator';
import { useTag } from './useTag';
import { useTagValidation } from './useTagValidation';
export type WhereClauseConfig = {
customKey: string;
customOp: string;
};
export const useAutoComplete = (
query: IBuilderQuery,
whereClauseConfig?: WhereClauseConfig,
shouldUseSuggestions?: boolean,
isInfraMonitoring?: boolean,
entity?: InfraMonitoringEntity | null,
isMetricsExplorer?: boolean,
): IAutoComplete => {
const [searchValue, setSearchValue] = useState<string>('');
const [searchKey, setSearchKey] = useState<string>('');
const { keys, results, isFetching, exampleQueries } = useFetchKeysAndValues(
searchValue,
query,
searchKey,
shouldUseSuggestions,
isInfraMonitoring,
entity,
isMetricsExplorer,
);
const [key, operator, result] = useSetCurrentKeyAndOperator(searchValue, keys);
const handleSearch = (value: string): void => {
const prefixFreeValue = getRemovePrefixFromKey(getTagToken(value).tagKey);
setSearchValue(value);
setSearchKey(prefixFreeValue);
};
const { isValidTag, isExist, isValidOperator, isMulti } = useTagValidation(
operator,
result,
);
const { handleAddTag, handleClearTag, tags, updateTag } = useTag(
isValidTag,
handleSearch,
query,
setSearchKey,
whereClauseConfig,
isInfraMonitoring,
);
const handleSelect = useCallback(
(value: string): void => {
if (isMulti) {
setSearchValue((prev: string) => {
const matches = prev?.matchAll(tagRegexp);
const [match] = matches ? Array.from(matches) : [];
const [, , , matchTagValue] = match;
const data = parse(matchTagValue).data.flat();
return replaceStringWithMaxLength(prev, data as string[], value);
});
}
if (!isMulti) {
handleAddTag(value);
}
},
[handleAddTag, isMulti],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent): void => {
if (
event.key === ' ' &&
(searchValue.endsWith(' ') || searchValue.length === 0)
) {
event.preventDefault();
}
if (event.key === 'Enter' && searchValue && isValidTag) {
if (isMulti) {
event.stopPropagation();
}
event.preventDefault();
handleAddTag(searchValue);
}
if (event.key === 'Backspace' && !searchValue) {
event.stopPropagation();
const last = tags[tags.length - 1];
handleClearTag(last);
}
},
[handleAddTag, handleClearTag, isMulti, isValidTag, searchValue, tags],
);
const handleOnBlur = (event: React.FocusEvent<HTMLInputElement>): void => {
event.preventDefault();
if (searchValue) {
if (
key &&
!operator &&
whereClauseConfig?.customKey === 'body' &&
whereClauseConfig.customOp === OPERATORS.CONTAINS
) {
const value = `${searchValue}${WHERE_CLAUSE_CUSTOM_SUFFIX}`;
handleAddTag(value);
return;
}
handleAddTag(searchValue);
}
};
const options = useOptions(
key,
keys,
operator,
searchValue,
isMulti,
isValidOperator,
isExist,
results,
result,
isFetching,
whereClauseConfig,
isInfraMonitoring,
);
return {
updateTag,
handleSearch,
handleClearTag,
handleSelect,
handleKeyDown,
handleOnBlur,
options,
tags,
searchValue,
isMulti,
isFetching,
setSearchKey,
setSearchValue,
searchKey,
key,
exampleQueries,
};
};
interface IAutoComplete {
updateTag: (value: string) => void;
handleSearch: (value: string) => void;
handleClearTag: (value: string) => void;
handleSelect: (value: string) => void;
handleKeyDown: (event: React.KeyboardEvent) => void;
handleOnBlur: (event: React.FocusEvent<HTMLInputElement>) => void;
options: Option[];
tags: string[];
searchValue: string;
isMulti: boolean;
isFetching: boolean;
setSearchKey: (value: string) => void;
setSearchValue: (value: string) => void;
searchKey: string;
key: string;
exampleQueries: TagFilter[];
isInfraMonitoring?: boolean;
}

View File

@@ -1,308 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDebounce } from 'react-use';
import { getAttributesValues } from 'api/queryBuilder/getAttributesValues';
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import {
GetK8sEntityToAggregateAttribute,
InfraMonitoringEntity,
} from 'container/InfraMonitoringK8sV2/constants';
import {
getRemovePrefixFromKey,
getTagToken,
isInNInOperator,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import useDebounceValue from 'hooks/useDebounce';
import { cloneDeep, isEqual, uniqWith, unset } from 'lodash-es';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { useGetAggregateKeys } from './useGetAggregateKeys';
import { useGetAttributeSuggestions } from './useGetAttributeSuggestions';
type IuseFetchKeysAndValues = {
keys: BaseAutocompleteData[];
results: string[];
isFetching: boolean;
sourceKeys: BaseAutocompleteData[];
handleRemoveSourceKey: (newSourceKey: string) => void;
exampleQueries: TagFilter[];
};
/**
* Custom hook to fetch attribute keys and values from an API
* @param searchValue - the search query value
* @param query - an object containing data for the query
* @returns an object containing the fetched attribute keys, results, and the status of the fetch
*/
export const useFetchKeysAndValues = (
searchValue: string,
query: IBuilderQuery,
searchKey: string,
shouldUseSuggestions?: boolean,
isInfraMonitoring?: boolean,
entity?: InfraMonitoringEntity | null,
isMetricsExplorer?: boolean,
): IuseFetchKeysAndValues => {
const [keys, setKeys] = useState<BaseAutocompleteData[]>([]);
const [exampleQueries, setExampleQueries] = useState<TagFilter[]>([]);
const [sourceKeys, setSourceKeys] = useState<BaseAutocompleteData[]>([]);
const [results, setResults] = useState<string[]>([]);
const [isAggregateFetching, setAggregateFetching] = useState<boolean>(false);
const memoizedSearchParams = useMemo(
() => [
searchKey,
query.dataSource,
query.aggregateOperator,
query.aggregateAttribute?.key,
],
[
searchKey,
query.dataSource,
query.aggregateOperator,
query.aggregateAttribute?.key,
],
);
const searchParams = useDebounceValue(memoizedSearchParams, DEBOUNCE_DELAY);
const queryFiltersWithoutId = useMemo(
() => ({
...query.filters,
items: query.filters?.items?.map((item) => {
const filterWithoutId = cloneDeep(item);
unset(filterWithoutId, 'id');
return filterWithoutId;
}),
}),
[query.filters],
);
const memoizedSuggestionsParams = useMemo(
() => [searchKey, query.dataSource, queryFiltersWithoutId],
[query.dataSource, queryFiltersWithoutId, searchKey],
);
const suggestionsParams = useDebounceValue(
memoizedSuggestionsParams,
DEBOUNCE_DELAY,
);
const isQueryEnabled = useMemo(
() =>
query.dataSource === DataSource.METRICS &&
!isInfraMonitoring &&
!isMetricsExplorer
? !!query.dataSource && !!query.aggregateAttribute?.dataType
: true,
[
isInfraMonitoring,
isMetricsExplorer,
query.aggregateAttribute?.dataType,
query.dataSource,
],
);
const { data, isFetching, status } = useGetAggregateKeys(
{
searchText: searchKey,
dataSource: query.dataSource,
aggregateOperator: query.aggregateOperator || '',
aggregateAttribute:
isInfraMonitoring && entity
? GetK8sEntityToAggregateAttribute(entity)
: query.aggregateAttribute?.key || '',
tagType: query.aggregateAttribute?.type ?? null,
},
{
queryKey: [searchParams],
enabled: isMetricsExplorer ? false : isQueryEnabled && !shouldUseSuggestions,
},
isInfraMonitoring, // isInfraMonitoring
entity, // infraMonitoringEntity
);
const {
data: suggestionsData,
isFetching: isFetchingSuggestions,
status: fetchingSuggestionsStatus,
} = useGetAttributeSuggestions(
{
searchText: searchKey,
dataSource: query.dataSource,
filters: query.filters || { items: [], op: 'AND' },
},
{
queryKey: [suggestionsParams],
enabled: isQueryEnabled && shouldUseSuggestions,
},
);
function isAttributeValuesResponse(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any,
): payload is IAttributeValuesResponse {
return (
payload &&
(Array.isArray(payload.stringAttributeValues) ||
payload.stringAttributeValues === null ||
Array.isArray(payload.numberAttributeValues) ||
payload.numberAttributeValues === null ||
Array.isArray(payload.boolAttributeValues) ||
payload.boolAttributeValues === null)
);
}
/**
* Fetches the options to be displayed based on the selected value
* @param value - the selected value
* @param query - an object containing data for the query
*/
const handleFetchOption = async (
value: string,
query: IBuilderQuery,
keys: BaseAutocompleteData[],
): Promise<void> => {
if (!value) {
return;
}
const { tagKey, tagOperator, tagValue } = getTagToken(value);
const filterAttributeKey = keys.find(
(item) => item.key === getRemovePrefixFromKey(tagKey),
);
setResults([]);
if (!tagKey || !tagOperator) {
return;
}
setAggregateFetching(true);
try {
let payload;
if (isInfraMonitoring && entity) {
const response = await getAttributesValues({
aggregateOperator: 'noop',
dataSource: query.dataSource,
aggregateAttribute:
GetK8sEntityToAggregateAttribute(entity) ||
query.aggregateAttribute?.key ||
'',
attributeKey: filterAttributeKey?.key ?? tagKey,
filterAttributeKeyDataType:
filterAttributeKey?.dataType ?? DataTypes.EMPTY,
tagType: filterAttributeKey?.type ?? '',
searchText: isInNInOperator(tagOperator)
? (tagValue[tagValue.length - 1]?.toString() ?? '')
: (tagValue?.toString() ?? ''),
});
payload = response.payload;
} else {
const response = await getAttributesValues({
aggregateOperator: query.aggregateOperator || '',
dataSource: query.dataSource,
aggregateAttribute: query.aggregateAttribute?.key || '',
attributeKey: filterAttributeKey?.key ?? tagKey,
filterAttributeKeyDataType:
filterAttributeKey?.dataType ?? DataTypes.EMPTY,
tagType: filterAttributeKey?.type ?? '',
searchText: isInNInOperator(tagOperator)
? (tagValue[tagValue.length - 1]?.toString() ?? '')
: (tagValue?.toString() ?? ''),
});
payload = response.payload;
}
if (payload && isAttributeValuesResponse(payload)) {
const dataType = filterAttributeKey?.dataType ?? DataTypes.String;
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
setResults(key ? payload[key] || [] : []);
return;
}
} catch (e) {
console.error(e);
} finally {
setAggregateFetching(false);
}
};
const handleRemoveSourceKey = useCallback((sourceKey: string) => {
setSourceKeys((prevState) =>
prevState.filter((item) => item.key !== sourceKey),
);
}, []);
// creates a ref to the fetch function so that it doesn't change on every render
const clearFetcher = useRef(handleFetchOption).current;
// debounces the fetch function to avoid excessive API calls
useDebounce(() => clearFetcher(searchValue, query, keys), 750, [
clearFetcher,
searchValue,
query,
keys,
]);
// update the fetched keys when the fetch status changes
useEffect(() => {
if (status === 'success' && data?.payload?.attributeKeys) {
setKeys(data.payload.attributeKeys);
setSourceKeys((prevState) =>
uniqWith([...(data.payload.attributeKeys ?? []), ...prevState], isEqual),
);
} else {
setKeys([]);
}
}, [data?.payload?.attributeKeys, status]);
useEffect(() => {
if (
fetchingSuggestionsStatus === 'success' &&
suggestionsData?.payload?.attributes
) {
if (!isInfraMonitoring) {
setKeys(suggestionsData.payload.attributes);
setSourceKeys((prevState) =>
uniqWith(
[...(suggestionsData.payload.attributes ?? []), ...prevState],
isEqual,
),
);
}
} else {
setKeys([]);
}
if (
fetchingSuggestionsStatus === 'success' &&
suggestionsData?.payload?.example_queries
) {
setExampleQueries(suggestionsData.payload.example_queries);
} else {
setExampleQueries([]);
}
}, [
suggestionsData?.payload?.attributes,
fetchingSuggestionsStatus,
suggestionsData?.payload?.example_queries,
isInfraMonitoring,
]);
return {
keys,
results,
isFetching: isFetching || isAggregateFetching || isFetchingSuggestions,
sourceKeys,
handleRemoveSourceKey,
exampleQueries,
};
};

View File

@@ -1,31 +0,0 @@
import { useMemo } from 'react';
import {
OPERATORS,
QUERY_BUILDER_OPERATORS_BY_TYPES,
} from 'constants/queryBuilder';
import { getRemovePrefixFromKey } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
type IOperators =
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.universal
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.string
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.bool
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.int64
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.float64;
export const useOperators = (
key: string,
keys: BaseAutocompleteData[],
): IOperators =>
useMemo(() => {
const currentKey = keys?.find((el) => el.key === getRemovePrefixFromKey(key));
const strippedKey = key.split(' ')[0];
return currentKey?.dataType
? QUERY_BUILDER_OPERATORS_BY_TYPES[
currentKey.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
]
: strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')
? [OPERATORS.HAS, OPERATORS.NHAS]
: QUERY_BUILDER_OPERATORS_BY_TYPES.universal;
}, [keys, key]);

View File

@@ -1,220 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { INFRA_SHORT_TO_LONG_OPERATOR_MAP } from 'constants/queryBuilder';
import {
checkCommaInValue,
getTagToken,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { Option } from 'container/QueryBuilder/type';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { isEmpty } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { WhereClauseConfig } from './useAutoComplete';
import { useOperators } from './useOperators';
export const WHERE_CLAUSE_CUSTOM_SUFFIX = '-custom';
export const useOptions = (
key: string,
keys: BaseAutocompleteData[],
operator: string,
searchValue: string,
isMulti: boolean,
isValidOperator: boolean,
isExist: boolean,
results: string[],
result: string[],
isFetching: boolean,
whereClauseConfig?: WhereClauseConfig,
isInfraMonitoring?: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): Option[] => {
const [options, setOptions] = useState<Option[]>([]);
const operators = useOperators(key, keys);
// get matching dynamic variables to suggest
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const variableName = dashboardDynamicVariables?.find(
(variable) => variable?.dynamicVariablesAttribute === key,
)?.name;
const variableAsValue = variableName ? `$${variableName}` : '';
const getLabel = useCallback(
(data: BaseAutocompleteData): Option['label'] => data?.key,
[],
);
const getOptionsFromKeys = useCallback(
(items: BaseAutocompleteData[]): Option[] =>
items?.map((item) => ({
label: `${getLabel(item)}`,
value: item.key,
dataType: item.dataType,
isIndexed: item?.isIndexed,
type: item?.type || '',
})),
[getLabel],
);
const getKeyOpValue = useCallback(
(items: string[]): Option[] =>
items?.map((item) => ({
label: `${key} ${operator} ${item}`,
value: `${key} ${operator} ${item}`,
})),
[key, operator],
);
const getOptionsWithValidOperator = useCallback(
(key: string, results: string[], searchValue: string) => {
const hasAllResults = results.every((value) => result.includes(value));
let newResults = results;
if (!isEmpty(variableAsValue)) {
newResults = [variableAsValue, ...newResults];
}
const values = getKeyOpValue(newResults);
return hasAllResults
? [
{
label: searchValue,
value: searchValue,
},
]
: [
{
label: searchValue,
value: searchValue,
},
...values,
];
},
[getKeyOpValue, result, variableAsValue],
);
const getKeyOperatorOptions = useCallback(
(key: string) => {
const keyOperator = key.split(' ');
const partialOperator = keyOperator?.[1];
const partialKey = keyOperator?.[0];
const filteredOperators = !isEmpty(partialOperator)
? operators?.filter((operator) =>
operator.startsWith(partialOperator?.toUpperCase()),
)
: operators;
const operatorsOptions = filteredOperators?.map((op) => {
const labelOp =
isInfraMonitoring && INFRA_SHORT_TO_LONG_OPERATOR_MAP[op]
? INFRA_SHORT_TO_LONG_OPERATOR_MAP[op]
: op;
return {
value: `${partialKey} ${op} `,
label: `${partialKey} ${labelOp} `,
};
});
if (whereClauseConfig) {
return [
{
label: `${searchValue} `,
value: `${searchValue}${WHERE_CLAUSE_CUSTOM_SUFFIX}`,
},
...operatorsOptions,
];
}
return operatorsOptions;
},
[isInfraMonitoring, operators, searchValue, whereClauseConfig],
);
useEffect(() => {
let newOptions: Option[] = [];
if (!key) {
newOptions = searchValue
? [
{
label: `${searchValue} `,
value: `${searchValue} `,
},
...getOptionsFromKeys(keys),
]
: getOptionsFromKeys(keys);
} else if (key && !operator) {
newOptions = getKeyOperatorOptions(key);
} else if (key && operator) {
if (isMulti) {
const resultsWithVariable = isEmpty(variableAsValue)
? results
: [variableAsValue, ...results];
newOptions = resultsWithVariable.map((item) => ({
label: checkCommaInValue(String(item)),
value: String(item),
}));
} else if (isExist) {
newOptions = [];
} else if (isValidOperator) {
newOptions = getOptionsWithValidOperator(key, results, searchValue);
}
}
if (newOptions.length > 0) {
setOptions(newOptions);
}
if (isFetching) {
setOptions([]);
}
}, [
whereClauseConfig,
getKeyOpValue,
getOptionsFromKeys,
isExist,
isMulti,
isValidOperator,
key,
keys,
operator,
operators,
result,
results,
searchValue,
getKeyOperatorOptions,
getOptionsWithValidOperator,
isFetching,
variableAsValue,
]);
return useMemo(
() =>
(
options.filter(
(option, index, self) =>
index ===
self.findIndex(
(o) =>
o.label === option.label &&
o.value === option.value &&
(o.type || '') === (option.type || '') &&
(o.dataType || '') === (option.dataType || ''), // keep entries with same key but different type/dataType
) && option.value !== '',
) || []
).map((option) => {
const { tagValue } = getTagToken(searchValue);
if (isMulti) {
return {
...option,
selected: tagValue
.filter((i) => i.trim().replace(/^\s+/, '') === option.value)
.includes(option.value),
};
}
return option;
}),
[isMulti, options, searchValue],
);
};

View File

@@ -1,32 +0,0 @@
import { useMemo } from 'react';
import {
getRemovePrefixFromKey,
getTagToken,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
type ICurrentKeyAndOperator = [string, string, string[]];
export const useSetCurrentKeyAndOperator = (
value: string,
keys: BaseAutocompleteData[],
): ICurrentKeyAndOperator => {
const [key, operator, result] = useMemo(() => {
let key = '';
let operator = '';
let result: string[] = [];
const { tagKey, tagOperator, tagValue } = getTagToken(value);
const isSuggestKey = keys?.some(
(el) => el?.key === getRemovePrefixFromKey(tagKey),
);
if (isSuggestKey || keys.length === 0) {
key = tagKey || '';
operator = tagOperator || '';
result = tagValue || [];
}
return [key, operator, result];
}, [value, keys]);
return [key, operator, result];
};

View File

@@ -1,18 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { INFRA_SHORT_TO_LONG_OPERATOR_MAP } from 'constants/queryBuilder';
import {
getOperatorFromValue,
getTagToken,
isExistsNotExistsOperator,
isInNInOperator,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
} from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { unparse } from 'papaparse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { WhereClauseConfig } from './useAutoComplete';
import type { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
/**
* Helper for formatting a TagFilter object into filter item strings
@@ -42,95 +34,3 @@ export function queryFilterTags(
return `${ele.key?.key} ${displayOp} ${ele.value}`;
});
}
type IUseTag = {
handleAddTag: (value: string) => void;
handleClearTag: (value: string) => void;
tags: string[];
updateTag: (value: string) => void;
};
/**
* A custom React hook for handling tags.
* @param {string} key - A string value to identify tags.
* @param {boolean} isValidTag - A boolean value to indicate whether the tag is valid.
* @param {function} handleSearch - A callback function to handle search.
* @returns {IUseTag} The return object containing handlers and tags.
*/
export const useTag = (
isValidTag: boolean,
handleSearch: (value: string) => void,
query: IBuilderQuery,
setSearchKey: (value: string) => void,
whereClauseConfig?: WhereClauseConfig,
isInfraMonitoring?: boolean,
): IUseTag => {
const initTagsData = useMemo(
() =>
queryFilterTags(
query?.filters || { items: [], op: 'AND' },
isInfraMonitoring,
),
[query?.filters, isInfraMonitoring],
);
const [tags, setTags] = useState<string[]>(initTagsData);
const updateTag = (value: string): void => {
const newTags = tags?.filter((item: string) => item !== value);
setTags(newTags);
};
/**
* Adds a new tag to the tag list.
* @param {string} value - The tag value to be added.
*/
const handleAddTag = useCallback(
(value: string): void => {
const { tagKey } = getTagToken(value);
const parts = tagKey.split('-');
// this is done to ensure that `hello-world` also gets converted to `body CONTAINS hello-world`
let id = parts[parts.length - 1];
let key = parts.slice(0, -1).join('-');
if (parts.length === 1) {
id = '';
[key] = parts;
}
if (id === 'custom') {
const customValue = whereClauseConfig
? `${whereClauseConfig.customKey} ${whereClauseConfig.customOp} ${key}`
: '';
setTags((prevTags) =>
prevTags.includes(customValue) ? prevTags : [...prevTags, customValue],
);
handleSearch('');
setSearchKey('');
return;
}
if ((value && key && isValidTag) || isExistsNotExistsOperator(value)) {
setTags((prevTags) => [...prevTags, value]);
handleSearch('');
setSearchKey('');
}
},
[whereClauseConfig, isValidTag, handleSearch, setSearchKey],
);
/**
* Removes a tag from the tag list.
* @param {string} value - The tag value to be removed.
*/
const handleClearTag = useCallback((value: string): void => {
setTags((prevTags) => prevTags.filter((v) => v !== value));
}, []);
useEffect(() => {
setTags(initTagsData);
}, [initTagsData]);
return { handleAddTag, handleClearTag, tags, updateTag };
};

View File

@@ -6,7 +6,7 @@ import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBui
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';

View File

@@ -16,7 +16,7 @@ import LogsError from 'container/LogsError/LogsError';
import { EmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import createQueryParams from 'lib/createQueryParams';
import { Compass } from '@signozhq/icons';

View File

@@ -4,7 +4,7 @@ import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { OPERATORS } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';

View File

@@ -7,7 +7,7 @@ import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBui
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { PANEL_TYPES, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { ArrowDownToDot, ArrowUpFromDot } from '@signozhq/icons';

View File

@@ -1,99 +1,269 @@
package signozapiserver
import (
"encoding/json"
"net/http"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"github.com/tidwall/gjson"
)
func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/auth_domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.List), handler.OpenAPIDef{
ID: "ListAuthDomains",
Tags: []string{"authdomains"},
Summary: "List all auth domains",
Description: "This endpoint lists all auth domains",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.GettableAuthDomain, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/auth_domains", handler.New(
provider.authzMiddleware.CheckResources(provider.authDomainHandler.List, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListAuthDomains",
Tags: []string{"authdomains"},
Summary: "List all auth domains",
Description: "This endpoint lists all auth domains",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.GettableAuthDomain, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceAuthDomain,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/auth_domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Create), handler.OpenAPIDef{
ID: "CreateAuthDomain",
Tags: []string{"authdomains"},
Summary: "Create auth domain",
Description: "This endpoint creates an auth domain",
Request: new(authtypes.PostableAuthDomain),
RequestContentType: "application/json",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/auth_domains", handler.New(
provider.authzMiddleware.CheckResources(provider.authDomainHandler.Create, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateAuthDomain",
Tags: []string{"authdomains"},
Summary: "Create auth domain",
Description: "This endpoint creates an auth domain",
Request: new(authtypes.PostableAuthDomain),
RequestContentType: "application/json",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbAttach),
coretypes.ResourceRole.Scope(coretypes.VerbAttach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceAuthDomain,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceMetaResourceAuthDomain,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: authDomainRoleNamesExtractor(),
TargetSelector: coretypes.IDSelector,
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Get), handler.OpenAPIDef{
ID: "GetAuthDomain",
Tags: []string{"authdomains"},
Summary: "Get auth domain by ID",
Description: "This endpoint returns an auth domain by ID",
Request: nil,
RequestContentType: "",
Response: new(authtypes.GettableAuthDomain),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authDomainHandler.Get, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetAuthDomain",
Tags: []string{"authdomains"},
Summary: "Get auth domain by ID",
Description: "This endpoint returns an auth domain by ID",
Request: nil,
RequestContentType: "",
Response: new(authtypes.GettableAuthDomain),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceAuthDomain,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Update), handler.OpenAPIDef{
ID: "UpdateAuthDomain",
Tags: []string{"authdomains"},
Summary: "Update auth domain",
Description: "This endpoint updates an auth domain",
Request: new(authtypes.UpdatableAuthDomain),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authDomainHandler.Update, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateAuthDomain",
Tags: []string{"authdomains"},
Summary: "Update auth domain",
Description: "This endpoint updates an auth domain",
Request: new(authtypes.UpdatableAuthDomain),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbUpdate),
coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbDetach),
coretypes.ResourceRole.Scope(coretypes.VerbAttach),
coretypes.ResourceRole.Scope(coretypes.VerbDetach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceAuthDomain,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceMetaResourceAuthDomain,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: authDomainRoleNamesExtractor(),
TargetSelector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceMetaResourceAuthDomain,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: provider.authDomainStoredRoleNamesExtractor(),
TargetSelector: coretypes.IDSelector,
},
),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Delete), handler.OpenAPIDef{
ID: "DeleteAuthDomain",
Tags: []string{"authdomains"},
Summary: "Delete auth domain",
Description: "This endpoint deletes an auth domain",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authDomainHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteAuthDomain",
Tags: []string{"authdomains"},
Summary: "Delete auth domain",
Description: "This endpoint deletes an auth domain",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceAuthDomain,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
// The extracted names are the roles the request body's mapping grants at SSO
// login — see authDomainEffectiveRoleNames.
func authDomainRoleNamesExtractor() coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
roleMappingJSON := gjson.GetBytes(ec.RequestBody, "roleMapping")
if !roleMappingJSON.Exists() || roleMappingJSON.Type == gjson.Null {
return authDomainEffectiveRoleNames(nil), nil
}
roleMapping := new(authtypes.RoleMapping)
if err := json.Unmarshal([]byte(roleMappingJSON.Raw), roleMapping); err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid role mapping: %v", err)
}
return authDomainEffectiveRoleNames(roleMapping), nil
}}
}
// The extracted names are the roles the stored domain's mapping grants at SSO
// login — an update replaces that mapping, so the caller must be able to detach
// them.
func (provider *provider) authDomainStoredRoleNamesExtractor() coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
if err != nil {
return nil, err
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return nil, err
}
id, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return nil, err
}
authDomain, err := provider.authDomainModule.GetByOrgIDAndID(ec.Request.Context(), orgID, id)
if err != nil {
return nil, err
}
return authDomainEffectiveRoleNames(authDomain.RoleMapping()), nil
}}
}
// The effective names are the roles a domain grants at SSO login: the mapped
// roles plus the default (signoz-viewer when unset), or every role when the IDP
// role attribute is trusted. Never empty — a check with no selectors is forbidden.
func authDomainEffectiveRoleNames(roleMapping *authtypes.RoleMapping) []string {
if roleMapping == nil {
return []string{authtypes.SigNozViewerRoleName}
}
if roleMapping.UseRoleAttribute {
return []string{coretypes.WildCardSelectorString}
}
roleNames := roleMapping.RoleNames()
if !slices.Contains(roleNames, roleMapping.DefaultRoleName()) {
roleNames = append(roleNames, roleMapping.DefaultRoleName())
}
return roleNames
}

View File

@@ -51,6 +51,7 @@ type provider struct {
userHandler user.Handler
sessionHandler session.Handler
authDomainHandler authdomain.Handler
authDomainModule authdomain.Module
preferenceHandler preference.Handler
globalHandler global.Handler
promoteHandler promote.Handler
@@ -88,6 +89,7 @@ func NewFactory(
userHandler user.Handler,
sessionHandler session.Handler,
authDomainHandler authdomain.Handler,
authDomainModule authdomain.Module,
preferenceHandler preference.Handler,
globalHandler global.Handler,
promoteHandler promote.Handler,
@@ -128,6 +130,7 @@ func NewFactory(
userHandler,
sessionHandler,
authDomainHandler,
authDomainModule,
preferenceHandler,
globalHandler,
promoteHandler,
@@ -170,6 +173,7 @@ func newProvider(
userHandler user.Handler,
sessionHandler session.Handler,
authDomainHandler authdomain.Handler,
authDomainModule authdomain.Module,
preferenceHandler preference.Handler,
globalHandler global.Handler,
promoteHandler promote.Handler,
@@ -211,6 +215,7 @@ func newProvider(
authzService: authzService,
sessionHandler: sessionHandler,
authDomainHandler: authDomainHandler,
authDomainModule: authDomainModule,
preferenceHandler: preferenceHandler,
globalHandler: globalHandler,
promoteHandler: promoteHandler,

View File

@@ -138,66 +138,6 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.SetRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccountRoleDeprecated",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
Description: "This endpoint assigns a role to a service account",
Request: new(serviceaccounttypes.DeprecatedPostableServiceAccountRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: true,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceServiceAccount,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("id")),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteServiceAccountRoleDeprecated",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
Description: "This endpoint revokes a role from service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: true,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceServiceAccount,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.PathParam("rid")),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authzMiddleware.OpenAccess(provider.serviceAccountHandler.UpdateMe), handler.OpenAPIDef{
ID: "UpdateMyServiceAccount",
Tags: []string{"serviceaccount"},

View File

@@ -214,64 +214,6 @@ func (handler *handler) UpdateMe(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) SetRole(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
req := new(serviceaccounttypes.DeprecatedPostableServiceAccountRole)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
_, err = handler.module.SetRole(ctx, valuer.MustNewUUID(claims.OrgID), id, req.ID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) DeleteRole(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
roleID, err := valuer.NewUUID(mux.Vars(r)["rid"])
if err != nil {
render.Error(rw, err)
return
}
err = handler.module.DeleteRole(ctx, valuer.MustNewUUID(claims.OrgID), id, roleID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) CreateServiceAccountRole(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)

View File

@@ -94,10 +94,6 @@ type Handler interface {
UpdateMe(http.ResponseWriter, *http.Request)
SetRole(http.ResponseWriter, *http.Request)
DeleteRole(http.ResponseWriter, *http.Request)
CreateServiceAccountRole(http.ResponseWriter, *http.Request)
GetServiceAccountRole(http.ResponseWriter, *http.Request)

View File

@@ -64,6 +64,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ user.Handler }{},
struct{ session.Handler }{},
struct{ authdomain.Handler }{},
struct{ authdomain.Module }{},
struct{ preference.Handler }{},
struct{ global.Handler }{},
struct{ promote.Handler }{},

View File

@@ -243,6 +243,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewFixSavedViewSelectFieldsFactory(sqlstore),
sqlmigration.NewDeleteOrphanUserRolesFactory(),
sqlmigration.NewMigrateLambdaDashboardsFactory(),
sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore),
)
}
@@ -317,6 +318,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
impluser.NewHandler(modules.UserSetter, modules.UserGetter),
implsession.NewHandler(modules.Session, globalConfig),
implauthdomain.NewHandler(modules.AuthDomain),
modules.AuthDomain,
implpreference.NewHandler(modules.Preference),
handlers.Global,
implpromote.NewHandler(modules.Promote),

View File

@@ -0,0 +1,167 @@
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 addAuthDomainTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddAuthDomainTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_auth_domain_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addAuthDomainTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addAuthDomainTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addAuthDomainTuples) 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
// auth-domain moved from the legacy AdminAccess role gate to CheckResources,
// which on enterprise requires real tuples -- existing orgs never had these
// written, only new orgs get them from the registry at bootstrap.
tuples := []migrationTuple{
{authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "create"},
{authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "read"},
{authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "update"},
{authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "delete"},
{authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "list"},
{authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "attach"},
{authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "detach"},
}
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
}
}
}
}
// The auth-domain attach/detach transactions are new to the registry, so the
// managed-role transaction groups stored per org (served by the roles API)
// must be re-synced from it, like 105_update_role_transaction_groups did.
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 *addAuthDomainTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -33,6 +33,8 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)},
{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
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},

View File

@@ -1274,6 +1274,264 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
}
}
func TestAreaChartPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/AreaChartPanel",
"spec": {}
},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &AreaChartPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*AreaChartPanelSpec)
assert.Equal(t, "solid", spec.ChartAppearance.FillMode.ValueOrDefault(), "area fillMode defaults to solid, where the TimeSeries FillMode defaults to none")
assert.Nil(t, spec.ChartAppearance.FillOpacity, "an omitted fillOpacity stays nil so the renderer applies the kind default")
assert.Equal(t, "none", spec.Visualization.Stack.ValueOrDefault(), "expected Stack default none")
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
assert.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
assert.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
assert.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
output, err := json.Marshal(d)
require.NoError(t, err, "marshal dashboard failed")
outputStr := string(output)
for field, want := range map[string]string{
"fillMode": `"solid"`,
"stack": `"none"`,
"fillOpacity": `null`,
} {
assert.Contains(t, outputStr, `"`+field+`":`+want, "expected stored/response JSON to contain %s:%s", field, want)
}
}
func TestAreaChartPanelRoundTrip(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/AreaChartPanel",
"spec": {
"visualization": {"timePreference": "global_time", "fillSpans": false, "stack": "percent"},
"chartAppearance": {"fillMode": "gradient", "fillOpacity": 0.4}
}
},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &AreaChartPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*AreaChartPanelSpec)
assert.Equal(t, "percent", spec.Visualization.Stack.ValueOrDefault(), "expected stack percent")
assert.Equal(t, "gradient", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected fillMode gradient")
output, err := json.Marshal(d)
require.NoError(t, err, "marshal dashboard failed")
assert.Contains(t, string(output), `"stack":"percent"`, "expected stack in stored/response JSON")
assert.Contains(t, string(output), `"fillMode":"gradient"`, "expected fillMode in stored/response JSON")
}
func TestAreaChartPanelFillOpacity(t *testing.T) {
tests := []struct {
scenario string
chartAppearance string
expectedFillOpacitySet bool
expectedFillOpacityValue FillOpacity
expectedMarshalledJSON string
}{
{
scenario: "zero is a set value, not an absent one",
chartAppearance: `{"fillOpacity": 0}`,
expectedFillOpacitySet: true,
expectedFillOpacityValue: 0,
expectedMarshalledJSON: `"fillOpacity":0`,
},
{
scenario: "fully opaque upper bound",
chartAppearance: `{"fillOpacity": 1}`,
expectedFillOpacitySet: true,
expectedFillOpacityValue: 1,
expectedMarshalledJSON: `"fillOpacity":1`,
},
{
scenario: "typical fractional value",
chartAppearance: `{"fillOpacity": 0.4}`,
expectedFillOpacitySet: true,
expectedFillOpacityValue: 0.4,
expectedMarshalledJSON: `"fillOpacity":0.4`,
},
{
scenario: "precision beyond one decimal place survives",
chartAppearance: `{"fillOpacity": 0.125}`,
expectedFillOpacitySet: true,
expectedFillOpacityValue: 0.125,
expectedMarshalledJSON: `"fillOpacity":0.125`,
},
{
scenario: "omitted field stays nil so the renderer applies the kind default",
chartAppearance: `{}`,
expectedFillOpacitySet: false,
expectedMarshalledJSON: `"fillOpacity":null`,
},
{
scenario: "explicit null stays nil rather than decoding as zero",
chartAppearance: `{"fillOpacity": null}`,
expectedFillOpacitySet: false,
expectedMarshalledJSON: `"fillOpacity":null`,
},
}
for _, test := range tests {
t.Run(test.scenario, func(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/AreaChartPanel", "spec": {"chartAppearance": ` + test.chartAppearance + `}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &AreaChartPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*AreaChartPanelSpec)
if !test.expectedFillOpacitySet {
assert.Nil(t, spec.ChartAppearance.FillOpacity, "expected fillOpacity to stay unset")
} else {
require.NotNil(t, spec.ChartAppearance.FillOpacity, "expected fillOpacity to decode as a set value")
assert.Equal(t, test.expectedFillOpacityValue, *spec.ChartAppearance.FillOpacity, "unexpected decoded fillOpacity")
}
output, err := json.Marshal(d)
require.NoError(t, err, "marshal dashboard failed")
assert.Contains(t, string(output), test.expectedMarshalledJSON, "unexpected fillOpacity in stored/response JSON")
})
}
}
func TestInvalidateAreaChartPanelSpecValues(t *testing.T) {
tests := []struct {
scenario string
panelKind string
panelSpec string
expectedErrorSubstring string
}{
{
scenario: "unknown stack mode",
panelKind: "signoz/AreaChartPanel",
panelSpec: `{"visualization": {"stack": "stacked"}}`,
expectedErrorSubstring: "stack mode",
},
{
scenario: "unknown area fill mode",
panelKind: "signoz/AreaChartPanel",
panelSpec: `{"chartAppearance": {"fillMode": "striped"}}`,
expectedErrorSubstring: "fill mode",
},
{
scenario: "fill opacity on a 0-100 scale",
panelKind: "signoz/AreaChartPanel",
panelSpec: `{"chartAppearance": {"fillOpacity": 40}}`,
expectedErrorSubstring: "invalid fillOpacity 40: must be between 0 and 1",
},
{
scenario: "negative fill opacity",
panelKind: "signoz/AreaChartPanel",
panelSpec: `{"chartAppearance": {"fillOpacity": -0.5}}`,
expectedErrorSubstring: "invalid fillOpacity -0.5: must be between 0 and 1",
},
{
scenario: "non-numeric fill opacity",
panelKind: "signoz/AreaChartPanel",
panelSpec: `{"chartAppearance": {"fillOpacity": "0.4"}}`,
expectedErrorSubstring: "cannot unmarshal string",
},
{
scenario: "stack on a time series panel",
panelKind: "signoz/TimeSeriesPanel",
panelSpec: `{"visualization": {"stack": "normal"}}`,
expectedErrorSubstring: `unknown field`,
},
{
scenario: "fill opacity on a time series panel",
panelKind: "signoz/TimeSeriesPanel",
panelSpec: `{"chartAppearance": {"fillOpacity": 0.4}}`,
expectedErrorSubstring: `unknown field`,
},
{
scenario: "stacked bar chart on an area panel",
panelKind: "signoz/AreaChartPanel",
panelSpec: `{"visualization": {"stackedBarChart": true}}`,
expectedErrorSubstring: `unknown field`,
},
{
scenario: "stack on a bar chart panel",
panelKind: "signoz/BarChartPanel",
panelSpec: `{"visualization": {"stack": "percent"}}`,
expectedErrorSubstring: `unknown field`,
},
}
for _, test := range tests {
t.Run(test.scenario, func(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "` + test.panelKind + `", "spec": ` + test.panelSpec + `},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected the spec to be rejected")
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
})
}
}
func TestNumberPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],

View File

@@ -30,6 +30,7 @@ func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
@@ -60,6 +61,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
return []any{
PanelPluginVariant[TimeSeriesPanelSpec]{Kind: string(PanelKindTimeSeries)},
PanelPluginVariant[BarChartPanelSpec]{Kind: string(PanelKindBarChart)},
PanelPluginVariant[AreaChartPanelSpec]{Kind: string(PanelKindAreaChart)},
PanelPluginVariant[NumberPanelSpec]{Kind: string(PanelKindNumber)},
PanelPluginVariant[PieChartPanelSpec]{Kind: string(PanelKindPieChart)},
PanelPluginVariant[TablePanelSpec]{Kind: string(PanelKindTable)},
@@ -223,6 +225,7 @@ var (
panelPluginSpecs = map[PanelPluginKind]func() any{
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
@@ -245,6 +248,7 @@ var (
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindAreaChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},

View File

@@ -183,7 +183,8 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
return nil, err
}
// fillGaps lives on the panel visualization; only timeseries and bar chart carry it.
// fillGaps lives on the panel visualization; only timeseries, bar chart and
// area chart carry it.
fillGaps := false
switch panelSpec := panel.Spec.Plugin.Spec.(type) {
case *TimeSeriesPanelSpec:
@@ -194,6 +195,10 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
if panelSpec != nil {
fillGaps = panelSpec.Visualization.FillSpans
}
case *AreaChartPanelSpec:
if panelSpec != nil {
fillGaps = panelSpec.Visualization.FillSpans
}
}
return &qb.QueryRangeRequest{

View File

@@ -168,6 +168,7 @@ type PanelPluginKind string
const (
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
PanelKindTable PanelPluginKind = "signoz/TablePanel"
@@ -176,7 +177,7 @@ const (
)
func (PanelPluginKind) Enum() []any {
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList}
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList}
}
type TimeSeriesPanelSpec struct {
@@ -204,6 +205,30 @@ type BarChartPanelSpec struct {
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
}
type AreaChartPanelSpec struct {
Visualization AreaChartVisualization `json:"visualization"`
Formatting PanelFormatting `json:"formatting"`
ChartAppearance AreaChartAppearance `json:"chartAppearance"`
Axes Axes `json:"axes"`
Legend Legend `json:"legend"`
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
}
// AreaChartAppearance repeats the line-drawing fields rather than embedding
// TimeSeriesChartAppearance: both carry a `fillMode` under different enums, and
// a duplicated json tag across an embed boundary is resolved by depth, which the
// schema reflector does not model.
type AreaChartAppearance struct {
LineInterpolation LineInterpolation `json:"lineInterpolation"`
ShowPoints bool `json:"showPoints"`
LineStyle LineStyle `json:"lineStyle"`
FillMode AreaFillMode `json:"fillMode"`
// FillOpacity is a pointer so an omitted field resolves to the kind default at
// render time; a plain value would make the Go zero value a transparent fill.
FillOpacity *FillOpacity `json:"fillOpacity"`
SpanGaps SpanGaps `json:"spanGaps"`
}
type NumberPanelSpec struct {
Visualization BasicVisualization `json:"visualization"`
Formatting PanelFormatting `json:"formatting"`
@@ -262,6 +287,12 @@ type BarChartVisualization struct {
StackedBarChart bool `json:"stackedBarChart"`
}
type AreaChartVisualization struct {
BasicVisualization
FillSpans bool `json:"fillSpans"`
Stack StackMode `json:"stack"`
}
type PanelFormatting struct {
Unit string `json:"unit"`
DecimalPrecision PrecisionOption `json:"decimalPrecision"`
@@ -622,6 +653,112 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
}
}
// AreaFillMode is separate from FillMode so area panels can default to `solid`.
// An area panel that omits the field would otherwise render as a bare line, and
// flipping FillMode's default would change how existing TimeSeries panels render.
type AreaFillMode struct{ valuer.String }
var (
AreaFillModeSolid = AreaFillMode{valuer.NewString("solid")} // default
AreaFillModeGradient = AreaFillMode{valuer.NewString("gradient")}
// AreaFillModeNone lets an area panel degrade to a line without the save API
// rejecting it, so an Area -> TimeSeries -> Area round trip keeps its config.
AreaFillModeNone = AreaFillMode{valuer.NewString("none")}
)
func (AreaFillMode) Enum() []any {
return []any{AreaFillModeSolid, AreaFillModeGradient, AreaFillModeNone}
}
func (fm AreaFillMode) ValueOrDefault() string {
if fm.IsZero() {
return AreaFillModeSolid.StringValue()
}
return fm.StringValue()
}
func (fm AreaFillMode) MarshalJSON() ([]byte, error) {
return json.Marshal(fm.ValueOrDefault())
}
func (fm *AreaFillMode) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid fill mode: must be a string, one of `solid`, `gradient`, or `none`")
}
val := AreaFillMode{valuer.NewString(v)}
switch val {
case AreaFillModeSolid, AreaFillModeGradient, AreaFillModeNone:
*fm = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid fill mode %q: must be `solid`, `gradient`, or `none`", v)
}
}
// StackMode is area-only. Bar stacking stays on BarChartVisualization.StackedBarChart,
// so `percent` is not reachable from a bar panel.
type StackMode struct{ valuer.String }
var (
StackModeNone = StackMode{valuer.NewString("none")} // default
StackModeNormal = StackMode{valuer.NewString("normal")}
StackModePercent = StackMode{valuer.NewString("percent")}
)
func (StackMode) Enum() []any {
return []any{StackModeNone, StackModeNormal, StackModePercent}
}
func (sm StackMode) ValueOrDefault() string {
if sm.IsZero() {
return StackModeNone.StringValue()
}
return sm.StringValue()
}
func (sm StackMode) MarshalJSON() ([]byte, error) {
return json.Marshal(sm.ValueOrDefault())
}
func (sm *StackMode) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid stack mode: must be a string, one of `none`, `normal`, or `percent`")
}
val := StackMode{valuer.NewString(v)}
switch val {
case StackModeNone, StackModeNormal, StackModePercent:
*sm = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid stack mode %q: must be `none`, `normal`, or `percent`", v)
}
}
// FillOpacity is the alpha of an area fill, in 01 because that is what the
// chart layer consumes directly. Unlike the enums in this section it has no
// ValueOrDefault: 0 is a legitimate value, so the kind default lives at render
// time behind a nil pointer.
type FillOpacity float64
func (FillOpacity) PrepareJSONSchema(s *jsonschema.Schema) error {
s.WithMinimum(0).WithMaximum(1)
return nil
}
func (o *FillOpacity) UnmarshalJSON(data []byte) error {
var v float64
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid fillOpacity: must be a number between 0 and 1")
}
if v < 0 || v > 1 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid fillOpacity %v: must be between 0 and 1", v)
}
*o = FillOpacity(v)
return nil
}
type SpanGaps struct {
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
FillLessThan string `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`

View File

@@ -14,6 +14,11 @@ import (
// (transition.dashboardMigrateV5). Pre-v5 builder queries will produce
// invalid v2 envelopes — run the v4→v5 migration first.
//
// The v1 input shape is closed: nothing writes v1 dashboards any more, so these
// files only ever convert what v1 could already express. Panel kinds and spec
// fields added to v2 from here on need no converter entry — change these files
// only when a v2 type edit breaks the build.
//
// The conversion is split across sibling files by concern:
// - perses_v1_to_v2_tags.go tags
// - perses_v1_to_v2_panels.go widgets → panels (+ panel field mappers)

View File

@@ -69,10 +69,6 @@ type PostableServiceAccount struct {
Name string `json:"name" required:"true"`
}
type DeprecatedPostableServiceAccountRole struct {
ID valuer.UUID `json:"id" required:"true"`
}
type PostableServiceAccountRole struct {
ServiceAccountID valuer.UUID `json:"serviceAccountId" required:"true"`
RoleID valuer.UUID `json:"roleId" required:"true"`

View File

@@ -47,7 +47,9 @@
"read",
"update",
"delete",
"list"
"list",
"attach",
"detach"
]
},
{

View File

@@ -0,0 +1,511 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from wiremock.resources.mappings import Mapping
from fixtures import types
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
add_license,
change_user_role,
create_active_user,
)
from fixtures.role import find_role_by_name, transaction_group
_EDITOR_EMAIL = "editor+authdomainauthz@integration.test"
_EDITOR_PASSWORD = "password123Z$"
_VIEWER_EMAIL = "viewer+authdomainauthz@integration.test"
_VIEWER_PASSWORD = "password123Z$"
_ACTOR_ROLE_NAME = "auth-domain-fga-actor"
_ACTOR_EMAIL = "customrole+authdomainauthz@integration.test"
_ACTOR_PASSWORD = "password123Z$"
# Instance verbs are granted on _TARGET_A only; _TARGET_B must stay forbidden.
_TARGET_A = "target-a-authdomain.integration.test"
_TARGET_B = "target-b-authdomain.integration.test"
_ADMIN_DOMAIN = "admin-crud-authdomain.integration.test"
_ACTOR_DOMAIN = "actor-crud-authdomain.integration.test"
_SAML_CONFIG = {
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "saml-idp",
"certificate": "saml-cert",
},
}
def test_setup_managed_role_users_and_targets(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
for email, role, password, name in (
(_EDITOR_EMAIL, "signoz-editor", _EDITOR_PASSWORD, "auth domain authz editor"),
(_VIEWER_EMAIL, "signoz-viewer", _VIEWER_PASSWORD, "auth domain authz viewer"),
):
create_active_user(signoz, admin_token, email=email, role=role, password=password, name=name)
for name in (_TARGET_A, _TARGET_B):
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={"name": name, "enabled": True, "config": _SAML_CONFIG},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
def test_admin_can_crud(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={"name": _ADMIN_DOMAIN, "enabled": True, "config": _SAML_CONFIG},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
domain_id = response.json()["data"]["id"]
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert _ADMIN_DOMAIN in {domain["name"] for domain in response.json()["data"]}
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
json={"enabled": False, "config": _SAML_CONFIG},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_editor_and_viewer_forbidden(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
a_id = next(domain["id"] for domain in response.json()["data"] if domain["name"] == _TARGET_A)
for email, password in ((_EDITOR_EMAIL, _EDITOR_PASSWORD), (_VIEWER_EMAIL, _VIEWER_PASSWORD)):
token = get_token(email, password)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} list: expected 403, got {response.status_code}: {response.text}"
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={"name": _ACTOR_DOMAIN, "enabled": True, "config": _SAML_CONFIG},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} create: expected 403, got {response.status_code}: {response.text}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} get: expected 403, got {response.status_code}: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"),
json={"enabled": True, "config": _SAML_CONFIG},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} update: expected 403, got {response.status_code}: {response.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} delete: expected 403, got {response.status_code}: {response.text}"
def test_apply_license(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
) -> None:
add_license(signoz, make_http_mocks, get_token)
def test_setup_actor(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_role: Callable[..., str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_role(admin_token, _ACTOR_ROLE_NAME)
user_id = create_active_user(
signoz,
admin_token,
email=_ACTOR_EMAIL,
role="signoz-viewer",
password=_ACTOR_PASSWORD,
name="auth-domain-fga-test-user",
)
change_user_role(signoz, admin_token, user_id, "signoz-viewer", _ACTOR_ROLE_NAME)
def test_actor_without_grants_forbidden(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
a_id = next(domain["id"] for domain in response.json()["data"] if domain["name"] == _TARGET_A)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"list without grant: expected 403, got {response.status_code}: {response.text}"
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={"name": _ACTOR_DOMAIN, "enabled": True, "config": _SAML_CONFIG},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"create without grant: expected 403, got {response.status_code}: {response.text}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"read without grant: expected 403, got {response.status_code}: {response.text}"
def test_create_requires_attach_on_mapped_roles(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"),
json={
"description": "",
"transactionGroups": [
transaction_group("create", "metaresource", "auth-domain", ["*"]),
transaction_group("delete", "metaresource", "auth-domain", ["*"]),
transaction_group("list", "metaresource", "auth-domain", ["*"]),
transaction_group("attach", "metaresource", "auth-domain", ["*"]),
transaction_group("attach", "role", "role", ["signoz-viewer"]),
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
# No roleMapping means SSO users get signoz-viewer, which is granted.
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={"name": _ACTOR_DOMAIN, "enabled": True, "config": _SAML_CONFIG},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, f"create with default mapping: {response.text}"
domain_id = response.json()["data"]["id"]
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": _ACTOR_DOMAIN,
"enabled": True,
"config": _SAML_CONFIG,
"roleMapping": {"defaultRole": "EDITOR"},
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"map to unattachable role: expected 403, got {response.status_code}: {response.text}"
# Trusting the IDP role attribute can grant any role, so it needs attach on role "*".
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": _ACTOR_DOMAIN,
"enabled": True,
"config": _SAML_CONFIG,
"roleMapping": {"defaultRole": "VIEWER", "useRoleAttribute": True},
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"role attribute without wildcard attach: expected 403, got {response.status_code}: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"),
json={
"description": "",
"transactionGroups": [
transaction_group("create", "metaresource", "auth-domain", ["*"]),
transaction_group("delete", "metaresource", "auth-domain", ["*"]),
transaction_group("list", "metaresource", "auth-domain", ["*"]),
transaction_group("attach", "metaresource", "auth-domain", ["*"]),
transaction_group("attach", "role", "role", ["*"]),
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": _ACTOR_DOMAIN,
"enabled": True,
"config": _SAML_CONFIG,
"roleMapping": {"defaultRole": "EDITOR", "groupMappings": {"platform-team": "ADMIN"}},
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, f"create with wildcard role attach: {response.text}"
domain_id = response.json()["data"]["id"]
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_update_requires_detach_on_stored_roles(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": _ACTOR_DOMAIN,
"enabled": True,
"config": _SAML_CONFIG,
"roleMapping": {"defaultRole": "EDITOR"},
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
domain_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"),
json={
"description": "",
"transactionGroups": [
transaction_group("update", "metaresource", "auth-domain", [domain_id]),
transaction_group("attach", "metaresource", "auth-domain", [domain_id]),
transaction_group("detach", "metaresource", "auth-domain", [domain_id]),
transaction_group("attach", "role", "role", ["signoz-viewer"]),
transaction_group("detach", "role", "role", ["signoz-viewer"]),
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
# Dropping the mapping detaches the stored signoz-editor grant, which the
# actor cannot detach.
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
json={"enabled": True, "config": _SAML_CONFIG},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"drop mapping without detach on stored role: expected 403, got {response.status_code}: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"),
json={
"description": "",
"transactionGroups": [
transaction_group("update", "metaresource", "auth-domain", [domain_id]),
transaction_group("attach", "metaresource", "auth-domain", [domain_id]),
transaction_group("detach", "metaresource", "auth-domain", [domain_id]),
transaction_group("attach", "role", "role", ["signoz-viewer"]),
transaction_group("detach", "role", "role", ["signoz-editor"]),
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
json={"enabled": True, "config": _SAML_CONFIG},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"drop mapping with detach on stored role: {response.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_instance_verbs_scoped_to_granted_domain(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
ids = {domain["name"]: domain["id"] for domain in response.json()["data"]}
a_id, b_id = ids[_TARGET_A], ids[_TARGET_B]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"),
json={
"description": "",
"transactionGroups": [
transaction_group("read", "metaresource", "auth-domain", [a_id]),
transaction_group("update", "metaresource", "auth-domain", [a_id]),
transaction_group("delete", "metaresource", "auth-domain", [a_id]),
transaction_group("attach", "metaresource", "auth-domain", [a_id]),
transaction_group("detach", "metaresource", "auth-domain", [a_id]),
transaction_group("attach", "role", "role", ["signoz-viewer"]),
transaction_group("detach", "role", "role", ["signoz-viewer"]),
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"read granted domain: {response.text}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{b_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"read other domain: expected 403, got {response.status_code}: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"),
json={"enabled": False, "config": _SAML_CONFIG},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"update granted domain: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{b_id}"),
json={"enabled": False, "config": _SAML_CONFIG},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"update other domain: expected 403, got {response.status_code}: {response.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{b_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"delete other domain: expected 403, got {response.status_code}: {response.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"delete granted domain: {response.text}"