Compare commits

..

1 Commits

Author SHA1 Message Date
SagarRajput-7
19962459cb feat(sa-fga): changed the id from kind to kind+type 2026-05-11 18:39:07 +05:30
64 changed files with 425 additions and 2333 deletions

View File

@@ -2520,65 +2520,6 @@ components:
enabled:
type: boolean
type: object
InframonitoringtypesClusterRecord:
properties:
clusterCPU:
format: double
type: number
clusterCPUAllocatable:
format: double
type: number
clusterMemory:
format: double
type: number
clusterMemoryAllocatable:
format: double
type: number
clusterName:
type: string
meta:
additionalProperties:
type: string
nullable: true
type: object
nodeCountsByReadiness:
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
required:
- clusterName
- clusterCPU
- clusterCPUAllocatable
- clusterMemory
- clusterMemoryAllocatable
- nodeCountsByReadiness
- podCountsByPhase
- meta
type: object
InframonitoringtypesClusters:
properties:
endTimeBeforeRetention:
type: boolean
records:
items:
$ref: '#/components/schemas/InframonitoringtypesClusterRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
$ref: '#/components/schemas/InframonitoringtypesResponseType'
warning:
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
required:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesHostFilter:
properties:
expression:
@@ -2883,32 +2824,6 @@ components:
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesPostableClusters:
properties:
end:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
limit:
type: integer
offset:
type: integer
orderBy:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
start:
format: int64
type: integer
required:
- start
- end
- limit
type: object
InframonitoringtypesPostableHosts:
properties:
end:
@@ -11831,77 +11746,6 @@ paths:
summary: Health check
tags:
- health
/api/v2/infra_monitoring/clusters:
post:
deprecated: false
description: 'Returns a paginated list of Kubernetes clusters with key aggregated
metrics derived by summing per-node values within the group: CPU usage, CPU
allocatable, memory working set, memory allocatable. Each row also reports
per-group nodeCountsByReadiness ({ ready, notReady } from each node''s latest
k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending,
running, succeeded, failed, unknown } from each pod''s latest k8s.pod.phase
value). Each cluster includes metadata attributes (k8s.cluster.name). The
response type is ''list'' for the default k8s.cluster.name grouping or ''grouped_list''
for custom groupBy keys; in both modes every row aggregates nodes and pods
in the group. Supports filtering via a filter expression, custom groupBy,
ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination
via offset/limit. Also reports missing required metrics and whether the requested
time range falls before the data retention boundary. Numeric metric fields
(clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable)
return -1 as a sentinel when no data is available for that field.'
operationId: ListClusters
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/InframonitoringtypesPostableClusters'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/InframonitoringtypesClusters'
status:
type: string
required:
- status
- data
type: object
description: OK
"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:
- VIEWER
- tokenizer:
- VIEWER
summary: List Clusters for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/hosts:
post:
deprecated: false

View File

