mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-18 16:00:32 +01:00
Compare commits
8 Commits
fix/page-r
...
issue_4522
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6de08530 | ||
|
|
04824cf2f2 | ||
|
|
384c649ef8 | ||
|
|
68693f8ffd | ||
|
|
ca1f92f474 | ||
|
|
1ed3d8fc8c | ||
|
|
196aa301c4 | ||
|
|
51fcc22d8a |
@@ -1,14 +0,0 @@
|
||||
.wrapper {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.errorContent {
|
||||
background: var(--callout-error-background) !important;
|
||||
border-color: var(--callout-error-border) !important;
|
||||
backdrop-filter: blur(15px);
|
||||
border-radius: 4px !important;
|
||||
color: var(--foreground) !important;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { buildPermission } from 'hooks/useAuthZ/utils';
|
||||
import type { AuthZObject, BrandedPermission } from 'hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import AuthZTooltip from './AuthZTooltip';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const noPermissions = {
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: null,
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
|
||||
const TestButton = (
|
||||
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
): ReactElement => (
|
||||
<button type="button" {...props}>
|
||||
Action
|
||||
</button>
|
||||
);
|
||||
|
||||
const createPerm = buildPermission(
|
||||
'create',
|
||||
'serviceaccount:*' as AuthZObject<'create'>,
|
||||
);
|
||||
const attachSAPerm = (id: string): BrandedPermission =>
|
||||
buildPermission('attach', `serviceaccount:${id}` as AuthZObject<'attach'>);
|
||||
const attachRolePerm = buildPermission(
|
||||
'attach',
|
||||
'role:*' as AuthZObject<'attach'>,
|
||||
);
|
||||
|
||||
describe('AuthZTooltip — single check', () => {
|
||||
it('renders child unchanged when permission is granted', () => {
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: { [createPerm]: { isGranted: true } },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
<TestButton />
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Action' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables child when permission is denied', () => {
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: { [createPerm]: { isGranted: false } },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
<TestButton />
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables child while loading', () => {
|
||||
mockUseAuthZ.mockReturnValue({ ...noPermissions, isLoading: true });
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
<TestButton />
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
it('renders child enabled when all checks are granted', () => {
|
||||
const sa = attachSAPerm('sa-1');
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: true },
|
||||
[attachRolePerm]: { isGranted: true },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
<TestButton />
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Action' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables child when first check is denied, second granted', () => {
|
||||
const sa = attachSAPerm('sa-1');
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: true },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
<TestButton />
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables child when both checks are denied and lists denied permissions in data attr', () => {
|
||||
const sa = attachSAPerm('sa-1');
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: false },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
<TestButton />
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
|
||||
|
||||
const wrapper = screen.getByRole('button', { name: 'Action' }).parentElement;
|
||||
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(sa);
|
||||
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(
|
||||
attachRolePerm,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { ReactElement, cloneElement, useMemo } from 'react';
|
||||
import {
|
||||
TooltipRoot,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import type { BrandedPermission } from 'hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { parsePermission } from 'hooks/useAuthZ/utils';
|
||||
import styles from './AuthZTooltip.module.scss';
|
||||
|
||||
interface AuthZTooltipProps {
|
||||
checks: BrandedPermission[];
|
||||
children: ReactElement;
|
||||
enabled?: boolean;
|
||||
tooltipMessage?: string;
|
||||
}
|
||||
|
||||
function formatDeniedMessage(
|
||||
denied: BrandedPermission[],
|
||||
override?: string,
|
||||
): string {
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
const labels = denied.map((p) => {
|
||||
const { relation, object } = parsePermission(p);
|
||||
const resource = object.split(':')[0];
|
||||
return `${relation} ${resource}`;
|
||||
});
|
||||
return labels.length === 1
|
||||
? `You don't have ${labels[0]} permission`
|
||||
: `You don't have ${labels.join(', ')} permissions`;
|
||||
}
|
||||
|
||||
function AuthZTooltip({
|
||||
checks,
|
||||
children,
|
||||
enabled = true,
|
||||
tooltipMessage,
|
||||
}: AuthZTooltipProps): JSX.Element {
|
||||
const shouldCheck = enabled && checks.length > 0;
|
||||
|
||||
const { permissions, isLoading } = useAuthZ(checks, { enabled: shouldCheck });
|
||||
|
||||
const deniedPermissions = useMemo(() => {
|
||||
if (!permissions) {
|
||||
return [];
|
||||
}
|
||||
return checks.filter((p) => permissions[p]?.isGranted === false);
|
||||
}, [checks, permissions]);
|
||||
|
||||
if (shouldCheck && isLoading) {
|
||||
return (
|
||||
<span className={styles.wrapper}>
|
||||
{cloneElement(children, { disabled: true })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!shouldCheck || deniedPermissions.length === 0) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={styles.wrapper}
|
||||
data-denied-permissions={deniedPermissions.join(',')}
|
||||
>
|
||||
{cloneElement(children, { disabled: true })}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={styles.errorContent}>
|
||||
{formatDeniedMessage(deniedPermissions, tooltipMessage)}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthZTooltip;
|
||||
@@ -2,8 +2,6 @@ import { Controller, useForm } from 'react-hook-form';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { SACreatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
@@ -134,19 +132,17 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<AuthZTooltip checks={[SACreatePermission]}>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form="create-sa-form"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Create Service Account
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form="create-sa-form"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Create Service Account
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
|
||||
@@ -11,15 +11,6 @@ import {
|
||||
|
||||
import CreateServiceAccountModal from '../CreateServiceAccountModal';
|
||||
|
||||
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactElement;
|
||||
}): React.ReactElement => children,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
@@ -122,9 +113,7 @@ describe('CreateServiceAccountModal', () => {
|
||||
getErrorMessage: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const passedError = showErrorModal.mock.calls[0][0] as {
|
||||
getErrorMessage: () => string;
|
||||
};
|
||||
const passedError = showErrorModal.mock.calls[0][0] as any;
|
||||
expect(passedError.getErrorMessage()).toBe('Internal Server Error');
|
||||
});
|
||||
|
||||
@@ -143,9 +132,6 @@ describe('CreateServiceAccountModal', () => {
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
await waitForElementToBeRemoved(dialog);
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /New Service Account/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Name is required" after clearing the name field', async () => {
|
||||
@@ -156,8 +142,6 @@ describe('CreateServiceAccountModal', () => {
|
||||
await user.type(nameInput, 'Bot');
|
||||
await user.clear(nameInput);
|
||||
|
||||
await expect(
|
||||
screen.findByText('Name is required'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await screen.findByText('Name is required');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import { ReactElement } from 'react';
|
||||
import {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { BrandedPermission } from 'hooks/useAuthZ/types';
|
||||
import { buildPermission } from 'hooks/useAuthZ/utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
|
||||
|
||||
import { GuardAuthZ } from './GuardAuthZ';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL || '';
|
||||
const AUTHZ_CHECK_URL = `${BASE_URL}/api/v1/authz/check`;
|
||||
|
||||
function authzMockResponse(
|
||||
payload: AuthtypesTransactionDTO[],
|
||||
authorizedByIndex: boolean[],
|
||||
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
|
||||
return {
|
||||
data: payload.map((txn, i) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: authorizedByIndex[i] ?? false,
|
||||
})),
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
describe('GuardAuthZ', () => {
|
||||
const TestChild = (): ReactElement => <div>Protected Content</div>;
|
||||
const LoadingFallback = (): ReactElement => <div>Loading...</div>;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.callout {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import PermissionDeniedCallout from './PermissionDeniedCallout';
|
||||
|
||||
describe('PermissionDeniedCallout', () => {
|
||||
it('renders the permission name in the callout message', () => {
|
||||
render(<PermissionDeniedCallout permissionName="serviceaccount:attach" />);
|
||||
|
||||
expect(screen.getByText(/You don't have/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:attach/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/permission/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts an optional className', () => {
|
||||
const { container } = render(
|
||||
<PermissionDeniedCallout
|
||||
permissionName="serviceaccount:read"
|
||||
className="custom-class"
|
||||
/>,
|
||||
);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import cx from 'classnames';
|
||||
import styles from './PermissionDeniedCallout.module.scss';
|
||||
|
||||
interface PermissionDeniedCalloutProps {
|
||||
permissionName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function PermissionDeniedCallout({
|
||||
permissionName,
|
||||
className,
|
||||
}: PermissionDeniedCalloutProps): JSX.Element {
|
||||
return (
|
||||
<Callout
|
||||
type="error"
|
||||
showIcon
|
||||
size="small"
|
||||
className={cx(styles.callout, className)}
|
||||
>
|
||||
{`You don't have ${permissionName} permission`}
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionDeniedCallout;
|
||||
@@ -1,44 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 50vh;
|
||||
padding: var(--spacing-10);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-2);
|
||||
max-width: 512px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--label-base-500-font-size);
|
||||
font-weight: var(--label-base-500-font-weight);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: var(--label-base-400-font-size);
|
||||
font-weight: var(--label-base-400-font-weight);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.permission {
|
||||
font-family: monospace;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import PermissionDeniedFullPage from './PermissionDeniedFullPage';
|
||||
|
||||
describe('PermissionDeniedFullPage', () => {
|
||||
it('renders the title and subtitle with the permissionName interpolated', () => {
|
||||
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
|
||||
|
||||
expect(
|
||||
screen.getByText("Uh-oh! You don't have permission to view this page."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Please ask your SigNoz administrator to grant access/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with a different permissionName', () => {
|
||||
render(<PermissionDeniedFullPage permissionName="role:read" />);
|
||||
expect(screen.getByText(/role:read/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { CircleSlash2 } from '@signozhq/icons';
|
||||
|
||||
import styles from './PermissionDeniedFullPage.module.scss';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
|
||||
interface PermissionDeniedFullPageProps {
|
||||
permissionName: string;
|
||||
}
|
||||
|
||||
function PermissionDeniedFullPage({
|
||||
permissionName,
|
||||
}: PermissionDeniedFullPageProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<span className={styles.icon}>
|
||||
<CircleSlash2 color={Style.CALLOUT_WARNING_TITLE} size={14} />
|
||||
</span>
|
||||
<p className={styles.title}>
|
||||
Uh-oh! You don't have permission to view this page.
|
||||
</p>
|
||||
<p className={styles.subtitle}>
|
||||
You need <code className={styles.permission}>{permissionName}</code> to
|
||||
view this page. Please ask your SigNoz administrator to grant access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionDeniedFullPage;
|
||||
@@ -80,7 +80,6 @@ interface BaseProps {
|
||||
isError?: boolean;
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SingleProps extends BaseProps {
|
||||
@@ -124,7 +123,6 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
isError = internalError,
|
||||
error = convertToApiError(internalErrorObj),
|
||||
onRefetch = externalRoles === undefined ? internalRefetch : undefined,
|
||||
disabled,
|
||||
} = props;
|
||||
|
||||
const notFoundContent = isError ? (
|
||||
@@ -153,7 +151,6 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
</Checkbox>
|
||||
)}
|
||||
getPopupContainer={getPopupContainer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -171,7 +168,6 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
notFoundContent={notFoundContent}
|
||||
options={options}
|
||||
getPopupContainer={getPopupContainer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,6 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
APIKeyCreatePermission,
|
||||
buildSAAttachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { disabledDate } from '../utils';
|
||||
@@ -23,7 +18,6 @@ export interface KeyFormPhaseProps {
|
||||
isValid: boolean;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
function KeyFormPhase({
|
||||
@@ -34,7 +28,6 @@ function KeyFormPhase({
|
||||
isValid,
|
||||
onSubmit,
|
||||
onClose,
|
||||
accountId,
|
||||
}: KeyFormPhaseProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
@@ -118,25 +111,17 @@ function KeyFormPhase({
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
APIKeyCreatePermission,
|
||||
buildSAAttachPermission(accountId ?? ''),
|
||||
]}
|
||||
enabled={!!accountId}
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Create Key
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -161,7 +161,6 @@ function AddKeyModal(): JSX.Element {
|
||||
isValid={isValid}
|
||||
onSubmit={handleSubmit(handleCreate)}
|
||||
onClose={handleClose}
|
||||
accountId={accountId ?? undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { buildSADeletePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
@@ -67,7 +65,7 @@ function DeleteAccountModal(): JSX.Element {
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
void setIsDeleteOpen(null);
|
||||
setIsDeleteOpen(null);
|
||||
}
|
||||
|
||||
const content = (
|
||||
@@ -84,20 +82,15 @@ function DeleteAccountModal(): JSX.Element {
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<AuthZTooltip
|
||||
checks={[buildSADeletePermission(accountId ?? '')]}
|
||||
enabled={!!accountId}
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<Trash2 size={12} />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -7,12 +7,6 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
buildAPIKeyDeletePermission,
|
||||
buildAPIKeyUpdatePermission,
|
||||
buildSADetachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { disabledDate, formatLastObservedAt } from '../utils';
|
||||
@@ -30,8 +24,6 @@ export interface EditKeyFormProps {
|
||||
onClose: () => void;
|
||||
onRevokeClick: () => void;
|
||||
formatTimezoneAdjustedTimestamp: (ts: string, format: string) => string;
|
||||
canUpdate?: boolean;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
function EditKeyForm({
|
||||
@@ -45,8 +37,6 @@ function EditKeyForm({
|
||||
onClose,
|
||||
onRevokeClick,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
canUpdate = true,
|
||||
accountId = '',
|
||||
}: EditKeyFormProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
@@ -55,34 +45,12 @@ function EditKeyForm({
|
||||
<label className="edit-key-modal__label" htmlFor="edit-key-name">
|
||||
Name
|
||||
</label>
|
||||
{!canUpdate ? (
|
||||
<AuthZTooltip
|
||||
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
|
||||
enabled={!!keyItem?.id}
|
||||
>
|
||||
<div className="edit-key-modal__key-display">
|
||||
<span className="edit-key-modal__id-text">{keyItem?.name || '—'}</span>
|
||||
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
|
||||
</div>
|
||||
</AuthZTooltip>
|
||||
) : (
|
||||
<Input
|
||||
id="edit-key-name"
|
||||
className="edit-key-modal__input"
|
||||
placeholder="Enter key name"
|
||||
{...register('name')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="edit-key-modal__field">
|
||||
<label className="edit-key-modal__label" htmlFor="edit-key-id">
|
||||
ID
|
||||
</label>
|
||||
<div id="edit-key-id" className="edit-key-modal__key-display">
|
||||
<span className="edit-key-modal__id-text">{keyItem?.id || '—'}</span>
|
||||
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
|
||||
</div>
|
||||
<Input
|
||||
id="edit-key-name"
|
||||
className="edit-key-modal__input"
|
||||
placeholder="Enter key name"
|
||||
{...register('name')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="edit-key-modal__field">
|
||||
@@ -105,22 +73,21 @@ function EditKeyForm({
|
||||
type="single"
|
||||
value={field.value}
|
||||
onChange={(val): void => {
|
||||
if (val && canUpdate) {
|
||||
if (val) {
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
className="edit-key-modal__expiry-toggle"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={ExpiryMode.NONE}
|
||||
disabled={!canUpdate}
|
||||
className="edit-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
No Expiration
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={ExpiryMode.DATE}
|
||||
disabled={!canUpdate}
|
||||
className="edit-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
Set Expiration Date
|
||||
@@ -147,7 +114,6 @@ function EditKeyForm({
|
||||
popupClassName="edit-key-modal-datepicker-popup"
|
||||
getPopupContainer={popupContainer}
|
||||
disabledDate={disabledDate}
|
||||
disabled={!canUpdate}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -167,39 +133,26 @@ function EditKeyForm({
|
||||
</form>
|
||||
|
||||
<div className="edit-key-modal__footer">
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
buildAPIKeyDeletePermission(keyItem?.id ?? ''),
|
||||
buildSADetachPermission(accountId ?? ''),
|
||||
]}
|
||||
enabled={!!accountId && !!keyItem?.id}
|
||||
>
|
||||
<Button variant="link" color="destructive" onClick={onRevokeClick}>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<Button variant="link" color="destructive" onClick={onRevokeClick}>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
<div className="edit-key-modal__footer-right">
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<AuthZTooltip
|
||||
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
|
||||
enabled={!!accountId && !!keyItem?.id}
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -60,16 +60,6 @@
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
&__id-text {
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__lock-icon {
|
||||
color: var(--foreground);
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -16,8 +16,6 @@ import type {
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { buildAPIKeyUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
@@ -71,16 +69,6 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
|
||||
const expiryMode = watch('expiryMode');
|
||||
|
||||
const { permissions: editPermissions, isLoading: isAuthZLoading } = useAuthZ(
|
||||
editKeyId ? [buildAPIKeyUpdatePermission(editKeyId)] : [],
|
||||
{ enabled: !!editKeyId },
|
||||
);
|
||||
|
||||
const canUpdate = isAuthZLoading
|
||||
? false
|
||||
: (editPermissions?.[buildAPIKeyUpdatePermission(editKeyId ?? '')]
|
||||
?.isGranted ?? true);
|
||||
|
||||
const { mutate: updateKey, isLoading: isSaving } = useUpdateServiceAccountKey({
|
||||
mutation: {
|
||||
onSuccess: async () => {
|
||||
@@ -127,7 +115,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
});
|
||||
|
||||
function handleClose(): void {
|
||||
void setEditKeyId(null);
|
||||
setEditKeyId(null);
|
||||
setIsRevokeConfirmOpen(false);
|
||||
}
|
||||
|
||||
@@ -181,8 +169,6 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
isRevoking={isRevoking}
|
||||
onCancel={(): void => setIsRevokeConfirmOpen(false)}
|
||||
onConfirm={handleRevoke}
|
||||
accountId={selectedAccountId ?? undefined}
|
||||
keyId={keyItem?.id ?? undefined}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -204,8 +190,6 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
onClose={handleClose}
|
||||
onRevokeClick={(): void => setIsRevokeConfirmOpen(true)}
|
||||
formatTimezoneAdjustedTimestamp={formatTimezoneAdjustedTimestamp}
|
||||
canUpdate={canUpdate}
|
||||
accountId={selectedAccountId ?? ''}
|
||||
/>
|
||||
)}
|
||||
</DialogWrapper>
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { KeyRound, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton, Table } from 'antd';
|
||||
import { Skeleton, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
APIKeyCreatePermission,
|
||||
buildAPIKeyDeletePermission,
|
||||
buildSAAttachPermission,
|
||||
buildSADetachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs';
|
||||
@@ -24,15 +17,12 @@ interface KeysTabProps {
|
||||
keys: ServiceaccounttypesGettableFactorAPIKeyDTO[];
|
||||
isLoading: boolean;
|
||||
isDisabled?: boolean;
|
||||
canUpdate?: boolean;
|
||||
accountId?: string;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface BuildColumnsParams {
|
||||
isDisabled: boolean;
|
||||
accountId: string;
|
||||
onRevokeClick: (keyId: string) => void;
|
||||
handleformatLastObservedAt: (
|
||||
lastObservedAt: Date | null | undefined,
|
||||
@@ -52,7 +42,6 @@ function formatExpiry(expiresAt: number): JSX.Element {
|
||||
|
||||
function buildColumns({
|
||||
isDisabled,
|
||||
accountId,
|
||||
onRevokeClick,
|
||||
handleformatLastObservedAt,
|
||||
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesGettableFactorAPIKeyDTO> {
|
||||
@@ -103,34 +92,22 @@ function buildColumns({
|
||||
key: 'action',
|
||||
width: 48,
|
||||
align: 'right' as const,
|
||||
onCell: (): {
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
style: React.CSSProperties;
|
||||
} => ({
|
||||
onClick: (e): void => e.stopPropagation(),
|
||||
style: { cursor: 'default' },
|
||||
}),
|
||||
render: (_, record): JSX.Element => (
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
buildAPIKeyDeletePermission(record.id),
|
||||
buildSADetachPermission(accountId),
|
||||
]}
|
||||
enabled={!isDisabled && !!accountId}
|
||||
>
|
||||
<Tooltip title={isDisabled ? 'Service account disabled' : 'Revoke Key'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={(): void => {
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onRevokeClick(record.id);
|
||||
}}
|
||||
className="keys-tab__revoke-btn"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -140,7 +117,6 @@ function KeysTab({
|
||||
keys,
|
||||
isLoading,
|
||||
isDisabled = false,
|
||||
accountId = '',
|
||||
currentPage,
|
||||
pageSize,
|
||||
}: KeysTabProps): JSX.Element {
|
||||
@@ -167,20 +143,14 @@ function KeysTab({
|
||||
|
||||
const onRevokeClick = useCallback(
|
||||
(keyId: string): void => {
|
||||
void setRevokeKeyId(keyId);
|
||||
setRevokeKeyId(keyId);
|
||||
},
|
||||
[setRevokeKeyId],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
buildColumns({
|
||||
isDisabled,
|
||||
accountId,
|
||||
onRevokeClick,
|
||||
handleformatLastObservedAt,
|
||||
}),
|
||||
[isDisabled, accountId, onRevokeClick, handleformatLastObservedAt],
|
||||
() => buildColumns({ isDisabled, onRevokeClick, handleformatLastObservedAt }),
|
||||
[isDisabled, onRevokeClick, handleformatLastObservedAt],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -206,21 +176,16 @@ function KeysTab({
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
<AuthZTooltip
|
||||
checks={[APIKeyCreatePermission, buildSAAttachPermission(accountId)]}
|
||||
enabled={!isDisabled && !!accountId}
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsAddKeyOpen(true);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsAddKeyOpen(true);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
+ Add your first key
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
+ Add your first key
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,9 @@ import { LockKeyhole } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { buildSAUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
@@ -21,7 +19,6 @@ interface OverviewTabProps {
|
||||
localRoles: string[];
|
||||
onRolesChange: (v: string[]) => void;
|
||||
isDisabled: boolean;
|
||||
canUpdate?: boolean;
|
||||
availableRoles: AuthtypesRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
rolesError?: boolean;
|
||||
@@ -37,7 +34,6 @@ function OverviewTab({
|
||||
localRoles,
|
||||
onRolesChange,
|
||||
isDisabled,
|
||||
canUpdate = true,
|
||||
availableRoles,
|
||||
rolesLoading,
|
||||
rolesError,
|
||||
@@ -67,16 +63,11 @@ function OverviewTab({
|
||||
<label className="sa-drawer__label" htmlFor="sa-name">
|
||||
Name
|
||||
</label>
|
||||
{isDisabled || !canUpdate ? (
|
||||
<AuthZTooltip
|
||||
checks={[buildSAUpdatePermission(account.id)]}
|
||||
enabled={!isDisabled && !canUpdate}
|
||||
>
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{localName || '—'}</span>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
</AuthZTooltip>
|
||||
{isDisabled ? (
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{localName || '—'}</span>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id="sa-name"
|
||||
@@ -87,16 +78,6 @@ function OverviewTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__field">
|
||||
<label className="sa-drawer__label" htmlFor="sa-id">
|
||||
ID
|
||||
</label>
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{account.id || '—'}</span>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__field">
|
||||
<label className="sa-drawer__label" htmlFor="sa-email">
|
||||
Email Address
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
buildAPIKeyDeletePermission,
|
||||
buildSADetachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
@@ -28,16 +23,12 @@ export interface RevokeKeyFooterProps {
|
||||
isRevoking: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
accountId?: string;
|
||||
keyId?: string;
|
||||
}
|
||||
|
||||
export function RevokeKeyFooter({
|
||||
isRevoking,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
accountId,
|
||||
keyId,
|
||||
}: RevokeKeyFooterProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
@@ -45,23 +36,15 @@ export function RevokeKeyFooter({
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
buildAPIKeyDeletePermission(keyId ?? ''),
|
||||
buildSADetachPermission(accountId ?? ''),
|
||||
]}
|
||||
enabled={!!accountId && !!keyId}
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isRevoking}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isRevoking}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -132,8 +115,6 @@ function RevokeKeyModal(): JSX.Element {
|
||||
isRevoking={isRevoking}
|
||||
onCancel={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
accountId={accountId ?? undefined}
|
||||
keyId={revokeKeyId || undefined}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -16,8 +16,6 @@ import {
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { GuardAuthZ } from 'components/GuardAuthZ/GuardAuthZ';
|
||||
import PermissionDeniedCallout from 'components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import {
|
||||
@@ -29,15 +27,6 @@ import {
|
||||
RoleUpdateFailure,
|
||||
useServiceAccountRoleManager,
|
||||
} from 'hooks/serviceAccount/useServiceAccountRoleManager';
|
||||
import {
|
||||
APIKeyCreatePermission,
|
||||
APIKeyListPermission,
|
||||
buildSAAttachPermission,
|
||||
buildSADeletePermission,
|
||||
buildSAReadPermission,
|
||||
buildSAUpdatePermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
@@ -48,7 +37,6 @@ import {
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
import DeleteAccountModal from './DeleteAccountModal';
|
||||
import KeysTab from './KeysTab';
|
||||
@@ -108,22 +96,6 @@ function ServiceAccountDrawer({
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { permissions: drawerPermissions, isLoading: isAuthZLoading } = useAuthZ(
|
||||
selectedAccountId
|
||||
? [
|
||||
buildSAReadPermission(selectedAccountId),
|
||||
buildSAUpdatePermission(selectedAccountId),
|
||||
buildSADeletePermission(selectedAccountId),
|
||||
APIKeyListPermission,
|
||||
]
|
||||
: [],
|
||||
{ enabled: !!selectedAccountId },
|
||||
);
|
||||
|
||||
const canRead =
|
||||
drawerPermissions?.[buildSAReadPermission(selectedAccountId ?? '')]
|
||||
?.isGranted ?? false;
|
||||
|
||||
const {
|
||||
data: accountData,
|
||||
isLoading: isAccountLoading,
|
||||
@@ -132,7 +104,7 @@ function ServiceAccountDrawer({
|
||||
refetch: refetchAccount,
|
||||
} = useGetServiceAccount(
|
||||
{ id: selectedAccountId ?? '' },
|
||||
{ query: { enabled: canRead && !!selectedAccountId } },
|
||||
{ query: { enabled: !!selectedAccountId } },
|
||||
);
|
||||
|
||||
const account = useMemo(
|
||||
@@ -145,9 +117,7 @@ function ServiceAccountDrawer({
|
||||
currentRoles,
|
||||
isLoading: isRolesLoading,
|
||||
applyDiff,
|
||||
} = useServiceAccountRoleManager(selectedAccountId ?? '', {
|
||||
enabled: canRead && !!selectedAccountId,
|
||||
});
|
||||
} = useServiceAccountRoleManager(selectedAccountId ?? '');
|
||||
|
||||
const roleSessionRef = useRef<string | null>(null);
|
||||
|
||||
@@ -195,16 +165,9 @@ function ServiceAccountDrawer({
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
|
||||
const canListKeys =
|
||||
drawerPermissions?.[APIKeyListPermission]?.isGranted ?? false;
|
||||
|
||||
const canUpdate =
|
||||
drawerPermissions?.[buildSAUpdatePermission(selectedAccountId ?? '')]
|
||||
?.isGranted ?? true;
|
||||
|
||||
const { data: keysData, isLoading: keysLoading } = useListServiceAccountKeys(
|
||||
{ id: selectedAccountId ?? '' },
|
||||
{ query: { enabled: !!selectedAccountId && canListKeys } },
|
||||
{ query: { enabled: !!selectedAccountId } },
|
||||
);
|
||||
const keys = keysData?.data ?? [];
|
||||
|
||||
@@ -429,26 +392,18 @@ function ServiceAccountDrawer({
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && (
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
APIKeyCreatePermission,
|
||||
buildSAAttachPermission(selectedAccountId ?? ''),
|
||||
]}
|
||||
enabled={!isDeleted && !!selectedAccountId}
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
disabled={isDeleted}
|
||||
onClick={(): void => {
|
||||
void setIsAddKeyOpen(true);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
disabled={isDeleted}
|
||||
onClick={(): void => {
|
||||
void setIsAddKeyOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add Key
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<Plus size={12} />
|
||||
Add Key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -457,9 +412,7 @@ function ServiceAccountDrawer({
|
||||
activeTab === ServiceAccountDrawerTab.Keys ? ' sa-drawer__body--keys' : ''
|
||||
}`}
|
||||
>
|
||||
{(isAuthZLoading || isAccountLoading) && (
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
)}
|
||||
{isAccountLoading && <Skeleton active paragraph={{ rows: 6 }} />}
|
||||
{isAccountError && (
|
||||
<ErrorInPlace
|
||||
error={toAPIError(
|
||||
@@ -468,55 +421,38 @@ function ServiceAccountDrawer({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!isAuthZLoading &&
|
||||
!isAccountLoading &&
|
||||
!isAccountError &&
|
||||
selectedAccountId && (
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
object={`serviceaccount:${selectedAccountId}`}
|
||||
fallbackOnNoPermissions={(): JSX.Element => (
|
||||
<PermissionDeniedCallout permissionName="serviceaccount:read" />
|
||||
)}
|
||||
>
|
||||
<>
|
||||
{activeTab === ServiceAccountDrawerTab.Overview && account && (
|
||||
<OverviewTab
|
||||
account={account}
|
||||
localName={localName}
|
||||
onNameChange={handleNameChange}
|
||||
localRoles={localRoles}
|
||||
onRolesChange={(roles): void => {
|
||||
setLocalRoles(roles);
|
||||
clearRoleErrors();
|
||||
}}
|
||||
isDisabled={isDeleted}
|
||||
canUpdate={canUpdate}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
rolesError={rolesError}
|
||||
rolesErrorObj={rolesErrorObj}
|
||||
onRefetchRoles={refetchRoles}
|
||||
saveErrors={saveErrors}
|
||||
/>
|
||||
)}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys &&
|
||||
(canListKeys ? (
|
||||
<KeysTab
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDeleted}
|
||||
canUpdate={canUpdate}
|
||||
accountId={selectedAccountId}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
) : (
|
||||
<PermissionDeniedCallout permissionName="factor-api-key:list" />
|
||||
))}
|
||||
</>
|
||||
</GuardAuthZ>
|
||||
)}
|
||||
{!isAccountLoading && !isAccountError && (
|
||||
<>
|
||||
{activeTab === ServiceAccountDrawerTab.Overview && account && (
|
||||
<OverviewTab
|
||||
account={account}
|
||||
localName={localName}
|
||||
onNameChange={handleNameChange}
|
||||
localRoles={localRoles}
|
||||
onRolesChange={(roles): void => {
|
||||
setLocalRoles(roles);
|
||||
clearRoleErrors();
|
||||
}}
|
||||
isDisabled={isDeleted}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
rolesError={rolesError}
|
||||
rolesErrorObj={rolesErrorObj}
|
||||
onRefetchRoles={refetchRoles}
|
||||
saveErrors={saveErrors}
|
||||
/>
|
||||
)}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && (
|
||||
<KeysTab
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDeleted}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -546,21 +482,16 @@ function ServiceAccountDrawer({
|
||||
) : (
|
||||
<>
|
||||
{!isDeleted && (
|
||||
<AuthZTooltip
|
||||
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
|
||||
enabled={!!selectedAccountId}
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={(): void => {
|
||||
void setIsDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={(): void => {
|
||||
void setIsDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete Service Account
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<Trash2 size={12} />
|
||||
Delete Service Account
|
||||
</Button>
|
||||
)}
|
||||
{!isDeleted && (
|
||||
<div className="sa-drawer__footer-right">
|
||||
|
||||
@@ -6,15 +6,6 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import EditKeyModal from '../EditKeyModal';
|
||||
|
||||
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactElement;
|
||||
}): React.ReactElement => children,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
@@ -28,7 +19,7 @@ const mockKey: ServiceaccounttypesGettableFactorAPIKeyDTO = {
|
||||
id: 'key-1',
|
||||
name: 'Original Key Name',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null as unknown as Date,
|
||||
lastObservedAt: null as any,
|
||||
serviceAccountId: 'sa-1',
|
||||
};
|
||||
|
||||
|
||||
@@ -6,15 +6,6 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import KeysTab from '../KeysTab';
|
||||
|
||||
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactElement;
|
||||
}): React.ReactElement => children,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
@@ -29,7 +20,7 @@ const keys: ServiceaccounttypesGettableFactorAPIKeyDTO[] = [
|
||||
id: 'key-1',
|
||||
name: 'Production Key',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null as unknown as Date,
|
||||
lastObservedAt: null as any,
|
||||
serviceAccountId: 'sa-1',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDeny,
|
||||
setupAuthzDenyAll,
|
||||
} from 'tests/authz-test-utils';
|
||||
import {
|
||||
APIKeyListPermission,
|
||||
buildSADeletePermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
|
||||
import ServiceAccountDrawer from '../ServiceAccountDrawer';
|
||||
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
|
||||
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
|
||||
const SA_DELETE_ENDPOINT = '*/api/v1/service_accounts/sa-1';
|
||||
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
|
||||
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
|
||||
|
||||
const activeAccountResponse = {
|
||||
id: 'sa-1',
|
||||
name: 'CI Bot',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
jest.mock('@signozhq/ui/drawer', () => ({
|
||||
...jest.requireActual('@signozhq/ui/drawer'),
|
||||
DrawerWrapper: ({
|
||||
children,
|
||||
footer,
|
||||
open,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
function renderDrawer(
|
||||
searchParams: Record<string, string> = { account: 'sa-1' },
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
|
||||
<ServiceAccountDrawer onSuccess={jest.fn()} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
function setupBaseHandlers(): void {
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.get(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: activeAccountResponse })),
|
||||
),
|
||||
rest.put(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: listRolesSuccessResponse.data.filter(
|
||||
(r) => r.name === 'signoz-admin',
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('ServiceAccountDrawer — permissions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
setupBaseHandlers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('shows PermissionDeniedCallout inside drawer when read permission is denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/serviceaccount:read/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows drawer content when read permission is granted', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
expect(screen.queryByText(/serviceaccount:read/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows PermissionDeniedCallout in Keys tab when list-keys permission is denied', async () => {
|
||||
server.use(setupAuthzDeny(APIKeyListPermission));
|
||||
|
||||
renderDrawer();
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /keys/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/factor-api-key:list/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables Delete button when delete permission is denied', async () => {
|
||||
server.use(setupAuthzDeny(buildSADeletePermission('sa-1')));
|
||||
|
||||
renderDrawer();
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
const deleteBtn = screen.getByRole('button', {
|
||||
name: /Delete Service Account/i,
|
||||
});
|
||||
await waitFor(() => expect(deleteBtn).toBeDisabled());
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { setupAuthzAdmin } from 'tests/authz-test-utils';
|
||||
|
||||
import ServiceAccountDrawer from '../ServiceAccountDrawer';
|
||||
|
||||
@@ -99,7 +98,6 @@ describe('ServiceAccountDrawer', () => {
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
setupAuthzAdmin(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -302,6 +300,13 @@ describe('ServiceAccountDrawer', () => {
|
||||
await screen.findByText(/No keys/i);
|
||||
});
|
||||
|
||||
it('shows skeleton while loading account data', () => {
|
||||
renderDrawer();
|
||||
|
||||
// Skeleton renders while the fetch is in-flight
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state when account fetch fails', async () => {
|
||||
server.use(
|
||||
rest.get(SA_ENDPOINT, (_, res, ctx) =>
|
||||
@@ -354,7 +359,6 @@ describe('ServiceAccountDrawer – save-error UX', () => {
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
setupAuthzAdmin(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -625,7 +625,6 @@ function TanStackTableInner<TData>(
|
||||
defaultValue="10"
|
||||
onChange={(value): void => {
|
||||
setLimit(+value);
|
||||
setPage(1);
|
||||
pagination.onLimitChange?.(+value);
|
||||
}}
|
||||
items={paginationPageSizeItems}
|
||||
|
||||
@@ -401,59 +401,6 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(onLimitChange).toHaveBeenCalledWith(20);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets page to 1 when limit changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
|
||||
enableQueryParams: true,
|
||||
},
|
||||
onUrlUpdate,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Navigate to page 2
|
||||
const nav = screen.getByRole('navigation');
|
||||
const page2 = Array.from(nav.querySelectorAll('button')).find(
|
||||
(btn) => btn.textContent?.trim() === '2',
|
||||
);
|
||||
if (!page2) {
|
||||
throw new Error('Page 2 button not found in pagination');
|
||||
}
|
||||
await user.click(page2);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastPage = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('page'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastPage).toBe('2');
|
||||
});
|
||||
|
||||
// Change page size
|
||||
const comboboxTrigger = document.querySelector(
|
||||
'button[aria-haspopup="dialog"]',
|
||||
) as HTMLElement;
|
||||
await user.click(comboboxTrigger);
|
||||
|
||||
const option20 = await screen.findByRole('option', { name: '20' });
|
||||
await user.click(option20);
|
||||
|
||||
// Verify page reset to 1
|
||||
await waitFor(() => {
|
||||
const lastPage = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('page'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastPage).toBe('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sorting', () => {
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import { ReactElement } from 'react';
|
||||
import type { RouteComponentProps } from 'react-router-dom';
|
||||
import type {
|
||||
import {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
|
||||
|
||||
import { createGuardedRoute } from './createGuardedRoute';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL || '';
|
||||
const AUTHZ_CHECK_URL = `${BASE_URL}/api/v1/authz/check`;
|
||||
|
||||
function authzMockResponse(
|
||||
payload: AuthtypesTransactionDTO[],
|
||||
authorizedByIndex: boolean[],
|
||||
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
|
||||
return {
|
||||
data: payload.map((txn, i) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: authorizedByIndex[i] ?? false,
|
||||
})),
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
describe('createGuardedRoute', () => {
|
||||
const TestComponent = ({ testProp }: { testProp: string }): ReactElement => (
|
||||
<div>Test Component: {testProp}</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ function OnNoPermissionsFallback(response: {
|
||||
<br />
|
||||
Object: <span>{object}</span>
|
||||
<br />
|
||||
Please ask your SigNoz administrator to grant access.
|
||||
Ask your SigNoz administrator to grant access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Input as AntdInput } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ArrowRight } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import { OnboardingQuestionHeader } from '../OnboardingQuestionHeader';
|
||||
|
||||
@@ -33,31 +32,11 @@ const interestedInOptions: Record<string, string> = {
|
||||
openSourceTooling: 'Prefer open-source tooling',
|
||||
};
|
||||
|
||||
function seededShuffle<T>(array: T[], seed: string): T[] {
|
||||
const result = [...array];
|
||||
|
||||
let num = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
num = Math.imul(num + seed.charCodeAt(i), 2654435761);
|
||||
num = Math.abs(num);
|
||||
}
|
||||
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
num = Math.abs(Math.imul(num, 1664525) + 1013904223);
|
||||
const j = num % (i + 1);
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function AboutSigNozQuestions({
|
||||
signozDetails,
|
||||
setSignozDetails,
|
||||
onNext,
|
||||
}: AboutSigNozQuestionsProps): JSX.Element {
|
||||
const { versionData } = useAppContext();
|
||||
|
||||
const [interestInSignoz, setInterestInSignoz] = useState<string[]>(
|
||||
signozDetails?.interestInSignoz || [],
|
||||
);
|
||||
@@ -69,12 +48,6 @@ export function AboutSigNozQuestions({
|
||||
);
|
||||
const [isNextDisabled, setIsNextDisabled] = useState<boolean>(true);
|
||||
|
||||
const shuffledOptionKeys = useMemo(
|
||||
() =>
|
||||
seededShuffle(Object.keys(interestedInOptions), versionData?.version ?? ''),
|
||||
[versionData?.version],
|
||||
);
|
||||
|
||||
useEffect((): void => {
|
||||
if (
|
||||
discoverSignoz !== '' &&
|
||||
@@ -142,7 +115,7 @@ export function AboutSigNozQuestions({
|
||||
<div className="form-group">
|
||||
<div className="question">What got you interested in SigNoz?</div>
|
||||
<div className="checkbox-grid">
|
||||
{shuffledOptionKeys.map((option: string) => (
|
||||
{Object.keys(interestedInOptions).map((option: string) => (
|
||||
<div key={option} className="checkbox-item">
|
||||
<Checkbox
|
||||
id={`checkbox-${option}`}
|
||||
|
||||
@@ -29,6 +29,18 @@
|
||||
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;
|
||||
@@ -155,6 +167,7 @@
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&__body {
|
||||
|
||||
@@ -25,13 +25,10 @@ import { PermissionScope } from './PermissionSidePanel.types';
|
||||
|
||||
import './PermissionSidePanel.styles.scss';
|
||||
|
||||
const RELATIONS_ALL_ONLY = new Set(['list', 'create']);
|
||||
|
||||
interface ResourceRowProps {
|
||||
resource: ResourceDefinition;
|
||||
config: ResourceConfig;
|
||||
isExpanded: boolean;
|
||||
relation: string;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onScopeChange: (id: string, scope: ScopeType) => void;
|
||||
onSelectedIdsChange: (id: string, ids: string[]) => void;
|
||||
@@ -41,12 +38,10 @@ function ResourceRow({
|
||||
resource,
|
||||
config,
|
||||
isExpanded,
|
||||
relation,
|
||||
onToggleExpand,
|
||||
onScopeChange,
|
||||
onSelectedIdsChange,
|
||||
}: ResourceRowProps): JSX.Element {
|
||||
const showOnlySelected = !RELATIONS_ALL_ONLY.has(relation);
|
||||
return (
|
||||
<div className="psp-resource">
|
||||
<div
|
||||
@@ -83,40 +78,36 @@ function ResourceRow({
|
||||
<RadioGroupLabel htmlFor={`${resource.id}-all`}>All</RadioGroupLabel>
|
||||
</div>
|
||||
|
||||
{showOnlySelected && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="psp-resource__radio-item">
|
||||
<RadioGroupItem
|
||||
value={PermissionScope.NONE}
|
||||
id={`${resource.id}-none`}
|
||||
value={PermissionScope.ONLY_SELECTED}
|
||||
id={`${resource.id}-only-selected`}
|
||||
/>
|
||||
<RadioGroupLabel htmlFor={`${resource.id}-none`}>None</RadioGroupLabel>
|
||||
<RadioGroupLabel htmlFor={`${resource.id}-only-selected`}>
|
||||
Only selected
|
||||
</RadioGroupLabel>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{config.scope === PermissionScope.ONLY_SELECTED && showOnlySelected && (
|
||||
{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"
|
||||
open={false}
|
||||
allowClear
|
||||
suffixIcon={null}
|
||||
value={config.selectedIds}
|
||||
onChange={(vals: string[]): void =>
|
||||
onSelectedIdsChange(resource.id, vals)
|
||||
}
|
||||
placeholder="Type and press Enter to add..."
|
||||
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>
|
||||
)}
|
||||
@@ -130,12 +121,10 @@ function PermissionSidePanel({
|
||||
open,
|
||||
onClose,
|
||||
permissionLabel,
|
||||
relation,
|
||||
resources,
|
||||
initialConfig,
|
||||
isLoading = false,
|
||||
isSaving = false,
|
||||
canEdit = true,
|
||||
onSave,
|
||||
}: PermissionSidePanelProps): JSX.Element | null {
|
||||
const [config, setConfig] = useState<PermissionConfig>(() =>
|
||||
@@ -224,13 +213,13 @@ function PermissionSidePanel({
|
||||
<div className="permission-side-panel">
|
||||
<div className="permission-side-panel__header">
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="permission-side-panel__close"
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<X size={14} />
|
||||
<X size={16} />
|
||||
</Button>
|
||||
<span className="permission-side-panel__header-divider" />
|
||||
<span className="permission-side-panel__title">
|
||||
@@ -249,7 +238,6 @@ function PermissionSidePanel({
|
||||
resource={resource}
|
||||
config={config[resource.id] ?? DEFAULT_RESOURCE_CONFIG}
|
||||
isExpanded={expandedIds.has(resource.id)}
|
||||
relation={relation}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onScopeChange={handleScopeChange}
|
||||
onSelectedIdsChange={handleSelectedIdsChange}
|
||||
@@ -286,7 +274,7 @@ function PermissionSidePanel({
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
disabled={isLoading || unsavedCount === 0 || !canEdit}
|
||||
disabled={isLoading || unsavedCount === 0}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
|
||||
@@ -5,8 +5,6 @@ export interface ResourceOption {
|
||||
|
||||
export interface ResourceDefinition {
|
||||
id: string;
|
||||
kind: string;
|
||||
type: string;
|
||||
label: string;
|
||||
options?: ResourceOption[];
|
||||
}
|
||||
@@ -14,7 +12,6 @@ export interface ResourceDefinition {
|
||||
export enum PermissionScope {
|
||||
ALL = 'all',
|
||||
ONLY_SELECTED = 'only_selected',
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
export type ScopeType = PermissionScope;
|
||||
@@ -30,11 +27,9 @@ export interface PermissionSidePanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
permissionLabel: string;
|
||||
relation: string;
|
||||
resources: ResourceDefinition[];
|
||||
initialConfig?: PermissionConfig;
|
||||
isLoading?: boolean;
|
||||
isSaving?: boolean;
|
||||
canEdit?: boolean;
|
||||
onSave: (config: PermissionConfig) => void;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,8 @@
|
||||
|
||||
.role-details-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.role-details-title {
|
||||
@@ -29,6 +28,44 @@
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.role-details-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.role-details-tab {
|
||||
gap: 4px;
|
||||
padding: 0 16px;
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
|
||||
&[data-state='on'] {
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-tab-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.06px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.role-details-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -118,17 +155,6 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.role-details-permissions-learn-more {
|
||||
color: var(--primary);
|
||||
font-size: var(--font-size-xs);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-permission-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -256,6 +282,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Redirect, useHistory, useLocation } from 'react-router-dom';
|
||||
import { Trash2 } from '@signozhq/icons';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Table2, Trash2, Users } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import { Skeleton } from 'antd';
|
||||
import {
|
||||
getGetObjectsQueryKey,
|
||||
@@ -12,26 +13,17 @@ import {
|
||||
useGetRole,
|
||||
usePatchObjects,
|
||||
} from 'api/generated/services/role';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import permissionsType from 'hooks/useAuthZ/permissions.type';
|
||||
import {
|
||||
buildRoleDeletePermission,
|
||||
buildRoleReadPermission,
|
||||
buildRoleUpdatePermission,
|
||||
} from 'hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import type { AuthzResources } from '../utils';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
import { RoleType } from 'types/roles';
|
||||
import { handleApiError, toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { IS_ROLE_DETAILS_AND_CRUD_ENABLED } from '../config';
|
||||
import type { PermissionConfig } from '../PermissionSidePanel';
|
||||
import PermissionSidePanel from '../PermissionSidePanel';
|
||||
import CreateRoleModal from '../RolesComponents/CreateRoleModal';
|
||||
@@ -42,34 +34,35 @@ import {
|
||||
deriveResourcesForRelation,
|
||||
objectsToPermissionConfig,
|
||||
} from '../utils';
|
||||
import MembersTab from './components/MembersTab';
|
||||
import OverviewTab from './components/OverviewTab';
|
||||
import { ROLE_ID_REGEX } from './constants';
|
||||
|
||||
import './RoleDetailsPage.styles.scss';
|
||||
|
||||
type TabKey = 'overview' | 'members';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function RoleDetailsPage(): JSX.Element {
|
||||
const { pathname, search } = useLocation();
|
||||
const { pathname } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_ROLE_DETAILS_AND_CRUD_ENABLED) {
|
||||
history.push(ROUTES.ROLES_SETTINGS);
|
||||
}
|
||||
}, [history]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { activeLicense, isFetchingActiveLicense } = useAppContext();
|
||||
|
||||
const authzResources: AuthzResources = permissionsType.data;
|
||||
const authzResources = permissionsType.data as unknown as AuthzResources;
|
||||
|
||||
// Extract roleId from URL pathname since useParams doesn't work in nested routing
|
||||
// Extract channelId from URL pathname since useParams doesn't work in nested routing
|
||||
const roleIdMatch = pathname.match(ROLE_ID_REGEX);
|
||||
const roleId = roleIdMatch ? roleIdMatch[1] : '';
|
||||
|
||||
// Role name passed as query param by the listing page — used to check read permission
|
||||
// before the role details API resolves. Absent when navigating directly (e.g. deep link),
|
||||
// in which case we skip the FGA check and fall back to the BE guard.
|
||||
const nameFromQuery = useMemo(
|
||||
() => new URLSearchParams(search).get('name') ?? '',
|
||||
[search],
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('overview');
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [activePermission, setActivePermission] = useState<string | null>(null);
|
||||
@@ -82,27 +75,6 @@ function RoleDetailsPage(): JSX.Element {
|
||||
const isTransitioning = isFetching && role?.id !== roleId;
|
||||
const isManaged = role?.type === RoleType.MANAGED;
|
||||
|
||||
const roleName = role?.name ?? '';
|
||||
|
||||
// Read check — fires immediately using the name query param so we can gate the page
|
||||
// before the role details API resolves. Skipped when name is absent.
|
||||
const { permissions: readPerms, isLoading: isReadAuthZLoading } = useAuthZ(
|
||||
nameFromQuery ? [buildRoleReadPermission(nameFromQuery)] : [],
|
||||
{ enabled: !!nameFromQuery },
|
||||
);
|
||||
const hasReadPermission = nameFromQuery
|
||||
? (readPerms?.[buildRoleReadPermission(nameFromQuery)]?.isGranted ?? true)
|
||||
: true;
|
||||
|
||||
// Update check uses role name once loaded
|
||||
const { permissions: updatePerms, isLoading: isAuthZLoading } = useAuthZ(
|
||||
roleName && !isManaged ? [buildRoleUpdatePermission(roleName)] : [],
|
||||
{ enabled: !!roleName && !isManaged },
|
||||
);
|
||||
const hasUpdatePermission = isAuthZLoading
|
||||
? false
|
||||
: (updatePerms?.[buildRoleUpdatePermission(roleName)]?.isGranted ?? false);
|
||||
|
||||
const permissionTypes = useMemo(
|
||||
() => derivePermissionTypes(authzResources?.relations ?? null),
|
||||
[authzResources],
|
||||
@@ -118,11 +90,7 @@ function RoleDetailsPage(): JSX.Element {
|
||||
|
||||
const { data: objectsData, isLoading: isLoadingObjects } = useGetObjects(
|
||||
{ id: roleId, relation: activePermission ?? '' },
|
||||
{
|
||||
query: {
|
||||
enabled: !!activePermission && !!roleId && !isManaged,
|
||||
},
|
||||
},
|
||||
{ query: { enabled: !!activePermission && !!roleId && !isManaged } },
|
||||
);
|
||||
|
||||
const initialConfig = useMemo(() => {
|
||||
@@ -142,6 +110,7 @@ function RoleDetailsPage(): JSX.Element {
|
||||
getGetObjectsQueryKey({ id: roleId, relation: activePermission }),
|
||||
);
|
||||
}
|
||||
setActivePermission(null);
|
||||
};
|
||||
|
||||
const { mutate: patchObjects, isLoading: isSaving } = usePatchObjects({
|
||||
@@ -161,27 +130,7 @@ function RoleDetailsPage(): JSX.Element {
|
||||
},
|
||||
});
|
||||
|
||||
if (isFetchingActiveLicense) {
|
||||
return (
|
||||
<div className="role-details-page">
|
||||
<Skeleton
|
||||
active
|
||||
paragraph={{ rows: 8 }}
|
||||
className="role-details-skeleton"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeLicense?.status !== LicenseStatus.VALID) {
|
||||
return <Redirect to={ROUTES.ROLES_SETTINGS} />;
|
||||
}
|
||||
|
||||
if (!hasReadPermission && readPerms !== null) {
|
||||
return <PermissionDeniedFullPage permissionName="role:read" />;
|
||||
}
|
||||
|
||||
if (isLoading || isTransitioning || (!!nameFromQuery && isReadAuthZLoading)) {
|
||||
if (!IS_ROLE_DETAILS_AND_CRUD_ENABLED || isLoading || isTransitioning) {
|
||||
return (
|
||||
<div className="role-details-page">
|
||||
<Skeleton
|
||||
@@ -237,49 +186,73 @@ function RoleDetailsPage(): JSX.Element {
|
||||
<div className="role-details-page">
|
||||
<div className="role-details-header">
|
||||
<h2 className="role-details-title">Role — {role.name}</h2>
|
||||
</div>
|
||||
|
||||
<div className="role-details-nav">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={activeTab}
|
||||
onChange={(val): void => {
|
||||
if (val) {
|
||||
setActiveTab(val as TabKey);
|
||||
}
|
||||
}}
|
||||
className="role-details-tabs"
|
||||
>
|
||||
<ToggleGroupItem value="overview" className="role-details-tab">
|
||||
<Table2 size={14} />
|
||||
Overview
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="members" className="role-details-tab">
|
||||
<Users size={14} />
|
||||
Members
|
||||
<span className="role-details-tab-count">0</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
{!isManaged && (
|
||||
<div className="role-details-actions">
|
||||
<AuthZTooltip checks={[buildRoleDeletePermission(role.name)]}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={(): void => setIsDeleteModalOpen(true)}
|
||||
aria-label="Delete role"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<AuthZTooltip checks={[buildRoleUpdatePermission(role.name)]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
>
|
||||
Edit Role Details
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
className="role-details-delete-action-btn"
|
||||
onClick={(): void => setIsDeleteModalOpen(true)}
|
||||
aria-label="Delete role"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
>
|
||||
Edit Role Details
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<OverviewTab
|
||||
role={role || null}
|
||||
isManaged={isManaged}
|
||||
permissionTypes={permissionTypes}
|
||||
onPermissionClick={(key): void => setActivePermission(key)}
|
||||
/>
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab
|
||||
role={role || null}
|
||||
isManaged={isManaged}
|
||||
permissionTypes={permissionTypes}
|
||||
onPermissionClick={(key): void => setActivePermission(key)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'members' && <MembersTab />}
|
||||
|
||||
{!isManaged && (
|
||||
<>
|
||||
<PermissionSidePanel
|
||||
open={activePermission !== null}
|
||||
onClose={(): void => setActivePermission(null)}
|
||||
permissionLabel={activePermission ? capitalize(activePermission) : ''}
|
||||
relation={activePermission ?? ''}
|
||||
resources={resourcesForActivePermission}
|
||||
initialConfig={initialConfig}
|
||||
isLoading={isLoadingObjects}
|
||||
isSaving={isSaving}
|
||||
canEdit={hasUpdatePermission}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
jest.mock('../../config', () => ({ IS_ROLE_DETAILS_AND_CRUD_ENABLED: true }));
|
||||
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import {
|
||||
customRoleResponse,
|
||||
@@ -5,7 +7,6 @@ import {
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -14,16 +15,8 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
invalidLicense,
|
||||
mockUseAuthZDenyAll,
|
||||
mockUseAuthZGrantAll,
|
||||
} from 'tests/authz-test-utils';
|
||||
import RoleDetailsPage from '../RoleDetailsPage';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
import RoleDetailsPage from '../RoleDetailsPage';
|
||||
|
||||
const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
|
||||
const MANAGED_ROLE_ID = '019c24aa-2248-756f-9833-984f1ab63819';
|
||||
@@ -36,7 +29,7 @@ const allScopeObjectsResponse = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
resource: { kind: 'role', type: 'role' },
|
||||
resource: { kind: 'role', type: 'metaresources' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
@@ -53,10 +46,6 @@ function setupDefaultHandlers(roleId = CUSTOM_ROLE_ID): void {
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
@@ -74,6 +63,9 @@ describe('RoleDetailsPage', () => {
|
||||
screen.findByText('Role — billing-manager'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Overview')).toBeInTheDocument();
|
||||
expect(screen.getByText('Members')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText('Custom role for managing billing and invoices.'),
|
||||
).toBeInTheDocument();
|
||||
@@ -220,40 +212,6 @@ describe('RoleDetailsPage', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shows PermissionDeniedFullPage when read permission is denied via query param', async () => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}?name=billing-manager`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText(/you don't have permission to view this page/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to the roles list when license is not valid', async () => {
|
||||
render(
|
||||
<Switch>
|
||||
<Route path="/settings/roles/:roleId">
|
||||
<RoleDetailsPage />
|
||||
</Route>
|
||||
<Route path="/settings/roles" exact>
|
||||
<div data-testid="roles-list-redirect-target" />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
appContextOverrides: { activeLicense: invalidLicense },
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('roles-list-redirect-target'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('permission side panel', () => {
|
||||
beforeEach(() => {
|
||||
// Both hooks mocked so data renders synchronously — no React Query scheduler or MSW round-trip.
|
||||
@@ -280,18 +238,7 @@ describe('RoleDetailsPage', () => {
|
||||
const panel = document.querySelector(
|
||||
'.permission-side-panel',
|
||||
) as HTMLElement;
|
||||
await within(panel).findByRole('button', { name: 'role' });
|
||||
return panel;
|
||||
}
|
||||
|
||||
async function openReadPanel(): Promise<HTMLElement> {
|
||||
await screen.findByText('Role — billing-manager');
|
||||
fireEvent.click(screen.getByText('Read'));
|
||||
await screen.findByText('Edit Read Permissions');
|
||||
const panel = document.querySelector(
|
||||
'.permission-side-panel',
|
||||
) as HTMLElement;
|
||||
await within(panel).findByRole('button', { name: 'role' });
|
||||
await within(panel).findByRole('button', { name: 'Role' });
|
||||
return panel;
|
||||
}
|
||||
|
||||
@@ -306,7 +253,7 @@ describe('RoleDetailsPage', () => {
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
).toBeDisabled();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
|
||||
expect(
|
||||
@@ -334,7 +281,7 @@ describe('RoleDetailsPage', () => {
|
||||
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
@@ -370,11 +317,9 @@ describe('RoleDetailsPage', () => {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
const panel = await openReadPanel();
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
|
||||
// Default is NONE, so switch to Only selected first to reveal the combobox
|
||||
fireEvent.click(screen.getByText('Only selected'));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
|
||||
const combobox = within(panel).getByRole('combobox');
|
||||
fireEvent.change(combobox, { target: { value: 'role-001' } });
|
||||
@@ -397,48 +342,6 @@ describe('RoleDetailsPage', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('set scope to None on create panel (existing All) → patchObjects deletions: ["*"], additions: null', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
jest.spyOn(roleApi, 'useGetObjects').mockReturnValue({
|
||||
data: allScopeObjectsResponse,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
server.use(
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relations/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
|
||||
fireEvent.click(screen.getByText('None'));
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: null,
|
||||
deletions: [
|
||||
{
|
||||
resource: { kind: 'role', type: 'role' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('existing All scope changed to Only selected (empty) → patchObjects deletions: ["*"], additions: null', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
@@ -460,9 +363,9 @@ describe('RoleDetailsPage', () => {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
const panel = await openReadPanel();
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('Only selected'));
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
@@ -490,7 +393,7 @@ describe('RoleDetailsPage', () => {
|
||||
|
||||
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
|
||||
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Callout } from '@signozhq/ui/callout';
|
||||
|
||||
import { PermissionType, TimestampBadge } from '../../utils';
|
||||
import PermissionItem from './PermissionItem';
|
||||
import { AuthtypesRelationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
interface OverviewTabProps {
|
||||
role: {
|
||||
@@ -56,28 +55,18 @@ function OverviewTab({
|
||||
<div className="role-details-permissions">
|
||||
<div className="role-details-permissions-header">
|
||||
<span className="role-details-section-label">Permissions</span>
|
||||
<a
|
||||
href="https://signoz.io/docs/manage/administrator-guide/iam/permissions/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="role-details-permissions-learn-more"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
<hr className="role-details-permissions-divider" />
|
||||
</div>
|
||||
|
||||
<div className="role-details-permission-list">
|
||||
{permissionTypes
|
||||
.filter((p) => p.key !== AuthtypesRelationDTO.assignee)
|
||||
.map((permissionType) => (
|
||||
<PermissionItem
|
||||
key={permissionType.key}
|
||||
permissionType={permissionType}
|
||||
isManaged={isManaged}
|
||||
onPermissionClick={onPermissionClick}
|
||||
/>
|
||||
))}
|
||||
{permissionTypes.map((permissionType) => (
|
||||
<PermissionItem
|
||||
key={permissionType.key}
|
||||
permissionType={permissionType}
|
||||
isManaged={isManaged}
|
||||
onPermissionClick={onPermissionClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,8 +27,9 @@ function DeleteRoleModal({
|
||||
<Button
|
||||
key="cancel"
|
||||
className="cancel-btn"
|
||||
prefix={<X size={14} />}
|
||||
prefix={<X size={16} />}
|
||||
onClick={onCancel}
|
||||
size="sm"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
>
|
||||
@@ -37,9 +38,10 @@ function DeleteRoleModal({
|
||||
<Button
|
||||
key="delete"
|
||||
className="delete-btn"
|
||||
prefix={<Trash2 size={14} />}
|
||||
prefix={<Trash2 size={16} />}
|
||||
onClick={onConfirm}
|
||||
loading={isDeleting}
|
||||
size="sm"
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
>
|
||||
|
||||
@@ -4,19 +4,16 @@ import { Pagination, Skeleton } from 'antd';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RoleListPermission } from 'hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
import { RoleType } from 'types/roles';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { IS_ROLE_DETAILS_AND_CRUD_ENABLED } from '../config';
|
||||
|
||||
import '../RolesSettings.styles.scss';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -32,17 +29,7 @@ interface RolesListingTableProps {
|
||||
function RolesListingTable({
|
||||
searchQuery,
|
||||
}: RolesListingTableProps): JSX.Element {
|
||||
const { activeLicense } = useAppContext();
|
||||
const isValidLicense = activeLicense?.status === LicenseStatus.VALID;
|
||||
|
||||
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
|
||||
RoleListPermission,
|
||||
]);
|
||||
const hasListPermission = listPerms?.[RoleListPermission]?.isGranted ?? false;
|
||||
|
||||
const { data, isLoading, isError, error } = useListRoles({
|
||||
query: { enabled: hasListPermission },
|
||||
});
|
||||
const { data, isLoading, isError, error } = useListRoles();
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const history = useHistory();
|
||||
const urlQuery = useUrlQuery();
|
||||
@@ -164,11 +151,7 @@ function RolesListingTable({
|
||||
</>
|
||||
);
|
||||
|
||||
if (!hasListPermission && listPerms !== null) {
|
||||
return <PermissionDeniedFullPage permissionName="role:list" />;
|
||||
}
|
||||
|
||||
if (isAuthZLoading || isLoading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="roles-listing-table">
|
||||
<Skeleton active paragraph={{ rows: 5 }} />
|
||||
@@ -199,36 +182,33 @@ function RolesListingTable({
|
||||
);
|
||||
}
|
||||
|
||||
const navigateToRole = (roleId: string, roleName?: string): void => {
|
||||
const search = roleName ? `?name=${encodeURIComponent(roleName)}` : '';
|
||||
history.push(`${ROUTES.ROLE_DETAILS.replace(':roleId', roleId)}${search}`);
|
||||
const navigateToRole = (roleId: string): void => {
|
||||
history.push(ROUTES.ROLE_DETAILS.replace(':roleId', roleId));
|
||||
};
|
||||
|
||||
// todo: use table from periscope when its available for consumption
|
||||
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
|
||||
<div
|
||||
key={role.id}
|
||||
className={`roles-table-row${isValidLicense ? ' roles-table-row--clickable' : ''}`}
|
||||
role={isValidLicense ? 'button' : undefined}
|
||||
tabIndex={isValidLicense ? 0 : undefined}
|
||||
onClick={
|
||||
isValidLicense
|
||||
? (): void => {
|
||||
if (role.id) {
|
||||
navigateToRole(role.id, role.name);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={
|
||||
isValidLicense
|
||||
? (e): void => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && role.id) {
|
||||
navigateToRole(role.id, role.name);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`roles-table-row ${
|
||||
IS_ROLE_DETAILS_AND_CRUD_ENABLED ? 'roles-table-row--clickable' : ''
|
||||
}`}
|
||||
role="button"
|
||||
tabIndex={IS_ROLE_DETAILS_AND_CRUD_ENABLED ? 0 : -1}
|
||||
onClick={(): void => {
|
||||
if (IS_ROLE_DETAILS_AND_CRUD_ENABLED && role.id) {
|
||||
navigateToRole(role.id);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (
|
||||
IS_ROLE_DETAILS_AND_CRUD_ENABLED &&
|
||||
(e.key === 'Enter' || e.key === ' ') &&
|
||||
role.id
|
||||
) {
|
||||
navigateToRole(role.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="roles-table-cell roles-table-cell--name">
|
||||
{role.name ?? '—'}
|
||||
|
||||
@@ -22,21 +22,12 @@
|
||||
color: var(--foreground);
|
||||
font-family: Inter;
|
||||
font-style: normal;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.roles-settings-header-learn-more {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.roles-settings-content {
|
||||
@@ -294,23 +285,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
&::placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
// todo: https://github.com/SigNoz/components/issues/116
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
background: var(--input-background, transparent);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
padding: 6px 8px;
|
||||
font-family: Inter;
|
||||
font-size: var(--font-size-xs);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
@@ -319,7 +303,7 @@
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
color: var(--l3-foreground);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@@ -329,6 +313,25 @@
|
||||
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 {
|
||||
|
||||
@@ -2,11 +2,8 @@ import { useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { RoleCreatePermission } from 'hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
|
||||
import { IS_ROLE_DETAILS_AND_CRUD_ENABLED } from './config';
|
||||
import CreateRoleModal from './RolesComponents/CreateRoleModal';
|
||||
import RolesListingTable from './RolesComponents/RolesListingTable';
|
||||
|
||||
@@ -15,23 +12,13 @@ import './RolesSettings.styles.scss';
|
||||
function RolesSettings(): JSX.Element {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const { activeLicense } = useAppContext();
|
||||
const isValidLicense = activeLicense?.status === LicenseStatus.VALID;
|
||||
|
||||
return (
|
||||
<div className="roles-settings" data-testid="roles-settings">
|
||||
<div className="roles-settings-header">
|
||||
<h3 className="roles-settings-header-title">Roles</h3>
|
||||
<p className="roles-settings-header-description">
|
||||
Create and manage custom roles for your team.{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/manage/administrator-guide/iam/roles/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="roles-settings-header-learn-more"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
Create and manage custom roles for your team.
|
||||
</p>
|
||||
</div>
|
||||
<div className="roles-settings-content">
|
||||
@@ -42,18 +29,16 @@ function RolesSettings(): JSX.Element {
|
||||
value={searchQuery}
|
||||
onChange={(e): void => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{isValidLicense && (
|
||||
<AuthZTooltip checks={[RoleCreatePermission]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className="role-settings-toolbar-button"
|
||||
onClick={(): void => setIsCreateModalOpen(true)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Custom role
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
{IS_ROLE_DETAILS_AND_CRUD_ENABLED && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className="role-settings-toolbar-button"
|
||||
onClick={(): void => setIsCreateModalOpen(true)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Custom role
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<RolesListingTable searchQuery={searchQuery} />
|
||||
|
||||
@@ -5,19 +5,13 @@ import {
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { invalidLicense, mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
|
||||
import RolesSettings from '../RolesSettings';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const rolesApiURL = 'http://localhost/api/v1/roles';
|
||||
|
||||
describe('RolesSettings', () => {
|
||||
beforeEach(() => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
@@ -176,26 +170,6 @@ describe('RolesSettings', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('hides the create button and disables row clicks when license is not valid', async () => {
|
||||
render(<RolesSettings />, undefined, {
|
||||
appContextOverrides: { activeLicense: invalidLicense },
|
||||
});
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
// Create button must be absent
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /custom role/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Rows must not carry the clickable class or button role
|
||||
const rows = document.querySelectorAll('.roles-table-row');
|
||||
rows.forEach((row) => {
|
||||
expect(row).not.toHaveClass('roles-table-row--clickable');
|
||||
expect(row.getAttribute('role')).not.toBe('button');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles invalid dates gracefully by showing fallback', async () => {
|
||||
const invalidRole = {
|
||||
id: 'edge-0009',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
CoretypesResourceRefDTO,
|
||||
CoretypesObjectGroupDTO,
|
||||
CoretypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -7,7 +8,11 @@ import type {
|
||||
PermissionConfig,
|
||||
ResourceDefinition,
|
||||
} from '../PermissionSidePanel/PermissionSidePanel.types';
|
||||
import type { AuthzResources } from '../utils';
|
||||
|
||||
type AuthzResources = {
|
||||
resources: CoretypesResourceRefDTO[];
|
||||
relations: Record<string, string[]>;
|
||||
};
|
||||
import { PermissionScope } from '../PermissionSidePanel/PermissionSidePanel.types';
|
||||
import {
|
||||
buildConfig,
|
||||
@@ -36,14 +41,12 @@ jest.mock('../RoleDetails/constants', () => {
|
||||
|
||||
const dashboardResource: AuthzResources['resources'][number] = {
|
||||
kind: 'dashboard',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'read', 'update', 'delete', 'list'],
|
||||
type: 'metaresource' as CoretypesTypeDTO,
|
||||
};
|
||||
|
||||
const alertResource: AuthzResources['resources'][number] = {
|
||||
kind: 'alert',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'read', 'update', 'delete', 'list'],
|
||||
type: 'metaresource' as CoretypesTypeDTO,
|
||||
};
|
||||
|
||||
const baseAuthzResources: AuthzResources = {
|
||||
@@ -54,29 +57,9 @@ const baseAuthzResources: AuthzResources = {
|
||||
},
|
||||
};
|
||||
|
||||
// API payload resource refs — only kind+type, no allowedVerbs (matches CoretypesResourceRefDTO shape)
|
||||
const dashboardResourceRef = {
|
||||
kind: 'dashboard',
|
||||
type: 'metaresource' as CoretypesTypeDTO,
|
||||
};
|
||||
const alertResourceRef = {
|
||||
kind: 'alert',
|
||||
type: 'metaresource' as CoretypesTypeDTO,
|
||||
};
|
||||
|
||||
const resourceDefs: ResourceDefinition[] = [
|
||||
{
|
||||
id: 'metaresource:dashboard',
|
||||
kind: 'dashboard',
|
||||
type: 'metaresource',
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
id: 'metaresource:alert',
|
||||
kind: 'alert',
|
||||
type: 'metaresource',
|
||||
label: 'Alert',
|
||||
},
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'alert', label: 'Alert' },
|
||||
];
|
||||
|
||||
const ID_A = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||
@@ -86,24 +69,15 @@ const ID_C = 'cccccccc-0000-0000-0000-000000000003';
|
||||
describe('buildPatchPayload', () => {
|
||||
it('sends only the added selector as an addition', () => {
|
||||
const initial: PermissionConfig = {
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A],
|
||||
},
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
dashboard: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
'metaresource:dashboard': {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
@@ -114,31 +88,25 @@ describe('buildPatchPayload', () => {
|
||||
});
|
||||
|
||||
expect(result.additions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: [ID_B] },
|
||||
{ resource: dashboardResource, selectors: [ID_B] },
|
||||
]);
|
||||
expect(result.deletions).toBeNull();
|
||||
});
|
||||
|
||||
it('sends only the removed selector as a deletion', () => {
|
||||
const initial: PermissionConfig = {
|
||||
'metaresource:dashboard': {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B, ID_C],
|
||||
},
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
'metaresource:dashboard': {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_C],
|
||||
},
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
@@ -149,31 +117,25 @@ describe('buildPatchPayload', () => {
|
||||
});
|
||||
|
||||
expect(result.deletions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: [ID_B] },
|
||||
{ resource: dashboardResource, selectors: [ID_B] },
|
||||
]);
|
||||
expect(result.additions).toBeNull();
|
||||
});
|
||||
|
||||
it('treats selector order as irrelevant — produces no payload when IDs are identical', () => {
|
||||
const initial: PermissionConfig = {
|
||||
'metaresource:dashboard': {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
'metaresource:dashboard': {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_B, ID_A],
|
||||
},
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
@@ -189,21 +151,15 @@ describe('buildPatchPayload', () => {
|
||||
|
||||
it('replaces wildcard with specific IDs when switching all → only_selected', () => {
|
||||
const initial: PermissionConfig = {
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
'metaresource:dashboard': {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
@@ -214,30 +170,21 @@ describe('buildPatchPayload', () => {
|
||||
});
|
||||
|
||||
expect(result.deletions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: ['*'] },
|
||||
{ resource: dashboardResource, selectors: ['*'] },
|
||||
]);
|
||||
expect(result.additions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: [ID_A, ID_B] },
|
||||
{ resource: dashboardResource, selectors: [ID_A, ID_B] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('only deletes wildcard when switching all → only_selected with empty selector list', () => {
|
||||
const initial: PermissionConfig = {
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
dashboard: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
@@ -248,140 +195,19 @@ describe('buildPatchPayload', () => {
|
||||
});
|
||||
|
||||
expect(result.deletions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: ['*'] },
|
||||
{ resource: dashboardResource, selectors: ['*'] },
|
||||
]);
|
||||
expect(result.additions).toBeNull();
|
||||
});
|
||||
|
||||
it('ALL → NONE: deletes wildcard, no additions', () => {
|
||||
const initial: PermissionConfig = {
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
'metaresource:dashboard': { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
newConfig,
|
||||
initialConfig: initial,
|
||||
resources: resourceDefs,
|
||||
authzRes: baseAuthzResources,
|
||||
});
|
||||
|
||||
expect(result.deletions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: ['*'] },
|
||||
]);
|
||||
expect(result.additions).toBeNull();
|
||||
});
|
||||
|
||||
it('NONE → ALL: adds wildcard, no deletions', () => {
|
||||
const initial: PermissionConfig = {
|
||||
'metaresource:dashboard': { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
newConfig,
|
||||
initialConfig: initial,
|
||||
resources: resourceDefs,
|
||||
authzRes: baseAuthzResources,
|
||||
});
|
||||
|
||||
expect(result.additions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: ['*'] },
|
||||
]);
|
||||
expect(result.deletions).toBeNull();
|
||||
});
|
||||
|
||||
it('ONLY_SELECTED → NONE: deletes selected IDs, no additions', () => {
|
||||
const initial: PermissionConfig = {
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
'metaresource:dashboard': { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
newConfig,
|
||||
initialConfig: initial,
|
||||
resources: resourceDefs,
|
||||
authzRes: baseAuthzResources,
|
||||
});
|
||||
|
||||
expect(result.deletions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: [ID_A, ID_B] },
|
||||
]);
|
||||
expect(result.additions).toBeNull();
|
||||
});
|
||||
|
||||
it('NONE → ONLY_SELECTED with IDs: adds those IDs, no deletions', () => {
|
||||
const initial: PermissionConfig = {
|
||||
'metaresource:dashboard': { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A],
|
||||
},
|
||||
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
newConfig,
|
||||
initialConfig: initial,
|
||||
resources: resourceDefs,
|
||||
authzRes: baseAuthzResources,
|
||||
});
|
||||
|
||||
expect(result.additions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: [ID_A] },
|
||||
]);
|
||||
expect(result.deletions).toBeNull();
|
||||
});
|
||||
|
||||
it('NONE → NONE: no change, produces empty payload', () => {
|
||||
const initial: PermissionConfig = {
|
||||
'metaresource:dashboard': { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
newConfig: { ...initial },
|
||||
initialConfig: initial,
|
||||
resources: resourceDefs,
|
||||
authzRes: baseAuthzResources,
|
||||
});
|
||||
|
||||
expect(result.additions).toBeNull();
|
||||
expect(result.deletions).toBeNull();
|
||||
});
|
||||
|
||||
it('only includes resources that actually changed', () => {
|
||||
const initial: PermissionConfig = {
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A],
|
||||
},
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] }, // unchanged
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
}, // added ID_B
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] }, // unchanged
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A, ID_B] }, // added ID_B
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
@@ -392,7 +218,7 @@ describe('buildPatchPayload', () => {
|
||||
});
|
||||
|
||||
expect(result.additions).toStrictEqual([
|
||||
{ resource: alertResourceRef, selectors: [ID_B] },
|
||||
{ resource: alertResource, selectors: [ID_B] },
|
||||
]);
|
||||
expect(result.deletions).toBeNull();
|
||||
});
|
||||
@@ -401,12 +227,12 @@ describe('buildPatchPayload', () => {
|
||||
describe('objectsToPermissionConfig', () => {
|
||||
it('maps a wildcard selector to ALL scope', () => {
|
||||
const objects: CoretypesObjectGroupDTO[] = [
|
||||
{ resource: dashboardResourceRef, selectors: ['*'] },
|
||||
{ resource: dashboardResource, selectors: ['*'] },
|
||||
];
|
||||
|
||||
const result = objectsToPermissionConfig(objects, resourceDefs);
|
||||
|
||||
expect(result['metaresource:dashboard']).toStrictEqual({
|
||||
expect(result.dashboard).toStrictEqual({
|
||||
scope: PermissionScope.ALL,
|
||||
selectedIds: [],
|
||||
});
|
||||
@@ -414,26 +240,26 @@ describe('objectsToPermissionConfig', () => {
|
||||
|
||||
it('maps specific selectors to ONLY_SELECTED scope with the IDs', () => {
|
||||
const objects: CoretypesObjectGroupDTO[] = [
|
||||
{ resource: dashboardResourceRef, selectors: [ID_A, ID_B] },
|
||||
{ resource: dashboardResource, selectors: [ID_A, ID_B] },
|
||||
];
|
||||
|
||||
const result = objectsToPermissionConfig(objects, resourceDefs);
|
||||
|
||||
expect(result['metaresource:dashboard']).toStrictEqual({
|
||||
expect(result.dashboard).toStrictEqual({
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to NONE scope when resource is absent from API response', () => {
|
||||
it('defaults to ONLY_SELECTED with empty selectedIds when resource is absent from API response', () => {
|
||||
const result = objectsToPermissionConfig([], resourceDefs);
|
||||
|
||||
expect(result['metaresource:dashboard']).toStrictEqual({
|
||||
scope: PermissionScope.NONE,
|
||||
expect(result.dashboard).toStrictEqual({
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
});
|
||||
expect(result['metaresource:alert']).toStrictEqual({
|
||||
scope: PermissionScope.NONE,
|
||||
expect(result.alert).toStrictEqual({
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
});
|
||||
});
|
||||
@@ -442,11 +268,8 @@ describe('objectsToPermissionConfig', () => {
|
||||
describe('configsEqual', () => {
|
||||
it('returns true for identical configs', () => {
|
||||
const config: PermissionConfig = {
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A],
|
||||
},
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A] },
|
||||
};
|
||||
|
||||
expect(configsEqual(config, { ...config })).toBe(true);
|
||||
@@ -454,25 +277,22 @@ describe('configsEqual', () => {
|
||||
|
||||
it('returns false when configs differ', () => {
|
||||
const a: PermissionConfig = {
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
};
|
||||
const b: PermissionConfig = {
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
dashboard: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
|
||||
expect(configsEqual(a, b)).toBe(false);
|
||||
|
||||
const c: PermissionConfig = {
|
||||
'metaresource:dashboard': {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_C, ID_B],
|
||||
},
|
||||
};
|
||||
const d: PermissionConfig = {
|
||||
'metaresource:dashboard': {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
@@ -483,13 +303,13 @@ describe('configsEqual', () => {
|
||||
|
||||
it('returns true when selectedIds are the same but in different order', () => {
|
||||
const a: PermissionConfig = {
|
||||
'metaresource:dashboard': {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
};
|
||||
const b: PermissionConfig = {
|
||||
'metaresource:dashboard': {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_B, ID_A],
|
||||
},
|
||||
@@ -502,26 +322,23 @@ describe('configsEqual', () => {
|
||||
describe('buildConfig', () => {
|
||||
it('uses initial values when provided and defaults for resources not in initial', () => {
|
||||
const initial: PermissionConfig = {
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildConfig(resourceDefs, initial);
|
||||
|
||||
expect(result['metaresource:dashboard']).toStrictEqual({
|
||||
expect(result.dashboard).toStrictEqual({
|
||||
scope: PermissionScope.ALL,
|
||||
selectedIds: [],
|
||||
});
|
||||
expect(result['metaresource:alert']).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
|
||||
expect(result.alert).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
|
||||
});
|
||||
|
||||
it('applies DEFAULT_RESOURCE_CONFIG (NONE scope) to all resources when no initial is provided', () => {
|
||||
it('applies DEFAULT_RESOURCE_CONFIG to all resources when no initial is provided', () => {
|
||||
const result = buildConfig(resourceDefs);
|
||||
|
||||
expect(result['metaresource:dashboard']).toStrictEqual(
|
||||
DEFAULT_RESOURCE_CONFIG,
|
||||
);
|
||||
expect(result['metaresource:alert']).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
|
||||
expect(DEFAULT_RESOURCE_CONFIG.scope).toBe(PermissionScope.NONE);
|
||||
expect(result.dashboard).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
|
||||
expect(result.alert).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -558,10 +375,7 @@ describe('deriveResourcesForRelation', () => {
|
||||
const result = deriveResourcesForRelation(baseAuthzResources, 'create');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((r) => r.id)).toStrictEqual([
|
||||
'metaresource:dashboard',
|
||||
'metaresource:alert',
|
||||
]);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['dashboard', 'alert']);
|
||||
});
|
||||
|
||||
it('returns an empty array when authzResources is null', () => {
|
||||
@@ -573,41 +387,4 @@ describe('deriveResourcesForRelation', () => {
|
||||
deriveResourcesForRelation(baseAuthzResources, 'nonexistent'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('allowedVerbs filtering', () => {
|
||||
it('excludes resources whose allowedVerbs does not include the relation', () => {
|
||||
const authz: AuthzResources = {
|
||||
resources: [
|
||||
{
|
||||
kind: 'dashboard',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'read', 'update', 'delete', 'list'],
|
||||
},
|
||||
{
|
||||
kind: 'alert',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'read', 'update', 'delete', 'list', 'attach'],
|
||||
},
|
||||
],
|
||||
relations: { attach: ['metaresource'] },
|
||||
};
|
||||
|
||||
const result = deriveResourcesForRelation(authz, 'attach');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('metaresource:alert');
|
||||
});
|
||||
|
||||
it('requires both type-relation match and allowedVerbs — neither condition alone is sufficient', () => {
|
||||
const authz: AuthzResources = {
|
||||
resources: [
|
||||
{ kind: 'dashboard', type: 'metaresource', allowedVerbs: ['read'] },
|
||||
{ kind: 'role', type: 'role', allowedVerbs: ['create'] },
|
||||
],
|
||||
relations: { create: ['metaresource'] },
|
||||
};
|
||||
|
||||
expect(deriveResourcesForRelation(authz, 'create')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
1
frontend/src/container/RolesSettings/config.ts
Normal file
1
frontend/src/container/RolesSettings/config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const IS_ROLE_DETAILS_AND_CRUD_ENABLED = false;
|
||||
@@ -1,9 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import type {
|
||||
CoretypesObjectGroupDTO,
|
||||
CoretypesResourceRefDTO,
|
||||
CoretypesTypeDTO,
|
||||
CoretypesObjectGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { capitalize } from 'lodash-es';
|
||||
@@ -13,7 +12,6 @@ import type {
|
||||
PermissionConfig,
|
||||
ResourceConfig,
|
||||
ResourceDefinition,
|
||||
ScopeType,
|
||||
} from './PermissionSidePanel/PermissionSidePanel.types';
|
||||
import { PermissionScope } from './PermissionSidePanel/PermissionSidePanel.types';
|
||||
import {
|
||||
@@ -22,11 +20,7 @@ import {
|
||||
} from './RoleDetails/constants';
|
||||
|
||||
export type AuthzResources = {
|
||||
resources: ReadonlyArray<{
|
||||
kind: string;
|
||||
type: string;
|
||||
allowedVerbs: readonly string[];
|
||||
}>;
|
||||
resources: ReadonlyArray<CoretypesResourceRefDTO>;
|
||||
relations: Readonly<Record<string, ReadonlyArray<string>>>;
|
||||
};
|
||||
|
||||
@@ -74,14 +68,10 @@ export function deriveResourcesForRelation(
|
||||
}
|
||||
const supportedTypes = authzResources.relations[relation] ?? [];
|
||||
return authzResources.resources
|
||||
.filter(
|
||||
(r) => supportedTypes.includes(r.type) && r.allowedVerbs.includes(relation),
|
||||
)
|
||||
.filter((r) => supportedTypes.includes(r.type))
|
||||
.map((r) => ({
|
||||
id: `${r.type}:${r.kind}`,
|
||||
kind: r.kind,
|
||||
type: r.type,
|
||||
label: r.kind,
|
||||
id: r.kind,
|
||||
label: capitalize(r.kind).replaceAll('_', ' '),
|
||||
options: [],
|
||||
}));
|
||||
}
|
||||
@@ -92,12 +82,10 @@ export function objectsToPermissionConfig(
|
||||
): PermissionConfig {
|
||||
const config: PermissionConfig = {};
|
||||
for (const res of resources) {
|
||||
const obj = objects.find(
|
||||
(o) => o.resource.kind === res.kind && o.resource.type === res.type,
|
||||
);
|
||||
const obj = objects.find((o) => o.resource.kind === res.id);
|
||||
if (!obj) {
|
||||
config[res.id] = {
|
||||
scope: PermissionScope.NONE,
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
};
|
||||
} else {
|
||||
@@ -111,16 +99,6 @@ export function objectsToPermissionConfig(
|
||||
return config;
|
||||
}
|
||||
|
||||
function selectorsForScope(scope: ScopeType, selectedIds: string[]): string[] {
|
||||
if (scope === PermissionScope.ALL) {
|
||||
return ['*'];
|
||||
}
|
||||
if (scope === PermissionScope.ONLY_SELECTED) {
|
||||
return selectedIds;
|
||||
}
|
||||
return []; // NONE
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function buildPatchPayload({
|
||||
newConfig,
|
||||
@@ -140,19 +118,17 @@ export function buildPatchPayload({
|
||||
for (const res of resources) {
|
||||
const initial = initialConfig[res.id];
|
||||
const current = newConfig[res.id];
|
||||
const found = authzRes.resources.find(
|
||||
(r) => r.kind === res.kind && r.type === res.type,
|
||||
);
|
||||
const found = authzRes.resources.find((r) => r.kind === res.id);
|
||||
if (!found) {
|
||||
continue;
|
||||
}
|
||||
const resourceDef: CoretypesResourceRefDTO = {
|
||||
kind: found.kind,
|
||||
type: found.type as CoretypesTypeDTO,
|
||||
type: found.type,
|
||||
};
|
||||
|
||||
const initialScope = initial?.scope ?? PermissionScope.NONE;
|
||||
const currentScope = current?.scope ?? PermissionScope.NONE;
|
||||
const initialScope = initial?.scope ?? PermissionScope.ONLY_SELECTED;
|
||||
const currentScope = current?.scope ?? PermissionScope.ONLY_SELECTED;
|
||||
|
||||
if (initialScope === currentScope) {
|
||||
// Same scope — only diff individual selectors when both are ONLY_SELECTED
|
||||
@@ -168,20 +144,16 @@ export function buildPatchPayload({
|
||||
additions.push({ resource: resourceDef, selectors: added });
|
||||
}
|
||||
}
|
||||
// Both ALL or both NONE → no change, skip
|
||||
// Both ALL → no change, skip
|
||||
} else {
|
||||
// Scope changed — replace old selectors with new ones
|
||||
const initialSelectors = selectorsForScope(
|
||||
initialScope,
|
||||
initial?.selectedIds ?? [],
|
||||
);
|
||||
// Scope changed (ALL ↔ ONLY_SELECTED) — replace old with new
|
||||
const initialSelectors =
|
||||
initialScope === PermissionScope.ALL ? ['*'] : (initial?.selectedIds ?? []);
|
||||
if (initialSelectors.length > 0) {
|
||||
deletions.push({ resource: resourceDef, selectors: initialSelectors });
|
||||
}
|
||||
const currentSelectors = selectorsForScope(
|
||||
currentScope,
|
||||
current?.selectedIds ?? [],
|
||||
);
|
||||
const currentSelectors =
|
||||
currentScope === PermissionScope.ALL ? ['*'] : (current?.selectedIds ?? []);
|
||||
if (currentSelectors.length > 0) {
|
||||
additions.push({ resource: resourceDef, selectors: currentSelectors });
|
||||
}
|
||||
@@ -219,7 +191,7 @@ export function TimestampBadge({ date }: TimestampBadgeProps): JSX.Element {
|
||||
}
|
||||
|
||||
export const DEFAULT_RESOURCE_CONFIG: ResourceConfig = {
|
||||
scope: PermissionScope.NONE,
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { AuthtypesTransactionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
|
||||
import ServiceAccountsSettings from './ServiceAccountsSettings';
|
||||
|
||||
const SA_LIST_URL = 'http://localhost/api/v1/service_accounts';
|
||||
|
||||
function renderPage(): ReturnType<typeof render> {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={{}} hasMemory>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ServiceAccountsSettings — FGA', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(SA_LIST_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows PermissionDeniedFullPage when list permission is denied', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map(() => false),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/You don't have permission to view this page/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows table when list permission is granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map(() => true),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText(/You don't have permission to view this page/),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables New Service Account button when create permission is denied', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
// grant list, deny create — matched by relation name
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map((txn: AuthtypesTransactionDTO) => txn.relation === 'list'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /New Service Account/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('enables New Service Account button when create permission is granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map(() => true),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /New Service Account/i }),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,20 +5,12 @@ import { Input } from '@signozhq/ui/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import Spinner from 'components/Spinner';
|
||||
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
|
||||
import ServiceAccountsTable, {
|
||||
PAGE_SIZE,
|
||||
} from 'components/ServiceAccountsTable/ServiceAccountsTable';
|
||||
import {
|
||||
SACreatePermission,
|
||||
SAListPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
@@ -59,19 +51,13 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
|
||||
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
|
||||
SAListPermission,
|
||||
]);
|
||||
|
||||
const hasListPermission = listPerms?.[SAListPermission]?.isGranted ?? false;
|
||||
|
||||
const {
|
||||
data: serviceAccountsData,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch: handleCreateSuccess,
|
||||
} = useListServiceAccounts({ query: { enabled: hasListPermission } });
|
||||
} = useListServiceAccounts();
|
||||
|
||||
const allAccounts = useMemo(
|
||||
(): ServiceAccountRow[] =>
|
||||
@@ -126,9 +112,9 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
|
||||
const maxPage = Math.max(1, Math.ceil(filteredAccounts.length / PAGE_SIZE));
|
||||
if (currentPage > maxPage) {
|
||||
void setPage(maxPage);
|
||||
setPage(maxPage);
|
||||
} else if (currentPage < 1) {
|
||||
void setPage(1);
|
||||
setPage(1);
|
||||
}
|
||||
}, [filteredAccounts.length, currentPage, setPage]);
|
||||
|
||||
@@ -144,8 +130,8 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
void setFilterMode(FilterMode.All);
|
||||
void setPage(1);
|
||||
setFilterMode(FilterMode.All);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -157,8 +143,8 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
void setFilterMode(FilterMode.Active);
|
||||
void setPage(1);
|
||||
setFilterMode(FilterMode.Active);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -170,8 +156,8 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
void setFilterMode(FilterMode.Deleted);
|
||||
void setPage(1);
|
||||
setFilterMode(FilterMode.Deleted);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -190,7 +176,7 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: ServiceAccountRow): void => {
|
||||
void setSelectedAccountId(row.id);
|
||||
setSelectedAccountId(row.id);
|
||||
},
|
||||
[setSelectedAccountId],
|
||||
);
|
||||
@@ -198,9 +184,9 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
const handleDrawerSuccess = useCallback(
|
||||
(options?: { closeDrawer?: boolean }): void => {
|
||||
if (options?.closeDrawer) {
|
||||
void setSelectedAccountId(null);
|
||||
setSelectedAccountId(null);
|
||||
}
|
||||
void handleCreateSuccess();
|
||||
handleCreateSuccess();
|
||||
},
|
||||
[handleCreateSuccess, setSelectedAccountId],
|
||||
);
|
||||
@@ -222,76 +208,63 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAuthZLoading || isLoading ? (
|
||||
<Spinner height="50vh" />
|
||||
) : !hasListPermission ? (
|
||||
<PermissionDeniedFullPage permissionName="serviceaccount:list" />
|
||||
) : (
|
||||
<div className="sa-settings__list-section">
|
||||
<div className="sa-settings__controls">
|
||||
<Dropdown
|
||||
menu={{ items: filterMenuItems }}
|
||||
trigger={['click']}
|
||||
overlayClassName="sa-settings-filter-dropdown"
|
||||
<div className="sa-settings__controls">
|
||||
<Dropdown
|
||||
menu={{ items: filterMenuItems }}
|
||||
trigger={['click']}
|
||||
overlayClassName="sa-settings-filter-dropdown"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className="sa-settings-filter-trigger"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className="sa-settings-filter-trigger"
|
||||
>
|
||||
<span>{filterLabel}</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className="sa-settings-filter-trigger__chevron"
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<span>{filterLabel}</span>
|
||||
<ChevronDown size={12} className="sa-settings-filter-trigger__chevron" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<div className="sa-settings__search">
|
||||
<Input
|
||||
type="search"
|
||||
name="service-accounts-search"
|
||||
placeholder="Search by name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => {
|
||||
void setSearchQuery(e.target.value);
|
||||
void setPage(1);
|
||||
}}
|
||||
className="sa-settings-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AuthZTooltip checks={[SACreatePermission]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
New Service Account
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<div className="sa-settings__search">
|
||||
<Input
|
||||
type="search"
|
||||
name="service-accounts-search"
|
||||
placeholder="Search by name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => {
|
||||
setSearchQuery(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="sa-settings-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<ErrorInPlace
|
||||
error={toAPIError(
|
||||
error,
|
||||
'An unexpected error occurred while fetching service accounts.',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ServiceAccountsTable
|
||||
data={filteredAccounts}
|
||||
loading={isLoading}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
New Service Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<ErrorInPlace
|
||||
error={toAPIError(
|
||||
error,
|
||||
'An unexpected error occurred while fetching service accounts.',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ServiceAccountsTable
|
||||
data={filteredAccounts}
|
||||
loading={isLoading}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateServiceAccountModal />
|
||||
|
||||
@@ -3,14 +3,12 @@ import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { setupAuthzAdmin } from 'tests/authz-test-utils';
|
||||
|
||||
import ServiceAccountsSettings from '../ServiceAccountsSettings';
|
||||
|
||||
const SA_LIST_ENDPOINT = '*/api/v1/service_accounts';
|
||||
const SA_ENDPOINT = '*/api/v1/service_accounts/:id';
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
|
||||
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
jest.mock('@signozhq/ui/drawer', () => ({
|
||||
@@ -87,7 +85,6 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
setupAuthzAdmin(),
|
||||
rest.get(SA_LIST_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockServiceAccountsAPI })),
|
||||
),
|
||||
@@ -101,9 +98,6 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
@@ -184,17 +178,15 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
|
||||
it('saving changes in the drawer refetches the list', async () => {
|
||||
const listRefetchSpy = jest.fn();
|
||||
const putSpy = jest.fn();
|
||||
|
||||
server.use(
|
||||
rest.get(SA_LIST_ENDPOINT, (_, res, ctx) => {
|
||||
listRefetchSpy();
|
||||
return res(ctx.status(200), ctx.json({ data: mockServiceAccountsAPI }));
|
||||
}),
|
||||
rest.put(SA_ENDPOINT, async (req, res, ctx) => {
|
||||
putSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
rest.put(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
@@ -213,17 +205,9 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
const nameInput = await screen.findByDisplayValue('CI Bot');
|
||||
fireEvent.change(nameInput, { target: { value: 'CI Bot Updated' } });
|
||||
|
||||
await screen.findByDisplayValue('CI Bot Updated');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save Changes/i }));
|
||||
|
||||
// Wait for the PUT to complete with the right payload — confirms save fired
|
||||
await waitFor(() =>
|
||||
expect(putSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'CI Bot Updated' }),
|
||||
),
|
||||
);
|
||||
|
||||
await screen.findByDisplayValue('CI Bot Updated');
|
||||
await waitFor(() => {
|
||||
expect(listRefetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
@@ -238,13 +222,6 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
// Wait for authz check to resolve before clicking
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /New Service Account/i }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /New Service Account/i }));
|
||||
|
||||
await screen.findByRole('dialog', { name: /New Service Account/i });
|
||||
|
||||
@@ -374,7 +374,6 @@ export const settingsNavSections: SettingsNavSection[] = [
|
||||
icon: <Shield size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'roles',
|
||||
isBeta: true,
|
||||
},
|
||||
{
|
||||
key: ROUTES.MEMBERS_SETTINGS,
|
||||
|
||||
@@ -31,14 +31,10 @@ interface UseServiceAccountRoleManagerResult {
|
||||
|
||||
export function useServiceAccountRoleManager(
|
||||
accountId: string,
|
||||
options?: { enabled?: boolean },
|
||||
): UseServiceAccountRoleManagerResult {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useGetServiceAccountRoles(
|
||||
{ id: accountId },
|
||||
{ query: { enabled: options?.enabled ?? true } },
|
||||
);
|
||||
const { data, isLoading } = useGetServiceAccountRoles({ id: accountId });
|
||||
|
||||
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
|
||||
() => data?.data ?? [],
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { buildPermission } from '../utils';
|
||||
import type { BrandedPermission } from '../types';
|
||||
|
||||
// Collection-level — no specific role id needed
|
||||
export const RoleCreatePermission = buildPermission('create', 'role:*');
|
||||
export const RoleListPermission = buildPermission('list', 'role:*');
|
||||
|
||||
// Resource-level — require a specific role id
|
||||
export const buildRoleReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `role:${id}`);
|
||||
export const buildRoleUpdatePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('update', `role:${id}`);
|
||||
export const buildRoleDeletePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('delete', `role:${id}`);
|
||||
@@ -1,38 +0,0 @@
|
||||
import { buildPermission } from '../utils';
|
||||
import type { BrandedPermission } from '../types';
|
||||
|
||||
// Collection-level — wildcard selector required for correct response key matching
|
||||
export const SAListPermission = buildPermission('list', 'serviceaccount:*');
|
||||
export const SACreatePermission = buildPermission('create', 'serviceaccount:*');
|
||||
|
||||
// Resource-level — require a specific SA id
|
||||
export const buildSAReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `serviceaccount:${id}`);
|
||||
export const buildSAUpdatePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('update', `serviceaccount:${id}`);
|
||||
export const buildSADeletePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('delete', `serviceaccount:${id}`);
|
||||
export const buildSAAttachPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('attach', `serviceaccount:${id}`);
|
||||
export const buildSADetachPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('detach', `serviceaccount:${id}`);
|
||||
|
||||
// Wildcard role permissions — used alongside SA-level checks for role assign/revoke guards.
|
||||
// Backend requires both serviceaccount:attach AND role:attach to assign a role to a SA,
|
||||
// and serviceaccount:detach AND role:detach to remove a role from a SA.
|
||||
export const RoleAttachWildcardPermission = buildPermission('attach', 'role:*');
|
||||
export const RoleDetachWildcardPermission = buildPermission('detach', 'role:*');
|
||||
|
||||
// API key (factor-api-key) permissions.
|
||||
// Listing keys: factor-api-key:list.
|
||||
// Creating a key: factor-api-key:create (wildcard) + serviceaccount:attach.
|
||||
// Revoking a key: factor-api-key:delete (specific key) + serviceaccount:detach.
|
||||
export const APIKeyListPermission = buildPermission('list', 'factor-api-key:*');
|
||||
export const APIKeyCreatePermission = buildPermission(
|
||||
'create',
|
||||
'factor-api-key:*',
|
||||
);
|
||||
export const buildAPIKeyUpdatePermission = (keyId: string): BrandedPermission =>
|
||||
buildPermission('update', `factor-api-key:${keyId}`);
|
||||
export const buildAPIKeyDeletePermission = (keyId: string): BrandedPermission =>
|
||||
buildPermission('delete', `factor-api-key:${keyId}`);
|
||||
@@ -1,14 +1,35 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
|
||||
|
||||
import { BrandedPermission } from './types';
|
||||
import { useAuthZ } from './useAuthZ';
|
||||
import { buildPermission } from './utils';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL || '';
|
||||
const AUTHZ_CHECK_URL = `${BASE_URL}/api/v1/authz/check`;
|
||||
|
||||
function authzMockResponse(
|
||||
payload: AuthtypesTransactionDTO[],
|
||||
authorizedByIndex: boolean[],
|
||||
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
|
||||
return {
|
||||
data: payload.map((txn, i) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: authorizedByIndex[i] ?? false,
|
||||
})),
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
const wrapper = ({ children }: { children: ReactElement }): ReactElement => (
|
||||
<AllTheProviders>{children}</AllTheProviders>
|
||||
);
|
||||
|
||||
@@ -72,26 +72,18 @@ function SettingsPage(): JSX.Element {
|
||||
}
|
||||
|
||||
if (isCloudUser) {
|
||||
// Visible to all authenticated users
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
|
||||
if (isAdmin) {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.BILLING ||
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.INTEGRATIONS ||
|
||||
item.key === ROUTES.INGESTION_SETTINGS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
item.key === ROUTES.SHORTCUTS ||
|
||||
item.key === ROUTES.MCP_SERVER
|
||||
? true
|
||||
@@ -121,25 +113,17 @@ function SettingsPage(): JSX.Element {
|
||||
}
|
||||
|
||||
if (isEnterpriseSelfHostedUser) {
|
||||
// Visible to all authenticated users
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
|
||||
if (isAdmin) {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.BILLING ||
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.INTEGRATIONS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
item.key === ROUTES.INGESTION_SETTINGS ||
|
||||
item.key === ROUTES.MCP_SERVER
|
||||
? true
|
||||
@@ -168,22 +152,15 @@ function SettingsPage(): JSX.Element {
|
||||
}
|
||||
|
||||
if (!isCloudUser && !isEnterpriseSelfHostedUser) {
|
||||
// Visible to all authenticated users
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
|
||||
if (isAdmin) {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.ORG_SETTINGS || item.key === ROUTES.MEMBERS_SETTINGS
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_DETAILS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
|
||||
@@ -78,14 +78,11 @@ describe('SettingsPage nav sections', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['workspace', 'account', 'roles', 'service-accounts'])(
|
||||
'renders "%s" element',
|
||||
(id) => {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
it.each(['workspace', 'account'])('renders "%s" element', (id) => {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['billing'])('does not render "%s" element', (id) => {
|
||||
it.each(['billing', 'roles'])('does not render "%s" element', (id) => {
|
||||
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -62,16 +62,13 @@ export const getRoutes = (
|
||||
|
||||
settings.push(...alertChannels(t));
|
||||
|
||||
// Visible to all authenticated users
|
||||
settings.push(
|
||||
...serviceAccountsSettings(t),
|
||||
...rolesSettings(t),
|
||||
...roleDetails(t),
|
||||
);
|
||||
|
||||
// Admin-only: members management
|
||||
if (isAdmin) {
|
||||
settings.push(...membersSettings(t));
|
||||
settings.push(
|
||||
...membersSettings(t),
|
||||
...serviceAccountsSettings(t),
|
||||
...rolesSettings(t),
|
||||
...roleDetails(t),
|
||||
);
|
||||
}
|
||||
|
||||
if ((isCloudUser || isEnterpriseSelfHostedUser) && isAdmin) {
|
||||
|
||||
@@ -2,15 +2,19 @@ import { ReactElement } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import type {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { SINGLE_FLIGHT_WAIT_TIME_MS } from 'hooks/useAuthZ/constants';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
|
||||
|
||||
import { AppProvider, useAppContext } from '../App';
|
||||
|
||||
const AUTHZ_CHECK_URL = 'http://localhost/api/v1/authz/check';
|
||||
const MY_USER_URL = 'http://localhost/api/v2/users/me';
|
||||
const MY_ORG_URL = 'http://localhost/api/v2/orgs/me';
|
||||
|
||||
@@ -18,9 +22,26 @@ jest.mock('constants/env', () => ({
|
||||
ENVIRONMENT: { baseURL: 'http://localhost', wsURL: '' },
|
||||
}));
|
||||
|
||||
/**
|
||||
* Since we are mocking the check permissions, this is needed
|
||||
*/
|
||||
const waitForSinglePreflightToFinish = async (): Promise<void> =>
|
||||
await new Promise((r) => setTimeout(r, SINGLE_FLIGHT_WAIT_TIME_MS));
|
||||
|
||||
function authzMockResponse(
|
||||
payload: AuthtypesTransactionDTO[],
|
||||
authorizedByIndex: boolean[],
|
||||
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
|
||||
return {
|
||||
data: payload.map((txn, i) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: authorizedByIndex[i] ?? false,
|
||||
})),
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import type {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { gettableTransactionToPermission } from 'hooks/useAuthZ/utils';
|
||||
import type {
|
||||
BrandedPermission,
|
||||
UseAuthZOptions,
|
||||
UseAuthZResult,
|
||||
} from 'hooks/useAuthZ/types';
|
||||
import { rest } from 'msw';
|
||||
import type { RestHandler } from 'msw';
|
||||
import {
|
||||
LicenseEvent,
|
||||
LicensePlatform,
|
||||
type LicenseResModel,
|
||||
LicenseState,
|
||||
LicenseStatus,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
|
||||
export const AUTHZ_CHECK_URL = `${ENVIRONMENT.baseURL || ''}/api/v1/authz/check`;
|
||||
|
||||
export function authzMockResponse(
|
||||
payload: AuthtypesTransactionDTO[],
|
||||
authorizedByIndex: boolean[],
|
||||
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
|
||||
return {
|
||||
data: payload.map((txn, i) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: authorizedByIndex[i] ?? false,
|
||||
})),
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
export function setupAuthzAdmin(): RestHandler {
|
||||
return rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map(() => true),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Denies all permission checks. */
|
||||
export function setupAuthzDenyAll(): RestHandler {
|
||||
return rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map(() => false),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Grants all permissions except the ones listed — matched precisely by relation + object. */
|
||||
export function setupAuthzDeny(
|
||||
...permissions: BrandedPermission[]
|
||||
): RestHandler {
|
||||
const denied = new Set<BrandedPermission>(permissions);
|
||||
return rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map((txn) => !denied.has(gettableTransactionToPermission(txn))),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Denies all permissions except the ones listed — matched precisely by relation + object. */
|
||||
export function setupAuthzAllow(
|
||||
...permissions: BrandedPermission[]
|
||||
): RestHandler {
|
||||
const allowed = new Set<BrandedPermission>(permissions);
|
||||
return rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map((txn) => allowed.has(gettableTransactionToPermission(txn))),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLicense(
|
||||
overrides?: Partial<LicenseResModel>,
|
||||
): LicenseResModel {
|
||||
return {
|
||||
key: 'test-key',
|
||||
status: LicenseStatus.VALID,
|
||||
state: LicenseState.ACTIVATED,
|
||||
platform: LicensePlatform.CLOUD,
|
||||
event_queue: {
|
||||
created_at: '0',
|
||||
event: LicenseEvent.NO_EVENT,
|
||||
scheduled_at: '0',
|
||||
status: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
plan: {
|
||||
created_at: '0',
|
||||
description: '',
|
||||
is_active: true,
|
||||
name: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
plan_id: '0',
|
||||
free_until: '0',
|
||||
updated_at: '0',
|
||||
valid_from: 0,
|
||||
valid_until: 0,
|
||||
created_at: '0',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const invalidLicense = buildLicense({ status: LicenseStatus.INVALID });
|
||||
|
||||
export function mockUseAuthZGrantAll(
|
||||
permissions: BrandedPermission[],
|
||||
_options?: UseAuthZOptions,
|
||||
): UseAuthZResult {
|
||||
return {
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: Object.fromEntries(
|
||||
permissions.map((p) => [p, { isGranted: true }]),
|
||||
) as UseAuthZResult['permissions'],
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
export function mockUseAuthZDenyAll(
|
||||
permissions: BrandedPermission[],
|
||||
_options?: UseAuthZOptions,
|
||||
): UseAuthZResult {
|
||||
return {
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: Object.fromEntries(
|
||||
permissions.map((p) => [p, { isGranted: false }]),
|
||||
) as UseAuthZResult['permissions'],
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
HOME: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ALERTS_NEW: ['ADMIN', 'EDITOR'],
|
||||
ORG_SETTINGS: ['ADMIN'],
|
||||
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SERVICE_MAP: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ALL_CHANNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
INGESTION_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -72,7 +72,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR', 'ANONYMOUS'],
|
||||
PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SERVICE_METRICS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SIGN_UP: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACES_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -98,10 +98,10 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
GET_STARTED_AZURE_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
ROLES_SETTINGS: ['ADMIN'],
|
||||
ROLE_DETAILS: ['ADMIN'],
|
||||
MEMBERS_SETTINGS: ['ADMIN'],
|
||||
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN'],
|
||||
BILLING: ['ADMIN'],
|
||||
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
|
||||
16
pkg/cache/memorycache/provider.go
vendored
16
pkg/cache/memorycache/provider.go
vendored
@@ -64,8 +64,7 @@ func New(ctx context.Context, settings factory.ProviderSettings, config cache.Co
|
||||
o.ObserveInt64(telemetry.setsRejected, int64(metrics.SetsRejected()), metric.WithAttributes(attributes...))
|
||||
o.ObserveInt64(telemetry.getsDropped, int64(metrics.GetsDropped()), metric.WithAttributes(attributes...))
|
||||
o.ObserveInt64(telemetry.getsKept, int64(metrics.GetsKept()), metric.WithAttributes(attributes...))
|
||||
o.ObserveInt64(telemetry.costUsed, int64(metrics.CostAdded())-int64(metrics.CostEvicted()), metric.WithAttributes(attributes...))
|
||||
o.ObserveInt64(telemetry.totalCost, cc.MaxCost(), metric.WithAttributes(attributes...))
|
||||
o.ObserveInt64(telemetry.totalCost, int64(cc.MaxCost()), metric.WithAttributes(attributes...))
|
||||
return nil
|
||||
},
|
||||
telemetry.cacheRatio,
|
||||
@@ -80,7 +79,6 @@ func New(ctx context.Context, settings factory.ProviderSettings, config cache.Co
|
||||
telemetry.setsRejected,
|
||||
telemetry.getsDropped,
|
||||
telemetry.getsKept,
|
||||
telemetry.costUsed,
|
||||
telemetry.totalCost,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -114,13 +112,11 @@ func (provider *provider) Set(ctx context.Context, orgID valuer.UUID, cacheKey s
|
||||
}
|
||||
|
||||
if cloneable, ok := data.(cachetypes.Cloneable); ok {
|
||||
cost := max(cloneable.Cost(), 1)
|
||||
// Clamp to a minimum of 1: ristretto treats cost 0 specially and we
|
||||
// never want zero-size entries to bypass admission accounting.
|
||||
span.SetAttributes(attribute.Bool("memory.cloneable", true))
|
||||
span.SetAttributes(attribute.Int64("memory.cost", cost))
|
||||
span.SetAttributes(attribute.Int64("memory.cost", 1))
|
||||
toCache := cloneable.Clone()
|
||||
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, cost, ttl); !ok {
|
||||
// In case of contention we are choosing to evict the cloneable entries first hence cost is set to 1
|
||||
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, 1, ttl); !ok {
|
||||
return errors.New(errors.TypeInternal, errors.CodeInternal, "error writing to cache")
|
||||
}
|
||||
|
||||
@@ -129,15 +125,15 @@ func (provider *provider) Set(ctx context.Context, orgID valuer.UUID, cacheKey s
|
||||
}
|
||||
|
||||
toCache, err := provider.marshalBinary(ctx, data)
|
||||
cost := int64(len(toCache))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cost := max(int64(len(toCache)), 1)
|
||||
|
||||
span.SetAttributes(attribute.Bool("memory.cloneable", false))
|
||||
span.SetAttributes(attribute.Int64("memory.cost", cost))
|
||||
|
||||
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, cost, ttl); !ok {
|
||||
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, 1, ttl); !ok {
|
||||
return errors.New(errors.TypeInternal, errors.CodeInternal, "error writing to cache")
|
||||
}
|
||||
|
||||
|
||||
43
pkg/cache/memorycache/provider_test.go
vendored
43
pkg/cache/memorycache/provider_test.go
vendored
@@ -31,10 +31,6 @@ func (cloneable *CloneableA) Clone() cachetypes.Cacheable {
|
||||
}
|
||||
}
|
||||
|
||||
func (cloneable *CloneableA) Cost() int64 {
|
||||
return int64(len(cloneable.Key)) + 16
|
||||
}
|
||||
|
||||
func (cloneable *CloneableA) MarshalBinary() ([]byte, error) {
|
||||
return json.Marshal(cloneable)
|
||||
}
|
||||
@@ -169,45 +165,6 @@ func TestSetGetWithDifferentTypes(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// LargeCloneable reports a large byte cost so we can test ristretto eviction
|
||||
// without allocating the full payload in memory.
|
||||
type LargeCloneable struct {
|
||||
Key string
|
||||
CostHint int64
|
||||
}
|
||||
|
||||
func (c *LargeCloneable) Clone() cachetypes.Cacheable {
|
||||
return &LargeCloneable{Key: c.Key, CostHint: c.CostHint}
|
||||
}
|
||||
|
||||
func (c *LargeCloneable) Cost() int64 { return c.CostHint }
|
||||
|
||||
func (c *LargeCloneable) MarshalBinary() ([]byte, error) { return json.Marshal(c) }
|
||||
|
||||
func (c *LargeCloneable) UnmarshalBinary(data []byte) error { return json.Unmarshal(data, c) }
|
||||
|
||||
func TestCloneableExceedingMaxCostIsRejected(t *testing.T) {
|
||||
const maxCost int64 = 1 << 20 // 1 MiB
|
||||
const oversize int64 = 2 << 20 // 2 MiB, larger than the entire cache
|
||||
|
||||
c, err := New(context.Background(), factorytest.NewSettings(), cache.Config{Provider: "memory", Memory: cache.Memory{
|
||||
NumCounters: 10 * 1000,
|
||||
MaxCost: maxCost,
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
|
||||
orgID := valuer.GenerateUUID()
|
||||
const key = "oversize-key"
|
||||
assert.NoError(t, c.Set(context.Background(), orgID, key,
|
||||
&LargeCloneable{Key: key, CostHint: oversize}, time.Minute))
|
||||
|
||||
// Ristretto rejects any entry with cost > MaxCost (policy.go:100). Probe
|
||||
// ristretto directly to confirm no admission, instead of relying on metrics.
|
||||
cc := c.(*provider).cc
|
||||
_, ok := cc.Get(strings.Join([]string{orgID.StringValue(), key}, "::"))
|
||||
assert.False(t, ok, "entry with Cost() > MaxCost must be rejected")
|
||||
}
|
||||
|
||||
func TestCloneableConcurrentSetGet(t *testing.T) {
|
||||
cache, err := New(context.Background(), factorytest.NewSettings(), cache.Config{Provider: "memory", Memory: cache.Memory{
|
||||
NumCounters: 10 * 1000,
|
||||
|
||||
53
pkg/cache/memorycache/telemetry.go
vendored
53
pkg/cache/memorycache/telemetry.go
vendored
@@ -7,18 +7,17 @@ import (
|
||||
|
||||
type telemetry struct {
|
||||
cacheRatio metric.Float64ObservableGauge
|
||||
cacheHits metric.Int64ObservableCounter
|
||||
cacheMisses metric.Int64ObservableCounter
|
||||
costAdded metric.Int64ObservableCounter
|
||||
costEvicted metric.Int64ObservableCounter
|
||||
keysAdded metric.Int64ObservableCounter
|
||||
keysEvicted metric.Int64ObservableCounter
|
||||
keysUpdated metric.Int64ObservableCounter
|
||||
setsDropped metric.Int64ObservableCounter
|
||||
setsRejected metric.Int64ObservableCounter
|
||||
getsDropped metric.Int64ObservableCounter
|
||||
getsKept metric.Int64ObservableCounter
|
||||
costUsed metric.Int64ObservableGauge
|
||||
cacheHits metric.Int64ObservableGauge
|
||||
cacheMisses metric.Int64ObservableGauge
|
||||
costAdded metric.Int64ObservableGauge
|
||||
costEvicted metric.Int64ObservableGauge
|
||||
keysAdded metric.Int64ObservableGauge
|
||||
keysEvicted metric.Int64ObservableGauge
|
||||
keysUpdated metric.Int64ObservableGauge
|
||||
setsDropped metric.Int64ObservableGauge
|
||||
setsRejected metric.Int64ObservableGauge
|
||||
getsDropped metric.Int64ObservableGauge
|
||||
getsKept metric.Int64ObservableGauge
|
||||
totalCost metric.Int64ObservableGauge
|
||||
}
|
||||
|
||||
@@ -29,67 +28,62 @@ func newMetrics(meter metric.Meter) (*telemetry, error) {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
cacheHits, err := meter.Int64ObservableCounter("signoz.cache.hits", metric.WithDescription("Hits is the number of Get calls where a value was found for the corresponding key."))
|
||||
cacheHits, err := meter.Int64ObservableGauge("signoz.cache.hits", metric.WithDescription("Hits is the number of Get calls where a value was found for the corresponding key."))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
cacheMisses, err := meter.Int64ObservableCounter("signoz.cache.misses", metric.WithDescription("Misses is the number of Get calls where a value was not found for the corresponding key"))
|
||||
cacheMisses, err := meter.Int64ObservableGauge("signoz.cache.misses", metric.WithDescription("Misses is the number of Get calls where a value was not found for the corresponding key"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
costAdded, err := meter.Int64ObservableCounter("signoz.cache.cost.added", metric.WithDescription("CostAdded is the sum of costs that have been added (successful Set calls)"))
|
||||
costAdded, err := meter.Int64ObservableGauge("signoz.cache.cost.added", metric.WithDescription("CostAdded is the sum of costs that have been added (successful Set calls)"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
costEvicted, err := meter.Int64ObservableCounter("signoz.cache.cost.evicted", metric.WithDescription("CostEvicted is the sum of all costs that have been evicted"))
|
||||
costEvicted, err := meter.Int64ObservableGauge("signoz.cache.cost.evicted", metric.WithDescription("CostEvicted is the sum of all costs that have been evicted"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
keysAdded, err := meter.Int64ObservableCounter("signoz.cache.keys.added", metric.WithDescription("KeysAdded is the total number of Set calls where a new key-value item was added"))
|
||||
keysAdded, err := meter.Int64ObservableGauge("signoz.cache.keys.added", metric.WithDescription("KeysAdded is the total number of Set calls where a new key-value item was added"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
keysEvicted, err := meter.Int64ObservableCounter("signoz.cache.keys.evicted", metric.WithDescription("KeysEvicted is the total number of keys evicted"))
|
||||
keysEvicted, err := meter.Int64ObservableGauge("signoz.cache.keys.evicted", metric.WithDescription("KeysEvicted is the total number of keys evicted"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
keysUpdated, err := meter.Int64ObservableCounter("signoz.cache.keys.updated", metric.WithDescription("KeysUpdated is the total number of Set calls where the value was updated"))
|
||||
keysUpdated, err := meter.Int64ObservableGauge("signoz.cache.keys.updated", metric.WithDescription("KeysUpdated is the total number of Set calls where the value was updated"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
setsDropped, err := meter.Int64ObservableCounter("signoz.cache.sets.dropped", metric.WithDescription("SetsDropped is the number of Set calls that don't make it into internal buffers (due to contention or some other reason)"))
|
||||
setsDropped, err := meter.Int64ObservableGauge("signoz.cache.sets.dropped", metric.WithDescription("SetsDropped is the number of Set calls that don't make it into internal buffers (due to contention or some other reason)"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
setsRejected, err := meter.Int64ObservableCounter("signoz.cache.sets.rejected", metric.WithDescription("SetsRejected is the number of Set calls rejected by the policy (TinyLFU)"))
|
||||
setsRejected, err := meter.Int64ObservableGauge("signoz.cache.sets.rejected", metric.WithDescription("SetsRejected is the number of Set calls rejected by the policy (TinyLFU)"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
getsDropped, err := meter.Int64ObservableCounter("signoz.cache.gets.dropped", metric.WithDescription("GetsDropped is the number of Get calls that don't make it into internal buffers (due to contention or some other reason)"))
|
||||
getsDropped, err := meter.Int64ObservableGauge("signoz.cache.gets.dropped", metric.WithDescription("GetsDropped is the number of Get calls that don't make it into internal buffers (due to contention or some other reason)"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
getsKept, err := meter.Int64ObservableCounter("signoz.cache.gets.kept", metric.WithDescription("GetsKept is the number of Get calls that make it into internal buffers"))
|
||||
getsKept, err := meter.Int64ObservableGauge("signoz.cache.gets.kept", metric.WithDescription("GetsKept is the number of Get calls that make it into internal buffers"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
costUsed, err := meter.Int64ObservableGauge("signoz.cache.cost.used", metric.WithDescription("CostUsed is the current retained cost in the cache (CostAdded - CostEvicted)."))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
totalCost, err := meter.Int64ObservableGauge("signoz.cache.total.cost", metric.WithDescription("TotalCost is the configured MaxCost ceiling for the cache."))
|
||||
totalCost, err := meter.Int64ObservableGauge("signoz.cache.total.cost", metric.WithDescription("TotalCost is the available cost configured for the cache"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
@@ -111,7 +105,6 @@ func newMetrics(meter metric.Meter) (*telemetry, error) {
|
||||
setsRejected: setsRejected,
|
||||
getsDropped: getsDropped,
|
||||
getsKept: getsKept,
|
||||
costUsed: costUsed,
|
||||
totalCost: totalCost,
|
||||
}, nil
|
||||
}
|
||||
|
||||
4
pkg/cache/rediscache/provider_test.go
vendored
4
pkg/cache/rediscache/provider_test.go
vendored
@@ -29,10 +29,6 @@ func (cacheable *CacheableA) Clone() cachetypes.Cacheable {
|
||||
}
|
||||
}
|
||||
|
||||
func (cacheable *CacheableA) Cost() int64 {
|
||||
return int64(len(cacheable.Key)) + 16
|
||||
}
|
||||
|
||||
func (cacheable *CacheableA) MarshalBinary() ([]byte, error) {
|
||||
return json.Marshal(cacheable)
|
||||
}
|
||||
|
||||
@@ -335,8 +335,10 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
|
||||
}
|
||||
case qbtypes.RequestTypeScalar:
|
||||
result := q.processScalarFormula(ctx, results, formula, req)
|
||||
// For scalar results, apply limit by processScalarFormula itself since it needs to be applied before converting back to scalar format
|
||||
results[name] = result
|
||||
if result != nil {
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
|
||||
results[name] = result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,9 +526,6 @@ func (q *querier) processScalarFormula(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply ordering (and limit) before converting to scalar format.
|
||||
formulaSeries = qbtypes.ApplySeriesLimit(formulaSeries, formula.Order, formula.Limit)
|
||||
|
||||
// Convert back to scalar format
|
||||
scalarResult := &qbtypes.ScalarData{
|
||||
QueryName: formula.Name,
|
||||
|
||||
@@ -1,155 +1,15 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// scalarInputResult builds a ScalarData result with one group column ("service")
|
||||
// and one aggregation column ("__result"), holding the provided (service, value) rows.
|
||||
func scalarInputResult(queryName string, rows []struct {
|
||||
service string
|
||||
value float64
|
||||
}) *qbtypes.Result {
|
||||
serviceKey := telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
resultKey := telemetrytypes.TelemetryFieldKey{
|
||||
Name: "__result",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
|
||||
}
|
||||
|
||||
data := make([][]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
data = append(data, []any{r.service, r.value})
|
||||
}
|
||||
|
||||
return &qbtypes.Result{
|
||||
Value: &qbtypes.ScalarData{
|
||||
QueryName: queryName,
|
||||
Columns: []*qbtypes.ColumnDescriptor{
|
||||
{
|
||||
TelemetryFieldKey: serviceKey,
|
||||
QueryName: queryName,
|
||||
Type: qbtypes.ColumnTypeGroup,
|
||||
},
|
||||
{
|
||||
TelemetryFieldKey: resultKey,
|
||||
QueryName: queryName,
|
||||
AggregationIndex: 0,
|
||||
Type: qbtypes.ColumnTypeAggregation,
|
||||
},
|
||||
},
|
||||
Data: data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessScalarFormula_AppliesOrderAndLimit(t *testing.T) {
|
||||
q := &querier{
|
||||
logger: instrumentationtest.New().Logger(),
|
||||
}
|
||||
|
||||
// Mimic what a dashboard emits: orderBy keyed by the formula name ("F1"),
|
||||
// which applyFormulas rewrites to __result before sorting.
|
||||
orderByFormula := func(name string, dir qbtypes.OrderDirection) []qbtypes.OrderBy {
|
||||
return []qbtypes.OrderBy{
|
||||
{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
},
|
||||
},
|
||||
Direction: dir,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// A+B per service: a=101, b=11, c=2
|
||||
makeInputs := func() map[string]*qbtypes.Result {
|
||||
return map[string]*qbtypes.Result{
|
||||
"A": scalarInputResult("A", []struct {
|
||||
service string
|
||||
value float64
|
||||
}{
|
||||
{"a", 100},
|
||||
{"b", 10},
|
||||
{"c", 1},
|
||||
}),
|
||||
"B": scalarInputResult("B", []struct {
|
||||
service string
|
||||
value float64
|
||||
}{
|
||||
{"a", 1},
|
||||
{"b", 0},
|
||||
{"c", 1},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
makeReq := func(formula qbtypes.QueryBuilderFormula) *qbtypes.QueryRangeRequest {
|
||||
return &qbtypes.QueryRangeRequest{
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{Type: qbtypes.QueryTypeBuilder, Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{Name: "A"}},
|
||||
{Type: qbtypes.QueryTypeBuilder, Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{Name: "B"}},
|
||||
{Type: qbtypes.QueryTypeFormula, Spec: formula},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("F1 desc with limit truncates and sorts", func(t *testing.T) {
|
||||
formula := qbtypes.QueryBuilderFormula{
|
||||
Name: "F1",
|
||||
Expression: "A + B",
|
||||
Order: orderByFormula("F1", qbtypes.OrderDirectionDesc),
|
||||
Limit: 2,
|
||||
}
|
||||
|
||||
out := q.applyFormulas(context.Background(), makeInputs(), makeReq(formula))
|
||||
got, ok := out["F1"]
|
||||
require.True(t, ok, "formula result missing")
|
||||
scalar, ok := got.Value.(*qbtypes.ScalarData)
|
||||
require.True(t, ok, "expected *ScalarData, got %T", got.Value)
|
||||
|
||||
// Limit=2 + F1 desc: the two largest __result rows in descending order.
|
||||
require.Len(t, scalar.Data, 2, "limit=2 was ignored before the fix")
|
||||
require.Equal(t, "a", scalar.Data[0][0])
|
||||
require.InDelta(t, 101.0, scalar.Data[0][1].(float64), 1e-9)
|
||||
require.Equal(t, "b", scalar.Data[1][0])
|
||||
require.InDelta(t, 10.0, scalar.Data[1][1].(float64), 1e-9)
|
||||
})
|
||||
|
||||
t.Run("F1 desc without limit sorts all rows", func(t *testing.T) {
|
||||
formula := qbtypes.QueryBuilderFormula{
|
||||
Name: "F1",
|
||||
Expression: "A / B",
|
||||
Order: orderByFormula("F1", qbtypes.OrderDirectionAsc),
|
||||
}
|
||||
|
||||
out := q.applyFormulas(context.Background(), makeInputs(), makeReq(formula))
|
||||
got, ok := out["F1"]
|
||||
require.True(t, ok)
|
||||
scalar, ok := got.Value.(*qbtypes.ScalarData)
|
||||
require.True(t, ok)
|
||||
|
||||
require.Len(t, scalar.Data, 2)
|
||||
require.Equal(t, "c", scalar.Data[0][0])
|
||||
require.InDelta(t, 1.0, scalar.Data[0][1].(float64), 1e-9)
|
||||
require.Equal(t, "a", scalar.Data[1][0])
|
||||
require.InDelta(t, 100.0, scalar.Data[1][1].(float64), 1e-9)
|
||||
})
|
||||
}
|
||||
|
||||
// Multiple series with different number of labels, shouldn't panic and should align labels correctly.
|
||||
func TestConvertTimeSeriesDataToScalar_RaggedLabels(t *testing.T) {
|
||||
label := func(name string, value any) *qbtypes.Label {
|
||||
|
||||
@@ -769,13 +769,6 @@ func ParseQueryRangeParams(r *http.Request) (*v3.QueryRangeParamsV3, *model.ApiE
|
||||
return nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
|
||||
}
|
||||
|
||||
// Clamp the top-level Step for PromQL
|
||||
if queryRangeParams.CompositeQuery.QueryType == v3.QueryTypePromQL {
|
||||
if minStep := common.MinAllowedStepInterval(queryRangeParams.Start, queryRangeParams.End); queryRangeParams.Step < minStep {
|
||||
queryRangeParams.Step = minStep
|
||||
}
|
||||
}
|
||||
|
||||
// prepare the variables for the corresponding query type
|
||||
formattedVars := make(map[string]interface{})
|
||||
for name, value := range queryRangeParams.Variables {
|
||||
|
||||
@@ -41,11 +41,6 @@ func (c *GetWaterfallSpansForTraceWithMetadataCache) Clone() cachetypes.Cacheabl
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GetWaterfallSpansForTraceWithMetadataCache) Cost() int64 {
|
||||
const perSpanBytes = 256
|
||||
return int64(c.TotalSpans) * perSpanBytes
|
||||
}
|
||||
|
||||
func (c *GetWaterfallSpansForTraceWithMetadataCache) MarshalBinary() (data []byte, err error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
@@ -71,16 +66,6 @@ func (c *GetFlamegraphSpansForTraceCache) Clone() cachetypes.Cacheable {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GetFlamegraphSpansForTraceCache) Cost() int64 {
|
||||
const perSpanBytes = 128
|
||||
var spans int64
|
||||
for _, row := range c.SelectedSpans {
|
||||
spans += int64(len(row))
|
||||
}
|
||||
spans += int64(len(c.TraceRoots))
|
||||
return spans * perSpanBytes
|
||||
}
|
||||
|
||||
func (c *GetFlamegraphSpansForTraceCache) MarshalBinary() (data []byte, err error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ func (c *conditionBuilder) conditionFor(
|
||||
column := columns[0]
|
||||
if len(key.Evolutions) > 0 {
|
||||
// we will use the corresponding column and its evolution entry for the query
|
||||
newColumns, _, err := selectEvolutionsForColumns(columns, key.Evolutions, startNs, endNs)
|
||||
newColumns, _, err := qbtypes.SelectEvolutionsForColumns(columns, key.Evolutions, startNs, endNs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@ package telemetrylogs
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz-otel-collector/utils"
|
||||
@@ -137,113 +133,6 @@ func (m *fieldMapper) getColumn(ctx context.Context, key *telemetrytypes.Telemet
|
||||
return nil, qbtypes.ErrColumnNotFound
|
||||
}
|
||||
|
||||
// selectEvolutionsForColumns selects the appropriate evolution entries for each column based on the time range.
|
||||
// Logic:
|
||||
// - Finds the latest base evolution (<= tsStartTime) across ALL columns
|
||||
// - Rejects all evolutions before this latest base evolution
|
||||
// - For duplicate evolutions it considers the oldest one (first in ReleaseTime)
|
||||
// - For each column, includes its evolution if it's >= latest base evolution and <= tsEndTime
|
||||
// - Results are sorted by ReleaseTime descending (newest first)
|
||||
func selectEvolutionsForColumns(columns []*schema.Column, evolutions []*telemetrytypes.EvolutionEntry, tsStart, tsEnd uint64) ([]*schema.Column, []*telemetrytypes.EvolutionEntry, error) {
|
||||
|
||||
sortedEvolutions := make([]*telemetrytypes.EvolutionEntry, len(evolutions))
|
||||
copy(sortedEvolutions, evolutions)
|
||||
|
||||
// sort the evolutions by ReleaseTime ascending
|
||||
sort.Slice(sortedEvolutions, func(i, j int) bool {
|
||||
return sortedEvolutions[i].ReleaseTime.Before(sortedEvolutions[j].ReleaseTime)
|
||||
})
|
||||
|
||||
tsStartTime := time.Unix(0, int64(tsStart))
|
||||
tsEndTime := time.Unix(0, int64(tsEnd))
|
||||
|
||||
// Build evolution map: column name -> evolution
|
||||
evolutionMap := make(map[string]*telemetrytypes.EvolutionEntry)
|
||||
for _, evolution := range sortedEvolutions {
|
||||
if _, exists := evolutionMap[evolution.ColumnName+":"+evolution.FieldName+":"+strconv.Itoa(int(evolution.Version))]; exists {
|
||||
// since if there is duplicate we would just use the oldest one.
|
||||
continue
|
||||
}
|
||||
evolutionMap[evolution.ColumnName+":"+evolution.FieldName+":"+strconv.Itoa(int(evolution.Version))] = evolution
|
||||
}
|
||||
|
||||
// Find the latest base evolution (<= tsStartTime) across ALL columns
|
||||
// Evolutions are sorted, so we can break early
|
||||
var latestBaseEvolutionAcrossAll *telemetrytypes.EvolutionEntry
|
||||
for _, evolution := range sortedEvolutions {
|
||||
if evolution.ReleaseTime.After(tsStartTime) {
|
||||
break
|
||||
}
|
||||
latestBaseEvolutionAcrossAll = evolution
|
||||
}
|
||||
|
||||
// We shouldn't reach this, it basically means there is something wrong with the evolutions data
|
||||
if latestBaseEvolutionAcrossAll == nil {
|
||||
return nil, nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "no base evolution found for columns %v", columns)
|
||||
}
|
||||
|
||||
columnLookUpMap := make(map[string]*schema.Column)
|
||||
for _, column := range columns {
|
||||
columnLookUpMap[column.Name] = column
|
||||
}
|
||||
|
||||
// Collect column-evolution pairs
|
||||
type colEvoPair struct {
|
||||
column *schema.Column
|
||||
evolution *telemetrytypes.EvolutionEntry
|
||||
}
|
||||
pairs := []colEvoPair{}
|
||||
|
||||
for _, evolution := range evolutionMap {
|
||||
// Reject evolutions before the latest base evolution
|
||||
if evolution.ReleaseTime.Before(latestBaseEvolutionAcrossAll.ReleaseTime) {
|
||||
continue
|
||||
}
|
||||
// skip evolutions after tsEndTime
|
||||
if evolution.ReleaseTime.After(tsEndTime) || evolution.ReleaseTime.Equal(tsEndTime) {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := columnLookUpMap[evolution.ColumnName]; !exists {
|
||||
return nil, nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "evolution column %s not found in columns %v", evolution.ColumnName, columns)
|
||||
}
|
||||
|
||||
pairs = append(pairs, colEvoPair{columnLookUpMap[evolution.ColumnName], evolution})
|
||||
}
|
||||
|
||||
// If no pairs found, fall back to latestBaseEvolutionAcrossAll for matching columns
|
||||
if len(pairs) == 0 {
|
||||
for _, column := range columns {
|
||||
// Use latestBaseEvolutionAcrossAll if this column name matches its column name
|
||||
if column.Name == latestBaseEvolutionAcrossAll.ColumnName {
|
||||
pairs = append(pairs, colEvoPair{column, latestBaseEvolutionAcrossAll})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by ReleaseTime descending (newest first)
|
||||
slices.SortFunc(pairs, func(a, b colEvoPair) int {
|
||||
// Sort by ReleaseTime descending (newest first)
|
||||
if a.evolution.ReleaseTime.After(b.evolution.ReleaseTime) {
|
||||
return -1
|
||||
}
|
||||
if a.evolution.ReleaseTime.Before(b.evolution.ReleaseTime) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
// Extract results
|
||||
newColumns := make([]*schema.Column, len(pairs))
|
||||
evolutionsEntries := make([]*telemetrytypes.EvolutionEntry, len(pairs))
|
||||
for i, pair := range pairs {
|
||||
newColumns[i] = pair.column
|
||||
evolutionsEntries[i] = pair.evolution
|
||||
}
|
||||
|
||||
return newColumns, evolutionsEntries, nil
|
||||
}
|
||||
|
||||
func (m *fieldMapper) FieldFor(ctx context.Context, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
columns, err := m.getColumn(ctx, key)
|
||||
if err != nil {
|
||||
@@ -254,7 +143,7 @@ func (m *fieldMapper) FieldFor(ctx context.Context, tsStart, tsEnd uint64, key *
|
||||
var evolutionsEntries []*telemetrytypes.EvolutionEntry
|
||||
if len(key.Evolutions) > 0 {
|
||||
// we will use the corresponding column and its evolution entry for the query
|
||||
newColumns, evolutionsEntries, err = selectEvolutionsForColumns(columns, key.Evolutions, tsStart, tsEnd)
|
||||
newColumns, evolutionsEntries, err = qbtypes.SelectEvolutionsForColumns(columns, key.Evolutions, tsStart, tsEnd)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -536,390 +536,6 @@ func TestFieldForWithEvolutions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectEvolutionsForColumns(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
columns []*schema.Column
|
||||
evolutions []*telemetrytypes.EvolutionEntry
|
||||
tsStart uint64
|
||||
tsEnd uint64
|
||||
expectedColumns []string // column names
|
||||
expectedEvols []string // evolution column names
|
||||
expectedError bool
|
||||
errorStr string
|
||||
}{
|
||||
{
|
||||
name: "New evolutions at tsStartTime - should include latest evolution",
|
||||
columns: []*schema.Column{
|
||||
logsV2Columns["resources_string"],
|
||||
logsV2Columns["resource"],
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 1,
|
||||
ReleaseTime: time.Date(2024, 2, 25, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 25, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 30, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resource"},
|
||||
expectedEvols: []string{"resource"},
|
||||
},
|
||||
{
|
||||
name: "New evolutions after tsStartTime but less than tsEndTime - should include both",
|
||||
columns: []*schema.Column{
|
||||
logsV2Columns["resources_string"],
|
||||
logsV2Columns["resource"],
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 1,
|
||||
ReleaseTime: time.Date(2024, 2, 3, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resource", "resources_string"}, // sorted by ReleaseTime desc
|
||||
expectedEvols: []string{"resource", "resources_string"},
|
||||
},
|
||||
{
|
||||
name: "Columns without matching evolutions - should exclude them",
|
||||
columns: []*schema.Column{
|
||||
logsV2Columns["resources_string"],
|
||||
logsV2Columns["resource"], // no evolution for this
|
||||
logsV2Columns["attributes_string"], // no evolution for this
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resources_string"},
|
||||
expectedEvols: []string{"resources_string"},
|
||||
},
|
||||
{
|
||||
name: "New evolutions at tsEndTime - should not include new evolution",
|
||||
columns: []*schema.Column{
|
||||
logsV2Columns["resources_string"],
|
||||
logsV2Columns["resource"],
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 1,
|
||||
ReleaseTime: time.Date(2024, 2, 30, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 25, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 30, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resources_string"},
|
||||
expectedEvols: []string{"resources_string"},
|
||||
},
|
||||
{
|
||||
name: "New evolutions after tsEndTime - should exclude new",
|
||||
columns: []*schema.Column{
|
||||
logsV2Columns["resources_string"],
|
||||
logsV2Columns["resource"],
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 1,
|
||||
ReleaseTime: time.Date(2024, 2, 25, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resources_string"},
|
||||
expectedEvols: []string{"resources_string"},
|
||||
},
|
||||
{
|
||||
name: "Empty columns array",
|
||||
columns: []*schema.Column{},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{},
|
||||
expectedEvols: []string{},
|
||||
expectedError: true,
|
||||
errorStr: "column resources_string not found",
|
||||
},
|
||||
{
|
||||
name: "Duplicate evolutions - should use first encountered (oldest if sorted)",
|
||||
columns: []*schema.Column{
|
||||
logsV2Columns["resource"],
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 1,
|
||||
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 1,
|
||||
ReleaseTime: time.Date(2024, 1, 20, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resource"},
|
||||
expectedEvols: []string{"resource"}, // should use first one (older)
|
||||
},
|
||||
{
|
||||
name: "Genuine Duplicate evolutions with new version- should consider both",
|
||||
columns: []*schema.Column{
|
||||
logsV2Columns["resources_string"],
|
||||
logsV2Columns["resource"],
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 1,
|
||||
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 2,
|
||||
ReleaseTime: time.Date(2024, 1, 20, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 1, 16, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resources_string", "resource"},
|
||||
expectedEvols: []string{"resources_string", "resource"}, // should use first one (older)
|
||||
},
|
||||
{
|
||||
name: "Evolution exactly at tsEndTime",
|
||||
columns: []*schema.Column{
|
||||
logsV2Columns["resources_string"],
|
||||
logsV2Columns["resource"],
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
ReleaseTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC), // exactly at tsEnd
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resources_string"}, // resource excluded because After(tsEnd) is true
|
||||
expectedEvols: []string{"resources_string"},
|
||||
},
|
||||
{
|
||||
name: "Single evolution after tsStartTime - JSON body",
|
||||
columns: []*schema.Column{
|
||||
logsV2Columns[LogsV2BodyV2Column],
|
||||
logsV2Columns[LogsV2BodyPromotedColumn],
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: LogsV2BodyV2Column,
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
FieldName: "__all__",
|
||||
ReleaseTime: time.Unix(0, 0),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: LogsV2BodyPromotedColumn,
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
FieldName: "user.name",
|
||||
ReleaseTime: time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{LogsV2BodyPromotedColumn, LogsV2BodyV2Column}, // sorted by ReleaseTime desc (newest first)
|
||||
expectedEvols: []string{LogsV2BodyPromotedColumn, LogsV2BodyV2Column},
|
||||
},
|
||||
{
|
||||
name: "No evolution after tsStartTime - JSON body",
|
||||
columns: []*schema.Column{
|
||||
logsV2Columns[LogsV2BodyV2Column],
|
||||
logsV2Columns[LogsV2BodyPromotedColumn],
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: LogsV2BodyV2Column,
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
FieldName: "__all__",
|
||||
ReleaseTime: time.Unix(0, 0),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: LogsV2BodyPromotedColumn,
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
FieldName: "user.name",
|
||||
ReleaseTime: time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 3, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{LogsV2BodyPromotedColumn},
|
||||
expectedEvols: []string{LogsV2BodyPromotedColumn},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resultColumns, resultEvols, err := selectEvolutionsForColumns(tc.columns, tc.evolutions, tc.tsStart, tc.tsEnd)
|
||||
|
||||
if tc.expectedError {
|
||||
assert.Contains(t, err.Error(), tc.errorStr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(tc.expectedColumns), len(resultColumns), "column count mismatch")
|
||||
assert.Equal(t, len(tc.expectedEvols), len(resultEvols), "evolution count mismatch")
|
||||
|
||||
resultColumnNames := make([]string, len(resultColumns))
|
||||
for i, col := range resultColumns {
|
||||
resultColumnNames[i] = col.Name
|
||||
}
|
||||
resultEvolNames := make([]string, len(resultEvols))
|
||||
for i, evol := range resultEvols {
|
||||
resultEvolNames[i] = evol.ColumnName
|
||||
}
|
||||
|
||||
for i := range tc.expectedColumns {
|
||||
assert.Equal(t, resultColumnNames[i], tc.expectedColumns[i], "expected column missing: "+tc.expectedColumns[i])
|
||||
}
|
||||
for i := range tc.expectedEvols {
|
||||
assert.Equal(t, resultEvolNames[i], tc.expectedEvols[i], "expected evolution missing: "+tc.expectedEvols[i])
|
||||
}
|
||||
// Verify sorting: should be descending by ReleaseTime
|
||||
for i := 0; i < len(resultEvols)-1; i++ {
|
||||
assert.True(t, !resultEvols[i].ReleaseTime.Before(resultEvols[i+1].ReleaseTime),
|
||||
"evolutions should be sorted descending by ReleaseTime")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldForWithMaterialized(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -344,6 +344,11 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err = t.updateColumnEvolutionMetadataForKeys(ctx, keys); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return keys, complete, nil
|
||||
}
|
||||
|
||||
@@ -689,7 +694,7 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, fieldKeySelectors
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := t.updateColumnEvolutionMetadataForKeys(ctx, keys); err != nil {
|
||||
if err := t.updateColumnEvolutionMetadataForKeys(ctx, keys); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
@@ -2370,8 +2375,8 @@ func (k *telemetryMetaStore) fetchEvolutionEntryFromClickHouse(ctx context.Conte
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// Get retrieves all evolutions for the given selectors from DB.
|
||||
func (k *telemetryMetaStore) updateColumnEvolutionMetadataForKeys(ctx context.Context, keysToUpdate []*telemetrytypes.TelemetryFieldKey) (map[string][]*telemetrytypes.EvolutionEntry, error) {
|
||||
// updateColumnEvolutionMetadataForKeys updates the evolution field for keys.
|
||||
func (k *telemetryMetaStore) updateColumnEvolutionMetadataForKeys(ctx context.Context, keysToUpdate []*telemetrytypes.TelemetryFieldKey) error {
|
||||
|
||||
var metadataKeySelectors []*telemetrytypes.EvolutionSelector
|
||||
for _, keySelector := range keysToUpdate {
|
||||
@@ -2385,7 +2390,7 @@ func (k *telemetryMetaStore) updateColumnEvolutionMetadataForKeys(ctx context.Co
|
||||
|
||||
evolutions, err := k.fetchEvolutionEntryFromClickHouse(ctx, metadataKeySelectors)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to fetch evolution from clickhouse %s", err.Error())
|
||||
return errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to fetch evolution from clickhouse %s", err.Error())
|
||||
}
|
||||
|
||||
evolutionsByUniqueKey := make(map[string][]*telemetrytypes.EvolutionEntry)
|
||||
@@ -2416,7 +2421,7 @@ func (k *telemetryMetaStore) updateColumnEvolutionMetadataForKeys(ctx context.Co
|
||||
}
|
||||
}
|
||||
}
|
||||
return evolutionsByUniqueKey, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// chunkSizeFirstSeenMetricMetadata limits the number of tuples per SQL query to avoid hitting the max_query_size limit.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryaudit"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
@@ -17,7 +18,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
cmock "github.com/srikanthccv/ClickHouse-go-mock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -89,6 +89,19 @@ func TestGetKeys(t *testing.T) {
|
||||
{Name: "tag_data_type", Type: "String"},
|
||||
{Name: "priority", Type: "UInt8"},
|
||||
}, [][]any{{"http.method", "tag", "String", 1}, {"http.method", "tag", "String", 1}}))
|
||||
|
||||
mock.ExpectQuery(`FROM signoz_metadata\.distributed_column_evolution_metadata`).
|
||||
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
|
||||
WillReturnRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "signal", Type: "String"},
|
||||
{Name: "column_name", Type: "String"},
|
||||
{Name: "column_type", Type: "String"},
|
||||
{Name: "field_context", Type: "String"},
|
||||
{Name: "field_name", Type: "String"},
|
||||
{Name: "version", Type: "UInt32"},
|
||||
{Name: "release_time", Type: "Float64"},
|
||||
}, [][]any{}))
|
||||
|
||||
keys, _, err := metadata.GetKeys(context.Background(), &telemetrytypes.FieldKeySelector{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
@@ -247,6 +260,27 @@ func TestApplyBackwardCompatibleKeys(t *testing.T) {
|
||||
}, rows))
|
||||
}
|
||||
|
||||
// getTracesKeys / getLogsKeys both fetch evolution metadata; return an empty
|
||||
// result so the existing test data flows through unchanged. Each input key
|
||||
// becomes one selector contributing four bound args.
|
||||
if hasTraces || hasLogs {
|
||||
evoArgs := make([]any, 0, len(tt.inputKeys)*4)
|
||||
for range tt.inputKeys {
|
||||
evoArgs = append(evoArgs, nil, nil, nil, nil)
|
||||
}
|
||||
mock.ExpectQuery(`FROM signoz_metadata\.distributed_column_evolution_metadata`).
|
||||
WithArgs(evoArgs...).
|
||||
WillReturnRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "signal", Type: "String"},
|
||||
{Name: "column_name", Type: "String"},
|
||||
{Name: "column_type", Type: "String"},
|
||||
{Name: "field_context", Type: "String"},
|
||||
{Name: "field_name", Type: "String"},
|
||||
{Name: "version", Type: "UInt32"},
|
||||
{Name: "release_time", Type: "Float64"},
|
||||
}, [][]any{}))
|
||||
}
|
||||
|
||||
selectors := []*telemetrytypes.FieldKeySelector{}
|
||||
for _, key := range tt.inputKeys {
|
||||
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
|
||||
|
||||
@@ -161,7 +161,33 @@ func (c *conditionBuilder) conditionFor(
|
||||
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
|
||||
|
||||
var value any
|
||||
switch columns[0].Type.GetType() {
|
||||
column := columns[0]
|
||||
if len(key.Evolutions) > 0 {
|
||||
// we will use the corresponding column and its evolution entry for the query
|
||||
newColumns, _, err := qbtypes.SelectEvolutionsForColumns(columns, key.Evolutions, startNs, endNs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(newColumns) == 0 {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "no valid evolution found for field %s in the given time range", key.Name)
|
||||
}
|
||||
|
||||
// Multiple columns means fieldExpression is a multiIf returning NULL when none match,
|
||||
// so a simple null check is sufficient.
|
||||
if len(newColumns) > 1 {
|
||||
if operator == qbtypes.FilterOperatorExists {
|
||||
return sb.IsNotNull(fieldExpression), nil
|
||||
} else {
|
||||
return sb.IsNull(fieldExpression), nil
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise we have to find the correct exist operator based on the column type
|
||||
column = newColumns[0]
|
||||
}
|
||||
|
||||
switch column.Type.GetType() {
|
||||
case schema.ColumnTypeEnumJSON:
|
||||
if operator == qbtypes.FilterOperatorExists {
|
||||
return sb.IsNotNull(fieldExpression), nil
|
||||
@@ -178,7 +204,7 @@ func (c *conditionBuilder) conditionFor(
|
||||
return sb.E(fieldExpression, value), nil
|
||||
}
|
||||
case schema.ColumnTypeEnumLowCardinality:
|
||||
switch elementType := columns[0].Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
|
||||
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
|
||||
case schema.ColumnTypeEnumString:
|
||||
value = ""
|
||||
if operator == qbtypes.FilterOperatorExists {
|
||||
@@ -202,14 +228,14 @@ func (c *conditionBuilder) conditionFor(
|
||||
return sb.E(fieldExpression, value), nil
|
||||
}
|
||||
case schema.ColumnTypeEnumMap:
|
||||
keyType := columns[0].Type.(schema.MapColumnType).KeyType
|
||||
keyType := column.Type.(schema.MapColumnType).KeyType
|
||||
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, columns[0].Type)
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
|
||||
}
|
||||
|
||||
switch valueType := columns[0].Type.(schema.MapColumnType).ValueType; valueType.GetType() {
|
||||
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
|
||||
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
|
||||
leftOperand := fmt.Sprintf("mapContains(%s, '%s')", columns[0].Name, key.Name)
|
||||
leftOperand := fmt.Sprintf("mapContains(%s, '%s')", column.Name, key.Name)
|
||||
if key.Materialized {
|
||||
leftOperand = telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key)
|
||||
}
|
||||
@@ -222,7 +248,7 @@ func (c *conditionBuilder) conditionFor(
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for map column type %s", valueType)
|
||||
}
|
||||
default:
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for column type %s", columns[0].Type)
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for column type %s", column.Type)
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
|
||||
@@ -3,6 +3,7 @@ package telemetrytraces
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
func TestConditionFor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
mockEvolution := mockEvolutionData(time.Date(2025, 10, 26, 0, 10, 0, 0, time.UTC))
|
||||
testCases := []struct {
|
||||
name string
|
||||
key telemetrytypes.TelemetryFieldKey
|
||||
@@ -213,6 +215,7 @@ func TestConditionFor(t *testing.T) {
|
||||
Name: "service.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Evolutions: mockEvolution,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorExists,
|
||||
value: nil,
|
||||
@@ -225,6 +228,7 @@ func TestConditionFor(t *testing.T) {
|
||||
Name: "service.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Evolutions: mockEvolution,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotExists,
|
||||
value: nil,
|
||||
@@ -302,3 +306,85 @@ func TestConditionFor(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionForResourceWithEvolution(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
releaseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
evolutions := mockEvolutionData(releaseTime)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
key telemetrytypes.TelemetryFieldKey
|
||||
operator qbtypes.FilterOperator
|
||||
tsStart uint64
|
||||
tsEnd uint64
|
||||
expectedSQL string
|
||||
}{
|
||||
{
|
||||
name: "Exists - window after release - JSON only",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Evolutions: evolutions,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorExists,
|
||||
tsStart: uint64(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedSQL: "WHERE resource.`service.name`::String IS NOT NULL",
|
||||
},
|
||||
{
|
||||
name: "NotExists - window after release - JSON only",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Evolutions: evolutions,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotExists,
|
||||
tsStart: uint64(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedSQL: "WHERE resource.`service.name`::String IS NULL",
|
||||
},
|
||||
{
|
||||
name: "Exists - window before release - map only",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Evolutions: evolutions,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorExists,
|
||||
tsStart: uint64(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedSQL: "WHERE mapContains(resources_string, 'service.name') = ?",
|
||||
},
|
||||
{
|
||||
name: "Exists - window straddles release - multiIf null check",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Evolutions: evolutions,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorExists,
|
||||
tsStart: uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedSQL: "WHERE multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL",
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
conditionBuilder := NewConditionBuilder(fm)
|
||||
|
||||
for _, tc := range testCases {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cond, err := conditionBuilder.ConditionFor(ctx, tc.tsStart, tc.tsEnd, &tc.key, tc.operator, nil, sb)
|
||||
require.NoError(t, err)
|
||||
sb.Where(cond)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
assert.Contains(t, sql, tc.expectedSQL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package telemetrytraces
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
var (
|
||||
IntrinsicFields = map[string]telemetrytypes.TelemetryFieldKey{
|
||||
|
||||
@@ -174,7 +174,7 @@ func (m *defaultFieldMapper) getColumn(
|
||||
) ([]*schema.Column, error) {
|
||||
switch key.FieldContext {
|
||||
case telemetrytypes.FieldContextResource:
|
||||
return []*schema.Column{indexV3Columns["resource"]}, nil
|
||||
return []*schema.Column{indexV3Columns["resources_string"], indexV3Columns["resource"]}, nil
|
||||
case telemetrytypes.FieldContextScope:
|
||||
return []*schema.Column{}, qbtypes.ErrColumnNotFound
|
||||
case telemetrytypes.FieldContextAttribute:
|
||||
@@ -254,63 +254,92 @@ func (m *defaultFieldMapper) FieldFor(
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(columns) != 1 {
|
||||
return "", errors.Newf(errors.TypeInternal, errors.CodeInternal, "expected exactly 1 column, got %d", len(columns))
|
||||
|
||||
var newColumns []*schema.Column
|
||||
var evolutionsEntries []*telemetrytypes.EvolutionEntry
|
||||
if len(key.Evolutions) > 0 {
|
||||
// we will use the corresponding column and its evolution entry for the query
|
||||
newColumns, evolutionsEntries, err = qbtypes.SelectEvolutionsForColumns(columns, key.Evolutions, startNs, endNs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
newColumns = columns
|
||||
}
|
||||
column := columns[0]
|
||||
|
||||
switch column.Type.GetType() {
|
||||
case schema.ColumnTypeEnumJSON:
|
||||
// json is only supported for resource context as of now
|
||||
if key.FieldContext != telemetrytypes.FieldContextResource {
|
||||
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns, got %s", key.FieldContext.String)
|
||||
}
|
||||
oldColumn := indexV3Columns["resources_string"]
|
||||
oldKeyName := fmt.Sprintf("%s['%s']", oldColumn.Name, key.Name)
|
||||
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
|
||||
// once clickHouse dependency is updated, we need to check if we can remove it.
|
||||
if key.Materialized {
|
||||
oldKeyName = telemetrytypes.FieldKeyToMaterializedColumnName(key)
|
||||
oldKeyNameExists := telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key)
|
||||
return fmt.Sprintf("multiIf(%s.`%s` IS NOT NULL, %s.`%s`::String, %s==true, %s, NULL)", column.Name, key.Name, column.Name, key.Name, oldKeyNameExists, oldKeyName), nil
|
||||
} else {
|
||||
return fmt.Sprintf("multiIf(%s.`%s` IS NOT NULL, %s.`%s`::String, mapContains(%s, '%s'), %s, NULL)", column.Name, key.Name, column.Name, key.Name, oldColumn.Name, key.Name, oldKeyName), nil
|
||||
}
|
||||
case schema.ColumnTypeEnumString,
|
||||
schema.ColumnTypeEnumUInt64,
|
||||
schema.ColumnTypeEnumUInt32,
|
||||
schema.ColumnTypeEnumInt8,
|
||||
schema.ColumnTypeEnumInt16,
|
||||
schema.ColumnTypeEnumBool,
|
||||
schema.ColumnTypeEnumDateTime64,
|
||||
schema.ColumnTypeEnumFixedString:
|
||||
return column.Name, nil
|
||||
case schema.ColumnTypeEnumLowCardinality:
|
||||
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
|
||||
case schema.ColumnTypeEnumString:
|
||||
return column.Name, nil
|
||||
default:
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "value type %s is not supported for low cardinality column type %s", elementType, column.Type)
|
||||
}
|
||||
case schema.ColumnTypeEnumMap:
|
||||
keyType := column.Type.(schema.MapColumnType).KeyType
|
||||
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
|
||||
exprs := []string{}
|
||||
existExpr := []string{}
|
||||
for i, column := range newColumns {
|
||||
// Use evolution column name if available, otherwise use the column name
|
||||
columnName := column.Name
|
||||
if evolutionsEntries != nil && evolutionsEntries[i] != nil {
|
||||
columnName = evolutionsEntries[i].ColumnName
|
||||
}
|
||||
|
||||
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
|
||||
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumFloat64, schema.ColumnTypeEnumBool:
|
||||
// a key could have been materialized, if so return the materialized column name
|
||||
if key.Materialized {
|
||||
return telemetrytypes.FieldKeyToMaterializedColumnName(key), nil
|
||||
switch column.Type.GetType() {
|
||||
case schema.ColumnTypeEnumJSON:
|
||||
// json is only supported for resource context as of now
|
||||
if key.FieldContext != telemetrytypes.FieldContextResource {
|
||||
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns, got %s", key.FieldContext.String)
|
||||
}
|
||||
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
|
||||
// once clickHouse dependency is updated, we need to check if we can remove it.
|
||||
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
|
||||
existExpr = append(existExpr, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
|
||||
case schema.ColumnTypeEnumString,
|
||||
schema.ColumnTypeEnumUInt64,
|
||||
schema.ColumnTypeEnumUInt32,
|
||||
schema.ColumnTypeEnumInt8,
|
||||
schema.ColumnTypeEnumInt16,
|
||||
schema.ColumnTypeEnumBool,
|
||||
schema.ColumnTypeEnumDateTime64,
|
||||
schema.ColumnTypeEnumFixedString:
|
||||
exprs = append(exprs, column.Name)
|
||||
case schema.ColumnTypeEnumLowCardinality:
|
||||
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
|
||||
case schema.ColumnTypeEnumString:
|
||||
exprs = append(exprs, column.Name)
|
||||
default:
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "value type %s is not supported for low cardinality column type %s", elementType, column.Type)
|
||||
}
|
||||
case schema.ColumnTypeEnumMap:
|
||||
keyType := column.Type.(schema.MapColumnType).KeyType
|
||||
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
|
||||
}
|
||||
|
||||
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
|
||||
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumFloat64, schema.ColumnTypeEnumBool:
|
||||
// a key could have been materialized, if so return the materialized column name
|
||||
if key.Materialized {
|
||||
exprs = append(exprs, telemetrytypes.FieldKeyToMaterializedColumnName(key))
|
||||
existExpr = append(existExpr, fmt.Sprintf("%s==true", telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key)))
|
||||
} else {
|
||||
exprs = append(exprs, fmt.Sprintf("%s['%s']", columnName, key.Name))
|
||||
existExpr = append(existExpr, fmt.Sprintf("mapContains(%s, '%s')", columnName, key.Name))
|
||||
}
|
||||
default:
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "value type %s is not supported for map column type %s", valueType, column.Type)
|
||||
}
|
||||
return fmt.Sprintf("%s['%s']", column.Name, key.Name), nil
|
||||
default:
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "value type %s is not supported for map column type %s", valueType, column.Type)
|
||||
}
|
||||
}
|
||||
|
||||
if len(exprs) == 1 {
|
||||
return exprs[0], nil
|
||||
} else if len(exprs) > 1 {
|
||||
// Ensure existExpr has the same length as exprs
|
||||
if len(existExpr) != len(exprs) {
|
||||
return "", errors.New(errors.TypeInternal, errors.CodeInternal, "length of exist exprs doesn't match to that of exprs")
|
||||
}
|
||||
finalExprs := []string{}
|
||||
for i, expr := range exprs {
|
||||
finalExprs = append(finalExprs, fmt.Sprintf("%s, %s", existExpr[i], expr))
|
||||
}
|
||||
return "multiIf(" + strings.Join(finalExprs, ", ") + ", NULL)", nil
|
||||
}
|
||||
|
||||
// should not reach here
|
||||
return column.Name, nil
|
||||
return columns[0].Name, nil
|
||||
}
|
||||
|
||||
// ColumnExpressionFor returns the column expression for the given field
|
||||
|
||||
@@ -3,6 +3,7 @@ package telemetrytraces
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
func TestGetFieldKeyName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
mockEvolution := mockEvolutionData(time.Date(2024, 6, 2, 0, 0, 0, 0, time.UTC))
|
||||
testCases := []struct {
|
||||
name string
|
||||
key telemetrytypes.TelemetryFieldKey
|
||||
@@ -63,6 +65,7 @@ func TestGetFieldKeyName(t *testing.T) {
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
Evolutions: mockEvolution,
|
||||
},
|
||||
expectedResult: "multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)",
|
||||
expectedError: nil,
|
||||
@@ -74,6 +77,7 @@ func TestGetFieldKeyName(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Materialized: true,
|
||||
Evolutions: mockEvolution,
|
||||
},
|
||||
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`==true, `resource_string_deployment$$environment`, NULL)",
|
||||
expectedError: nil,
|
||||
@@ -92,7 +96,7 @@ func TestGetFieldKeyName(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fm := NewFieldMapper()
|
||||
result, err := fm.FieldFor(ctx, 0, 0, &tc.key)
|
||||
result, err := fm.FieldFor(ctx, uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()), uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano()), &tc.key)
|
||||
|
||||
if tc.expectedError != nil {
|
||||
assert.Equal(t, tc.expectedError, err)
|
||||
@@ -103,3 +107,86 @@ func TestGetFieldKeyName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldForResourceWithEvolution(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
releaseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
evolutions := mockEvolutionData(releaseTime)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
key telemetrytypes.TelemetryFieldKey
|
||||
tsStart uint64
|
||||
tsEnd uint64
|
||||
expectedResult string
|
||||
}{
|
||||
{
|
||||
name: "Window straddles release - both columns",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
Evolutions: evolutions,
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedResult: "multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)",
|
||||
},
|
||||
{
|
||||
name: "Window fully after release - JSON column only",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
Evolutions: evolutions,
|
||||
},
|
||||
tsStart: uint64(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedResult: "resource.`service.name`::String",
|
||||
},
|
||||
{
|
||||
name: "Window fully before release - map column only",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
Evolutions: evolutions,
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedResult: "resources_string['service.name']",
|
||||
},
|
||||
{
|
||||
name: "Window fully after release - materialized resource",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Materialized: true,
|
||||
Evolutions: evolutions,
|
||||
},
|
||||
tsStart: uint64(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedResult: "resource.`deployment.environment`::String",
|
||||
},
|
||||
{
|
||||
name: "Window straddles release - materialized resource",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Materialized: true,
|
||||
Evolutions: evolutions,
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`==true, `resource_string_deployment$$environment`, NULL)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fm := NewFieldMapper()
|
||||
result, err := fm.FieldFor(ctx, tc.tsStart, tc.tsEnd, &tc.key)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedResult, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,13 +82,6 @@ func (b *traceQueryStatementBuilder) Build(
|
||||
start = querybuilder.ToNanoSecs(start)
|
||||
end = querybuilder.ToNanoSecs(end)
|
||||
|
||||
keySelectors := getKeySelectors(query)
|
||||
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
/*
|
||||
Adding a tech debt note here:
|
||||
This piece of code is a hot fix and should be removed once we close issue: engineering-pod/issues/3622
|
||||
@@ -124,6 +117,14 @@ func (b *traceQueryStatementBuilder) Build(
|
||||
-------------------------------- End of tech debt ----------------------------
|
||||
*/
|
||||
|
||||
// since we are modifying the selectFields, they might include keys which need evolutions so we should get keys after that.
|
||||
keySelectors := getKeySelectors(query)
|
||||
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query = b.adjustKeys(ctx, keys, query, requestType)
|
||||
|
||||
// Create SQL builder
|
||||
|
||||
@@ -16,6 +16,9 @@ import (
|
||||
)
|
||||
|
||||
func TestStatementBuilder(t *testing.T) {
|
||||
// releaseTime is chosen so it lands inside the standard [1747947419000, 1747983448000]ms
|
||||
// test window, keeping the multiIf SQL form for resource fields.
|
||||
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
@@ -355,7 +358,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
|
||||
@@ -394,6 +397,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStatementBuilderListQuery(t *testing.T) {
|
||||
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
@@ -650,7 +654,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
|
||||
@@ -683,6 +687,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
@@ -703,6 +708,15 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
"service.name": {
|
||||
{
|
||||
Name: "service.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Evolutions: mockEvolutionData(time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)),
|
||||
},
|
||||
},
|
||||
},
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
@@ -728,6 +742,15 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
"service.name": {
|
||||
{
|
||||
Name: "service.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Evolutions: mockEvolutionData(time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)),
|
||||
},
|
||||
},
|
||||
},
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
@@ -758,7 +781,7 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = c.keysMap
|
||||
if mockMetadataStore.KeysMap == nil {
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
|
||||
}
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
@@ -788,7 +811,90 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatementBuilderGroupByResourceEvolution(t *testing.T) {
|
||||
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
startMs uint64
|
||||
endMs uint64
|
||||
expected qbtypes.Statement
|
||||
}{
|
||||
{
|
||||
name: "window straddles release - both JSON and map branches",
|
||||
startMs: 1747947419000, // 2025-05-22 21:56:59 UTC, ~3m before release
|
||||
endMs: 1747983448000, // 2025-05-23 07:57:28 UTC, ~10h after release
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "window after release - JSON column only",
|
||||
startMs: 1747960000000, // 2025-05-23 00:26:40 UTC, ~2.5h after release
|
||||
endMs: 1747983448000, // 2025-05-23 07:57:28 UTC
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
|
||||
Args: []any{"1747960000000000000", "1747983448000000000", uint64(1747958200), uint64(1747983448), 10, "1747960000000000000", "1747983448000000000", uint64(1747958200), uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "window before release - map column only",
|
||||
startMs: 1747900000000, // 2025-05-22 08:26:40 UTC, ~13.5h before release
|
||||
endMs: 1747947000000, // 2025-05-22 21:50:00 UTC, ~10m before release
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(mapContains(resources_string, 'service.name') = ?, resources_string['service.name'], NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(mapContains(resources_string, 'service.name') = ?, resources_string['service.name'], NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
|
||||
Args: []any{true, "1747900000000000000", "1747947000000000000", uint64(1747898200), uint64(1747947000), 10, true, "1747900000000000000", "1747947000000000000", uint64(1747898200), uint64(1747947000)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "count()"},
|
||||
},
|
||||
Filter: &qbtypes.Filter{},
|
||||
Limit: 10,
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
q, err := statementBuilder.Build(context.Background(), c.startMs, c.endMs, qbtypes.RequestTypeTimeSeries, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.expected.Query, q.Query)
|
||||
require.Equal(t, c.expected.Args, q.Args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatementBuilderTraceQuery(t *testing.T) {
|
||||
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
@@ -911,7 +1017,7 @@ func TestStatementBuilderTraceQuery(t *testing.T) {
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
|
||||
@@ -944,6 +1050,7 @@ func TestStatementBuilderTraceQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdjustKey(t *testing.T) {
|
||||
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
inputKey telemetrytypes.TelemetryFieldKey
|
||||
@@ -957,7 +1064,7 @@ func TestAdjustKey(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
expectedKey: IntrinsicFields["trace_id"],
|
||||
},
|
||||
{
|
||||
@@ -967,7 +1074,7 @@ func TestAdjustKey(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextBody, // incorrect context
|
||||
FieldDataType: telemetrytypes.FieldDataTypeInt64,
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
expectedKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "duration_nano",
|
||||
FieldContext: telemetrytypes.FieldContextSpan, // should be corrected
|
||||
@@ -981,7 +1088,7 @@ func TestAdjustKey(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextSpan, // correct context
|
||||
FieldDataType: telemetrytypes.FieldDataTypeInt64,
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
expectedKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "duration_nano",
|
||||
FieldContext: telemetrytypes.FieldContextSpan, // should be corrected
|
||||
@@ -995,8 +1102,8 @@ func TestAdjustKey(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
expectedKey: *buildCompleteFieldKeyMap()["service.name"][0],
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
expectedKey: *buildCompleteFieldKeyMap(releaseTime)["service.name"][0],
|
||||
},
|
||||
{
|
||||
name: "single matching key with context specified - override",
|
||||
@@ -1005,8 +1112,8 @@ func TestAdjustKey(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
expectedKey: *buildCompleteFieldKeyMap()["cart.items_count"][0],
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
expectedKey: *buildCompleteFieldKeyMap(releaseTime)["cart.items_count"][0],
|
||||
},
|
||||
{
|
||||
name: "multiple matching keys - all materialized",
|
||||
@@ -1043,7 +1150,7 @@ func TestAdjustKey(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
expectedKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "mixed.materialization.key",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
@@ -1057,7 +1164,7 @@ func TestAdjustKey(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
expectedKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "mixed.materialization.key",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
@@ -1072,7 +1179,7 @@ func TestAdjustKey(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
expectedKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "unknown.field",
|
||||
Materialized: false,
|
||||
@@ -1085,7 +1192,7 @@ func TestAdjustKey(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
expectedKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
@@ -1100,7 +1207,7 @@ func TestAdjustKey(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
expectedKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "cart.items_count",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
@@ -1115,7 +1222,7 @@ func TestAdjustKey(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
expectedKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
@@ -1158,6 +1265,7 @@ func TestAdjustKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdjustKeys(t *testing.T) {
|
||||
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
|
||||
@@ -1183,7 +1291,7 @@ func TestAdjustKeys(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
expectedSelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{
|
||||
Name: "service.name",
|
||||
@@ -1220,7 +1328,7 @@ func TestAdjustKeys(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
expectedGroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
@@ -1267,7 +1375,7 @@ func TestAdjustKeys(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
expectedOrder: []qbtypes.OrderBy{
|
||||
{
|
||||
Key: qbtypes.OrderByKey{
|
||||
@@ -1326,7 +1434,7 @@ func TestAdjustKeys(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
expectedSelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{
|
||||
Name: "trace_id",
|
||||
@@ -1381,7 +1489,7 @@ func TestAdjustKeys(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
keysMap: buildCompleteFieldKeyMap(),
|
||||
keysMap: buildCompleteFieldKeyMap(releaseTime),
|
||||
// After alias adjustment, name becomes "span.duration" with FieldContextUnspecified
|
||||
// "span.duration" is not in keysMap, so context stays unspecified
|
||||
expectedOrder: []qbtypes.OrderBy{
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package telemetrytraces
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
func buildCompleteFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||
func buildCompleteFieldKeyMap(releaseTime time.Time) map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||
keysMap := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"service.name": {
|
||||
{
|
||||
@@ -115,7 +117,33 @@ func buildCompleteFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||
for _, keys := range keysMap {
|
||||
for _, key := range keys {
|
||||
key.Signal = telemetrytypes.SignalTraces
|
||||
if key.FieldContext == telemetrytypes.FieldContextResource {
|
||||
key.Evolutions = mockEvolutionData(releaseTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
return keysMap
|
||||
}
|
||||
|
||||
// mockEvolutionData returns the canonical resource-column evolution timeline used in tests:
|
||||
// the legacy resources_string map at epoch 0 and the JSON resource column released at releaseTime.
|
||||
func mockEvolutionData(releaseTime time.Time) []*telemetrytypes.EvolutionEntry {
|
||||
return []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
ColumnName: "resources_string",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldName: "__all__",
|
||||
ReleaseTime: time.Unix(0, 0),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
ReleaseTime: releaseTime,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,21 +398,27 @@ func (b *traceOperatorCTEBuilder) buildNotCTE(leftCTE, rightCTE string) (string,
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFromCTE string, requestType qbtypes.RequestType) (*qbtypes.Statement, error) {
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, b.getKeySelectors())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.adjustKeys(keys)
|
||||
|
||||
switch requestType {
|
||||
case qbtypes.RequestTypeRaw:
|
||||
return b.buildListQuery(ctx, selectFromCTE)
|
||||
return b.buildListQuery(ctx, selectFromCTE, keys)
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
return b.buildTimeSeriesQuery(ctx, selectFromCTE)
|
||||
return b.buildTimeSeriesQuery(ctx, selectFromCTE, keys)
|
||||
case qbtypes.RequestTypeTrace:
|
||||
return b.buildTraceQuery(ctx, selectFromCTE)
|
||||
return b.buildTraceQuery(ctx, selectFromCTE, keys)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
return b.buildScalarQuery(ctx, selectFromCTE)
|
||||
return b.buildScalarQuery(ctx, selectFromCTE, keys)
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type: %s", requestType)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
// Select core fields
|
||||
@@ -434,22 +440,6 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
|
||||
"parent_span_id": true,
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add selectFields using ColumnExpressionFor since we now have all base table columns
|
||||
for _, field := range b.operator.SelectFields {
|
||||
if selectedFields[field.Name] {
|
||||
@@ -526,6 +516,15 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
|
||||
})
|
||||
}
|
||||
|
||||
for _, field := range b.operator.SelectFields {
|
||||
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: field.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: field.FieldContext,
|
||||
FieldDataType: field.FieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
for i := range keySelectors {
|
||||
keySelectors[i].Signal = telemetrytypes.SignalTraces
|
||||
}
|
||||
@@ -533,7 +532,7 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
|
||||
return keySelectors
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
sb.Select(fmt.Sprintf(
|
||||
@@ -541,12 +540,6 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
|
||||
int64(b.operator.StepInterval.Seconds()),
|
||||
))
|
||||
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allGroupByArgs []any
|
||||
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
@@ -625,8 +618,7 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
|
||||
combinedArgs := append(allGroupByArgs, allAggChArgs...)
|
||||
|
||||
// Add HAVING clause if specified
|
||||
err = b.addHavingClause(sb)
|
||||
if err != nil {
|
||||
if err := b.addHavingClause(sb); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -653,17 +645,11 @@ func (b *traceOperatorCTEBuilder) buildTraceSummaryCTE(selectFromCTE string) {
|
||||
b.addCTE("trace_summary", sql, args, []string{"all_spans", selectFromCTE})
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
b.buildTraceSummaryCTE(selectFromCTE)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allGroupByArgs []any
|
||||
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
@@ -745,8 +731,7 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
sb.GroupBy(groupByKeys...)
|
||||
}
|
||||
|
||||
err = b.addHavingClause(sb)
|
||||
if err != nil {
|
||||
if err := b.addHavingClause(sb); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -802,15 +787,9 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allGroupByArgs []any
|
||||
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
@@ -892,8 +871,7 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
|
||||
combinedArgs := append(allGroupByArgs, allAggChArgs...)
|
||||
|
||||
// Add HAVING clause if specified
|
||||
err = b.addHavingClause(sb)
|
||||
if err != nil {
|
||||
if err := b.addHavingClause(sb); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -936,3 +914,16 @@ func (b *traceOperatorCTEBuilder) aggOrderBy(k qbtypes.OrderBy) (int, bool) {
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) adjustKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey) {
|
||||
// todo: this needs to be updated w.r.t trace statement builder.
|
||||
for i := range b.operator.SelectFields {
|
||||
querybuilder.AdjustKey(&b.operator.SelectFields[i], keys, nil)
|
||||
}
|
||||
for i := range b.operator.GroupBy {
|
||||
querybuilder.AdjustKey(&b.operator.GroupBy[i].TelemetryFieldKey, keys, nil)
|
||||
}
|
||||
for i := range b.operator.Order {
|
||||
querybuilder.AdjustKey(&b.operator.Order[i].Key.TelemetryFieldKey, keys, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
@@ -390,7 +391,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
|
||||
@@ -443,6 +444,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTraceOperatorStatementBuilderErrors(t *testing.T) {
|
||||
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
operator qbtypes.QueryBuilderTraceOperator
|
||||
@@ -506,7 +508,7 @@ func TestTraceOperatorStatementBuilderErrors(t *testing.T) {
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
@@ -16,12 +17,13 @@ import (
|
||||
)
|
||||
|
||||
func TestTraceTimeRangeOptimization(t *testing.T) {
|
||||
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
|
||||
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
|
||||
mockMetadataStore.KeysMap["trace_id"] = []*telemetrytypes.TelemetryFieldKey{{
|
||||
Name: "trace_id",
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
|
||||
@@ -18,10 +18,6 @@ type Cloneable interface {
|
||||
// Creates a deep copy of the Cacheable. This method is useful for memory caches to avoid the need for serialization/deserialization. It also prevents
|
||||
// race conditions in the memory cache.
|
||||
Clone() Cacheable
|
||||
// Cost returns the weight of this entry for cost-based cache accounting
|
||||
// and eviction. Typically derived from the approximate retained byte size,
|
||||
// but the value represents cache cost, not literal bytes.
|
||||
Cost() int64
|
||||
}
|
||||
|
||||
func NewSha1CacheKey(val string) string {
|
||||
|
||||
@@ -59,21 +59,3 @@ func (c *CachedData) Clone() cachetypes.Cacheable {
|
||||
|
||||
return clonedCachedData
|
||||
}
|
||||
|
||||
// Cost approximates the retained bytes of this CachedData for use as the
|
||||
// ristretto cache cost. The dominant contributor is the serialized bucket
|
||||
// values (json.RawMessage); other fields are fixed-size or small strings.
|
||||
func (c *CachedData) Cost() int64 {
|
||||
var size int64
|
||||
for _, b := range c.Buckets {
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
// Value is the bulk of the payload
|
||||
size += int64(len(b.Value))
|
||||
}
|
||||
for _, w := range c.Warnings {
|
||||
size += int64(len(w))
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
119
pkg/types/querybuildertypes/querybuildertypesv5/evolution.go
Normal file
119
pkg/types/querybuildertypes/querybuildertypesv5/evolution.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// SelectEvolutionsForColumns selects the appropriate evolution entries for each column based on the time range.
|
||||
// Logic:
|
||||
// - Finds the latest base evolution (<= tsStartTime) across ALL columns
|
||||
// - Rejects all evolutions before this latest base evolution
|
||||
// - For duplicate evolutions it considers the oldest one (first in ReleaseTime)
|
||||
// - For each column, includes its evolution if it's >= latest base evolution and <= tsEndTime
|
||||
// - Results are sorted by ReleaseTime descending (newest first)
|
||||
func SelectEvolutionsForColumns(columns []*schema.Column, evolutions []*telemetrytypes.EvolutionEntry, tsStart, tsEnd uint64) ([]*schema.Column, []*telemetrytypes.EvolutionEntry, error) {
|
||||
|
||||
sortedEvolutions := make([]*telemetrytypes.EvolutionEntry, len(evolutions))
|
||||
copy(sortedEvolutions, evolutions)
|
||||
|
||||
// sort the evolutions by ReleaseTime ascending
|
||||
sort.Slice(sortedEvolutions, func(i, j int) bool {
|
||||
return sortedEvolutions[i].ReleaseTime.Before(sortedEvolutions[j].ReleaseTime)
|
||||
})
|
||||
|
||||
tsStartTime := time.Unix(0, int64(tsStart))
|
||||
tsEndTime := time.Unix(0, int64(tsEnd))
|
||||
|
||||
// Build evolution map: column name -> evolution
|
||||
evolutionMap := make(map[string]*telemetrytypes.EvolutionEntry)
|
||||
for _, evolution := range sortedEvolutions {
|
||||
if _, exists := evolutionMap[evolution.ColumnName+":"+evolution.FieldName+":"+strconv.Itoa(int(evolution.Version))]; exists {
|
||||
// since if there is duplicate we would just use the oldest one.
|
||||
continue
|
||||
}
|
||||
evolutionMap[evolution.ColumnName+":"+evolution.FieldName+":"+strconv.Itoa(int(evolution.Version))] = evolution
|
||||
}
|
||||
|
||||
// Find the latest base evolution (<= tsStartTime) across ALL columns
|
||||
// Evolutions are sorted, so we can break early
|
||||
var latestBaseEvolutionAcrossAll *telemetrytypes.EvolutionEntry
|
||||
for _, evolution := range sortedEvolutions {
|
||||
if evolution.ReleaseTime.After(tsStartTime) {
|
||||
break
|
||||
}
|
||||
latestBaseEvolutionAcrossAll = evolution
|
||||
}
|
||||
|
||||
// We shouldn't reach this, it basically means there is something wrong with the evolutions data
|
||||
if latestBaseEvolutionAcrossAll == nil {
|
||||
return nil, nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "no base evolution found for columns %v", columns)
|
||||
}
|
||||
|
||||
columnLookUpMap := make(map[string]*schema.Column)
|
||||
for _, column := range columns {
|
||||
columnLookUpMap[column.Name] = column
|
||||
}
|
||||
|
||||
// Collect column-evolution pairs
|
||||
type colEvoPair struct {
|
||||
column *schema.Column
|
||||
evolution *telemetrytypes.EvolutionEntry
|
||||
}
|
||||
pairs := []colEvoPair{}
|
||||
|
||||
for _, evolution := range evolutionMap {
|
||||
// Reject evolutions before the latest base evolution
|
||||
if evolution.ReleaseTime.Before(latestBaseEvolutionAcrossAll.ReleaseTime) {
|
||||
continue
|
||||
}
|
||||
// skip evolutions after tsEndTime
|
||||
if evolution.ReleaseTime.After(tsEndTime) || evolution.ReleaseTime.Equal(tsEndTime) {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := columnLookUpMap[evolution.ColumnName]; !exists {
|
||||
return nil, nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "evolution column %s not found in columns %v", evolution.ColumnName, columns)
|
||||
}
|
||||
|
||||
pairs = append(pairs, colEvoPair{columnLookUpMap[evolution.ColumnName], evolution})
|
||||
}
|
||||
|
||||
// If no pairs found, fall back to latestBaseEvolutionAcrossAll for matching columns
|
||||
if len(pairs) == 0 {
|
||||
for _, column := range columns {
|
||||
// Use latestBaseEvolutionAcrossAll if this column name matches its column name
|
||||
if column.Name == latestBaseEvolutionAcrossAll.ColumnName {
|
||||
pairs = append(pairs, colEvoPair{column, latestBaseEvolutionAcrossAll})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by ReleaseTime descending (newest first)
|
||||
slices.SortFunc(pairs, func(a, b colEvoPair) int {
|
||||
// Sort by ReleaseTime descending (newest first)
|
||||
if a.evolution.ReleaseTime.After(b.evolution.ReleaseTime) {
|
||||
return -1
|
||||
}
|
||||
if a.evolution.ReleaseTime.Before(b.evolution.ReleaseTime) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
// Extract results
|
||||
newColumns := make([]*schema.Column, len(pairs))
|
||||
evolutionsEntries := make([]*telemetrytypes.EvolutionEntry, len(pairs))
|
||||
for i, pair := range pairs {
|
||||
newColumns[i] = pair.column
|
||||
evolutionsEntries[i] = pair.evolution
|
||||
}
|
||||
|
||||
return newColumns, evolutionsEntries, nil
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
LogsV2BodyV2Column = "body_v2"
|
||||
LogsV2BodyPromotedColumn = "body_promoted"
|
||||
)
|
||||
|
||||
var (
|
||||
resources_string = &schema.Column{Name: "resources_string", Type: schema.MapColumnType{
|
||||
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
|
||||
ValueType: schema.ColumnTypeString,
|
||||
}}
|
||||
resource = &schema.Column{Name: "resource", Type: schema.JSONColumnType{}}
|
||||
attributes_string = &schema.Column{Name: "attributes_string", Type: schema.MapColumnType{
|
||||
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
|
||||
ValueType: schema.ColumnTypeString,
|
||||
}}
|
||||
body_v2 = &schema.Column{Name: LogsV2BodyV2Column, Type: schema.JSONColumnType{}}
|
||||
body_promoted = &schema.Column{Name: LogsV2BodyPromotedColumn, Type: schema.JSONColumnType{}}
|
||||
)
|
||||
|
||||
func TestSelectEvolutionsForColumns(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
columns []*schema.Column
|
||||
evolutions []*telemetrytypes.EvolutionEntry
|
||||
tsStart uint64
|
||||
tsEnd uint64
|
||||
expectedColumns []string // column names
|
||||
expectedEvols []string // evolution column names
|
||||
expectedError bool
|
||||
errorStr string
|
||||
}{
|
||||
{
|
||||
name: "New evolutions at tsStartTime - should include latest evolution",
|
||||
columns: []*schema.Column{
|
||||
resources_string,
|
||||
resource,
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 1,
|
||||
ReleaseTime: time.Date(2024, 2, 25, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 25, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 30, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resource"},
|
||||
expectedEvols: []string{"resource"},
|
||||
},
|
||||
{
|
||||
name: "New evolutions after tsStartTime but less than tsEndTime - should include both",
|
||||
columns: []*schema.Column{
|
||||
resources_string,
|
||||
resource,
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 1,
|
||||
ReleaseTime: time.Date(2024, 2, 3, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resource", "resources_string"}, // sorted by ReleaseTime desc
|
||||
expectedEvols: []string{"resource", "resources_string"},
|
||||
},
|
||||
{
|
||||
name: "Columns without matching evolutions - should exclude them",
|
||||
columns: []*schema.Column{
|
||||
resources_string,
|
||||
resource, // no evolution for this
|
||||
attributes_string, // no evolution for this
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resources_string"},
|
||||
expectedEvols: []string{"resources_string"},
|
||||
},
|
||||
{
|
||||
name: "New evolutions at tsEndTime - should not include new evolution",
|
||||
columns: []*schema.Column{
|
||||
resources_string,
|
||||
resource,
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 1,
|
||||
ReleaseTime: time.Date(2024, 2, 30, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 25, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 30, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resources_string"},
|
||||
expectedEvols: []string{"resources_string"},
|
||||
},
|
||||
{
|
||||
name: "New evolutions after tsEndTime - should exclude new",
|
||||
columns: []*schema.Column{
|
||||
resources_string,
|
||||
resource,
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 1,
|
||||
ReleaseTime: time.Date(2024, 2, 25, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resources_string"},
|
||||
expectedEvols: []string{"resources_string"},
|
||||
},
|
||||
{
|
||||
name: "Empty columns array",
|
||||
columns: []*schema.Column{},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{},
|
||||
expectedEvols: []string{},
|
||||
expectedError: true,
|
||||
errorStr: "column resources_string not found",
|
||||
},
|
||||
{
|
||||
name: "Duplicate evolutions - should use first encountered (oldest if sorted)",
|
||||
columns: []*schema.Column{
|
||||
resource,
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 1,
|
||||
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 1,
|
||||
ReleaseTime: time.Date(2024, 1, 20, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resource"},
|
||||
expectedEvols: []string{"resource"}, // should use first one (older)
|
||||
},
|
||||
{
|
||||
name: "Genuine Duplicate evolutions with new version- should consider both",
|
||||
columns: []*schema.Column{
|
||||
resources_string,
|
||||
resource,
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 0,
|
||||
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 1,
|
||||
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
Version: 2,
|
||||
ReleaseTime: time.Date(2024, 1, 20, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 1, 16, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resources_string", "resource"},
|
||||
expectedEvols: []string{"resources_string", "resource"}, // should use first one (older)
|
||||
},
|
||||
{
|
||||
name: "Evolution exactly at tsEndTime",
|
||||
columns: []*schema.Column{
|
||||
resources_string,
|
||||
resource,
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resources_string",
|
||||
ColumnType: "Map(LowCardinality(String), String)",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: "resource",
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldName: "__all__",
|
||||
ReleaseTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC), // exactly at tsEnd
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{"resources_string"}, // resource excluded because After(tsEnd) is true
|
||||
expectedEvols: []string{"resources_string"},
|
||||
},
|
||||
{
|
||||
name: "Single evolution after tsStartTime - JSON body",
|
||||
columns: []*schema.Column{
|
||||
body_v2,
|
||||
body_promoted,
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: LogsV2BodyV2Column,
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
FieldName: "__all__",
|
||||
ReleaseTime: time.Unix(0, 0),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: LogsV2BodyPromotedColumn,
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
FieldName: "user.name",
|
||||
ReleaseTime: time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{LogsV2BodyPromotedColumn, LogsV2BodyV2Column}, // sorted by ReleaseTime desc (newest first)
|
||||
expectedEvols: []string{LogsV2BodyPromotedColumn, LogsV2BodyV2Column},
|
||||
},
|
||||
{
|
||||
name: "No evolution after tsStartTime - JSON body",
|
||||
columns: []*schema.Column{
|
||||
body_v2,
|
||||
body_promoted,
|
||||
},
|
||||
evolutions: []*telemetrytypes.EvolutionEntry{
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: LogsV2BodyV2Column,
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
FieldName: "__all__",
|
||||
ReleaseTime: time.Unix(0, 0),
|
||||
},
|
||||
{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
ColumnName: LogsV2BodyPromotedColumn,
|
||||
ColumnType: "JSON()",
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
FieldName: "user.name",
|
||||
ReleaseTime: time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 2, 3, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedColumns: []string{LogsV2BodyPromotedColumn},
|
||||
expectedEvols: []string{LogsV2BodyPromotedColumn},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resultColumns, resultEvols, err := SelectEvolutionsForColumns(tc.columns, tc.evolutions, tc.tsStart, tc.tsEnd)
|
||||
|
||||
if tc.expectedError {
|
||||
assert.Contains(t, err.Error(), tc.errorStr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(tc.expectedColumns), len(resultColumns), "column count mismatch")
|
||||
assert.Equal(t, len(tc.expectedEvols), len(resultEvols), "evolution count mismatch")
|
||||
|
||||
resultColumnNames := make([]string, len(resultColumns))
|
||||
for i, col := range resultColumns {
|
||||
resultColumnNames[i] = col.Name
|
||||
}
|
||||
resultEvolNames := make([]string, len(resultEvols))
|
||||
for i, evol := range resultEvols {
|
||||
resultEvolNames[i] = evol.ColumnName
|
||||
}
|
||||
|
||||
for i := range tc.expectedColumns {
|
||||
assert.Equal(t, resultColumnNames[i], tc.expectedColumns[i], "expected column missing: "+tc.expectedColumns[i])
|
||||
}
|
||||
for i := range tc.expectedEvols {
|
||||
assert.Equal(t, resultEvolNames[i], tc.expectedEvols[i], "expected evolution missing: "+tc.expectedEvols[i])
|
||||
}
|
||||
// Verify sorting: should be descending by ReleaseTime
|
||||
for i := 0; i < len(resultEvols)-1; i++ {
|
||||
assert.True(t, !resultEvols[i].ReleaseTime.Before(resultEvols[i+1].ReleaseTime),
|
||||
"evolutions should be sorted descending by ReleaseTime")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
6
tests/fixtures/querier.py
vendored
6
tests/fixtures/querier.py
vendored
@@ -200,8 +200,6 @@ def build_formula_query(
|
||||
*,
|
||||
functions: list[dict] | None = None,
|
||||
disabled: bool = False,
|
||||
order: list[dict] | None = None,
|
||||
limit: int | None = None,
|
||||
) -> dict:
|
||||
spec: dict[str, Any] = {
|
||||
"name": name,
|
||||
@@ -210,10 +208,6 @@ def build_formula_query(
|
||||
}
|
||||
if functions:
|
||||
spec["functions"] = functions
|
||||
if order:
|
||||
spec["order"] = order
|
||||
if limit is not None:
|
||||
spec["limit"] = limit
|
||||
return {"type": "builder_formula", "spec": spec}
|
||||
|
||||
|
||||
|
||||
11
tests/fixtures/traces.py
vendored
11
tests/fixtures/traces.py
vendored
@@ -6,7 +6,7 @@ import uuid
|
||||
from abc import ABC
|
||||
from collections.abc import Callable, Generator
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import numpy as np
|
||||
@@ -236,6 +236,7 @@ class Traces(ABC):
|
||||
attributes_number: dict[str, np.float64]
|
||||
attributes_bool: dict[str, bool]
|
||||
resources_string: dict[str, str]
|
||||
resource_json: dict[str, str]
|
||||
events: list[str]
|
||||
links: str
|
||||
response_status_code: str
|
||||
@@ -273,6 +274,7 @@ class Traces(ABC):
|
||||
links: list[TracesLink] = [],
|
||||
trace_state: str = "",
|
||||
flags: np.uint32 = 0,
|
||||
resource_write_mode: Literal["legacy_only", "dual_write"] = "dual_write",
|
||||
) -> None:
|
||||
if timestamp is None:
|
||||
timestamp = datetime.datetime.now()
|
||||
@@ -322,8 +324,11 @@ class Traces(ABC):
|
||||
self.db_name = ""
|
||||
self.db_operation = ""
|
||||
|
||||
# Process resources and derive service_name
|
||||
# Process resources and derive service_name. Spans written before the
|
||||
# JSON-resource evolution time only populate resources_string (legacy_only);
|
||||
# spans at or after the evolution time dual-write to both columns.
|
||||
self.resources_string = {k: str(v) for k, v in resources.items()}
|
||||
self.resource_json = {} if resource_write_mode == "legacy_only" else dict(self.resources_string)
|
||||
self.service_name = self.resources_string.get("service.name", "default-service")
|
||||
|
||||
for k, v in self.resources_string.items():
|
||||
@@ -575,7 +580,7 @@ class Traces(ABC):
|
||||
self.db_operation,
|
||||
self.has_error,
|
||||
self.is_remote,
|
||||
self.resources_string,
|
||||
self.resource_json,
|
||||
],
|
||||
dtype=object,
|
||||
)
|
||||
|
||||
@@ -11,11 +11,6 @@ from fixtures.logs import Logs
|
||||
from fixtures.querier import (
|
||||
assert_identical_query_response,
|
||||
assert_minutely_bucket_values,
|
||||
build_formula_query,
|
||||
build_group_by_field,
|
||||
build_logs_aggregation,
|
||||
build_order_by,
|
||||
build_scalar_query,
|
||||
find_named_result,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
@@ -2116,180 +2111,3 @@ def test_logs_fill_zero_formula_with_group_by(
|
||||
expected_by_ts=expectations[service_name],
|
||||
context=f"logs/fillZero/F1/{service_name}",
|
||||
)
|
||||
|
||||
|
||||
def test_logs_formula_orderby_and_limit(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Test that formula results are correctly ordered and limited when
|
||||
order and limit are applied on the formula.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
logs: list[Logs] = []
|
||||
# For service-i (i in 0..9): insert (10 - i) ERROR logs and 2 INFO logs.
|
||||
# A counts ERROR, B counts INFO, so A/B = (10 - i) / 2.
|
||||
# service-0 ratio = 5.0 (highest), service-9 ratio = 0.5 (lowest).
|
||||
for i in range(10):
|
||||
for j in range(10 - i):
|
||||
logs.append(
|
||||
Logs(
|
||||
timestamp=now - timedelta(minutes=j + 1),
|
||||
resources={"service.name": f"service-{i}"},
|
||||
attributes={"code.file": "test.py"},
|
||||
body=f"Error log {i}-{j}",
|
||||
severity_text="ERROR",
|
||||
)
|
||||
)
|
||||
for k in range(2):
|
||||
logs.append(
|
||||
Logs(
|
||||
timestamp=now - timedelta(minutes=k + 1),
|
||||
resources={"service.name": f"service-{i}"},
|
||||
attributes={"code.file": "test.py"},
|
||||
body=f"Info log {i}-{k}",
|
||||
severity_text="INFO",
|
||||
)
|
||||
)
|
||||
# Extra INFO-only services that appear in B but not in A. The formula
|
||||
for name in ("service-info-only-1", "service-info-only-2"):
|
||||
for k in range(2):
|
||||
logs.append(
|
||||
Logs(
|
||||
timestamp=now - timedelta(minutes=k + 1),
|
||||
resources={"service.name": name},
|
||||
attributes={"code.file": "test.py"},
|
||||
body=f"Info log {name}-{k}",
|
||||
severity_text="INFO",
|
||||
)
|
||||
)
|
||||
|
||||
# Logs look like this (columns = minutes before `now`; query range is
|
||||
# (now - 15m, now], so the `now` column is the exclusive upper bound and
|
||||
# no log lands there). E = ERROR, I = INFO, X = both at that minute.
|
||||
#
|
||||
# t-10 t-9 t-8 t-7 t-6 t-5 t-4 t-3 t-2 t-1 |now | A B A/B
|
||||
# service-0: E E E E E E E E X X | | 10 2 5.0
|
||||
# service-1: . E E E E E E E X X | | 9 2 4.5
|
||||
# service-2: . . E E E E E E X X | | 8 2 4.0
|
||||
# service-3: . . . E E E E E X X | | 7 2 3.5
|
||||
# service-4: . . . . E E E E X X | | 6 2 3.0
|
||||
# service-5: . . . . . E E E X X | | 5 2 2.5
|
||||
# service-6: . . . . . . E E X X | | 4 2 2.0
|
||||
# service-7: . . . . . . . E X X | | 3 2 1.5
|
||||
# service-8: . . . . . . . . X X | | 2 2 1.0
|
||||
# service-9: . . . . . . . . I X | | 1 2 0.5
|
||||
# info-only-1: . . . . . . . . I I | | 0* 2 0.0
|
||||
# info-only-2: . . . . . . . . I I | | 0* 2 0.0
|
||||
#
|
||||
# * A is missing for the info-only services; because A is count(), the
|
||||
# formula evaluator defaults missing A to 0, yielding A/B = 0.
|
||||
insert_logs(logs)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
result = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=15)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type="scalar",
|
||||
queries=[
|
||||
build_scalar_query(
|
||||
name="A",
|
||||
signal="logs",
|
||||
aggregations=[build_logs_aggregation("count()")],
|
||||
group_by=[build_group_by_field("service.name")],
|
||||
filter_expression="severity_text = 'ERROR'",
|
||||
disabled=True,
|
||||
),
|
||||
build_scalar_query(
|
||||
name="B",
|
||||
signal="logs",
|
||||
aggregations=[build_logs_aggregation("count()")],
|
||||
group_by=[build_group_by_field("service.name")],
|
||||
filter_expression="severity_text = 'INFO'",
|
||||
disabled=True,
|
||||
),
|
||||
build_formula_query(
|
||||
"F1",
|
||||
"A / B",
|
||||
order=[build_order_by("__result", "desc")],
|
||||
limit=3,
|
||||
),
|
||||
build_formula_query(
|
||||
"F2",
|
||||
"A / B",
|
||||
order=[build_order_by("__result", "desc")],
|
||||
),
|
||||
build_formula_query(
|
||||
"F3",
|
||||
"A / B",
|
||||
order=[build_order_by("__result", "asc")],
|
||||
limit=3,
|
||||
),
|
||||
build_formula_query(
|
||||
"F4",
|
||||
"A / B",
|
||||
order=[build_order_by("__result", "asc")],
|
||||
),
|
||||
],
|
||||
)
|
||||
assert result.status_code == HTTPStatus.OK
|
||||
assert result.json()["status"] == "success"
|
||||
|
||||
results = result.json()["data"]["data"]["results"]
|
||||
|
||||
def extract_services_and_values(query_name: str) -> tuple[list, list]:
|
||||
res = find_named_result(results, query_name)
|
||||
assert res is not None, f"Expected formula result named {query_name}"
|
||||
cols = res["columns"]
|
||||
s_col = next(i for i, c in enumerate(cols) if c["name"] == "service.name")
|
||||
v_col = next(i for i, c in enumerate(cols) if c["name"] == "__result")
|
||||
rows = res["data"]
|
||||
return [row[s_col] for row in rows], [row[v_col] for row in rows]
|
||||
|
||||
# Because A is count(), canDefaultZero["A"] is true; the formula evaluator
|
||||
# defaults A to 0 for services that exist only in B. So the two INFO-only
|
||||
# services appear in the formula result with value 0.0 (extreme bottom in
|
||||
# desc order, extreme top in asc order). Their relative ordering is not
|
||||
# deterministic across separate formula evaluations (tied values).
|
||||
info_only_services = {"service-info-only-1", "service-info-only-2"}
|
||||
|
||||
# F2: desc, no limit -> 12 rows in descending order by value.
|
||||
f2_services, f2_values = extract_services_and_values("F2")
|
||||
assert len(f2_services) == 12, f"F2: expected 12 rows with no limit, got {len(f2_services)}"
|
||||
assert f2_values == [5.0, 4.5, 4.0, 3.5, 3.0, 2.5, 2.0, 1.5, 1.0, 0.5, 0.0, 0.0], f2_values
|
||||
# Top 10 have distinct positive values -> deterministic service ordering.
|
||||
assert f2_services[:10] == [f"service-{i}" for i in range(10)], f2_services[:10]
|
||||
# Tail 2 are the INFO-only services tied at 0.0 (order between them not guaranteed).
|
||||
assert set(f2_services[10:]) == info_only_services, f2_services[10:]
|
||||
|
||||
# F1: desc + limit 3 -> must be exactly the first 3 rows of F2.
|
||||
# Top 3 are not in the tie region, so prefix equality is safe.
|
||||
f1_services, f1_values = extract_services_and_values("F1")
|
||||
assert len(f1_services) == 3, f"F1: expected 3 rows after limit, got {len(f1_services)}"
|
||||
assert f1_services == f2_services[:3], f"F1 services {f1_services} are not the prefix of F2 services {f2_services}"
|
||||
assert f1_values == f2_values[:3], f"F1 values {f1_values} are not the prefix of F2 values {f2_values}"
|
||||
|
||||
# F4: asc, no limit -> 12 rows in ascending order by value.
|
||||
f4_services, f4_values = extract_services_and_values("F4")
|
||||
assert len(f4_services) == 12, f"F4: expected 12 rows with no limit, got {len(f4_services)}"
|
||||
assert f4_values == sorted(f4_values), f"F4 not ascending: {f4_values}"
|
||||
# First 2 are the INFO-only services tied at 0.0 (order between them not guaranteed).
|
||||
assert set(f4_services[:2]) == info_only_services, f4_services[:2]
|
||||
assert f4_values[:2] == [0.0, 0.0], f4_values[:2]
|
||||
# Tail 10 are service-9 down to service-0 by value.
|
||||
assert f4_services[2:] == [f"service-{i}" for i in reversed(range(10))], f4_services[2:]
|
||||
assert f4_values[2:] == [(10 - i) / 2 for i in reversed(range(10))], f4_values[2:]
|
||||
|
||||
# F3: asc + limit 3 -> values must match F4[:3] exactly; service set must
|
||||
# match too. Direct prefix equality on services would be flaky because the
|
||||
# two tied INFO-only entries can swap order between formula evaluations.
|
||||
f3_services, f3_values = extract_services_and_values("F3")
|
||||
assert len(f3_services) == 3, f"F3: expected 3 rows after limit, got {len(f3_services)}"
|
||||
assert f3_values == f4_values[:3], f"F3 values {f3_values} do not match F4[:3] values {f4_values[:3]}"
|
||||
assert set(f3_services) == set(f4_services[:3]), f"F3 services {f3_services} do not match F4[:3] services {f4_services[:3]}"
|
||||
|
||||
240
tests/integration/tests/querier/13_traces_resource_evolution.py
Normal file
240
tests/integration/tests/querier/13_traces_resource_evolution.py
Normal file
@@ -0,0 +1,240 @@
|
||||
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 (
|
||||
build_group_by_field,
|
||||
build_logs_aggregation,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces
|
||||
|
||||
|
||||
# we already create the evolution for resource during schema migration
|
||||
# since we have to create test data around it, we need to get the evolution time
|
||||
def _get_traces_resource_evolution_time_json(signoz: types.SigNoz) -> datetime:
|
||||
result = signoz.telemetrystore.conn.query(
|
||||
"""
|
||||
SELECT release_time
|
||||
FROM signoz_metadata.distributed_column_evolution_metadata
|
||||
WHERE signal = 'traces'
|
||||
AND field_context = 'resource'
|
||||
AND field_name = '__all__'
|
||||
AND column_name = 'resource'
|
||||
LIMIT 1
|
||||
"""
|
||||
).result_rows
|
||||
|
||||
assert result, "Expected traces resource evolution metadata to exist"
|
||||
|
||||
release_time_ns = int(result[0][0])
|
||||
return datetime.fromtimestamp(release_time_ns / 1e9, tz=UTC)
|
||||
|
||||
|
||||
# Spans with timestamps before the evolution time will have resources written only to resources_string.
|
||||
# Spans with timestamps at or after the evolution time will have resources written to both resources_string and resource (JSON).
|
||||
def _build_evolved_span(
|
||||
timestamp: datetime,
|
||||
evolution_time: datetime,
|
||||
service_name: str,
|
||||
name: str,
|
||||
) -> Traces:
|
||||
resource_write_mode = "legacy_only" if timestamp < evolution_time else "dual_write"
|
||||
return Traces(
|
||||
timestamp=timestamp,
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name=name,
|
||||
resources={
|
||||
"service.name": service_name,
|
||||
"deployment.environment": "integration",
|
||||
},
|
||||
resource_write_mode=resource_write_mode,
|
||||
)
|
||||
|
||||
|
||||
def _query_grouped_trace_series(
|
||||
signoz: types.SigNoz,
|
||||
token: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
group_by: str = "service.name",
|
||||
aggregation: str = "count()",
|
||||
) -> dict[str, list[dict]]:
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int(start.timestamp() * 1000),
|
||||
end_ms=int(end.timestamp() * 1000),
|
||||
request_type="time_series",
|
||||
queries=[
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"stepInterval": 60,
|
||||
"disabled": False,
|
||||
"groupBy": [build_group_by_field(group_by)],
|
||||
"having": {"expression": ""},
|
||||
"aggregations": [build_logs_aggregation(aggregation)],
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
assert len(results) == 1
|
||||
|
||||
aggregations = results[0]["aggregations"]
|
||||
assert len(aggregations) == 1
|
||||
|
||||
return index_series_by_label(aggregations[0]["series"], group_by)
|
||||
|
||||
|
||||
def _assert_grouped_series(
|
||||
series_by_group: dict[str, dict],
|
||||
expected_values_by_group: dict[str, dict[int, int]],
|
||||
) -> None:
|
||||
assert set(series_by_group.keys()) == set(expected_values_by_group.keys())
|
||||
|
||||
for group_name, expected_by_ts in expected_values_by_group.items():
|
||||
actual_values = sorted(
|
||||
series_by_group[group_name]["values"],
|
||||
key=lambda value: value["timestamp"],
|
||||
)
|
||||
expected_values = [{"timestamp": timestamp, "value": value} for timestamp, value in sorted(expected_by_ts.items())]
|
||||
assert actual_values == expected_values
|
||||
|
||||
|
||||
def _test_traces_resource_evolution(
|
||||
signoz: types.SigNoz,
|
||||
token: str,
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
# 1. Get the evolution time.
|
||||
# 2. Ingest spans before the evolution time.
|
||||
# 3. Ingest spans after the evolution time.
|
||||
# 4. Query the spans before the evolution time.
|
||||
# 5. Query the spans after the evolution time.
|
||||
# Both aggregation and group by should be checked.
|
||||
"""
|
||||
evolution_time = _get_traces_resource_evolution_time_json(signoz)
|
||||
evolution_time = evolution_time.replace(second=0, microsecond=0)
|
||||
|
||||
before_2 = evolution_time - timedelta(minutes=10)
|
||||
before_1 = evolution_time - timedelta(minutes=5)
|
||||
after_1 = evolution_time + timedelta(minutes=5)
|
||||
after_2 = evolution_time + timedelta(minutes=10)
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
_build_evolved_span(
|
||||
timestamp=before_2,
|
||||
evolution_time=evolution_time,
|
||||
service_name="svc-before-2",
|
||||
name="span before evolution 2",
|
||||
),
|
||||
_build_evolved_span(
|
||||
timestamp=before_1,
|
||||
evolution_time=evolution_time,
|
||||
service_name="svc-before-1",
|
||||
name="span before evolution 1",
|
||||
),
|
||||
_build_evolved_span(
|
||||
timestamp=after_1,
|
||||
evolution_time=evolution_time,
|
||||
service_name="svc-after-1",
|
||||
name="span after evolution 1",
|
||||
),
|
||||
_build_evolved_span(
|
||||
timestamp=after_2,
|
||||
evolution_time=evolution_time,
|
||||
service_name="svc-after-2",
|
||||
name="span after evolution 2",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
before_series = _query_grouped_trace_series(signoz, token, before_2 - timedelta(minutes=1), before_1 + timedelta(minutes=1))
|
||||
_assert_grouped_series(
|
||||
before_series,
|
||||
expected_values_by_group={
|
||||
"svc-before-2": {
|
||||
int(before_2.timestamp() * 1000): 1,
|
||||
},
|
||||
"svc-before-1": {
|
||||
int(before_1.timestamp() * 1000): 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
after_series = _query_grouped_trace_series(signoz, token, after_1 - timedelta(minutes=1), after_2 + timedelta(minutes=1))
|
||||
_assert_grouped_series(
|
||||
after_series,
|
||||
expected_values_by_group={
|
||||
"svc-after-1": {
|
||||
int(after_1.timestamp() * 1000): 1,
|
||||
},
|
||||
"svc-after-2": {
|
||||
int(after_2.timestamp() * 1000): 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
spanning_series = _query_grouped_trace_series(signoz, token, before_2, after_2 + timedelta(minutes=1))
|
||||
_assert_grouped_series(
|
||||
spanning_series,
|
||||
expected_values_by_group={
|
||||
"svc-before-2": {
|
||||
int(before_2.timestamp() * 1000): 1,
|
||||
},
|
||||
"svc-before-1": {
|
||||
int(before_1.timestamp() * 1000): 1,
|
||||
},
|
||||
"svc-after-1": {
|
||||
int(after_1.timestamp() * 1000): 1,
|
||||
},
|
||||
"svc-after-2": {
|
||||
int(after_2.timestamp() * 1000): 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# query to check aggregation on the resource field like count_distinct(service.name)
|
||||
aggregation_series = _query_grouped_trace_series(
|
||||
signoz,
|
||||
token,
|
||||
before_2,
|
||||
after_2 + timedelta(minutes=1),
|
||||
group_by="deployment.environment",
|
||||
aggregation="count_distinct(service.name)",
|
||||
)
|
||||
_assert_grouped_series(
|
||||
aggregation_series,
|
||||
expected_values_by_group={
|
||||
"integration": {
|
||||
int(before_2.timestamp() * 1000): 1,
|
||||
int(before_1.timestamp() * 1000): 1,
|
||||
int(after_1.timestamp() * 1000): 1,
|
||||
int(after_2.timestamp() * 1000): 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_traces_resource_evolution(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
_test_traces_resource_evolution(signoz, token, insert_traces)
|
||||
Reference in New Issue
Block a user