@@ -12,12 +12,10 @@ import type {
} from 'react-query';
import type {
InframonitoringtypesPostableClustersDTO,
InframonitoringtypesPostableHostsDTO,
InframonitoringtypesPostableNamespacesDTO,
InframonitoringtypesPostableNodesDTO,
InframonitoringtypesPostablePodsDTO,
ListClusters200,
ListHosts200,
ListNamespaces200,
ListNodes200,
@@ -28,90 +26,6 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* @summary List Clusters for Infra Monitoring
*/
export const listClusters = (
inframonitoringtypesPostableClustersDTO: BodyType<InframonitoringtypesPostableClustersDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListClusters200>({
url: `/api/v2/infra_monitoring/clusters`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: inframonitoringtypesPostableClustersDTO,
signal,
});
};
export const getListClustersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listClusters>>,
TError,
{ data: BodyType<InframonitoringtypesPostableClustersDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof listClusters>>,
TError,
{ data: BodyType<InframonitoringtypesPostableClustersDTO> },
TContext
> => {
const mutationKey = ['listClusters'];
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 listClusters>>,
{ data: BodyType<InframonitoringtypesPostableClustersDTO> }
> = (props) => {
const { data } = props ?? {};
return listClusters(data);
};
return { mutationFn, ...mutationOptions };
};
export type ListClustersMutationResult = NonNullable<
Awaited<ReturnType<typeof listClusters>>
>;
export type ListClustersMutationBody =
BodyType<InframonitoringtypesPostableClustersDTO>;
export type ListClustersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List Clusters for Infra Monitoring
*/
export const useListClusters = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listClusters>>,
TError,
{ data: BodyType<InframonitoringtypesPostableClustersDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof listClusters>>,
TError,
{ data: BodyType<InframonitoringtypesPostableClustersDTO> },
TContext
> => {
const mutationOptions = getListClustersMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available for that field.
* @summary List Hosts for Infra Monitoring

View File

@@ -4568,66 +4568,6 @@ export interface GlobaltypesTokenizerConfigDTO {
enabled?: boolean;
}
/**
* @nullable
*/
export type InframonitoringtypesClusterRecordDTOMeta = {
[key: string]: string;
} | null;
export interface InframonitoringtypesClusterRecordDTO {
/**
* @type number
* @format double
*/
clusterCPU: number;
/**
* @type number
* @format double
*/
clusterCPUAllocatable: number;
/**
* @type number
* @format double
*/
clusterMemory: number;
/**
* @type number
* @format double
*/
clusterMemoryAllocatable: number;
/**
* @type string
*/
clusterName: string;
/**
* @type object
* @nullable true
*/
meta: InframonitoringtypesClusterRecordDTOMeta;
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
}
export interface InframonitoringtypesClustersDTO {
/**
* @type boolean
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @nullable true
*/
records: InframonitoringtypesClusterRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
total: number;
type: InframonitoringtypesResponseTypeDTO;
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesHostFilterDTO {
/**
* @type string
@@ -4945,34 +4885,6 @@ export interface InframonitoringtypesPodsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesPostableClustersDTO {
/**
* @type integer
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array
* @nullable true
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
/**
* @type integer
*/
limit: number;
/**
* @type integer
*/
offset?: number;
orderBy?: Querybuildertypesv5OrderByDTO;
/**
* @type integer
* @format int64
*/
start: number;
}
export interface InframonitoringtypesPostableHostsDTO {
/**
* @type integer
@@ -9412,14 +9324,6 @@ export type Healthz503 = {
status: string;
};
export type ListClusters200 = {
data: InframonitoringtypesClustersDTO;
/**
* @type string
*/
status: string;
};
export type ListHosts200 = {
data: InframonitoringtypesHostsDTO;
/**

View File

@@ -2,9 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { SearchOutlined } from '@ant-design/icons';
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Loader } from '@signozhq/icons';
import {
Button,
Input,
@@ -517,9 +516,7 @@ export default function CeleryOverviewTable({
bordered={false}
loading={{
spinning: isLoading,
indicator: (
<Spin indicator={<Loader size={14} className="animate-spin" />} />
),
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText: isLoading ? null : <Typography.Text>No data</Typography.Text>,

View File

@@ -1,8 +1,7 @@
import { Typography } from '@signozhq/ui/typography';
import { ReactNode, useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { CaretDownOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { CaretDownOutlined, LoadingOutlined } from '@ant-design/icons';
import { Modal, Select, Spin, Tooltip, Tree, TreeDataNode } from 'antd';
import { OnboardingStatusResponse } from 'api/messagingQueues/onboarding/getOnboardingStatus';
import { QueryParams } from 'constants/query';
@@ -223,7 +222,7 @@ function AttributeCheckList({
>
{loading ? (
<div className="loader-container">
<Spin indicator={<Loader className="animate-spin" />} size="large" />
<Spin indicator={<LoadingOutlined spin />} size="large" />
</div>
) : (
<div className="modal-content">

View File

@@ -7,9 +7,12 @@ import React, {
useState,
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import { DownOutlined, ReloadOutlined } from '@ant-design/icons';
import {
DownOutlined,
LoadingOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Loader } from '@signozhq/icons';
import { Button, Checkbox, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
@@ -1697,7 +1700,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{loading && (
<div className="navigation-loading">
<div className="navigation-icons">
<Loader className="animate-spin" />
<LoadingOutlined />
</div>
<div className="navigation-text">Refreshing values...</div>
</div>
@@ -1705,7 +1708,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{!loading && waitingMessage && (
<div className="navigation-loading">
<div className="navigation-icons">
<Loader className="animate-spin" />
<LoadingOutlined />
</div>
<div className="navigation-text" title={waitingMessage}>
{waitingMessage}

View File

@@ -6,9 +6,13 @@ import React, {
useRef,
useState,
} from 'react';
import { CloseOutlined, DownOutlined, ReloadOutlined } from '@ant-design/icons';
import {
CloseOutlined,
DownOutlined,
LoadingOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Loader } from '@signozhq/icons';
import { Select } from 'antd';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
@@ -577,7 +581,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
{loading && (
<div className="navigation-loading">
<div className="navigation-icons">
<Loader className="animate-spin" />
<LoadingOutlined />
</div>
<div className="navigation-text">Refreshing values...</div>
</div>
@@ -585,7 +589,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
{!loading && waitingMessage && (
<div className="navigation-loading">
<div className="navigation-icons">
<Loader className="animate-spin" />
<LoadingOutlined />
</div>
<div className="navigation-text" title={waitingMessage}>
{waitingMessage}

View File

@@ -16,8 +16,8 @@ interface OverviewTabProps {
account: ServiceAccountRow;
localName: string;
onNameChange: (v: string) => void;
localRoles: string[];
onRolesChange: (v: string[]) => void;
localRole: string;
onRoleChange: (v: string | undefined) => void;
isDisabled: boolean;
availableRoles: AuthtypesRoleDTO[];
rolesLoading?: boolean;
@@ -31,8 +31,8 @@ function OverviewTab({
account,
localName,
onNameChange,
localRoles,
onRolesChange,
localRole,
onRoleChange,
isDisabled,
availableRoles,
rolesLoading,
@@ -95,15 +95,10 @@ function OverviewTab({
{isDisabled ? (
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<div className="sa-drawer__disabled-roles">
{localRoles.length > 0 ? (
localRoles.map((roleId) => {
const role = availableRoles.find((r) => r.id === roleId);
return (
<Badge key={roleId} color="vanilla">
{role?.name ?? roleId}
</Badge>
);
})
{localRole ? (
<Badge color="vanilla">
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
</Badge>
) : (
<span className="sa-drawer__input-text"></span>
)}
@@ -113,15 +108,14 @@ function OverviewTab({
) : (
<RolesSelect
id="sa-roles"
mode="multiple"
roles={availableRoles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={onRefetchRoles}
value={localRoles}
onChange={onRolesChange}
placeholder="Select roles"
value={localRole}
onChange={onRoleChange}
placeholder="Select role"
/>
)}
</div>

View File

@@ -8,7 +8,9 @@ import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { Pagination, Skeleton } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getGetServiceAccountRolesQueryKey,
getListServiceAccountsQueryKey,
useDeleteServiceAccountRole,
useGetServiceAccount,
useListServiceAccountKeys,
useUpdateServiceAccount,
@@ -35,7 +37,7 @@ import {
useQueryState,
} from 'nuqs';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import { retryOn429, toAPIError } from 'utils/errorUtils';
import AddKeyModal from './AddKeyModal';
import DeleteAccountModal from './DeleteAccountModal';
@@ -90,7 +92,7 @@ function ServiceAccountDrawer({
parseAsBoolean.withDefault(false),
);
const [localName, setLocalName] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [localRole, setLocalRole] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
@@ -138,7 +140,7 @@ function ServiceAccountDrawer({
if (!account?.id) {
roleSessionRef.current = null;
} else if (account.id !== roleSessionRef.current && !isRolesLoading) {
setLocalRoles(currentRoles.map((r) => r.id).filter(Boolean) as string[]);
setLocalRole(currentRoles[0]?.id ?? '');
roleSessionRef.current = account.id;
}
}, [account?.id, currentRoles, isRolesLoading]);
@@ -149,13 +151,7 @@ function ServiceAccountDrawer({
const isDirty =
account !== null &&
(localName !== (account.name ?? '') ||
JSON.stringify([...localRoles].sort()) !==
JSON.stringify(
currentRoles
.map((r) => r.id)
.filter(Boolean)
.sort(),
));
localRole !== (currentRoles[0]?.id ?? ''));
const {
roles: availableRoles,
@@ -183,6 +179,27 @@ function ServiceAccountDrawer({
// the retry for this mutation is safe due to the api being idempotent on backend
const { mutateAsync: updateMutateAsync } = useUpdateServiceAccount();
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
mutation: {
retry: retryOn429,
},
});
const executeRolesOperation = useCallback(
async (accountId: string): Promise<RoleUpdateFailure[]> => {
if (localRole === '' && currentRoles[0]?.id) {
await deleteRole({
pathParams: { id: accountId, rid: currentRoles[0].id },
});
await queryClient.invalidateQueries(
getGetServiceAccountRolesQueryKey({ id: accountId }),
);
return [];
}
return applyDiff([localRole].filter(Boolean), availableRoles);
},
[localRole, currentRoles, availableRoles, applyDiff, deleteRole, queryClient],
);
const retryNameUpdate = useCallback(async (): Promise<void> => {
if (!account) {
@@ -250,7 +267,7 @@ function ServiceAccountDrawer({
const retryRolesUpdate = useCallback(async (): Promise<void> => {
try {
const failures = await applyDiff([...localRoles], availableRoles);
const failures = await executeRolesOperation(selectedAccountId ?? '');
if (failures.length === 0) {
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Roles update'));
} else {
@@ -266,7 +283,7 @@ function ServiceAccountDrawer({
),
);
}
}, [localRoles, availableRoles, applyDiff, failuresToSaveErrors]);
}, [selectedAccountId, executeRolesOperation, failuresToSaveErrors]);
const handleSave = useCallback(async (): Promise<void> => {
if (!account || !isDirty) {
@@ -285,7 +302,7 @@ function ServiceAccountDrawer({
const [nameResult, rolesResult] = await Promise.allSettled([
namePromise,
applyDiff([...localRoles], availableRoles),
executeRolesOperation(account.id),
]);
const errors: SaveError[] = [];
@@ -326,10 +343,8 @@ function ServiceAccountDrawer({
account,
isDirty,
localName,
localRoles,
availableRoles,
updateMutateAsync,
applyDiff,
executeRolesOperation,
refetchAccount,
onSuccess,
queryClient,
@@ -428,9 +443,9 @@ function ServiceAccountDrawer({
account={account}
localName={localName}
onNameChange={handleNameChange}
localRoles={localRoles}
onRolesChange={(roles): void => {
setLocalRoles(roles);
localRole={localRole}
onRoleChange={(role): void => {
setLocalRole(role ?? '');
clearRoleErrors();
}}
isDisabled={isDeleted}

View File

@@ -151,7 +151,7 @@ describe('ServiceAccountDrawer', () => {
});
});
it('adding a role fires POST for the new role and no DELETE for existing roles', async () => {
it('changing roles enables Save; clicking Save sends role add request without delete', async () => {
const roleSpy = jest.fn();
const deleteSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -171,7 +171,6 @@ describe('ServiceAccountDrawer', () => {
await screen.findByDisplayValue('CI Bot');
// Add signoz-viewer while keeping signoz-admin selected
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-viewer'));
@@ -189,43 +188,6 @@ describe('ServiceAccountDrawer', () => {
});
});
it('removing a role fires DELETE for the removed role and no POST', async () => {
const roleSpy = jest.fn();
const deleteSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
roleSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => {
deleteSpy();
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
renderDrawer();
await screen.findByDisplayValue('CI Bot');
// Remove the signoz-admin tag from the multi-select
const adminTag = await screen.findByTitle('signoz-admin');
const removeBtn = adminTag.querySelector(
'.ant-select-selection-item-remove',
) as Element;
await user.click(removeBtn);
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(deleteSpy).toHaveBeenCalled();
expect(roleSpy).not.toHaveBeenCalled();
});
});
it('"Delete Service Account" opens confirm dialog; confirming sends delete request', async () => {
const deleteSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });

View File

@@ -1,5 +1,5 @@
import { CSSProperties } from 'react';
import { Loader } from '@signozhq/icons';
import { LoadingOutlined } from '@ant-design/icons';
import { Spin, SpinProps } from 'antd';
import { SpinerStyle } from './styles';
@@ -7,14 +7,7 @@ import { SpinerStyle } from './styles';
function Spinner({ size, tip, height, style }: SpinnerProps): JSX.Element {
return (
<SpinerStyle height={height} style={style}>
<Spin
spinning
size={size}
tip={tip}
indicator={
<Loader className="animate-spin" role="img" aria-label="loading" />
}
/>
<Spin spinning size={size} tip={tip} indicator={<LoadingOutlined spin />} />
</SpinerStyle>
);
}

View File

@@ -10,7 +10,7 @@ import {
} from 'react';
import type { TableComponents } from 'react-virtuoso';
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
import { Loader } from '@signozhq/icons';
import { LoadingOutlined } from '@ant-design/icons';
import { DndContext, pointerWithin } from '@dnd-kit/core';
import {
horizontalListSortingStrategy,
@@ -580,10 +580,7 @@ function TanStackTableInner<TData>(
className={viewStyles.tanstackLoadingOverlay}
data-testid="tanstack-infinite-loader"
>
<Spin
indicator={<Loader className="animate-spin" />}
tip="Loading more..."
/>
<Spin indicator={<LoadingOutlined spin />} tip="Loading more..." />
</div>
)}
{showPagination && pagination && (

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
import { Loader } from '@signozhq/icons';
import { LoadingOutlined } from '@ant-design/icons';
import { Spin, Switch, Table, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
@@ -202,9 +202,7 @@ function TopErrors({
columns={topErrorsColumnsConfig}
loading={{
spinning: isLoading || isRefetching,
indicator: (
<Spin indicator={<Loader size={14} className="animate-spin" />} />
),
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
dataSource={isLoading || isRefetching ? [] : formattedTopErrorsData}
locale={{

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Loader } from '@signozhq/icons';
import { LoadingOutlined } from '@ant-design/icons';
import { Spin, Table } from 'antd';
import logEvent from 'api/common/logEvent';
import emptyStateUrl from 'assets/Icons/emptyState.svg';
@@ -205,9 +205,7 @@ function DomainList(): JSX.Element {
columns={columnsConfig}
loading={{
spinning: isFetching || isLoading,
indicator: (
<Spin indicator={<Loader size={14} className="animate-spin" />} />
),
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
scroll={{ x: true }}
tableLayout="fixed"

View File

@@ -2,8 +2,9 @@ import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { UseQueryResult } from 'react-query';
import { useInterval } from 'react-use';
import { Button } from '@signozhq/ui';
import { Compass, Loader, ScrollText } from '@signozhq/icons';
import { LoadingOutlined } from '@ant-design/icons';
import { Compass, ScrollText } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Modal, Spin } from 'antd';
import setRetentionApi from 'api/settings/setRetention';
import setRetentionApiV2 from 'api/settings/setRetentionV2';
@@ -479,11 +480,7 @@ function GeneralSettings({
saveButtonText:
metricsTtlValuesPayload.status === 'pending' ? (
<span>
<Spin
spinning
size="small"
indicator={<Loader className="animate-spin" />}
/>{' '}
<Spin spinning size="small" indicator={<LoadingOutlined spin />} />{' '}
{t('retention_save_button.pending', { name: 'metrics' })}
</span>
) : (
@@ -526,11 +523,7 @@ function GeneralSettings({
saveButtonText:
tracesTtlValuesPayload.status === 'pending' ? (
<span>
<Spin
spinning
size="small"
indicator={<Loader className="animate-spin" />}
/>{' '}
<Spin spinning size="small" indicator={<LoadingOutlined spin />} />{' '}
{t('retention_save_button.pending', { name: 'traces' })}
</span>
) : (
@@ -572,11 +565,7 @@ function GeneralSettings({
saveButtonText:
logsTtlValuesPayload.status === 'pending' ? (
<span>
<Spin
spinning
size="small"
indicator={<Loader className="animate-spin" />}
/>{' '}
<Spin spinning size="small" indicator={<LoadingOutlined spin />} />{' '}
{t('retention_save_button.pending', { name: 'logs' })}
</span>
) : (

View File

@@ -9,8 +9,11 @@ import React, {
import { useQueryClient } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux'; // old code, TODO: fix this correctly
import { SearchOutlined, SyncOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import {
LoadingOutlined,
SearchOutlined,
SyncOutlined,
} from '@ant-design/icons';
import { Button, Input, Spin } from 'antd';
import cx from 'classnames';
import { ToggleGraphProps } from 'components/Graph/types';
@@ -335,7 +338,7 @@ function FullView({
)}
<div className="time-container">
{response.isFetching && (
<Spin spinning indicator={<Loader className="animate-spin" />} />
<Spin spinning indicator={<LoadingOutlined spin />} />
)}
<TimePreference
selectedTime={selectedTime}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LoaderCircle } from '@signozhq/icons';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Input, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
@@ -79,7 +79,7 @@ export function RequestDashboardBtn(): JSX.Element {
className="periscope-btn primary"
icon={
isSubmittingRequestForDashboard ? (
<LoaderCircle className="animate-spin" size={12} />
<LoadingOutlined />
) : (
<Check size={12} />
)

View File

@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { LoaderCircle } from '@signozhq/icons';
import { Spin } from 'antd';
import { CircleCheck } from 'lucide-react';
@@ -21,13 +21,7 @@ export default function QueryStatus(
const content = useMemo((): React.ReactElement => {
if (loading) {
return (
<Spin
spinning
size="small"
indicator={<LoaderCircle className="animate-spin" />}
/>
);
return <Spin spinning size="small" indicator={<LoadingOutlined spin />} />;
}
if (error) {
return (

View File

@@ -1,5 +1,5 @@
import { ReactNode, useCallback, useMemo, useState } from 'react';
import { Loader } from '@signozhq/icons';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Popover, Spin } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -32,13 +32,7 @@ function FieldItem({
const renderContent = useMemo(() => {
if (isLoading) {
return (
<Spin
spinning
size="small"
indicator={<Loader className="animate-spin" />}
/>
);
return <Spin spinning size="small" indicator={<LoadingOutlined spin />} />;
}
if (isHovered) {

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { Loader } from '@signozhq/icons';
import { LoadingOutlined } from '@ant-design/icons';
import { Spin, Table, TablePaginationConfig, TableProps, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { SorterResult } from 'antd/es/table/interface';
@@ -79,7 +79,7 @@ function MetricsTable({
indicator: (
<Spin
data-testid="metrics-table-loading-state"
indicator={<Loader size={14} className="animate-spin" />}
indicator={<LoadingOutlined size={14} spin />}
/>
),
}}

View File

@@ -1,8 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons';
import { LoaderCircle } from '@signozhq/icons';
import {
CheckCircleTwoTone,
CloseCircleTwoTone,
LoadingOutlined,
} from '@ant-design/icons';
import logEvent from 'api/common/logEvent';
import MessagingQueueHealthCheck from 'components/MessagingQueueHealthCheck/MessagingQueueHealthCheck';
import { QueryParams } from 'constants/query';
@@ -338,7 +341,7 @@ export default function ConnectionStatus(): JSX.Element {
<div className="label"> Status </div>
<div className="status">
{isQueryServiceLoading && <LoaderCircle className="animate-spin" />}
{isQueryServiceLoading && <LoadingOutlined />}
{!isQueryServiceLoading &&
isReceivingData &&
(getStartedSource !== 'kafka' ? (

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LoaderCircle } from '@signozhq/icons';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Card, Form, Input, Select, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
@@ -272,7 +272,7 @@ export default function DataSource(): JSX.Element {
className="periscope-btn primary"
icon={
isSubmittingRequestForDataSource ? (
<LoaderCircle className="animate-spin" />
<LoadingOutlined />
) : (
<Check size={12} />
)

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LoaderCircle } from '@signozhq/icons';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Card, Form, Input, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
@@ -185,7 +185,7 @@ export default function EnvironmentDetails(): JSX.Element {
className="periscope-btn primary"
icon={
isSubmittingRequestForEnvironment ? (
<LoaderCircle className="animate-spin" />
<LoadingOutlined />
) : (
<Check size={12} />
)

View File

@@ -1,6 +1,9 @@
import { useEffect, useState } from 'react';
import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons';
import { LoaderCircle } from '@signozhq/icons';
import {
CheckCircleTwoTone,
CloseCircleTwoTone,
LoadingOutlined,
} from '@ant-design/icons';
import logEvent from 'api/common/logEvent';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -242,7 +245,7 @@ export default function LogsConnectionStatus(): JSX.Element {
<div className="label"> Status </div>
<div className="status">
{(loading || isFetching) && <LoaderCircle className="animate-spin" />}
{(loading || isFetching) && <LoadingOutlined />}
{!(loading || isFetching) && isReceivingData && (
<>
<CheckCircleTwoTone twoToneColor="#52c41a" />

View File

@@ -2,9 +2,9 @@ import {
CheckCircleFilled,
CloseCircleFilled,
ExclamationCircleFilled,
LoadingOutlined,
MinusCircleFilled,
} from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { Spin } from 'antd';
export function getDeploymentStage(value: string): string {
@@ -28,17 +28,7 @@ export function getDeploymentStageIcon(value: string): JSX.Element {
switch (value) {
case 'in_progress':
return (
<Spin
indicator={
<Loader
size="large"
className="animate-spin"
role="img"
aria-label="loading"
data-icon="loading"
/>
}
/>
<Spin indicator={<LoadingOutlined style={{ fontSize: 15 }} spin />} />
);
case 'deployed':
return <CheckCircleFilled />;

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { LinkOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { LinkOutlined, LoadingOutlined } from '@ant-design/icons';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -231,7 +230,7 @@ const useBaseAggregateOptions = ({
key={key}
icon={
isLoading ? (
<Loader className="animate-spin" />
<LoadingOutlined spin />
) : (
<span style={{ color: aggregateData?.seriesColor }}>{icon}</span>
)

View File

@@ -29,18 +29,6 @@
border-bottom: 1px solid var(--l1-border);
}
&__close {
width: 16px;
height: 16px;
padding: 0;
color: var(--foreground);
flex-shrink: 0;
&:hover {
color: var(--l1-foreground);
}
}
&__header-divider {
display: block;
width: 1px;

View File

@@ -78,39 +78,50 @@ function ResourceRow({
<RadioGroupLabel htmlFor={`${resource.id}-all`}>All</RadioGroupLabel>
</div>
<div className="psp-resource__radio-item">
<RadioGroupItem
value={PermissionScope.ONLY_SELECTED}
id={`${resource.id}-only-selected`}
/>
<RadioGroupLabel htmlFor={`${resource.id}-only-selected`}>
Only selected
</RadioGroupLabel>
</div>
{resource.type === 'metaresources' ? (
<div className="psp-resource__radio-item">
<RadioGroupItem
value={PermissionScope.ONLY_SELECTED}
id={`${resource.id}-none`}
/>
<RadioGroupLabel htmlFor={`${resource.id}-none`}>None</RadioGroupLabel>
</div>
) : (
<div className="psp-resource__radio-item">
<RadioGroupItem
value={PermissionScope.ONLY_SELECTED}
id={`${resource.id}-only-selected`}
/>
<RadioGroupLabel htmlFor={`${resource.id}-only-selected`}>
Only selected
</RadioGroupLabel>
</div>
)}
</RadioGroup>
{config.scope === PermissionScope.ONLY_SELECTED && (
<div className="psp-resource__select-wrapper">
{/* TODO: right now made to only accept user input, we need to give it proper resource based value fetching from APIs */}
<Select
mode="tags"
value={config.selectedIds}
onChange={(vals: string[]): void =>
onSelectedIdsChange(resource.id, vals)
}
options={resource.options ?? []}
placeholder="Select resources..."
className="psp-resource__select"
popupClassName="psp-resource__select-popup"
showSearch
filterOption={(input, option): boolean =>
String(option?.label ?? '')
.toLowerCase()
.includes(input.toLowerCase())
}
/>
</div>
)}
{config.scope === PermissionScope.ONLY_SELECTED &&
resource.type !== 'metaresources' && (
<div className="psp-resource__select-wrapper">
{/* TODO: right now made to only accept user input, we need to give it proper resource based value fetching from APIs */}
<Select
mode="tags"
value={config.selectedIds}
onChange={(vals: string[]): void =>
onSelectedIdsChange(resource.id, vals)
}
options={resource.options ?? []}
placeholder="Select resources..."
className="psp-resource__select"
popupClassName="psp-resource__select-popup"
showSearch
filterOption={(input, option): boolean =>
String(option?.label ?? '')
.toLowerCase()
.includes(input.toLowerCase())
}
/>
</div>
)}
</div>
)}
</div>
@@ -213,13 +224,13 @@ function PermissionSidePanel({
<div className="permission-side-panel">
<div className="permission-side-panel__header">
<Button
variant="ghost"
variant="link"
color="secondary"
size="icon"
className="permission-side-panel__close"
onClick={onClose}
aria-label="Close panel"
>
<X size={16} />
<X size={14} />
</Button>
<span className="permission-side-panel__header-divider" />
<span className="permission-side-panel__title">

View File

@@ -5,6 +5,8 @@ export interface ResourceOption {
export interface ResourceDefinition {
id: string;
kind: string;
type: string;
label: string;
options?: ResourceOption[];
}

View File

@@ -282,30 +282,6 @@
}
}
.role-details-delete-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
min-width: 32px;
border: none;
border-radius: 2px;
background: transparent;
color: var(--destructive);
opacity: 0.6;
padding: 0;
transition:
background-color 0.2s,
opacity 0.2s;
box-shadow: none;
&:hover {
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
opacity: 0.9;
}
}
.role-details-delete-modal {
width: calc(100% - 30px) !important;
max-width: 384px;

View File

@@ -110,7 +110,6 @@ function RoleDetailsPage(): JSX.Element {
getGetObjectsQueryKey({ id: roleId, relation: activePermission }),
);
}
setActivePermission(null);
};
const { mutate: patchObjects, isLoading: isSaving } = usePatchObjects({
@@ -213,18 +212,16 @@ function RoleDetailsPage(): JSX.Element {
{!isManaged && (
<div className="role-details-actions">
<Button
variant="ghost"
variant="link"
color="destructive"
className="role-details-delete-action-btn"
onClick={(): void => setIsDeleteModalOpen(true)}
aria-label="Delete role"
>
<Trash2 size={14} />
<Trash2 size={12} />
</Button>
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setIsEditModalOpen(true)}
>
Edit Role Details

View File

@@ -27,9 +27,8 @@ function DeleteRoleModal({
<Button
key="cancel"
className="cancel-btn"
prefix={<X size={16} />}
prefix={<X size={14} />}
onClick={onCancel}
size="sm"
variant="solid"
color="secondary"
>
@@ -38,10 +37,9 @@ function DeleteRoleModal({
<Button
key="delete"
className="delete-btn"
prefix={<Trash2 size={16} />}
prefix={<Trash2 size={14} />}
onClick={onConfirm}
loading={isDeleting}
size="sm"
variant="solid"
color="destructive"
>

View File

@@ -285,12 +285,19 @@
}
}
// todo: https://github.com/SigNoz/components/issues/116
input,
input {
&::placeholder {
opacity: 0.4;
}
}
textarea {
width: 100%;
background: var(--l3-background);
border: 1px solid var(--l1-border);
box-sizing: border-box;
min-height: 100px;
resize: vertical;
background: var(--input-background, transparent);
border: 1px solid var(--border);
border-radius: 2px;
padding: 6px 8px;
font-family: Inter;
@@ -303,7 +310,7 @@
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
color: var(--muted-foreground);
opacity: 0.4;
}
@@ -313,25 +320,6 @@
box-shadow: none;
}
}
input {
height: 32px;
}
input:disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
border-color: var(--l1-border);
box-shadow: none;
}
}
textarea {
min-height: 100px;
resize: vertical;
}
}
.ant-modal-footer {

View File

@@ -58,8 +58,18 @@ const baseAuthzResources: AuthzResources = {
};
const resourceDefs: ResourceDefinition[] = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'alert', label: 'Alert' },
{
id: 'metaresource:dashboard',
kind: 'dashboard',
type: 'metaresource',
label: 'Dashboard',
},
{
id: 'metaresource:alert',
kind: 'alert',
type: 'metaresource',
label: 'Alert',
},
];
const ID_A = 'aaaaaaaa-0000-0000-0000-000000000001';
@@ -69,15 +79,24 @@ const ID_C = 'cccccccc-0000-0000-0000-000000000003';
describe('buildPatchPayload', () => {
it('sends only the added selector as an addition', () => {
const initial: PermissionConfig = {
dashboard: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A] },
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A],
},
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const newConfig: PermissionConfig = {
dashboard: {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
},
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const result = buildPatchPayload({
@@ -95,18 +114,24 @@ describe('buildPatchPayload', () => {
it('sends only the removed selector as a deletion', () => {
const initial: PermissionConfig = {
dashboard: {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B, ID_C],
},
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const newConfig: PermissionConfig = {
dashboard: {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_C],
},
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const result = buildPatchPayload({
@@ -124,18 +149,24 @@ describe('buildPatchPayload', () => {
it('treats selector order as irrelevant — produces no payload when IDs are identical', () => {
const initial: PermissionConfig = {
dashboard: {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
},
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const newConfig: PermissionConfig = {
dashboard: {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_B, ID_A],
},
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const result = buildPatchPayload({
@@ -151,15 +182,21 @@ describe('buildPatchPayload', () => {
it('replaces wildcard with specific IDs when switching all → only_selected', () => {
const initial: PermissionConfig = {
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const newConfig: PermissionConfig = {
dashboard: {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
},
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const result = buildPatchPayload({
@@ -179,12 +216,21 @@ describe('buildPatchPayload', () => {
it('only deletes wildcard when switching all → only_selected with empty selector list', () => {
const initial: PermissionConfig = {
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const newConfig: PermissionConfig = {
dashboard: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const result = buildPatchPayload({
@@ -202,12 +248,18 @@ describe('buildPatchPayload', () => {
it('only includes resources that actually changed', () => {
const initial: PermissionConfig = {
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A] },
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A],
},
};
const newConfig: PermissionConfig = {
dashboard: { scope: PermissionScope.ALL, selectedIds: [] }, // unchanged
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A, ID_B] }, // added ID_B
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] }, // unchanged
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
}, // added ID_B
};
const result = buildPatchPayload({
@@ -232,7 +284,7 @@ describe('objectsToPermissionConfig', () => {
const result = objectsToPermissionConfig(objects, resourceDefs);
expect(result.dashboard).toStrictEqual({
expect(result['metaresource:dashboard']).toStrictEqual({
scope: PermissionScope.ALL,
selectedIds: [],
});
@@ -245,7 +297,7 @@ describe('objectsToPermissionConfig', () => {
const result = objectsToPermissionConfig(objects, resourceDefs);
expect(result.dashboard).toStrictEqual({
expect(result['metaresource:dashboard']).toStrictEqual({
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
});
@@ -254,11 +306,11 @@ describe('objectsToPermissionConfig', () => {
it('defaults to ONLY_SELECTED with empty selectedIds when resource is absent from API response', () => {
const result = objectsToPermissionConfig([], resourceDefs);
expect(result.dashboard).toStrictEqual({
expect(result['metaresource:dashboard']).toStrictEqual({
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
});
expect(result.alert).toStrictEqual({
expect(result['metaresource:alert']).toStrictEqual({
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
});
@@ -268,8 +320,11 @@ describe('objectsToPermissionConfig', () => {
describe('configsEqual', () => {
it('returns true for identical configs', () => {
const config: PermissionConfig = {
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A] },
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A],
},
};
expect(configsEqual(config, { ...config })).toBe(true);
@@ -277,22 +332,25 @@ describe('configsEqual', () => {
it('returns false when configs differ', () => {
const a: PermissionConfig = {
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
};
const b: PermissionConfig = {
dashboard: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
expect(configsEqual(a, b)).toBe(false);
const c: PermissionConfig = {
dashboard: {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_C, ID_B],
},
};
const d: PermissionConfig = {
dashboard: {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
},
@@ -303,13 +361,13 @@ describe('configsEqual', () => {
it('returns true when selectedIds are the same but in different order', () => {
const a: PermissionConfig = {
dashboard: {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
},
};
const b: PermissionConfig = {
dashboard: {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_B, ID_A],
},
@@ -322,23 +380,25 @@ describe('configsEqual', () => {
describe('buildConfig', () => {
it('uses initial values when provided and defaults for resources not in initial', () => {
const initial: PermissionConfig = {
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
};
const result = buildConfig(resourceDefs, initial);
expect(result.dashboard).toStrictEqual({
expect(result['metaresource:dashboard']).toStrictEqual({
scope: PermissionScope.ALL,
selectedIds: [],
});
expect(result.alert).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
expect(result['metaresource:alert']).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
});
it('applies DEFAULT_RESOURCE_CONFIG to all resources when no initial is provided', () => {
const result = buildConfig(resourceDefs);
expect(result.dashboard).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
expect(result.alert).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
expect(result['metaresource:dashboard']).toStrictEqual(
DEFAULT_RESOURCE_CONFIG,
);
expect(result['metaresource:alert']).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
});
});
@@ -375,7 +435,10 @@ describe('deriveResourcesForRelation', () => {
const result = deriveResourcesForRelation(baseAuthzResources, 'create');
expect(result).toHaveLength(2);
expect(result.map((r) => r.id)).toStrictEqual(['dashboard', 'alert']);
expect(result.map((r) => r.id)).toStrictEqual([
'metaresource:dashboard',
'metaresource:alert',
]);
});
it('returns an empty array when authzResources is null', () => {

View File

@@ -1 +1 @@
export const IS_ROLE_DETAILS_AND_CRUD_ENABLED = false;
export const IS_ROLE_DETAILS_AND_CRUD_ENABLED = true;

View File

@@ -70,7 +70,9 @@ export function deriveResourcesForRelation(
return authzResources.resources
.filter((r) => supportedTypes.includes(r.type))
.map((r) => ({
id: r.kind,
id: `${r.type}:${r.kind}`,
kind: r.kind,
type: r.type,
label: capitalize(r.kind).replaceAll('_', ' '),
options: [],
}));
@@ -82,7 +84,9 @@ export function objectsToPermissionConfig(
): PermissionConfig {
const config: PermissionConfig = {};
for (const res of resources) {
const obj = objects.find((o) => o.resource.kind === res.id);
const obj = objects.find(
(o) => o.resource.kind === res.kind && o.resource.type === res.type,
);
if (!obj) {
config[res.id] = {
scope: PermissionScope.ONLY_SELECTED,
@@ -118,7 +122,9 @@ export function buildPatchPayload({
for (const res of resources) {
const initial = initialConfig[res.id];
const current = newConfig[res.id];
const resourceDef = authzRes.resources.find((r) => r.kind === res.id);
const resourceDef = authzRes.resources.find(
(r) => r.kind === res.kind && r.type === res.type,
);
if (!resourceDef) {
continue;
}

View File

@@ -1,5 +1,5 @@
import { ChangeEvent, useEffect, useMemo, useState } from 'react';
import { Loader } from '@signozhq/icons';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Input, Spin } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
@@ -215,7 +215,7 @@ function AddSpanToFunnelModal({
<Spin
className="add-span-to-funnel-modal__loading-spinner"
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
indicator={<Loader className="animate-spin" />}
indicator={<LoadingOutlined spin />}
>
{selectedFunnelId && funnelDetails?.payload && (
<FunnelProvider

View File

@@ -1,7 +1,6 @@
import { useCallback, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons';
import { Button, Spin, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
@@ -189,9 +188,7 @@ function Filters({
/>
</div>
)}
{isFetching && (
<Spin indicator={<Loader className="animate-spin" />} size="small" />
)}
{isFetching && <Spin indicator={<LoadingOutlined spin />} size="small" />}
{error && (
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
<InfoCircleOutlined size={14} />

View File

@@ -3,7 +3,6 @@ import { useQueryClient } from 'react-query';
import {
getGetServiceAccountRolesQueryKey,
useCreateServiceAccountRole,
useDeleteServiceAccountRole,
useGetServiceAccountRoles,
} from 'api/generated/services/serviceaccount';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
@@ -45,9 +44,6 @@ export function useServiceAccountRoleManager(
const { mutateAsync: createRole } = useCreateServiceAccountRole({
mutation: { retry: retryOn429 },
});
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
mutation: { retry: retryOn429 },
});
const invalidateRoles = useCallback(
() =>
@@ -72,21 +68,14 @@ export function useServiceAccountRoleManager(
const addedRoles = availableRoles.filter(
(r) => r.id && desiredRoleIds.has(r.id) && !currentRoleIds.has(r.id),
);
const removedRoles = currentRoles.filter(
(r) => r.id && !desiredRoleIds.has(r.id),
);
// TODO: re-enable deletes once BE for this is streamlined
const allOperations = [
...addedRoles.map((role) => ({
role,
run: (): ReturnType<typeof createRole> =>
createRole({ pathParams: { id: accountId }, data: { id: role.id } }),
})),
...removedRoles.map((role) => ({
role,
run: (): ReturnType<typeof deleteRole> =>
deleteRole({ pathParams: { id: accountId, rid: role.id ?? '' } }),
})),
];
const results = await Promise.allSettled(
@@ -117,7 +106,7 @@ export function useServiceAccountRoleManager(
return failures;
},
[accountId, currentRoles, createRole, deleteRole, invalidateRoles],
[accountId, currentRoles, createRole, invalidateRoles],
);
return {

View File

@@ -25,7 +25,7 @@ describe('computeVisualLayout', () => {
it('should handle empty input', () => {
const layout = computeVisualLayout([]);
expect(layout.totalVisualRows).toBe(0);
expect(layout.visualRows).toStrictEqual([]);
expect(layout.visualRows).toEqual([]);
});
it('should handle single root, no children — 1 visual row', () => {
@@ -36,7 +36,7 @@ describe('computeVisualLayout', () => {
});
const layout = computeVisualLayout([[root]]);
expect(layout.totalVisualRows).toBe(1);
expect(layout.visualRows[0]).toStrictEqual([root]);
expect(layout.visualRows[0]).toEqual([root]);
expect(layout.spanToVisualRow['root']).toBe(0);
});

View File

@@ -44,12 +44,12 @@ function makeSpan(
describe('getVisibleSpans', () => {
it('returns empty array for empty input', () => {
expect(getVisibleSpans([], new Set())).toStrictEqual([]);
expect(getVisibleSpans([], new Set())).toEqual([]);
});
it('returns single root span with no children', () => {
const spans = [makeSpan({ span_id: 'root', level: 0 })];
expect(getVisibleSpans(spans, new Set())).toStrictEqual(spans);
expect(getVisibleSpans(spans, new Set())).toEqual(spans);
});
it('returns all spans for flat tree (all level 0, no children)', () => {
@@ -58,7 +58,7 @@ describe('getVisibleSpans', () => {
makeSpan({ span_id: 'b', level: 0 }),
makeSpan({ span_id: 'c', level: 0 }),
];
expect(getVisibleSpans(spans, new Set())).toStrictEqual(spans);
expect(getVisibleSpans(spans, new Set())).toEqual(spans);
});
it('returns all spans when all parents are expanded', () => {
@@ -68,7 +68,7 @@ describe('getVisibleSpans', () => {
makeSpan({ span_id: 'b', level: 1 }),
];
const uncollapsed = new Set(['root']);
expect(getVisibleSpans(spans, uncollapsed)).toStrictEqual(spans);
expect(getVisibleSpans(spans, uncollapsed)).toEqual(spans);
});
it('hides children when root is collapsed', () => {
@@ -99,7 +99,7 @@ describe('getVisibleSpans', () => {
// root and A expanded, but A is collapsed
const uncollapsed = new Set(['root']); // A not in set → collapsed
const result = getVisibleSpans(spans, uncollapsed);
expect(result.map((s) => s.span_id)).toStrictEqual(['root', 'A']);
expect(result.map((s) => s.span_id)).toEqual(['root', 'A']);
});
it('collapses one subtree while sibling subtree stays visible', () => {
@@ -116,7 +116,7 @@ describe('getVisibleSpans', () => {
// root and childB expanded, childA collapsed
const uncollapsed = new Set(['root', 'childB']);
const result = getVisibleSpans(spans, uncollapsed);
expect(result.map((s) => s.span_id)).toStrictEqual([
expect(result.map((s) => s.span_id)).toEqual([
'root',
'childA', // visible but collapsed
'childB', // visible and expanded
@@ -134,11 +134,11 @@ describe('getVisibleSpans', () => {
// Collapsed
const collapsed = getVisibleSpans(spans, new Set());
expect(collapsed.map((s) => s.span_id)).toStrictEqual(['root']);
expect(collapsed.map((s) => s.span_id)).toEqual(['root']);
// Uncollapsed
const uncollapsed = getVisibleSpans(spans, new Set(['root']));
expect(uncollapsed.map((s) => s.span_id)).toStrictEqual(['root', 'a', 'b']);
expect(uncollapsed.map((s) => s.span_id)).toEqual(['root', 'a', 'b']);
});
it('hides all levels below collapsed span in deeply nested tree', () => {
@@ -154,7 +154,7 @@ describe('getVisibleSpans', () => {
// Expand L0 and L1, collapse L2
const uncollapsed = new Set(['L0', 'L1']);
const result = getVisibleSpans(spans, uncollapsed);
expect(result.map((s) => s.span_id)).toStrictEqual(['L0', 'L1', 'L2']);
expect(result.map((s) => s.span_id)).toEqual(['L0', 'L1', 'L2']);
});
it('shows only root-level spans when all parents are collapsed', () => {
@@ -166,7 +166,7 @@ describe('getVisibleSpans', () => {
];
const uncollapsed = new Set<string>(); // nothing expanded
const result = getVisibleSpans(spans, uncollapsed);
expect(result.map((s) => s.span_id)).toStrictEqual(['root1', 'root2']);
expect(result.map((s) => s.span_id)).toEqual(['root1', 'root2']);
});
it('leaf span in uncollapsed set has no effect', () => {
@@ -177,7 +177,7 @@ describe('getVisibleSpans', () => {
// leaf is in uncollapsed set but has_children=false, should make no difference
const uncollapsed = new Set(['root', 'leaf']);
const result = getVisibleSpans(spans, uncollapsed);
expect(result).toStrictEqual(spans);
expect(result).toEqual(spans);
});
it('handles 10k spans within 50ms', () => {
@@ -218,13 +218,7 @@ describe('getVisibleSpans', () => {
// root and C expanded; A and B collapsed
const uncollapsed = new Set(['root', 'C']);
const result = getVisibleSpans(spans, uncollapsed);
expect(result.map((s) => s.span_id)).toStrictEqual([
'root',
'A',
'B',
'C',
'C1',
]);
expect(result.map((s) => s.span_id)).toEqual(['root', 'A', 'B', 'C', 'C1']);
});
});
@@ -240,7 +234,7 @@ describe('getAncestorSpanIds', () => {
makeSpan({ span_id: 'child', level: 1, parent_span_id: 'root' }),
];
const ancestors = getAncestorSpanIds(spans, 'child');
expect(ancestors).toStrictEqual(new Set(['root']));
expect(ancestors).toEqual(new Set(['root']));
});
it('returns all ancestors for deeply nested span', () => {
@@ -261,7 +255,7 @@ describe('getAncestorSpanIds', () => {
makeSpan({ span_id: 'L3', level: 3, parent_span_id: 'L2' }),
];
const ancestors = getAncestorSpanIds(spans, 'L3');
expect(ancestors).toStrictEqual(new Set(['L2', 'L1', 'L0']));
expect(ancestors).toEqual(new Set(['L2', 'L1', 'L0']));
});
it('returns empty set for unknown span', () => {

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { Loader } from '@signozhq/icons';
import { LoadingOutlined } from '@ant-design/icons';
import { Empty, Spin } from 'antd';
import {
BarController,
@@ -97,7 +97,7 @@ function FunnelGraph(): JSX.Element {
}
return (
<Spin spinning={isFetching} indicator={<Loader className="animate-spin" />}>
<Spin spinning={isFetching} indicator={<LoadingOutlined spin />}>
<div className={cx('funnel-graph', `funnel-graph--${totalSteps}-columns`)}>
<div className="funnel-graph__chart-container">
<canvas ref={canvasRef} />

View File

@@ -54,23 +54,9 @@ describe('globalTimeStore', () => {
expect(result.current.lastRefreshTimestamp).toBe(0);
});
it('should have lastComputedMinMax computed from initial selectedTime', () => {
it('should have lastComputedMinMax with default values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
// Now computes min/max on store creation, no longer starts with 0
expect(result.current.lastComputedMinMax.minTime).toBeGreaterThan(0);
expect(result.current.lastComputedMinMax.maxTime).toBeGreaterThan(0);
});
it('should not crash with invalid relative time format', () => {
// Invalid time formats should not crash store creation
const store = createGlobalTimeStore({
selectedTime: 'invalid_time' as GlobalTimeSelectedTime,
});
// Store should be created successfully
expect(store.getState().selectedTime).toBe('invalid_time');
// Should fallback to {0, 0} when parsing fails
expect(store.getState().lastComputedMinMax).toStrictEqual({
expect(result.current.lastComputedMinMax).toStrictEqual({
minTime: 0,
maxTime: 0,
});
@@ -738,8 +724,8 @@ describe('globalTimeStore', () => {
const wrapper = createIsolatedWrapper();
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Initial state now has computed values (no longer 0)
expect(result.current.lastComputedMinMax.maxTime).toBeGreaterThan(0);
// Initial state has 0 values
expect(result.current.lastComputedMinMax.maxTime).toBe(0);
act(() => {
result.current.setSelectedTime('15m');

View File

@@ -134,17 +134,17 @@ describe('useComputedMinMaxSync', () => {
jest.useRealTimers();
});
it('should have computed min/max on store creation (no longer needs mount sync)', () => {
it('should compute min/max on mount when store has zero values', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
// Store now computes min/max on creation, not on mount
expect(contextStore.getState().lastComputedMinMax.minTime).toBeGreaterThan(0);
expect(contextStore.getState().lastComputedMinMax.maxTime).toBeGreaterThan(0);
expect(contextStore.getState().lastComputedMinMax).toStrictEqual({
minTime: 0,
maxTime: 0,
});
// Hook still works but is a no-op when values already exist
renderHook(() => useComputedMinMaxSync(contextStore));
// Values remain computed
// Should have computed values now
expect(contextStore.getState().lastComputedMinMax.maxTime).toBeGreaterThan(0);
expect(contextStore.getState().lastComputedMinMax.minTime).toBeGreaterThan(0);
});

View File

@@ -12,7 +12,6 @@ import {
computeRounded5sMinMax,
isCustomTimeRange,
parseSelectedTime,
safeParseSelectedTime,
} from './utils';
export type GlobalTimeStoreApi = StoreApi<GlobalTimeStore>;
@@ -41,7 +40,7 @@ export function createGlobalTimeStore(
refreshInterval,
isRefreshEnabled: computeIsRefreshEnabled(selectedTime, refreshInterval),
lastRefreshTimestamp: 0,
lastComputedMinMax: safeParseSelectedTime(selectedTime),
lastComputedMinMax: { minTime: 0, maxTime: 0 },
setSelectedTime: (
time: GlobalTimeSelectedTime,

View File

@@ -61,8 +61,6 @@ const fallbackDurationInNanoSeconds = 30 * 1000 * NANO_SECOND_MULTIPLIER; // 30s
* Parse the selectedTime string to get min/max time values.
* For relative times, computes fresh values based on Date.now().
* For custom times, extracts the stored min/max values.
*
* @throws Error - When selectedTime is relativeTime and it's invalid
*/
export function parseSelectedTime(selectedTime: string): ParsedTimeRange {
if (isCustomTimeRange(selectedTime)) {
@@ -80,18 +78,6 @@ export function parseSelectedTime(selectedTime: string): ParsedTimeRange {
return getMinMaxForSelectedTime(selectedTime as Time, 0, 0);
}
/**
* The {@ref parseSelectedTime} can throw errors, this handles and fallbacks to 0,0 if invalid selected time is provided
*/
export function safeParseSelectedTime(selectedTime: string): ParsedTimeRange {
try {
return parseSelectedTime(selectedTime);
} catch (e) {
console.error('Error parsing selected time:', e);
return { minTime: 0, maxTime: 0 };
}
}
/**
* @deprecated Use store.getAutoRefreshQueryKey() instead.
* Access via: const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);

View File

@@ -86,24 +86,5 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/clusters", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListClusters),
handler.OpenAPIDef{
ID: "ListClusters",
Tags: []string{"inframonitoring"},
Summary: "List Clusters for Infra Monitoring",
Description: "Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.",
Request: new(inframonitoringtypes.PostableClusters),
RequestContentType: "application/json",
Response: new(inframonitoringtypes.Clusters),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -1,140 +0,0 @@
package implinframonitoring
import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
)
// buildClusterRecords assembles the page records. Node condition counts and
// pod phase counts come from the respective per-group maps in both modes;
// every row is a group of nodes+pods, so there's no per-row "current state"
// concept (analogous to namespaces).
func buildClusterRecords(
resp *qbtypes.QueryRangeResponse,
pageGroups []map[string]string,
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
nodeConditionCountsMap map[string]nodeConditionCounts,
podPhaseCountsMap map[string]podPhaseCounts,
) []inframonitoringtypes.ClusterRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
records := make([]inframonitoringtypes.ClusterRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
clusterName := labels[clusterNameAttrKey]
record := inframonitoringtypes.ClusterRecord{ // initialize with default values
ClusterName: clusterName,
ClusterCPU: -1,
ClusterCPUAllocatable: -1,
ClusterMemory: -1,
ClusterMemoryAllocatable: -1,
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
if v, exists := metrics["A"]; exists {
record.ClusterCPU = v
}
if v, exists := metrics["B"]; exists {
record.ClusterCPUAllocatable = v
}
if v, exists := metrics["C"]; exists {
record.ClusterMemory = v
}
if v, exists := metrics["D"]; exists {
record.ClusterMemoryAllocatable = v
}
}
if conditionCountsForGroup, ok := nodeConditionCountsMap[compositeKey]; ok {
record.NodeCountsByReadiness = inframonitoringtypes.NodeCountsByReadiness{
Ready: conditionCountsForGroup.Ready,
NotReady: conditionCountsForGroup.NotReady,
}
}
if phaseCountsForGroup, ok := podPhaseCountsMap[compositeKey]; ok {
record.PodCountsByPhase = inframonitoringtypes.PodCountsByPhase{
Pending: phaseCountsForGroup.Pending,
Running: phaseCountsForGroup.Running,
Succeeded: phaseCountsForGroup.Succeeded,
Failed: phaseCountsForGroup.Failed,
Unknown: phaseCountsForGroup.Unknown,
}
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
}
return records
}
func (m *module) getTopClusterGroups(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableClusters,
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
queryNamesForOrderBy := orderByToClustersQueryNames[orderByKey]
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]
topReq := &qbtypes.QueryRangeRequest{
Start: uint64(req.Start),
End: uint64(req.End),
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: make([]qbtypes.QueryEnvelope, 0, len(queryNamesForOrderBy)),
},
}
for _, envelope := range m.newClustersTableListQuery().CompositeQuery.Queries {
if !slices.Contains(queryNamesForOrderBy, envelope.GetQueryName()) {
continue
}
copied := envelope
if copied.Type == qbtypes.QueryTypeBuilder {
existingExpr := ""
if f := copied.GetFilter(); f != nil {
existingExpr = f.Expression
}
reqFilterExpr := ""
if req.Filter != nil {
reqFilterExpr = req.Filter.Expression
}
merged := mergeFilterExpressions(existingExpr, reqFilterExpr)
copied.SetFilter(&qbtypes.Filter{Expression: merged})
copied.SetGroupBy(req.GroupBy)
}
topReq.CompositeQuery.Queries = append(topReq.CompositeQuery.Queries, copied)
}
resp, err := m.querier.QueryRange(ctx, orgID, topReq)
if err != nil {
return nil, err
}
allMetricGroups := parseAndSortGroups(resp, rankingQueryName, req.GroupBy, req.OrderBy.Direction)
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
}
func (m *module) getClustersTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableClusters) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range clusterAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
return m.getMetadata(ctx, clustersTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
}

View File

@@ -1,140 +0,0 @@
package implinframonitoring
import (
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// TODO(nikhilmantri0902): change to k8s.cluster.uid after showing the missing
// data banner. Carried forward from v1 (see k8sClusterUIDAttrKey in
// pkg/query-service/app/inframetrics/clusters.go).
const clusterNameAttrKey = "k8s.cluster.name"
var clusterNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: clusterNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
}
// clustersTableMetricNamesList drives the existence/retention check.
// Includes k8s.node.condition_ready and k8s.pod.phase so the response
// short-circuits cleanly when a cluster doesn't ship those metrics — even
// though they aren't part of the QB composite query (they're queried separately
// via getPerGroupNodeConditionCounts and getPerGroupPodPhaseCounts).
var clustersTableMetricNamesList = []string{
"k8s.node.cpu.usage",
"k8s.node.allocatable_cpu",
"k8s.node.memory.working_set",
"k8s.node.allocatable_memory",
"k8s.node.condition_ready", //TODO(nikhilmantri0902): should these metrics be used to count groups k8s.node.condition_ready and k8s.pod.phase
"k8s.pod.phase",
}
var clusterAttrKeysForMetadata = []string{
"k8s.cluster.name",
}
var orderByToClustersQueryNames = map[string][]string{
inframonitoringtypes.ClustersOrderByCPU: {"A"},
inframonitoringtypes.ClustersOrderByCPUAllocatable: {"B"},
inframonitoringtypes.ClustersOrderByMemory: {"C"},
inframonitoringtypes.ClustersOrderByMemoryAllocatable: {"D"},
}
// newClustersTableListQuery builds the composite QB v5 request for the clusters list.
// Cluster-scope metrics are derived by summing per-node metrics within the
// group (default group: k8s.cluster.name). Node condition counts and pod phase
// counts are derived separately via getPerGroupNodeConditionCounts and
// getPerGroupPodPhaseCounts respectively (works for both list and grouped_list
// modes), so neither is included here. Query letters A/B/C/D mirror the v1
// implementation and the v2 nodes list.
func (m *module) newClustersTableListQuery() *qbtypes.QueryRangeRequest {
queries := []qbtypes.QueryEnvelope{
// Query A: CPU usage — sum of node CPU within the group.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.cpu.usage",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{clusterNameGroupByKey},
Disabled: false,
},
},
// Query B: CPU allocatable — sum of node allocatable CPU within the group.
// TimeAggregationLatest is the closest v5 equivalent of v1's AnyLast;
// allocatable values change rarely so divergence in practice is negligible.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "B",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.allocatable_cpu",
TimeAggregation: metrictypes.TimeAggregationLatest,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{clusterNameGroupByKey},
Disabled: false,
},
},
// Query C: Memory working set — sum of node memory within the group.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "C",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.memory.working_set",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{clusterNameGroupByKey},
Disabled: false,
},
},
// Query D: Memory allocatable — sum of node allocatable memory within the group.
// Same Latest caveat as Query B.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "D",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.allocatable_memory",
TimeAggregation: metrictypes.TimeAggregationLatest,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{clusterNameGroupByKey},
Disabled: false,
},
},
}
return &qbtypes.QueryRangeRequest{
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: queries,
},
}
}

View File

@@ -117,27 +117,3 @@ func (h *handler) ListNamespaces(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, result)
}
func (h *handler) ListClusters(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
var parsedReq inframonitoringtypes.PostableClusters
if err := binding.JSON.BindBody(req.Body, &parsedReq); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.ListClusters(req.Context(), orgID, &parsedReq)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -426,105 +426,3 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
return resp, nil
}
func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableClusters) (*inframonitoringtypes.Clusters, error) {
if err := req.Validate(); err != nil {
return nil, err
}
resp := &inframonitoringtypes.Clusters{}
if req.OrderBy == nil {
req.OrderBy = &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: inframonitoringtypes.ClustersOrderByCPU,
},
},
Direction: qbtypes.OrderDirectionDesc,
}
}
if len(req.GroupBy) == 0 {
req.GroupBy = []qbtypes.GroupByKey{clusterNameGroupByKey}
resp.Type = inframonitoringtypes.ResponseTypeList
} else {
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
}
missingMetrics, minFirstReportedUnixMilli, err := m.getMetricsExistenceAndEarliestTime(ctx, clustersTableMetricNamesList)
if err != nil {
return nil, err
}
if len(missingMetrics) > 0 {
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: missingMetrics}
resp.Records = []inframonitoringtypes.ClusterRecord{}
resp.Total = 0
return resp, nil
}
if req.End < int64(minFirstReportedUnixMilli) {
resp.EndTimeBeforeRetention = true
resp.Records = []inframonitoringtypes.ClusterRecord{}
resp.Total = 0
return resp, nil
}
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: []string{}}
metadataMap, err := m.getClustersTableMetadata(ctx, req)
if err != nil {
return nil, err
}
resp.Total = len(metadataMap)
pageGroups, err := m.getTopClusterGroups(ctx, orgID, req, metadataMap)
if err != nil {
return nil, err
}
if len(pageGroups) == 0 {
resp.Records = []inframonitoringtypes.ClusterRecord{}
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newClustersTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
// Reuse the nodes condition-counts CTE function via a temp struct — it reads only
// Start/End/Filter/GroupBy from PostableNodes. With default groupBy
// [k8s.cluster.name], counts are bucketed per cluster; with a custom groupBy,
// they aggregate across clusters in that group.
nodeConditionCountsMap, err := m.getPerGroupNodeConditionCounts(ctx, &inframonitoringtypes.PostableNodes{
Start: req.Start,
End: req.End,
Filter: req.Filter,
GroupBy: req.GroupBy,
}, pageGroups)
if err != nil {
return nil, err
}
// Same pattern for pod phase counts via PostablePods shim.
podPhaseCountsMap, err := m.getPerGroupPodPhaseCounts(ctx, &inframonitoringtypes.PostablePods{
Start: req.Start,
End: req.End,
Filter: req.Filter,
GroupBy: req.GroupBy,
}, pageGroups)
if err != nil {
return nil, err
}
resp.Records = buildClusterRecords(queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCountsMap, podPhaseCountsMap)
resp.Warning = queryResp.Warning
return resp, nil
}

View File

@@ -13,7 +13,6 @@ type Handler interface {
ListPods(http.ResponseWriter, *http.Request)
ListNodes(http.ResponseWriter, *http.Request)
ListNamespaces(http.ResponseWriter, *http.Request)
ListClusters(http.ResponseWriter, *http.Request)
}
type Module interface {
@@ -21,5 +20,4 @@ type Module interface {
ListPods(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (*inframonitoringtypes.Pods, error)
ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error)
ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNamespaces) (*inframonitoringtypes.Namespaces, error)
ListClusters(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableClusters) (*inframonitoringtypes.Clusters, error)
}

View File

@@ -377,7 +377,7 @@ func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID
}
func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, role *authtypes.Role) error {
serviceAccount, err := module.Get(ctx, orgID, id)
serviceAccount, err := module.GetWithRoles(ctx, orgID, id)
if err != nil {
return err
}
@@ -387,12 +387,24 @@ func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.
return err
}
err = module.authz.Grant(ctx, orgID, []string{role.Name}, authtypes.MustNewSubject(coretypes.NewResourceServiceAccount(), id.String(), orgID, nil))
err = module.authz.ModifyGrant(ctx, orgID, serviceAccount.RoleNames(), []string{role.Name}, authtypes.MustNewSubject(coretypes.NewResourceServiceAccount(), id.String(), orgID, nil))
if err != nil {
return err
}
err = module.store.CreateServiceAccountRole(ctx, serviceAccountRole)
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
err = module.store.DeleteServiceAccountRoles(ctx, serviceAccount.ID)
if err != nil {
return err
}
err = module.store.CreateServiceAccountRole(ctx, serviceAccountRole)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}

View File

@@ -207,6 +207,21 @@ func (store *store) CreateServiceAccountRole(ctx context.Context, serviceAccount
return nil
}
func (store *store) DeleteServiceAccountRoles(ctx context.Context, serviceAccountID valuer.UUID) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(serviceaccounttypes.ServiceAccountRole)).
Where("service_account_id = ?", serviceAccountID).
Exec(ctx)
if err != nil {
return err
}
return nil
}
func (store *store) DeleteServiceAccountRole(ctx context.Context, serviceAccountID valuer.UUID, roleID valuer.UUID) error {
_, err := store.
sqlstore.

View File

@@ -413,21 +413,18 @@ func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFro
}
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
keySelectors := b.getKeySelectors()
for _, field := range b.operator.SelectFields {
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: field.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: field.FieldContext,
FieldDataType: field.FieldDataType,
})
}
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, err
}
sb := sqlbuilder.NewSelectBuilder()
// Select core fields
sb.Select(
"timestamp",
"trace_id",
"span_id",
"name",
"duration_nano",
"parent_span_id",
)
coreFields := []string{"timestamp", "trace_id", "span_id", "name", "duration_nano", "parent_span_id"}
selectedFields := map[string]bool{
"timestamp": true,
"trace_id": true,
@@ -437,23 +434,23 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
"parent_span_id": true,
}
// CH 25.12.5 distributed-analyzer regression (ClickHouse/ClickHouse#103508):
// native columns from a Distributed-table-backed CTE get renamed col → col_0
// in the shard block, making them invisible to the outer SELECT/ORDER BY.
// Fix: rename every core field to a safe alias (_s_<col>) in the inner SELECT
// so the analyzer never sees the original name as an ORDER BY target. The
// outer SELECT re-exposes them under their original names.
innerCoreExprs := make([]string, len(coreFields))
outerCoreExprs := make([]string, len(coreFields))
for i, f := range coreFields {
innerCoreExprs[i] = fmt.Sprintf("%s AS _s_%s", f, f)
outerCoreExprs[i] = fmt.Sprintf("_s_%s AS %s", f, f)
// Get keys for selectFields
keySelectors := b.getKeySelectors()
for _, field := range b.operator.SelectFields {
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: field.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: field.FieldContext,
FieldDataType: field.FieldDataType,
})
}
innerSB := sqlbuilder.NewSelectBuilder()
innerSB.Select(innerCoreExprs...)
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, err
}
var additionalSelectedFields []string
// Add selectFields using ColumnExpressionFor since we now have all base table columns
for _, field := range b.operator.SelectFields {
if selectedFields[field.Name] {
continue
@@ -464,56 +461,41 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
slog.String("field", field.Name), errors.Attr(err))
continue
}
innerSB.SelectMore(colExpr)
sb.SelectMore(colExpr)
selectedFields[field.Name] = true
additionalSelectedFields = append(additionalSelectedFields, field.Name)
}
sb.From(selectFromCTE)
// Add order by support using ColumnExpressionFor
orderApplied := false
for _, orderBy := range b.operator.Order {
if selectedFields[orderBy.Key.Name] {
continue
}
colExpr, err := b.stmtBuilder.fm.ColumnExpressionFor(ctx, b.start, b.end, &orderBy.Key.TelemetryFieldKey, keys)
if err != nil {
return nil, err
}
innerSB.SelectMore(colExpr)
selectedFields[orderBy.Key.Name] = true
sb.OrderBy(fmt.Sprintf("%s %s", colExpr, orderBy.Direction.StringValue()))
orderApplied = true
}
innerSB.From(selectFromCTE)
innerSQL, innerArgs := innerSB.BuildWithFlavor(sqlbuilder.ClickHouse)
// Outer SELECT: re-exposes core fields under their original names and acts as
// a projection barrier so ORDER BY-only fields do not leak into the result.
outerSB := sqlbuilder.NewSelectBuilder()
outerSB.Select(outerCoreExprs...)
for _, name := range additionalSelectedFields {
outerSB.SelectMore(fmt.Sprintf("`%s`", name))
}
outerSB.From(fmt.Sprintf("(%s) AS t", innerSQL))
if len(b.operator.Order) > 0 {
for _, orderBy := range b.operator.Order {
outerSB.OrderBy(fmt.Sprintf("`%s` %s", orderBy.Key.Name, orderBy.Direction.StringValue()))
}
} else {
outerSB.OrderBy("timestamp DESC")
if !orderApplied {
sb.OrderBy("timestamp DESC")
}
if b.operator.Limit > 0 {
outerSB.Limit(b.operator.Limit)
sb.Limit(b.operator.Limit)
} else {
outerSB.Limit(100)
}
if b.operator.Offset > 0 {
outerSB.Offset(b.operator.Offset)
sb.Limit(100)
}
outerSQL, outerArgs := outerSB.BuildWithFlavor(sqlbuilder.ClickHouse)
if b.operator.Offset > 0 {
sb.Offset(b.operator.Offset)
}
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return &qbtypes.Statement{
Query: outerSQL,
Args: append(innerArgs, outerArgs...),
Query: sql,
Args: args,
}, nil
}

View File

@@ -67,7 +67,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_DIR_DESC_B AS (SELECT p.* FROM A AS p INNER JOIN B AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id) SELECT _s_timestamp AS timestamp, _s_trace_id AS trace_id, _s_span_id AS span_id, _s_name AS name, _s_duration_nano AS duration_nano, _s_parent_span_id AS parent_span_id, `service.name` FROM (SELECT timestamp AS _s_timestamp, trace_id AS _s_trace_id, span_id AS _s_span_id, name AS _s_name, duration_nano AS _s_duration_nano, parent_span_id AS _s_parent_span_id, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name` FROM A_DIR_DESC_B) AS t ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_DIR_DESC_B AS (SELECT p.* FROM A AS p INNER JOIN B AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name` FROM A_DIR_DESC_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -104,7 +104,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id) SELECT _s_timestamp AS timestamp, _s_trace_id AS trace_id, _s_span_id AS span_id, _s_name AS name, _s_duration_nano AS duration_nano, _s_parent_span_id AS parent_span_id FROM (SELECT timestamp AS _s_timestamp, trace_id AS _s_trace_id, span_id AS _s_span_id, name AS _s_name, duration_nano AS _s_duration_nano, parent_span_id AS _s_parent_span_id FROM A_INDIR_DESC_B) AS t ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_INDIR_DESC_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 5},
},
expectedErr: nil,
@@ -141,7 +141,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT _s_timestamp AS timestamp, _s_trace_id AS trace_id, _s_span_id AS span_id, _s_name AS name, _s_duration_nano AS duration_nano, _s_parent_span_id AS parent_span_id FROM (SELECT timestamp AS _s_timestamp, trace_id AS _s_trace_id, span_id AS _s_span_id, name AS _s_name, duration_nano AS _s_duration_nano, parent_span_id AS _s_parent_span_id FROM A_AND_B) AS t ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_AND_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 15},
},
expectedErr: nil,
@@ -178,7 +178,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_OR_B AS (SELECT * FROM A UNION DISTINCT SELECT * FROM B) SELECT _s_timestamp AS timestamp, _s_trace_id AS trace_id, _s_span_id AS span_id, _s_name AS name, _s_duration_nano AS duration_nano, _s_parent_span_id AS parent_span_id FROM (SELECT timestamp AS _s_timestamp, trace_id AS _s_trace_id, span_id AS _s_span_id, name AS _s_name, duration_nano AS _s_duration_nano, parent_span_id AS _s_parent_span_id FROM A_OR_B) AS t ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_OR_B AS (SELECT * FROM A UNION DISTINCT SELECT * FROM B) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_OR_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 20},
},
expectedErr: nil,
@@ -215,7 +215,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_not_B AS (SELECT l.* FROM A AS l WHERE l.trace_id GLOBAL NOT IN (SELECT DISTINCT trace_id FROM B)) SELECT _s_timestamp AS timestamp, _s_trace_id AS trace_id, _s_span_id AS span_id, _s_name AS name, _s_duration_nano AS duration_nano, _s_parent_span_id AS parent_span_id FROM (SELECT timestamp AS _s_timestamp, trace_id AS _s_trace_id, span_id AS _s_span_id, name AS _s_name, duration_nano AS _s_duration_nano, parent_span_id AS _s_parent_span_id FROM A_not_B) AS t ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_not_B AS (SELECT l.* FROM A AS l WHERE l.trace_id GLOBAL NOT IN (SELECT DISTINCT trace_id FROM B)) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_not_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -380,72 +380,11 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_DIR_DESC_B AS (SELECT p.* FROM A AS p INNER JOIN B AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_D AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), D AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_D) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), C_DIR_DESC_D AS (SELECT p.* FROM C AS p INNER JOIN D AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), A_DIR_DESC_B_AND_C_DIR_DESC_D AS (SELECT l.* FROM A_DIR_DESC_B AS l INNER JOIN C_DIR_DESC_D AS r ON l.trace_id = r.trace_id) SELECT _s_timestamp AS timestamp, _s_trace_id AS trace_id, _s_span_id AS span_id, _s_name AS name, _s_duration_nano AS duration_nano, _s_parent_span_id AS parent_span_id FROM (SELECT timestamp AS _s_timestamp, trace_id AS _s_trace_id, span_id AS _s_span_id, name AS _s_name, duration_nano AS _s_duration_nano, parent_span_id AS _s_parent_span_id FROM A_DIR_DESC_B_AND_C_DIR_DESC_D) AS t ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_DIR_DESC_B AS (SELECT p.* FROM A AS p INNER JOIN B AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_D AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), D AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_D) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), C_DIR_DESC_D AS (SELECT p.* FROM C AS p INNER JOIN D AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), A_DIR_DESC_B_AND_C_DIR_DESC_D AS (SELECT l.* FROM A_DIR_DESC_B AS l INNER JOIN C_DIR_DESC_D AS r ON l.trace_id = r.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_DIR_DESC_B_AND_C_DIR_DESC_D ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "auth", "%service.name%", "%service.name\":\"auth%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 5},
},
expectedErr: nil,
},
{
// order-by field (http.request.method) is not present in SelectFields;
// it must be included in the inner SELECT so the outer ORDER BY can
// reference it by alias, but must NOT appear in the outer SELECT list.
name: "order by field not in select fields",
requestType: qbtypes.RequestTypeRaw,
operator: qbtypes.QueryBuilderTraceOperator{
Expression: "A => B",
SelectFields: []telemetrytypes.TelemetryFieldKey{
{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
Order: []qbtypes.OrderBy{
{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "http.request.method",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
Limit: 10,
},
compositeQuery: &qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{
Expression: "service.name = 'frontend'",
},
},
},
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "B",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{
Expression: "service.name = 'backend'",
},
},
},
},
},
expected: qbtypes.Statement{
// http.request.method is in the inner SELECT (so ORDER BY can reach it)
// but is absent from the outer SELECT column list — only the ORDER BY clause references it.
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_DIR_DESC_B AS (SELECT p.* FROM A AS p INNER JOIN B AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id) SELECT _s_timestamp AS timestamp, _s_trace_id AS trace_id, _s_span_id AS span_id, _s_name AS name, _s_duration_nano AS duration_nano, _s_parent_span_id AS parent_span_id, `service.name` FROM (SELECT timestamp AS _s_timestamp, trace_id AS _s_trace_id, span_id AS _s_span_id, name AS _s_name, duration_nano AS _s_duration_nano, parent_span_id AS _s_parent_span_id, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, attributes_string['http.request.method'] AS `http.request.method` FROM A_DIR_DESC_B) AS t ORDER BY `http.request.method` desc LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
},
}
fm := NewFieldMapper()

View File

@@ -1,105 +0,0 @@
package inframonitoringtypes
import (
"encoding/json"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type Clusters struct {
Type ResponseType `json:"type" required:"true"`
Records []ClusterRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
Warning *qbtypes.QueryWarnData `json:"warning,omitempty"`
}
type ClusterRecord struct {
// TODO(nikhilmantri0902): once the underlying attr key is migrated to
// k8s.cluster.uid (see clusterNameAttrKey TODO in implinframonitoring),
// surface ClusterUID alongside (or replace) ClusterName.
ClusterName string `json:"clusterName" required:"true"`
ClusterCPU float64 `json:"clusterCPU" required:"true"`
ClusterCPUAllocatable float64 `json:"clusterCPUAllocatable" required:"true"`
ClusterMemory float64 `json:"clusterMemory" required:"true"`
ClusterMemoryAllocatable float64 `json:"clusterMemoryAllocatable" required:"true"`
NodeCountsByReadiness NodeCountsByReadiness `json:"nodeCountsByReadiness" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}
// PostableClusters is the request body for the v2 clusters list API.
type PostableClusters struct {
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Filter *qbtypes.Filter `json:"filter"`
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
OrderBy *qbtypes.OrderBy `json:"orderBy"`
Offset int `json:"offset"`
Limit int `json:"limit" required:"true"`
}
// Validate ensures PostableClusters contains acceptable values.
func (req *PostableClusters) Validate() error {
if req == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
if req.Start <= 0 {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid start time %d: start must be greater than 0",
req.Start,
)
}
if req.End <= 0 {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid end time %d: end must be greater than 0",
req.End,
)
}
if req.Start >= req.End {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid time range: start (%d) must be less than end (%d)",
req.Start,
req.End,
)
}
if req.Limit < 1 || req.Limit > 5000 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be between 1 and 5000")
}
if req.Offset < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
}
if req.OrderBy != nil {
if !slices.Contains(ClustersValidOrderByKeys, req.OrderBy.Key.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
}
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
}
}
return nil
}
// UnmarshalJSON validates input immediately after decoding.
func (req *PostableClusters) UnmarshalJSON(data []byte) error {
type raw PostableClusters
var decoded raw
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*req = PostableClusters(decoded)
return req.Validate()
}

View File

@@ -1,15 +0,0 @@
package inframonitoringtypes
const (
ClustersOrderByCPU = "cpu"
ClustersOrderByCPUAllocatable = "cpu_allocatable"
ClustersOrderByMemory = "memory"
ClustersOrderByMemoryAllocatable = "memory_allocatable"
)
var ClustersValidOrderByKeys = []string{
ClustersOrderByCPU,
ClustersOrderByCPUAllocatable,
ClustersOrderByMemory,
ClustersOrderByMemoryAllocatable,
}

View File

@@ -1,291 +0,0 @@
package inframonitoringtypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestPostableClusters_Validate(t *testing.T) {
tests := []struct {
name string
req *PostableClusters
wantErr bool
}{
{
name: "valid request",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
},
wantErr: false,
},
{
name: "nil request",
req: nil,
wantErr: true,
},
{
name: "start time zero",
req: &PostableClusters{
Start: 0,
End: 2000,
Limit: 100,
Offset: 0,
},
wantErr: true,
},
{
name: "start time negative",
req: &PostableClusters{
Start: -1000,
End: 2000,
Limit: 100,
Offset: 0,
},
wantErr: true,
},
{
name: "end time zero",
req: &PostableClusters{
Start: 1000,
End: 0,
Limit: 100,
Offset: 0,
},
wantErr: true,
},
{
name: "start time greater than end time",
req: &PostableClusters{
Start: 2000,
End: 1000,
Limit: 100,
Offset: 0,
},
wantErr: true,
},
{
name: "start time equal to end time",
req: &PostableClusters{
Start: 1000,
End: 1000,
Limit: 100,
Offset: 0,
},
wantErr: true,
},
{
name: "limit zero",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 0,
Offset: 0,
},
wantErr: true,
},
{
name: "limit negative",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: -10,
Offset: 0,
},
wantErr: true,
},
{
name: "limit exceeds max",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 5001,
Offset: 0,
},
wantErr: true,
},
{
name: "offset negative",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: -5,
},
wantErr: true,
},
{
name: "orderBy nil is valid",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
},
wantErr: false,
},
{
name: "orderBy with valid key cpu and direction asc",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: ClustersOrderByCPU,
},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: false,
},
{
name: "orderBy with valid key cpu_allocatable and direction desc",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: ClustersOrderByCPUAllocatable,
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: false,
},
{
name: "orderBy with valid key memory and direction desc",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: ClustersOrderByMemory,
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: false,
},
{
name: "orderBy with valid key memory_allocatable and direction asc",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: ClustersOrderByMemoryAllocatable,
},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: false,
},
{
name: "orderBy with condition key is rejected",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "condition",
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: true,
},
{
name: "orderBy with pod_phase key is rejected",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "pod_phase",
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: true,
},
{
name: "orderBy with invalid key",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "unknown",
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: true,
},
{
name: "orderBy with valid key but invalid direction",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: ClustersOrderByMemory,
},
},
Direction: qbtypes.OrderDirection{String: valuer.NewString("invalid")},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.req.Validate()
if tt.wantErr {
require.Error(t, err)
require.True(t, errors.Ast(err, errors.TypeInvalidInput), "expected error to be of type InvalidInput")
} else {
require.NoError(t, err)
}
})
}
}

View File

@@ -245,6 +245,7 @@ type Store interface {
// Service Account Role
CreateServiceAccountRole(context.Context, *ServiceAccountRole) error
DeleteServiceAccountRoles(context.Context, valuer.UUID) error
DeleteServiceAccountRole(context.Context, valuer.UUID, valuer.UUID) error
// Service Account Factor API Key

View File

@@ -72,7 +72,6 @@ class TraceOperatorQuery:
return_spans_from: str
limit: int | None = None
order: list[OrderBy] | None = None
select_fields: list[TelemetryFieldKey] | None = None
def to_dict(self) -> dict:
spec: dict[str, Any] = {
@@ -84,8 +83,6 @@ class TraceOperatorQuery:
spec["limit"] = self.limit
if self.order:
spec["order"] = [o.to_dict() if hasattr(o, "to_dict") else o for o in self.order]
if self.select_fields:
spec["selectFields"] = [f.to_dict() for f in self.select_fields]
return {"type": "builder_trace_operator", "spec": spec}

View File

@@ -73,30 +73,6 @@ def get_first_key_id(signoz: types.SigNoz, token: str, service_account_id: str)
return resp.json()["data"][0]["id"]
def create_service_account_with_roles(signoz: types.SigNoz, token: str, name: str, roles: list[str]) -> str:
"""Create a service account and assign multiple roles."""
resp = requests.post(
signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE),
json={"name": name},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.CREATED, resp.text
service_account_id = resp.json()["data"]["id"]
for role in roles:
role_id = find_role_by_name(signoz, token, role)
role_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
json={"id": role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert role_resp.status_code == HTTPStatus.NO_CONTENT, role_resp.text
return service_account_id
def find_service_account_by_name(signoz: types.SigNoz, token: str, name: str) -> dict:
"""Find a service account by name from the list endpoint."""
list_resp = requests.get(

View File

@@ -1,537 +0,0 @@
"""
Integration tests for TraceOperatorQuery (builder_trace_operator) through the
/api/v5/query_range endpoint.
Covers:
1. Basic trace operator (A => B) — returns matched spans from the correct trace.
2. Order by a field absent from selectFields — must not return a server error.
Guards against the ClickHouse NOT_FOUND_COLUMN_IN_BLOCK regression where
ordering by a column absent from an outer SELECT caused a query failure.
"""
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
OrderBy,
TelemetryFieldKey,
TraceOperatorQuery,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
def _builder_query(name: str, filter_expr: str, limit: int = 100) -> dict:
return {
"type": "builder_query",
"spec": {
"name": name,
"signal": "traces",
"filter": {"expression": filter_expr},
"limit": limit,
},
}
def test_trace_operator_query_basic(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Setup:
Insert one parent span and one child span in the same trace.
Tests:
A => B (parent has a direct child) returns the parent span (returnSpansFrom=A)
from the correct trace.
"""
parent_trace_id = TraceIdGenerator.trace_id()
parent_span_id = TraceIdGenerator.span_id()
child_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=5),
trace_id=parent_trace_id,
span_id=parent_span_id,
parent_span_id="",
name="parent-op",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-a"},
attributes={"operation.type": "parent"},
),
Traces(
timestamp=now - timedelta(seconds=9),
duration=timedelta(seconds=2),
trace_id=parent_trace_id,
span_id=child_span_id,
parent_span_id=parent_span_id,
name="child-op",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-a"},
attributes={"operation.type": "child"},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[
_builder_query("A", "operation.type = 'parent'"),
_builder_query("B", "operation.type = 'child'"),
TraceOperatorQuery(
name="C",
expression="A => B",
return_spans_from="A",
limit=100,
).to_dict(),
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
rows = results[0].get("rows") or []
assert len(rows) == 1
assert rows[0]["data"]["trace_id"] == parent_trace_id
assert rows[0]["data"]["name"] == "parent-op"
def test_trace_operator_query_order_by_field_not_in_select_fields(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Setup:
Two traces, each with a grandparent → middle → grandchild chain:
Trace 1: grandparent (svc-a, http.method=POST) → middle → grandchild
Trace 2: grandparent (svc-b, http.method=GET) → middle → grandchild
Tests:
A -> B (indirect descendant) with selectFields=[service.name] and
order=[http.method DESC], where http.method is NOT in selectFields.
1. Query succeeds (no NOT_FOUND_COLUMN_IN_BLOCK error from ClickHouse).
2. Results are actually ordered: POST sorts before GET descending, so
svc-a must come before svc-b.
"""
trace_id_1 = TraceIdGenerator.trace_id()
trace_id_2 = TraceIdGenerator.trace_id()
gp_span_id_1 = TraceIdGenerator.span_id()
mid_span_id_1 = TraceIdGenerator.span_id()
gc_span_id_1 = TraceIdGenerator.span_id()
gp_span_id_2 = TraceIdGenerator.span_id()
mid_span_id_2 = TraceIdGenerator.span_id()
gc_span_id_2 = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(
[
# Trace 1 — grandparent has http.method=POST (sorts first in DESC)
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=5),
trace_id=trace_id_1,
span_id=gp_span_id_1,
parent_span_id="",
name="gp-op",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-a"},
attributes={"operation.type": "grandparent", "http.method": "POST"},
),
Traces(
timestamp=now - timedelta(seconds=9),
duration=timedelta(seconds=3),
trace_id=trace_id_1,
span_id=mid_span_id_1,
parent_span_id=gp_span_id_1,
name="mid-op",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-a"},
attributes={"operation.type": "middle"},
),
Traces(
timestamp=now - timedelta(seconds=8),
duration=timedelta(seconds=1),
trace_id=trace_id_1,
span_id=gc_span_id_1,
parent_span_id=mid_span_id_1,
name="gc-op",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-a"},
attributes={"operation.type": "grandchild"},
),
# Trace 2 — grandparent has http.method=GET (sorts second in DESC)
Traces(
timestamp=now - timedelta(seconds=7),
duration=timedelta(seconds=5),
trace_id=trace_id_2,
span_id=gp_span_id_2,
parent_span_id="",
name="gp-op",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-b"},
attributes={"operation.type": "grandparent", "http.method": "GET"},
),
Traces(
timestamp=now - timedelta(seconds=6),
duration=timedelta(seconds=3),
trace_id=trace_id_2,
span_id=mid_span_id_2,
parent_span_id=gp_span_id_2,
name="mid-op",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-b"},
attributes={"operation.type": "middle"},
),
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=1),
trace_id=trace_id_2,
span_id=gc_span_id_2,
parent_span_id=mid_span_id_2,
name="gc-op",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-b"},
attributes={"operation.type": "grandchild"},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[
_builder_query("A", "operation.type = 'grandparent'"),
_builder_query("B", "operation.type = 'grandchild'"),
TraceOperatorQuery(
name="C",
expression="A -> B", # indirect descendant
return_spans_from="A",
limit=100,
select_fields=[
TelemetryFieldKey(name="service.name", field_data_type="string", field_context="resource"),
],
order=[
# http.method is intentionally absent from select_fields
OrderBy(
key=TelemetryFieldKey(name="http.method", field_data_type="string", field_context="attribute"),
direction="desc",
),
],
).to_dict(),
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
rows = results[0].get("rows") or []
# Both grandparent spans must be returned
assert len(rows) == 2
# Ordering: POST > GET in DESC — svc-a (POST) must come before svc-b (GET)
assert rows[0]["data"]["service.name"] == "svc-a", f"Expected svc-a (POST) first in http.method DESC order, got {rows[0]['data']['service.name']}"
assert rows[1]["data"]["service.name"] == "svc-b", f"Expected svc-b (GET) second in http.method DESC order, got {rows[1]['data']['service.name']}"
def test_trace_operator_query_order_by_select_field(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Setup:
Two traces each with a parent → child pair; the parents have distinct durations
(5 s and 1 s).
Tests:
A => B with order=[duration_nano DESC] and no explicit selectFields.
1. Query succeeds (no NOT_FOUND_COLUMN_IN_BLOCK error).
2. Longer-duration parent (5 s) appears before the shorter one (1 s).
"""
trace_id_1 = TraceIdGenerator.trace_id()
trace_id_2 = TraceIdGenerator.trace_id()
parent_span_id_1 = TraceIdGenerator.span_id()
child_span_id_1 = TraceIdGenerator.span_id()
parent_span_id_2 = TraceIdGenerator.span_id()
child_span_id_2 = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=5),
trace_id=trace_id_1,
span_id=parent_span_id_1,
parent_span_id="",
name="parent-long",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-long"},
attributes={"operation.type": "parent"},
),
Traces(
timestamp=now - timedelta(seconds=9),
duration=timedelta(seconds=1),
trace_id=trace_id_1,
span_id=child_span_id_1,
parent_span_id=parent_span_id_1,
name="child-long",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-long"},
attributes={"operation.type": "child"},
),
Traces(
timestamp=now - timedelta(seconds=8),
duration=timedelta(seconds=1),
trace_id=trace_id_2,
span_id=parent_span_id_2,
parent_span_id="",
name="parent-short",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-short"},
attributes={"operation.type": "parent"},
),
Traces(
timestamp=now - timedelta(seconds=7),
duration=timedelta(seconds=1),
trace_id=trace_id_2,
span_id=child_span_id_2,
parent_span_id=parent_span_id_2,
name="child-short",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-short"},
attributes={"operation.type": "child"},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[
_builder_query("A", "operation.type = 'parent'"),
_builder_query("B", "operation.type = 'child'"),
TraceOperatorQuery(
name="C",
expression="A => B",
return_spans_from="A",
limit=100,
order=[
OrderBy(
key=TelemetryFieldKey(name="duration_nano", field_context="span"),
direction="desc",
),
],
).to_dict(),
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
rows = results[0].get("rows") or []
assert len(rows) == 2
# DESC: 5 s parent first, 1 s parent second
assert rows[0]["data"]["name"] == "parent-long", f"Expected parent-long (5s) first in duration_nano DESC, got {rows[0]['data']['name']}"
assert rows[1]["data"]["name"] == "parent-short", f"Expected parent-short (1s) second in duration_nano DESC, got {rows[1]['data']['name']}"
def test_trace_operator_query_order_by_non_core_field_in_select(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Setup:
Two traces each with a parent → child pair; parents have distinct http.method
values (POST and GET).
Tests:
A => B with selectFields=[http.method] and order=[http.method DESC].
1. Query succeeds (no NOT_FOUND_COLUMN_IN_BLOCK error).
2. http.method is present in every result row (it is in selectFields).
3. Results are ordered DESC — POST before GET.
"""
trace_id_1 = TraceIdGenerator.trace_id()
trace_id_2 = TraceIdGenerator.trace_id()
parent_span_id_1 = TraceIdGenerator.span_id()
child_span_id_1 = TraceIdGenerator.span_id()
parent_span_id_2 = TraceIdGenerator.span_id()
child_span_id_2 = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=3),
trace_id=trace_id_1,
span_id=parent_span_id_1,
parent_span_id="",
name="parent-post",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-post"},
attributes={"operation.type": "parent", "http.method": "POST"},
),
Traces(
timestamp=now - timedelta(seconds=9),
duration=timedelta(seconds=1),
trace_id=trace_id_1,
span_id=child_span_id_1,
parent_span_id=parent_span_id_1,
name="child-post",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-post"},
attributes={"operation.type": "child"},
),
Traces(
timestamp=now - timedelta(seconds=8),
duration=timedelta(seconds=3),
trace_id=trace_id_2,
span_id=parent_span_id_2,
parent_span_id="",
name="parent-get",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-get"},
attributes={"operation.type": "parent", "http.method": "GET"},
),
Traces(
timestamp=now - timedelta(seconds=7),
duration=timedelta(seconds=1),
trace_id=trace_id_2,
span_id=child_span_id_2,
parent_span_id=parent_span_id_2,
name="child-get",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-get"},
attributes={"operation.type": "child"},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
http_method_field = TelemetryFieldKey(
name="http.method",
field_data_type="string",
field_context="attribute",
)
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[
_builder_query("A", "operation.type = 'parent'"),
_builder_query("B", "operation.type = 'child'"),
TraceOperatorQuery(
name="C",
expression="A => B",
return_spans_from="A",
limit=100,
select_fields=[http_method_field],
order=[OrderBy(key=http_method_field, direction="desc")],
).to_dict(),
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
rows = results[0].get("rows") or []
assert len(rows) == 2
# http.method must be present in every row (it is in selectFields)
for row in rows:
assert "http.method" in row["data"], f"http.method missing from row: {row['data']}"
# DESC: POST before GET
assert rows[0]["data"]["http.method"] == "POST", f"Expected POST first in http.method DESC, got {rows[0]['data']['http.method']}"
assert rows[1]["data"]["http.method"] == "GET", f"Expected GET second in http.method DESC, got {rows[1]['data']['http.method']}"

View File

@@ -44,13 +44,13 @@ def test_assign_role_to_service_account(
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""POST /{id}/roles adds a role alongside existing ones."""
"""POST /{id}/roles replaces existing role, verify via GET."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# create service account with viewer role
service_account_id = create_service_account(signoz, token, "sa-assign-role", role="signoz-viewer")
# assign editor role (additive — viewer stays)
# assign editor role (replaces viewer)
editor_role_id = find_role_by_name(signoz, token, "signoz-editor")
assign_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
@@ -60,7 +60,7 @@ def test_assign_role_to_service_account(
)
assert assign_resp.status_code == HTTPStatus.NO_CONTENT, assign_resp.text
# verify both viewer and editor roles are present
# verify only editor role is present (viewer was replaced)
roles_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
headers={"Authorization": f"Bearer {token}"},
@@ -68,31 +68,9 @@ def test_assign_role_to_service_account(
)
assert roles_resp.status_code == HTTPStatus.OK, roles_resp.text
role_names = [r["name"] for r in roles_resp.json()["data"]]
assert len(role_names) == 2
assert "signoz-viewer" in role_names
assert len(role_names) == 1
assert "signoz-editor" in role_names
# assign admin role — all three should be present
admin_role_id = find_role_by_name(signoz, token, "signoz-admin")
assign_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
json={"id": admin_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert assign_resp.status_code == HTTPStatus.NO_CONTENT, assign_resp.text
roles_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert roles_resp.status_code == HTTPStatus.OK, roles_resp.text
role_names = [r["name"] for r in roles_resp.json()["data"]]
assert len(role_names) == 3
assert "signoz-viewer" in role_names
assert "signoz-editor" in role_names
assert "signoz-admin" in role_names
assert "signoz-viewer" not in role_names
def test_assign_role_idempotent(
@@ -125,16 +103,16 @@ def test_assign_role_idempotent(
assert role_names.count("signoz-viewer") == 1
def test_assign_role_expands_access(
def test_assign_role_replaces_access(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Adding a higher-privilege role expands the SA's access."""
"""After role replacement, SA loses old permissions and gains new ones."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# create SA with viewer role and an API key
service_account_id, api_key = create_service_account_with_key(signoz, token, "sa-role-expand-access", role="signoz-viewer")
service_account_id, api_key = create_service_account_with_key(signoz, token, "sa-role-replace-access", role="signoz-viewer")
# viewer should get 403 on admin-only endpoint
resp = requests.get(
@@ -144,7 +122,7 @@ def test_assign_role_expands_access(
)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"Expected 403 for viewer on admin endpoint, got {resp.status_code}: {resp.text}"
# assign admin role (additive — viewer stays)
# assign admin role (replaces viewer)
admin_role_id = find_role_by_name(signoz, token, "signoz-admin")
assign_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
@@ -160,9 +138,9 @@ def test_assign_role_expands_access(
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert resp.status_code == HTTPStatus.OK, f"Expected 200 after adding admin role, got {resp.status_code}: {resp.text}"
assert resp.status_code == HTTPStatus.OK, f"Expected 200 for admin on admin endpoint, got {resp.status_code}: {resp.text}"
# verify both roles are present
# verify only admin role is present
roles_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
headers={"Authorization": f"Bearer {token}"},
@@ -170,9 +148,9 @@ def test_assign_role_expands_access(
)
assert roles_resp.status_code == HTTPStatus.OK, roles_resp.text
role_names = [r["name"] for r in roles_resp.json()["data"]]
assert len(role_names) == 2
assert len(role_names) == 1
assert "signoz-admin" in role_names
assert "signoz-viewer" in role_names
assert "signoz-viewer" not in role_names
def test_remove_role_from_service_account(
@@ -180,22 +158,13 @@ def test_remove_role_from_service_account(
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""DELETE /{id}/roles/{rid} revokes one role while keeping others."""
"""DELETE /{id}/roles/{rid} revokes a role."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
service_account_id = create_service_account(signoz, token, "sa-remove-role", role="signoz-editor")
# add admin role (now has editor + admin)
admin_role_id = find_role_by_name(signoz, token, "signoz-admin")
assign_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
json={"id": admin_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert assign_resp.status_code == HTTPStatus.NO_CONTENT, assign_resp.text
# remove editor role
editor_role_id = find_role_by_name(signoz, token, "signoz-editor")
# remove the role
resp = requests.delete(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles/{editor_role_id}"),
headers={"Authorization": f"Bearer {token}"},
@@ -203,7 +172,7 @@ def test_remove_role_from_service_account(
)
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
# verify editor is gone but admin remains
# verify role is gone
roles_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
headers={"Authorization": f"Bearer {token}"},
@@ -212,7 +181,6 @@ def test_remove_role_from_service_account(
assert roles_resp.status_code == HTTPStatus.OK, roles_resp.text
role_names = [r["name"] for r in roles_resp.json()["data"]]
assert "signoz-editor" not in role_names
assert "signoz-admin" in role_names
def test_remove_role_verify_access_lost(