Compare commits

..

3 Commits

Author SHA1 Message Date
Abhi Kumar
8f06859ebf fix(dashboards-v2): refine New Panel modal footer + section labels
- Keep the footer visible at all times; the "Add Panel" confirm is disabled
  until a panel type is selected (instead of hiding the whole footer).
- Footer layout: the section selector fills the available width and the
  confirm button takes its natural width (no more 50/50 split).
- Add a "Select panel type" label above the panel-type grid, matching the
  existing "Add panel to" label.
2026-07-06 19:35:56 +05:30
Abhi Kumar
7ea3ba4e09 feat(dashboards-v2): choose target section when adding a panel
Add a section picker to the New Panel modal so a panel can be placed in
any section (or the untitled root), not just the one the "Add panel"
action was triggered from. Sections are read from the cached dashboard
query via useDashboardSections (no refetch, no prop-drilling).

Picking a panel type now selects it (highlighted card) and reveals a
footer holding the section dropdown and an "Add Panel" button split
50/50; confirming shows a brief loader before navigating to the editor.
The chosen section's layoutIndex is threaded through
useCreatePanel.createPanel.
2026-07-06 19:35:56 +05:30
Abhi Kumar
af02e8b722 fix: ui fixes for panel selection modal 2026-07-06 19:35:56 +05:30
45 changed files with 1351 additions and 1923 deletions

View File

@@ -155,8 +155,8 @@ querier:
cache_ttl: 168h
# The interval for recent data that should not be cached.
flux_interval: 5m
# The maximum number of queries a single query range request runs at once.
max_concurrent_queries: 8
# The maximum number of concurrent queries for missing ranges.
max_concurrent_queries: 4
# When filtering logs by trace_id, clamp the query window to the trace time
# range with padding to include slightly delayed log exports. Logs only; set
# to 0 to disable.

View File

@@ -25,7 +25,6 @@ You are operating within a constrained context window and strict system prompts.
- Never create barrel files.
- When writing new css, prefer CSS Modules
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
- When writing code that could need authorization checks, read ./src/lib/authz/README.md
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
- Run `pnpm tsgo --noEmit`

View File

@@ -1,11 +1,10 @@
import { ComponentType } from 'react';
import { TabsProps } from 'antd';
import { History } from 'history';
export type TabRoutes = {
name: React.ReactNode;
route: string;
Component: ComponentType;
Component: () => JSX.Element;
key: string;
};

View File

@@ -1,21 +0,0 @@
# AuthZ
Permission-based authorization system for SigNoz frontend.
## Supported Resources
See [hooks/useAuthZ/permissions.type.ts](./hooks/useAuthZ/permissions.type.ts) for available resources and verbs.
If your page/content represents a resource not listed there, skip authz implementation — the backend doesn't enforce it yet.
## UI Gating
Need to gate UI based on permissions? See [components/README.md](./components/README.md).
Covers: AuthZButton, AuthZTooltip, withAuthZ*, AuthZGuard*, when to use each.
## Testing
Need to test authz behavior? See [utils/README.md](./utils/README.md).
Covers: MSW handlers, mock hooks, test patterns.

View File

@@ -1,81 +0,0 @@
import { ReactElement } from 'react';
import { render, screen } from 'tests/test-utils';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import type { AuthZObject } from 'lib/authz/hooks/useAuthZ/types';
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
import AuthZButton from './AuthZButton';
// AuthZButton is a thin composition over AuthZTooltip + Button. The denial
// tooltip / disabled-on-deny UX is owned and tested by AuthZTooltip; here we
// assert AuthZButton forwards the right props and renders a Button child.
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip');
const mockTooltip = AuthZTooltip as unknown as jest.Mock;
const createPerm = buildPermission(
'create',
'serviceaccount:*' as AuthZObject<'create'>,
);
describe('AuthZButton', () => {
beforeEach(() => {
mockTooltip.mockImplementation(
({ children }: { children: ReactElement }) => children,
);
});
afterEach(() => {
mockTooltip.mockReset();
});
it('renders a Button child with forwarded props', () => {
render(
<AuthZButton checks={[createPerm]} testId="create-btn">
Create
</AuthZButton>,
);
expect(screen.getByTestId('create-btn')).toBeInTheDocument();
expect(screen.getByTestId('create-btn').tagName).toBe('BUTTON');
});
it('forwards checks and enables the check by default', () => {
render(
<AuthZButton checks={[createPerm]} testId="create-btn">
Create
</AuthZButton>,
);
expect(mockTooltip).toHaveBeenCalledTimes(1);
expect(mockTooltip.mock.calls[0][0]).toMatchObject({
checks: [createPerm],
enabled: true,
});
});
it('forwards a custom tooltipMessage', () => {
render(
<AuthZButton
checks={[createPerm]}
tooltipMessage="Ask an admin"
testId="create-btn"
>
Create
</AuthZButton>,
);
expect(mockTooltip.mock.calls[0][0]).toMatchObject({
tooltipMessage: 'Ask an admin',
});
});
it('passes authZEnabled through as the tooltip enabled flag', () => {
render(
<AuthZButton checks={[createPerm]} authZEnabled={false} testId="create-btn">
Create
</AuthZButton>,
);
expect(mockTooltip.mock.calls[0][0]).toMatchObject({ enabled: false });
});
});

View File

@@ -1,37 +0,0 @@
import { Button, ButtonProps } from '@signozhq/ui/button';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
export type AuthZButtonProps = ButtonProps & {
/**
* Permissions required to enable the button (AND semantics).
*/
checks: BrandedPermission[];
/**
* Override the default denial tooltip message.
*/
tooltipMessage?: string;
/**
* Gate the permission check itself. When false, renders a plain button.
*/
authZEnabled?: boolean;
};
function AuthZButton({
checks,
tooltipMessage,
authZEnabled = true,
...buttonProps
}: AuthZButtonProps): JSX.Element {
return (
<AuthZTooltip
checks={checks}
enabled={authZEnabled}
tooltipMessage={tooltipMessage}
>
<Button {...buttonProps} />
</AuthZTooltip>
);
}
export default AuthZButton;

View File

@@ -1,202 +0,0 @@
import { render, screen, waitFor } from 'tests/test-utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
AUTHZ_CHECK_URL,
setupAuthzAllow,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import type { AuthZObject } from 'lib/authz/hooks/useAuthZ/types';
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
import { AuthZGuard } from './AuthZGuard';
import { AuthZGuardContent } from './AuthZGuardContent';
import { AuthZGuardPage } from './AuthZGuardPage';
const readPerm = buildPermission('read', 'role:*' as AuthZObject<'read'>);
const Protected = (): JSX.Element => <div>Protected content</div>;
describe('AuthZGuard', () => {
it('renders children when allowed', async () => {
server.use(setupAuthzAllow(readPerm));
render(
<AuthZGuard checks={[readPerm]}>
<Protected />
</AuthZGuard>,
);
await waitFor(() => {
expect(screen.getByText('Protected content')).toBeInTheDocument();
});
});
it('renders the fallback when denied', async () => {
server.use(setupAuthzDeny(readPerm));
render(
<AuthZGuard checks={[readPerm]} fallback={<div>No access</div>}>
<Protected />
</AuthZGuard>,
);
await waitFor(() => {
expect(screen.getByText('No access')).toBeInTheDocument();
});
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
});
it('passes denied permissions to a function fallback', async () => {
server.use(setupAuthzDeny(readPerm));
render(
<AuthZGuard
checks={[readPerm]}
fallback={({ deniedPermissions }): JSX.Element => (
<div>denied: {deniedPermissions.length}</div>
)}
>
<Protected />
</AuthZGuard>,
);
await waitFor(() => {
expect(screen.getByText('denied: 1')).toBeInTheDocument();
});
});
it('renders nothing for a denied check with no fallback', async () => {
server.use(setupAuthzDeny(readPerm));
const { container } = render(
<AuthZGuard checks={[readPerm]}>
<Protected />
</AuthZGuard>,
);
await waitFor(() => {
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
});
expect(container).toBeEmptyDOMElement();
});
it('renders the loading fallback while checking', () => {
server.use(setupAuthzAllow(readPerm));
render(
<AuthZGuard checks={[readPerm]} fallbackOnLoading={<div>Loading</div>}>
<Protected />
</AuthZGuard>,
);
expect(screen.getByText('Loading…')).toBeInTheDocument();
});
it('fails open on error by default (renders children)', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ error: 'boom' })),
),
);
render(
<AuthZGuard checks={[readPerm]} fallback={<div>No access</div>}>
<Protected />
</AuthZGuard>,
);
await waitFor(() => {
expect(screen.getByText('Protected content')).toBeInTheDocument();
});
});
it('renders the fallback on error when failOpenOnError is false', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ error: 'boom' })),
),
);
render(
<AuthZGuard
checks={[readPerm]}
onFailRenderContent={false}
fallback={<div>No access</div>}
>
<Protected />
</AuthZGuard>,
);
await waitFor(() => {
expect(screen.getByText('No access')).toBeInTheDocument();
});
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
});
});
describe('AuthZGuardPage', () => {
it('renders the full-page denied screen when denied', async () => {
server.use(setupAuthzDeny(readPerm));
render(
<AuthZGuardPage checks={[readPerm]}>
<Protected />
</AuthZGuardPage>,
);
await waitFor(() => {
expect(
screen.getByText('Uh-oh! You are not authorized'),
).toBeInTheDocument();
});
expect(screen.getByText('read:role:*')).toBeInTheDocument();
});
it('renders the app loader while checking', () => {
server.use(setupAuthzDeny(readPerm));
render(
<AuthZGuardPage checks={[readPerm]}>
<Protected />
</AuthZGuardPage>,
);
expect(
screen.getByText(
'OpenTelemetry-Native Logs, Metrics and Traces in a single pane',
),
).toBeInTheDocument();
});
});
describe('AuthZGuardContent', () => {
it('renders the denied callout when denied', async () => {
server.use(setupAuthzDeny(readPerm));
render(
<AuthZGuardContent checks={[readPerm]}>
<Protected />
</AuthZGuardContent>,
);
await waitFor(() => {
expect(screen.getByText('read:role:*')).toBeInTheDocument();
});
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
});
it('renders children when allowed', async () => {
server.use(setupAuthzAllow(readPerm));
render(
<AuthZGuardContent checks={[readPerm]}>
<Protected />
</AuthZGuardContent>,
);
await waitFor(() => {
expect(screen.getByText('Protected content')).toBeInTheDocument();
});
});
});

View File

@@ -1,82 +0,0 @@
import { ReactElement, ReactNode, useMemo } from 'react';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
export type AuthZGuardFallback =
| ReactNode
| ((info: { deniedPermissions: BrandedPermission[] }) => ReactNode);
export type AuthZGuardProps = {
/**
* Permissions required to render `children` (AND semantics).
*/
checks: BrandedPermission[];
children: ReactElement;
/**
* Rendered when denied. A function receives the denied permissions.
*/
fallback?: AuthZGuardFallback;
fallbackOnLoading?: ReactNode;
/**
* By default, we don't expect the check API request to fail, in those cases, we prefer to show the content and then let the API fail (during list/create).
*
* In case you want to have a different behavior when request fail, set to false.
*
* @default true
*/
onFailRenderContent?: boolean;
};
function resolveFallback(
fallback: AuthZGuardFallback | undefined,
deniedPermissions: BrandedPermission[],
): ReactNode {
if (typeof fallback === 'function') {
return fallback({ deniedPermissions });
}
return fallback ?? null;
}
export function AuthZGuard({
checks,
children,
fallback,
fallbackOnLoading,
onFailRenderContent = true,
}: AuthZGuardProps): JSX.Element | null {
const { isLoading, error, permissions } = useAuthZ(checks);
// TODO(authz): Use allowed/deniedPermissions from useAuthZ after devtools PR merges
const { allowed, deniedPermissions } = useMemo(() => {
if (!permissions) {
return { allowed: false, deniedPermissions: [] as BrandedPermission[] };
}
const denied = Object.entries(permissions)
.filter(([, { isGranted }]) => !isGranted)
.map(([perm]) => perm as BrandedPermission);
return {
allowed: denied.length === 0,
deniedPermissions: denied,
};
}, [permissions]);
if (isLoading) {
return <>{fallbackOnLoading ?? null}</>;
}
if (error) {
return onFailRenderContent ? (
children
) : (
<>{resolveFallback(fallback, deniedPermissions)}</>
);
}
if (!allowed) {
return <>{resolveFallback(fallback, deniedPermissions)}</>;
}
return children;
}

View File

@@ -1,21 +0,0 @@
import { ReactElement } from 'react';
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
import { AuthZGuard, AuthZGuardProps } from './AuthZGuard';
export function AuthZGuardContent({
fallback,
...rest
}: AuthZGuardProps): JSX.Element | null {
return (
<AuthZGuard
{...rest}
fallback={
fallback ??
(({ deniedPermissions }): ReactElement => (
<PermissionDeniedCallout deniedPermissions={deniedPermissions} />
))
}
/>
);
}

View File

@@ -1,24 +0,0 @@
import { ReactElement } from 'react';
import AppLoading from 'components/AppLoading/AppLoading';
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import { AuthZGuard, AuthZGuardProps } from './AuthZGuard';
export function AuthZGuardPage({
fallback,
fallbackOnLoading,
...rest
}: AuthZGuardProps): JSX.Element | null {
return (
<AuthZGuard
{...rest}
fallbackOnLoading={fallbackOnLoading ?? <AppLoading />}
fallback={
fallback ??
(({ deniedPermissions }): ReactElement => (
<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />
))
}
/>
);
}

View File

@@ -16,8 +16,6 @@ const noPermissions = {
isFetching: false,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [] as BrandedPermission[],
refetchPermissions: jest.fn(),
};
@@ -162,11 +160,11 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
</AuthZTooltip>,
);
const button = screen.getByRole('button', { name: 'Action' });
expect(button).toBeDisabled();
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
expect(button.getAttribute('data-denied-permissions')).toContain(sa);
expect(button.getAttribute('data-denied-permissions')).toContain(
const wrapper = screen.getByRole('button', { name: 'Action' }).parentElement;
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(sa);
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(
attachRolePerm,
);
});

View File

@@ -1,3 +1,7 @@
.wrapper {
cursor: not-allowed;
}
.errorContent {
background: var(--callout-error-background) !important;
border-color: var(--callout-error-border) !important;

View File

@@ -1,4 +1,4 @@
import { CSSProperties, ReactElement, cloneElement, useMemo } from 'react';
import { ReactElement, cloneElement, useMemo } from 'react';
import {
TooltipRoot,
TooltipContent,
@@ -11,13 +11,6 @@ import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
import { useAppContext } from 'providers/App/App';
import styles from './AuthZTooltip.module.scss';
const DISABLED_STYLE: CSSProperties = {
pointerEvents: 'all',
cursor: 'not-allowed',
};
const noOp = (): void => {};
interface AuthZTooltipProps {
checks: BrandedPermission[];
children: ReactElement;
@@ -56,13 +49,11 @@ function AuthZTooltip({
}, [checks, permissions]);
if (shouldCheck && isLoading) {
return cloneElement(children, {
disabled: true,
style: DISABLED_STYLE,
onClick: noOp,
onMouseDown: noOp,
onPointerDown: noOp,
});
return (
<span className={styles.wrapper}>
{cloneElement(children, { disabled: true })}
</span>
);
}
if (!shouldCheck || deniedPermissions.length === 0) {
@@ -73,14 +64,12 @@ function AuthZTooltip({
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger asChild>
{cloneElement(children, {
disabled: true,
style: DISABLED_STYLE,
onClick: noOp,
onMouseDown: noOp,
onPointerDown: noOp,
'data-denied-permissions': deniedPermissions.join(','),
})}
<span
className={styles.wrapper}
data-denied-permissions={deniedPermissions.join(',')}
>
{cloneElement(children, { disabled: true })}
</span>
</TooltipTrigger>
<TooltipContent className={styles.errorContent}>
{formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}

View File

@@ -0,0 +1,263 @@
import { ReactElement } from 'react';
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { buildPermission } from 'lib/authz/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 'lib/authz/utils/authz-test-utils';
import { GuardAuthZ } from './GuardAuthZ';
describe('GuardAuthZ', () => {
const TestChild = (): ReactElement => <div>Protected Content</div>;
const LoadingFallback = (): ReactElement => <div>Loading...</div>;
const NoPermissionFallback = (_response: {
requiredPermissionName: BrandedPermission;
}): ReactElement => <div>Access denied</div>;
const NoPermissionFallbackWithSuggestions = (response: {
requiredPermissionName: BrandedPermission;
}): ReactElement => (
<div>
Access denied. Required permission: {response.requiredPermissionName}
</div>
);
it('should render children when 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, [true])));
}),
);
render(
<GuardAuthZ relation="read" object="role:*">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
});
it('should render fallbackOnLoading when loading', () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) => {
return res(
ctx.delay('infinite'),
ctx.status(200),
ctx.json({ data: [], status: 'success' }),
);
}),
);
render(
<GuardAuthZ
relation="read"
object="role:*"
fallbackOnLoading={<LoadingFallback />}
>
<TestChild />
</GuardAuthZ>,
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render null when loading and no fallbackOnLoading provided', () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) => {
return res(
ctx.delay('infinite'),
ctx.status(200),
ctx.json({ data: [], status: 'success' }),
);
}),
);
const { container } = render(
<GuardAuthZ relation="read" object="role:*">
<TestChild />
</GuardAuthZ>,
);
expect(container.firstChild).toBeNull();
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render children when API error occurs and no fallbackOnError provided (fail open)', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
}),
);
render(
<GuardAuthZ relation="read" object="role:*">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
});
it('should render fallbackOnError when API error occurs and fallbackOnError is provided', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
}),
);
render(
<GuardAuthZ
relation="read"
object="role:*"
fallbackOnError={<div>Custom error fallback</div>}
>
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Custom error fallback')).toBeInTheDocument();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render fallbackOnNoPermissions when 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, [false])));
}),
);
render(
<GuardAuthZ
relation="update"
object="role:123"
fallbackOnNoPermissions={NoPermissionFallback}
>
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Access denied')).toBeInTheDocument();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render null when permission is denied and no fallbackOnNoPermissions provided', 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, [false])));
}),
);
const { container } = render(
<GuardAuthZ relation="update" object="role:123">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(container.firstChild).toBeNull();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render null when permissions object is null', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
return res(ctx.status(200), ctx.json({ data: [], status: 'success' }));
}),
);
const { container } = render(
<GuardAuthZ relation="read" object="role:*">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(container.firstChild).toBeNull();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should pass requiredPermissionName to fallbackOnNoPermissions', async () => {
const permission = buildPermission('update', 'role:123');
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, [false])));
}),
);
render(
<GuardAuthZ
relation="update"
object="role:123"
fallbackOnNoPermissions={NoPermissionFallbackWithSuggestions}
>
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(
screen.getByText(/Access denied. Required permission:/),
).toBeInTheDocument();
});
expect(
screen.getAllByText(
new RegExp(permission.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
).length,
).toBeGreaterThan(0);
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should handle different relation and object combinations', 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, [true])));
}),
);
const { rerender } = render(
<GuardAuthZ relation="read" object="role:*">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
rerender(
<GuardAuthZ relation="delete" object="role:456">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,50 @@
import { ReactElement } from 'react';
import {
AuthZObject,
AuthZRelation,
BrandedPermission,
} from 'lib/authz/hooks/useAuthZ/types';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
export type GuardAuthZProps<R extends AuthZRelation> = {
children: ReactElement;
relation: R;
object: AuthZObject<R>;
fallbackOnLoading?: JSX.Element;
fallbackOnError?: JSX.Element;
fallbackOnNoPermissions?: (response: {
requiredPermissionName: BrandedPermission;
}) => JSX.Element;
};
export function GuardAuthZ<R extends AuthZRelation>({
children,
relation,
object,
fallbackOnLoading,
fallbackOnError,
fallbackOnNoPermissions,
}: GuardAuthZProps<R>): JSX.Element | null {
const permission = buildPermission<R>(relation, object);
const { permissions, isLoading, error } = useAuthZ([permission]);
if (isLoading) {
return fallbackOnLoading ?? null;
}
if (error) {
return fallbackOnError ?? children;
}
if (!permissions?.[permission]?.isGranted) {
return (
fallbackOnNoPermissions?.({
requiredPermissionName: permission,
}) ?? null
);
}
return children;
}

View File

@@ -1,39 +1,18 @@
import { render, screen } from 'tests/test-utils';
import PermissionDeniedCallout from './PermissionDeniedCallout';
import {
buildPermission,
buildObjectString,
} from 'lib/authz/hooks/useAuthZ/utils';
describe('PermissionDeniedCallout', () => {
it('renders the permission name in the callout message', () => {
const deniedPermissions = [
buildPermission('read', buildObjectString('serviceaccount', '*')),
];
render(<PermissionDeniedCallout deniedPermissions={deniedPermissions} />);
render(<PermissionDeniedCallout permissionName="serviceaccount:attach" />);
expect(screen.getByText(/is not authorized/)).toBeInTheDocument();
expect(screen.getByText(/read:serviceaccount:\*/)).toBeInTheDocument();
});
it('renders multiple denied permissions', () => {
const deniedPermissions = [
buildPermission('read', buildObjectString('serviceaccount', '*')),
buildPermission('update', buildObjectString('role', 'admin')),
];
render(<PermissionDeniedCallout deniedPermissions={deniedPermissions} />);
expect(screen.getByText(/read:serviceaccount:\*/)).toBeInTheDocument();
expect(screen.getByText(/update:role:admin/)).toBeInTheDocument();
expect(screen.getByText(/serviceaccount:attach/)).toBeInTheDocument();
});
it('accepts an optional className', () => {
const deniedPermissions = [
buildPermission('read', buildObjectString('serviceaccount', '*')),
];
const { container } = render(
<PermissionDeniedCallout
deniedPermissions={deniedPermissions}
permissionName="serviceaccount:read"
className="custom-class"
/>,
);

View File

@@ -3,32 +3,18 @@ import cx from 'classnames';
import styles from './PermissionDeniedCallout.module.scss';
import { useAppContext } from 'providers/App/App';
import { Typography } from '@signozhq/ui/typography';
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
export interface PermissionDeniedCalloutProps {
/**
* @deprecated Use `deniedPermissions` instead. Will be removed after authz devtools PR merges.
*/
permissionName?: string;
deniedPermissions?: BrandedPermission[];
interface PermissionDeniedCalloutProps {
permissionName: string;
className?: string;
}
function PermissionDeniedCallout({
permissionName,
deniedPermissions,
className,
}: PermissionDeniedCalloutProps): JSX.Element {
const { user } = useAppContext();
// TODO(authz): Remove permissionName support after devtools PR merges
const formattedPermissions = deniedPermissions
? deniedPermissions.map(formatPermission)
: permissionName
? [permissionName]
: [];
return (
<Callout
type="error"
@@ -39,12 +25,7 @@ function PermissionDeniedCallout({
<Typography.Text className={styles.permission}>
<code className={styles.permissionCode}>user/{user.id}</code> is not
authorized to perform{' '}
{formattedPermissions.map((perm, idx) => (
<span key={perm}>
<code className={styles.permissionCode}>{perm}</code>
{idx < formattedPermissions.length - 1 && ', '}
</span>
))}
<code className={styles.permissionCode}>{permissionName}</code>
</Typography.Text>
</Callout>
);

View File

@@ -1,29 +1,17 @@
import { render, screen } from 'tests/test-utils';
import PermissionDeniedFullPage from './PermissionDeniedFullPage';
import {
buildPermission,
buildObjectString,
} from 'lib/authz/hooks/useAuthZ/utils';
describe('PermissionDeniedFullPage', () => {
it('renders the title and subtitle with the permissionName interpolated', () => {
const deniedPermissions = [
buildPermission('read', buildObjectString('serviceaccount', '*')),
];
render(<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />);
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
expect(screen.getByText('Uh-oh! You are not authorized')).toBeInTheDocument();
expect(screen.getByText(/read:serviceaccount:\*/)).toBeInTheDocument();
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
});
it('renders with multiple denied permissions', () => {
const deniedPermissions = [
buildPermission('read', buildObjectString('role', 'admin')),
buildPermission('update', buildObjectString('role', 'admin')),
];
render(<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />);
expect(screen.getByText(/read:role:admin/)).toBeInTheDocument();
expect(screen.getByText(/update:role:admin/)).toBeInTheDocument();
it('renders with a different permissionName', () => {
render(<PermissionDeniedFullPage permissionName="role:read" />);
expect(screen.getByText(/role:read/)).toBeInTheDocument();
});
});

View File

@@ -3,30 +3,16 @@ import { CircleSlash2 } from '@signozhq/icons';
import styles from './PermissionDeniedFullPage.module.scss';
import { Style } from '@signozhq/design-tokens';
import { useAppContext } from 'providers/App/App';
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
export interface PermissionDeniedFullPageProps {
/**
* @deprecated Use `deniedPermissions` instead. Will be removed after authz devtools PR merges.
*/
permissionName?: string;
deniedPermissions?: BrandedPermission[];
interface PermissionDeniedFullPageProps {
permissionName: string;
}
function PermissionDeniedFullPage({
permissionName,
deniedPermissions,
}: PermissionDeniedFullPageProps): JSX.Element {
const { user } = useAppContext();
// TODO(authz): Remove permissionName support after devtools PR merges
const formattedPermissions = deniedPermissions
? deniedPermissions.map(formatPermission)
: permissionName
? [permissionName]
: [];
return (
<div className={styles.container}>
<div className={styles.content}>
@@ -36,13 +22,7 @@ function PermissionDeniedFullPage({
<p className={styles.title}>Uh-oh! You are not authorized</p>
<p className={styles.subtitle}>
<code className={styles.permission}>user/{user.id}</code> is not authorized
to perform{' '}
{formattedPermissions.map((perm, idx) => (
<span key={perm}>
<code className={styles.permission}>{perm}</code>
{idx < formattedPermissions.length - 1 && ', '}
</span>
))}
to perform <code className={styles.permission}>{permissionName}</code>
</p>
</div>
</div>

View File

@@ -1,185 +0,0 @@
# AuthZ Components
Quick reference for permission-gating UI. All components use AND semantics: user needs ALL permissions in `checks` array.
## Decision Tree
```
Need to gate...
├── A button? → AuthZButton
├── Any element with tooltip on deny? → AuthZTooltip
├── A section inside a page? → withAuthZContent (preferred)
│ └── Need JSX wrapper? → AuthZGuardContent
├── An entire page/route? → withAuthZPage (preferred)
│ └── Need JSX wrapper? → AuthZGuardPage
├── Need full control over fallback? → withAuthZ / AuthZGuard
└── None of above fit?
├── Can create wrapper component? → Create it (like AuthZButton)
└── Last resort → useAuthZ hook directly
```
## Building Permissions
Use `buildPermission`, `buildObjectString` or pre-built constants. Never cast with `as BrandedPermission`.
```tsx
import { buildPermission, buildObjectString } from 'lib/authz/hooks/useAuthZ/utils';
import {
RoleCreatePermission,
buildRoleReadPermission
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
// Static permission (pre-built)
const checks = [RoleCreatePermission];
// Dynamic permission (builder fn)
const checks = [buildRoleReadPermission(roleId)];
// Custom permission (buildPermission + buildObjectString)
const checks = [buildPermission('read', buildObjectString('dashboard', dashboardId))];
```
## Creating Permission Helpers
When adding authz to a new resource, create a permissions file under `lib/authz/hooks/useAuthZ/permissions/`.
```tsx
// lib/authz/hooks/useAuthZ/permissions/dashboard.permissions.ts
import { buildPermission } from '../utils';
import type { BrandedPermission } from '../types';
// Collection-level — wildcard, no specific id needed
export const DashboardCreatePermission = buildPermission('create', 'dashboard:*');
export const DashboardListPermission = buildPermission('list', 'dashboard:*');
// Resource-level — require specific id
export const buildDashboardReadPermission = (id: string): BrandedPermission =>
buildPermission('read', `dashboard:${id}`);
export const buildDashboardUpdatePermission = (id: string): BrandedPermission =>
buildPermission('update', `dashboard:${id}`);
export const buildDashboardDeletePermission = (id: string): BrandedPermission =>
buildPermission('delete', `dashboard:${id}`);
```
Pattern:
- `<Resource><Action>Permission` → collection-level const (wildcard `*`)
- `build<Resource><Action>Permission(id)` → resource-level fn (specific id)
## Components
### AuthZButton
Button that disables + shows tooltip when denied.
```tsx
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
<AuthZButton checks={[SACreatePermission]} onClick={handleCreate}>
Create
</AuthZButton>
```
### AuthZTooltip
Wraps any element. Disables child + shows denial tooltip.
```tsx
import { buildSADeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
<AuthZTooltip checks={[buildSADeletePermission(accountId)]}>
<IconButton icon={<Trash />} onClick={handleDelete} />
</AuthZTooltip>
```
### withAuthZPage (preferred for pages)
HOC for route-level gating. Wrap at export. Shows `PermissionDeniedFullPage` + `AppLoading`.
```tsx
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
function RolesPage(): JSX.Element {
return <div>...</div>;
}
export default withAuthZPage(RolesPage, {
checks: [RoleListPermission],
});
```
### withAuthZContent (preferred for sections)
HOC for inline sections. Shows `PermissionDeniedCallout` on deny.
```tsx
import { buildRoleReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
function RoleEditor(): JSX.Element {
return <div>...</div>;
}
// Dynamic checks from route params
export default withAuthZContent(RoleEditor, {
checks: (_props, ctx) => [buildRoleReadPermission(ctx.params.roleId)],
});
```
### withAuthZ
HOC base. No default fallback. Use when you need custom fallback.
```tsx
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
export default withAuthZ(SecretPanel, {
checks: [buildPermission('write', 'settings:org')],
fallback: <p>No access</p>,
});
```
### AuthZGuardPage
JSX variant of `withAuthZPage`. Use when HOC not possible (conditional rendering).
```tsx
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
<AuthZGuardPage checks={[RoleListPermission]}>
<RolesPage />
</AuthZGuardPage>
```
### AuthZGuardContent
JSX variant of `withAuthZContent`. Use when HOC not possible.
```tsx
import { RoleCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
<AuthZGuardContent checks={[RoleCreatePermission]}>
<RoleEditor />
</AuthZGuardContent>
```
### AuthZGuard
JSX base guard. No default fallback. Use when you need custom fallback in JSX.
```tsx
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
<AuthZGuard
checks={[buildPermission('write', 'settings:org')]}
fallback={<p>No access</p>}
fallbackOnLoading={<Spinner />}
>
<SecretContent />
</AuthZGuard>
```
## Fallback Components
Don't use these components directly, always prefer using via `withAuthZ` and their variants.
- PermissionDeniedCallout: inline error callout. Shows `user/{id} is not authorized to perform {permissions}`.
- PermissionDeniedFullPage: full-page centered error. Same message, bigger presentation.

View File

@@ -0,0 +1,440 @@
import { ReactElement } from 'react';
import type { RouteComponentProps } from 'react-router-dom';
import type {
AuthtypesGettableTransactionDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
} from 'lib/authz/utils/authz-test-utils';
import { createGuardedRoute } from './createGuardedRoute';
describe('createGuardedRoute', () => {
const TestComponent = ({ testProp }: { testProp: string }): ReactElement => (
<div>Test Component: {testProp}</div>
);
it('should render component when 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, [true])));
}),
);
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
const mockMatch = {
params: {},
isExact: true,
path: '/dashboard',
url: '/dashboard',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: {} as unknown as RouteComponentProps['location'],
history: {} as unknown as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
it('should substitute route parameters in object string', 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, [true])));
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'role:{id}',
);
const mockMatch = {
params: { id: '123' },
isExact: true,
path: '/dashboard/:id',
url: '/dashboard/123',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: {} as unknown as RouteComponentProps['location'],
history: {} as unknown as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
it('should handle multiple route parameters', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = (await req.json()) as AuthtypesTransactionDTO[];
const txn = payload[0];
const responseData: AuthtypesGettableTransactionDTO[] = [
{
relation: txn.relation,
object: {
resource: {
kind: txn.object.resource.kind,
type: txn.object.resource.type,
},
selector: '123:456',
},
authorized: true,
},
];
return res(
ctx.status(200),
ctx.json({ data: responseData, status: 'success' }),
);
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'update',
'role:{id}:{version}',
);
const mockMatch = {
params: { id: '123', version: '456' },
isExact: true,
path: '/dashboard/:id/:version',
url: '/dashboard/123/456',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: {} as unknown as RouteComponentProps['location'],
history: {} as unknown as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
it('should keep placeholder when route parameter is missing', 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, [true])));
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'role:{id}',
);
const mockMatch = {
params: {},
isExact: true,
path: '/dashboard',
url: '/dashboard',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: {} as unknown as RouteComponentProps['location'],
history: {} as unknown as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
it('should render loading fallback when loading', () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) => {
return res(
ctx.delay('infinite'),
ctx.status(200),
ctx.json({ data: [], status: 'success' }),
);
}),
);
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
const mockMatch = {
params: {},
isExact: true,
path: '/dashboard',
url: '/dashboard',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: {} as unknown as RouteComponentProps['location'],
history: {} as unknown as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
expect(screen.getByText('SigNoz')).toBeInTheDocument();
expect(
screen.queryByText('Test Component: test-value'),
).not.toBeInTheDocument();
});
it('should render the component when API error occurs (fail open)', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
}),
);
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
const mockMatch = {
params: {},
isExact: true,
path: '/dashboard',
url: '/dashboard',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: {} as unknown as RouteComponentProps['location'],
history: {} as unknown as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
it('should render no permissions fallback when 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, [false])));
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'update',
'role:{id}',
);
const mockMatch = {
params: { id: '123' },
isExact: true,
path: '/dashboard/:id',
url: '/dashboard/123',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: {} as unknown as RouteComponentProps['location'],
history: {} as unknown as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
const heading = document.querySelector('h3');
expect(heading).toBeInTheDocument();
expect(heading?.textContent).toMatch(/not authorized/i);
});
expect(screen.getByText(/update:role:123/)).toBeInTheDocument();
expect(
screen.queryByText('Test Component: test-value'),
).not.toBeInTheDocument();
});
it('should pass all props to wrapped component', 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, [true])));
}),
);
const ComponentWithMultipleProps = ({
prop1,
prop2,
prop3,
}: {
prop1: string;
prop2: number;
prop3: boolean;
}): ReactElement => (
<div>
{prop1} - {prop2} - {prop3.toString()}
</div>
);
const GuardedComponent = createGuardedRoute(
ComponentWithMultipleProps,
'read',
'role:*',
);
const mockMatch = {
params: {},
isExact: true,
path: '/dashboard',
url: '/dashboard',
};
const props = {
prop1: 'value1',
prop2: 42,
prop3: true,
match: mockMatch,
location: {} as unknown as RouteComponentProps['location'],
history: {} as unknown as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('value1 - 42 - true')).toBeInTheDocument();
});
});
it('should memoize resolved object based on route params', async () => {
let requestCount = 0;
const requestedObjects: string[] = [];
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
requestCount++;
const payload = (await req.json()) as AuthtypesTransactionDTO[];
const obj = payload[0]?.object;
const kind = obj?.resource?.kind;
const selector = obj?.selector ?? '*';
const objectStr = `${kind}:${selector}`;
requestedObjects.push(objectStr ?? '');
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'role:{id}',
);
const mockMatch1 = {
params: { id: '123' },
isExact: true,
path: '/dashboard/:id',
url: '/dashboard/123',
};
const props1 = {
testProp: 'test-value-1',
match: mockMatch1,
location: {} as unknown as RouteComponentProps['location'],
history: {} as unknown as RouteComponentProps['history'],
};
const { unmount } = render(<GuardedComponent {...props1} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value-1')).toBeInTheDocument();
});
expect(requestCount).toBe(1);
expect(requestedObjects).toContain('role:123');
unmount();
const mockMatch2 = {
params: { id: '456' },
isExact: true,
path: '/dashboard/:id',
url: '/dashboard/456',
};
const props2 = {
testProp: 'test-value-2',
match: mockMatch2,
location: {} as unknown as RouteComponentProps['location'],
history: {} as unknown as RouteComponentProps['history'],
};
render(<GuardedComponent {...props2} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value-2')).toBeInTheDocument();
});
expect(requestCount).toBe(2);
expect(requestedObjects).toContain('role:456');
});
it('should handle different relation types', 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, [true])));
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'delete',
'role:{id}',
);
const mockMatch = {
params: { id: '789' },
isExact: true,
path: '/dashboard/:id',
url: '/dashboard/789',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: {} as unknown as RouteComponentProps['location'],
history: {} as unknown as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,41 @@
.guard-authz-error-no-authz {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 24px;
.guard-authz-error-no-authz-content {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 8px;
max-width: 500px;
}
img {
width: 32px;
height: 32px;
}
h3 {
font-size: 18px;
color: var(--l1-foreground);
line-height: 18px;
}
p {
font-size: 14px;
color: var(--l3-foreground);
line-height: 18px;
span {
background-color: var(--l3-background);
white-space: nowrap;
padding: 0 2px;
}
}
}

View File

@@ -0,0 +1,67 @@
import { ComponentType, ReactElement, useMemo } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import {
AuthZObject,
AuthZRelation,
BrandedPermission,
} from 'lib/authz/hooks/useAuthZ/types';
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
import { useAppContext } from 'providers/App/App';
import noDataUrl from 'assets/Icons/no-data.svg';
import AppLoading from '../../../../components/AppLoading/AppLoading';
import { GuardAuthZ } from '../GuardAuthZ/GuardAuthZ';
import './createGuardedRoute.styles.scss';
function OnNoPermissionsFallback(response: {
requiredPermissionName: BrandedPermission;
}): ReactElement {
const { user } = useAppContext();
return (
<div className="guard-authz-error-no-authz">
<div className="guard-authz-error-no-authz-content">
<img src={noDataUrl} alt="No permission" />
<h3>Uh-oh! You are not authorized</h3>
<p>
<code>user/{user.id}</code> is not authorized to perform{' '}
<code>{formatPermission(response.requiredPermissionName)}</code>
</p>
</div>
</div>
);
}
// eslint-disable-next-line @typescript-eslint/ban-types
export function createGuardedRoute<P extends object, R extends AuthZRelation>(
Component: ComponentType<P>,
relation: R,
object: AuthZObject<R>,
): ComponentType<P & RouteComponentProps<Record<string, string>>> {
return function GuardedRouteComponent(
props: P & RouteComponentProps<Record<string, string>>,
): ReactElement {
const resolvedObject = useMemo(() => {
const paramPattern = /\{([^}]+)\}/g;
return object.replace(paramPattern, (match, paramName) => {
const paramValue = props.match?.params?.[paramName];
return paramValue !== undefined ? paramValue : match;
}) as AuthZObject<R>;
}, [props.match?.params]);
return (
<GuardAuthZ
relation={relation}
object={resolvedObject}
fallbackOnLoading={<AppLoading />}
fallbackOnNoPermissions={(response): ReactElement => (
<OnNoPermissionsFallback {...response} />
)}
>
<Component {...props} />
</GuardAuthZ>
);
};
}

View File

@@ -1,578 +0,0 @@
import React from 'react';
import { render, screen, waitFor, act } from 'tests/test-utils';
import { useQueryClient } from 'react-query';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
setupAuthzAllow,
setupAuthzDeny,
setupAuthzGrantByPrefix,
} from 'lib/authz/utils/authz-test-utils';
import type { AuthZObject } from 'lib/authz/hooks/useAuthZ/types';
import {
buildObjectString,
buildPermission,
} from 'lib/authz/hooks/useAuthZ/utils';
import { withAuthZ, RouterContext } from './withAuthZ';
import { withAuthZContent } from './withAuthZContent';
import { withAuthZPage } from './withAuthZPage';
const mockUseParams = jest.fn();
const mockUseLocation = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: (): Record<string, string> => mockUseParams(),
useLocation: (): { pathname: string; search: string } => mockUseLocation(),
}));
const readPerm = buildPermission('read', 'role:*' as AuthZObject<'read'>);
function Base(): JSX.Element {
return <div>Base component</div>;
}
beforeEach(() => {
mockUseParams.mockReturnValue({});
mockUseLocation.mockReturnValue({ pathname: '/', search: '' });
});
describe('withAuthZ', () => {
it('renders the wrapped component when allowed', async () => {
server.use(setupAuthzAllow(readPerm));
const Guarded = withAuthZ(Base, { checks: [readPerm] });
render(<Guarded />);
await waitFor(() => {
expect(screen.getByText('Base component')).toBeInTheDocument();
});
});
it('renders nothing when denied without a fallback', async () => {
server.use(setupAuthzDeny(readPerm));
const Guarded = withAuthZ(Base, { checks: [readPerm] });
render(<Guarded />);
await waitFor(() => {
expect(screen.queryByText('Base component')).not.toBeInTheDocument();
});
});
it('renders the provided fallback when denied', async () => {
server.use(setupAuthzDeny(readPerm));
const Guarded = withAuthZ(Base, {
checks: [readPerm],
fallback: <div>No access</div>,
});
render(<Guarded />);
await waitFor(() => {
expect(screen.getByText('No access')).toBeInTheDocument();
});
});
it('resolves checks from props via the selector form', async () => {
type Props = { roleId: string };
const RoleView = ({ roleId }: Props): JSX.Element => <div>role {roleId}</div>;
const deniedPerm = buildPermission(
'read',
buildObjectString<'read'>('role', 'r-1'),
);
server.use(setupAuthzDeny(deniedPerm));
const Guarded = withAuthZ<Props>(RoleView, {
checks: ({ roleId }) => [
buildPermission('read', buildObjectString<'read'>('role', roleId)),
],
fallback: <div>denied selector</div>,
});
render(<Guarded roleId="r-1" />);
await waitFor(() => {
expect(screen.getByText('denied selector')).toBeInTheDocument();
});
expect(screen.queryByText('role r-1')).not.toBeInTheDocument();
});
it('sets a descriptive displayName', () => {
const Guarded = withAuthZ(Base, { checks: [readPerm] });
expect(Guarded.displayName).toBe('withAuthZ(Base)');
});
});
describe('withAuthZPage', () => {
it('renders the full-page denied screen when denied', async () => {
server.use(setupAuthzDeny(readPerm));
const Guarded = withAuthZPage(Base, { checks: [readPerm] });
render(<Guarded />);
await waitFor(() => {
expect(
screen.getByText('Uh-oh! You are not authorized'),
).toBeInTheDocument();
});
});
});
describe('withAuthZContent', () => {
it('renders the denied callout when denied', async () => {
server.use(setupAuthzDeny(readPerm));
const Guarded = withAuthZContent(Base, { checks: [readPerm] });
render(<Guarded />);
await waitFor(() => {
expect(screen.getByText('read:role:*')).toBeInTheDocument();
});
expect(screen.queryByText('Base component')).not.toBeInTheDocument();
});
});
describe('withAuthZ router context', () => {
it('extracts checks from route params via router.params', async () => {
mockUseParams.mockReturnValue({ roleId: 'r-123' });
const rolePerm = buildPermission(
'read',
buildObjectString<'read'>('role', 'r-123'),
);
server.use(setupAuthzAllow(rolePerm));
const RoleView = (): JSX.Element => <div>role view</div>;
const Guarded = withAuthZ(RoleView, {
checks: (_props, router: RouterContext) => [
buildPermission(
'read',
buildObjectString<'read'>('role', router.params.roleId ?? ''),
),
],
});
render(<Guarded />);
await waitFor(() => {
expect(screen.getByText('role view')).toBeInTheDocument();
});
});
it('extracts checks from query params via router.searchParams', async () => {
mockUseLocation.mockReturnValue({
pathname: '/roles',
search: '?roleId=r-456',
});
const rolePerm = buildPermission(
'read',
buildObjectString<'read'>('role', 'r-456'),
);
server.use(setupAuthzAllow(rolePerm));
const RoleListView = (): JSX.Element => <div>role list view</div>;
const Guarded = withAuthZ(RoleListView, {
checks: (_props, router: RouterContext) => [
buildPermission(
'read',
buildObjectString<'read'>('role', router.searchParams.get('roleId') ?? ''),
),
],
});
render(<Guarded />);
await waitFor(() => {
expect(screen.getByText('role list view')).toBeInTheDocument();
});
});
it('extracts checks from pathname via router.matchPath', async () => {
mockUseLocation.mockReturnValue({
pathname: '/settings/roles/r-789/edit',
search: '',
});
const rolePerm = buildPermission(
'update',
buildObjectString<'update'>('role', 'r-789'),
);
server.use(setupAuthzAllow(rolePerm));
const EditRoleView = (): JSX.Element => <div>edit role</div>;
const Guarded = withAuthZ(EditRoleView, {
checks: (_props, router: RouterContext) => {
const match = router.matchPath<{ roleId: string }>(
'/settings/roles/:roleId/edit',
);
return match
? [
buildPermission(
'update',
buildObjectString<'update'>('role', match.roleId),
),
]
: [];
},
});
render(<Guarded />);
await waitFor(() => {
expect(screen.getByText('edit role')).toBeInTheDocument();
});
});
it('denies when router-derived permission is not allowed', async () => {
mockUseParams.mockReturnValue({ roleId: 'r-denied' });
const deniedPerm = buildPermission(
'read',
buildObjectString<'read'>('role', 'r-denied'),
);
server.use(setupAuthzDeny(deniedPerm));
const RoleView = (): JSX.Element => <div>role view</div>;
const Guarded = withAuthZ(RoleView, {
checks: (_props, router: RouterContext) => [
buildPermission(
'read',
buildObjectString<'read'>('role', router.params.roleId ?? ''),
),
],
fallback: <div>access denied</div>,
});
render(<Guarded />);
await waitFor(() => {
expect(screen.getByText('access denied')).toBeInTheDocument();
});
expect(screen.queryByText('role view')).not.toBeInTheDocument();
});
});
describe('withAuthZ router context stability', () => {
let renderCount = 0;
function RenderCounter(): JSX.Element {
renderCount += 1;
return <div data-testid="render-count">{renderCount}</div>;
}
beforeEach(() => {
renderCount = 0;
server.use(setupAuthzGrantByPrefix('read||__||role'));
});
it('does not re-render when useParams returns new object with same values', async () => {
mockUseParams.mockReturnValue({ roleId: 'r-1' });
mockUseLocation.mockReturnValue({ pathname: '/', search: '' });
const Guarded = withAuthZ(RenderCounter, {
checks: (_props, router: RouterContext) => [
buildPermission(
'read',
buildObjectString<'read'>('role', router.params.roleId ?? '*'),
),
],
});
const { rerender } = render(<Guarded />);
await waitFor(() => {
expect(screen.getByTestId('render-count')).toBeInTheDocument();
});
const initialCount = renderCount;
// Return NEW object with SAME values — should not cause re-render
mockUseParams.mockReturnValue({ roleId: 'r-1' });
rerender(<Guarded />);
// Allow any pending effects to flush
await waitFor(() => {
expect(renderCount).toBe(initialCount);
});
});
it('does not re-render when useLocation returns new object with same pathname', async () => {
mockUseParams.mockReturnValue({});
mockUseLocation.mockReturnValue({ pathname: '/roles', search: '' });
const Guarded = withAuthZ(RenderCounter, {
checks: (_props, router: RouterContext) => {
const match = router.matchPath<{ id: string }>('/roles/:id');
return match
? [buildPermission('read', buildObjectString<'read'>('role', match.id))]
: [readPerm];
},
});
const { rerender } = render(<Guarded />);
await waitFor(() => {
expect(screen.getByTestId('render-count')).toBeInTheDocument();
});
const initialCount = renderCount;
// Return NEW object with SAME pathname — should not cause re-render
mockUseLocation.mockReturnValue({ pathname: '/roles', search: '' });
rerender(<Guarded />);
await waitFor(() => {
expect(renderCount).toBe(initialCount);
});
});
it('does not re-render when useLocation returns new object with same search params', async () => {
mockUseParams.mockReturnValue({});
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=keys' });
const Guarded = withAuthZ(RenderCounter, {
checks: (_props, router: RouterContext) => {
// Access searchParams to ensure it's part of the dependency chain
void router.searchParams.get('tab');
return [readPerm];
},
});
const { rerender } = render(<Guarded />);
await waitFor(() => {
expect(screen.getByTestId('render-count')).toBeInTheDocument();
});
const initialCount = renderCount;
// Return NEW object with SAME search — should not cause re-render
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=keys' });
rerender(<Guarded />);
await waitFor(() => {
expect(renderCount).toBe(initialCount);
});
});
it('re-renders when params values actually change', async () => {
mockUseParams.mockReturnValue({ roleId: 'r-1' });
mockUseLocation.mockReturnValue({ pathname: '/', search: '' });
const Guarded = withAuthZ(RenderCounter, {
checks: (_props, router: RouterContext) => [
buildPermission(
'read',
buildObjectString<'read'>('role', router.params.roleId ?? '*'),
),
],
});
const { unmount } = render(<Guarded />);
await waitFor(() => {
expect(screen.getByTestId('render-count')).toBeInTheDocument();
});
const initialCount = renderCount;
unmount();
// Return DIFFERENT values — re-mount with new mock values
mockUseParams.mockReturnValue({ roleId: 'r-2' });
render(<Guarded />);
await waitFor(() => {
expect(renderCount).toBeGreaterThan(initialCount);
});
});
it('re-renders when pathname actually changes', async () => {
mockUseParams.mockReturnValue({});
mockUseLocation.mockReturnValue({ pathname: '/roles', search: '' });
const Guarded = withAuthZ(RenderCounter, {
checks: [readPerm],
});
const { unmount } = render(<Guarded />);
await waitFor(() => {
expect(screen.getByTestId('render-count')).toBeInTheDocument();
});
const initialCount = renderCount;
unmount();
// DIFFERENT pathname — re-mount with new mock values
mockUseLocation.mockReturnValue({ pathname: '/users', search: '' });
render(<Guarded />);
await waitFor(() => {
expect(renderCount).toBeGreaterThan(initialCount);
});
});
it('re-renders when search params actually change', async () => {
mockUseParams.mockReturnValue({});
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=keys' });
const Guarded = withAuthZ(RenderCounter, {
checks: [readPerm],
});
const { unmount } = render(<Guarded />);
await waitFor(() => {
expect(screen.getByTestId('render-count')).toBeInTheDocument();
});
const initialCount = renderCount;
unmount();
// DIFFERENT search — re-mount with new mock values
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=details' });
render(<Guarded />);
await waitFor(() => {
expect(renderCount).toBeGreaterThan(initialCount);
});
});
});
describe('withAuthZContent cache invalidation', () => {
const testPerm = buildPermission(
'read',
'role:test-invalidation' as AuthZObject<'read'>,
);
// Callout displays permission as "relation:object" format
const displayedPerm = 'read:role:test-invalidation';
function ContentComponent(): JSX.Element {
return <div data-testid="protected-content">Protected Content</div>;
}
function InvalidationTrigger({
permission,
onReady,
}: {
permission: string;
onReady: (invalidate: () => Promise<void>) => void;
}): null {
const queryClient = useQueryClient();
React.useEffect(() => {
onReady(async () => {
// Reset query to initial state and trigger refetch (matches devtools behavior)
await queryClient.resetQueries(['authz', permission]);
});
}, [queryClient, permission, onReady]);
return null;
}
it('re-renders from allowed to denied when cache is invalidated', async () => {
let shouldGrant = true;
let invalidateFn: (() => Promise<void>) | null = null;
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, [shouldGrant])),
);
}),
);
const Guarded = withAuthZContent(ContentComponent, { checks: [testPerm] });
render(
<>
<Guarded />
<InvalidationTrigger
permission={testPerm}
onReady={(fn): void => {
invalidateFn = fn;
}}
/>
</>,
);
// Initially allowed - should show content
await waitFor(() => {
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
});
// Change server response to deny
shouldGrant = false;
// Invalidate cache
await act(async () => {
await invalidateFn?.();
});
// Should now show denied callout
await waitFor(() => {
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
});
// Callout should show the denied permission
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText(displayedPerm)).toBeInTheDocument();
});
it('re-renders from denied to allowed when cache is invalidated', async () => {
let shouldGrant = false;
let invalidateFn: (() => Promise<void>) | null = null;
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, [shouldGrant])),
);
}),
);
const Guarded = withAuthZContent(ContentComponent, { checks: [testPerm] });
render(
<>
<Guarded />
<InvalidationTrigger
permission={testPerm}
onReady={(fn): void => {
invalidateFn = fn;
}}
/>
</>,
);
// Initially denied - should show callout
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
// Change server response to allow
shouldGrant = true;
// Invalidate cache
await act(async () => {
await invalidateFn?.();
});
// Should now show protected content
await waitFor(() => {
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
});
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});

View File

@@ -1,13 +0,0 @@
import { ComponentType } from 'react';
import { AuthZGuard } from 'lib/authz/components/AuthZGuard/AuthZGuard';
import { createAuthZHOC, WithAuthZOptions } from './withAuthZ.utils';
export type { RouterContext, WithAuthZOptions } from './withAuthZ.utils';
export function withAuthZ<P extends object>(
Component: ComponentType<P>,
opts: WithAuthZOptions<P>,
): ComponentType<P> {
return createAuthZHOC(AuthZGuard, 'withAuthZ', Component, opts);
}

View File

@@ -1,108 +0,0 @@
import { ComponentType, ReactElement, createElement, useMemo } from 'react';
import {
matchPath as reactRouterMatchPath,
useLocation,
useParams,
} from 'react-router-dom';
import type { AuthZGuardProps } from 'lib/authz/components/AuthZGuard/AuthZGuard';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
export type RouterContext = {
/**
* Route params from useParams (e.g. `/roles/:roleId` → `{ roleId: "r-1" }`)
*/
params: Record<string, string | undefined>;
pathname: string;
/**
* Query params as URLSearchParams (use `.get('key')` to read)
*/
searchParams: URLSearchParams;
/**
* Extract params from pathname using a route pattern.
* Returns null if pattern doesn't match.
* @example router.matchPath<{ id: string }>('/edit/:id')?.id
*/
matchPath: <Params extends Record<string, string>>(
pattern: string,
) => Params | null;
};
export type WithAuthZOptions<P> = {
/**
* Static checks, or a selector deriving them from props and router context.
* Use router context to extract dynamic values from route params, pathname, or query params.
* @example
* // From route params
* checks: (props, router) => [buildPermission('read', `role:${router.params.roleId}`)]
* // From query params
* checks: (props, router) => [buildPermission('read', `dashboard:${router.searchParams.get('id')}`)]
* // From pathname matching
* checks: (props, router) => {
* const match = router.matchPath<{ id: string }>('/edit/:id');
* return match ? [buildPermission('update', `role:${match.id}`)] : [];
* }
*/
checks:
| BrandedPermission[]
| ((props: P, router: RouterContext) => BrandedPermission[]);
fallback?: AuthZGuardProps['fallback'];
fallbackOnLoading?: AuthZGuardProps['fallbackOnLoading'];
failOpenOnError?: AuthZGuardProps['onFailRenderContent'];
};
function useStableParams(): Record<string, string | undefined> {
const params = useParams();
const paramsJson = JSON.stringify(params);
return useMemo(() => JSON.parse(paramsJson), [paramsJson]);
}
function useRouterContext(): RouterContext {
const params = useStableParams();
const { pathname, search } = useLocation();
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
return useMemo(
(): RouterContext => ({
params,
pathname,
searchParams,
matchPath: <Params extends Record<string, string>>(
pattern: string,
): Params | null => {
const match = reactRouterMatchPath<Params>(pathname, {
path: pattern,
exact: false,
});
return match?.params ?? null;
},
}),
[params, pathname, searchParams],
);
}
export function createAuthZHOC<P extends object>(
Guard: ComponentType<AuthZGuardProps>,
hocName: string,
Component: ComponentType<P>,
opts: WithAuthZOptions<P>,
): ComponentType<P> {
const { checks, ...guardProps } = opts;
function Wrapped(props: P): ReactElement | null {
const router = useRouterContext();
const resolvedChecks =
typeof checks === 'function' ? checks(props, router) : checks;
return (
<Guard checks={resolvedChecks} {...guardProps}>
{createElement(Component, props)}
</Guard>
);
}
Wrapped.displayName = `${hocName}(${
Component.displayName || Component.name || 'Component'
})`;
return Wrapped;
}

View File

@@ -1,11 +0,0 @@
import { ComponentType } from 'react';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import { createAuthZHOC, WithAuthZOptions } from './withAuthZ.utils';
export function withAuthZContent<P extends object>(
Component: ComponentType<P>,
opts: WithAuthZOptions<P>,
): ComponentType<P> {
return createAuthZHOC(AuthZGuardContent, 'withAuthZContent', Component, opts);
}

View File

@@ -1,11 +0,0 @@
import { ComponentType } from 'react';
import { AuthZGuardPage } from 'lib/authz/components/AuthZGuard/AuthZGuardPage';
import { createAuthZHOC, WithAuthZOptions } from './withAuthZ.utils';
export function withAuthZPage<P extends object>(
Component: ComponentType<P>,
opts: WithAuthZOptions<P>,
): ComponentType<P> {
return createAuthZHOC(AuthZGuardPage, 'withAuthZPage', Component, opts);
}

View File

@@ -1,125 +0,0 @@
# AuthZ Test Utilities
Helpers for testing permission-gated components.
## File Naming
AuthZ tests live in `*.authz.test.tsx` files alongside other test files:
```
ComponentName/
├── ComponentName.tsx
├── __tests__/
│ ├── ComponentName.test.tsx # functional tests
│ └── ComponentName.authz.test.tsx # permission tests
```
## Test Structure
```tsx
import { server } from 'mocks-server/server';
import { setupAuthzAdmin, setupAuthzDenyAll } from 'lib/authz/utils/authz-test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
describe('ComponentName - AuthZ', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers(); // reset MSW handlers after each test
});
describe('permission denied', () => {
it('shows permission denied when read denied', async () => {
server.use(setupAuthzDenyAll());
render(<ComponentName />);
await expect(
screen.findByText(/not authorized/i),
).resolves.toBeInTheDocument();
});
});
describe('permission granted', () => {
it('renders content when permitted', async () => {
server.use(setupAuthzAdmin());
render(<ComponentName />);
await waitFor(() => {
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
});
});
});
});
```
Key points:
- Use `server.use()` at start of each test (not `beforeEach`) for explicit setup
- Call `server.resetHandlers()` in `afterEach` to avoid test pollution
- Use `waitFor` or `findBy*` queries since authz checks are async
- Group tests by permission scenario: denied, granted, partial, loading
## MSW Handlers
Mock `/api/v1/authz/check` endpoint responses.
```tsx
import { server } from 'mocks-server/server';
import {
setupAuthzAdmin,
setupAuthzDenyAll,
setupAuthzDeny,
setupAuthzAllow,
setupAuthzGrantByPrefix,
} from 'lib/authz/utils/authz-test-utils';
// Grant all permissions
server.use(setupAuthzAdmin());
// Deny all permissions
server.use(setupAuthzDenyAll());
// Grant all except specific permissions
server.use(setupAuthzDeny(RoleCreatePermission, RoleDeletePermission));
// Deny all except specific permissions
server.use(setupAuthzAllow(RoleListPermission));
// Grant by relation prefix (e.g., grant read/delete, deny update)
server.use(setupAuthzGrantByPrefix('read', 'delete'));
```
## Custom Mock Response
For fine-grained control over responses.
```tsx
import { rest } from 'msw';
import { AUTHZ_CHECK_URL, authzMockResponse } from 'lib/authz/utils/authz-test-utils';
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
// [true, false] = first permission granted, second denied
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true, false])));
}),
);
```
## Testing Loading State
Use `ctx.delay('infinite')` to hold response indefinitely:
```tsx
it('shows skeleton while checking permissions', () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) =>
res(ctx.delay('infinite')),
),
);
render(<ComponentName />);
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
```

View File

@@ -104,25 +104,6 @@ export function setupAuthzAllow(
});
}
/** Grants permissions that start with any of the given prefixes. */
export function setupAuthzGrantByPrefix(...prefixes: string[]): 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((txn) => {
const perm = gettableTransactionToPermission(txn);
return prefixes.some((prefix) => perm.startsWith(prefix));
}),
),
),
);
});
}
export function buildLicense(
overrides?: Partial<LicenseResModel>,
): LicenseResModel {

View File

@@ -63,8 +63,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const { patchAsync } = useOptimisticPatch();
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const {
isPickerOpen,
openPicker,
closePicker,
createPanel,
targetLayoutIndex,
} = useCreatePanel();
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
@@ -183,6 +188,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
open={isPickerOpen}
onClose={closePicker}
onSelect={createPanel}
defaultLayoutIndex={targetLayoutIndex}
/>
</section>
);

View File

@@ -1,22 +1,69 @@
.panelTypeSection {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.grid {
align-self: stretch;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.typeButton {
.panelTypeCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
padding: 12px;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--l1-border);
gap: 12px;
cursor: pointer;
font: inherit;
border-radius: 4px;
color: var(--l1-foreground);
cursor: pointer;
text-align: left;
transition:
transform 180ms ease,
border-color 180ms ease;
&:hover {
border-color: var(--bg-robin-500);
background-color: var(--l2-background-hover);
border-color: var(--bg-robin-400);
}
&:active {
transform: translateY(2px);
}
}
.panelTypeCardSelected {
border-color: var(--bg-robin-400);
background-color: var(--l2-background-hover);
box-shadow: inset 0 0 0 1px var(--bg-robin-400);
}
.footerActions {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 8px;
width: 100%;
}
.footerPicker {
// Take all the width left over by the (natural-width) confirm button.
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.pickerLabel {
color: var(--l3-foreground);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}

View File

@@ -1,45 +1,135 @@
import { Modal } from 'antd';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import cx from 'classnames';
import { useDashboardSections } from '../../../hooks/useDashboardSections';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from './constants';
import SectionPicker from './SectionPicker';
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
import styles from './PanelTypeSelectionModal.module.scss';
import { Plus } from '@signozhq/icons';
interface PanelTypeSelectionModalProps {
open: boolean;
onClose: () => void;
onSelect: (panelKind: PanelKind) => void;
onSelect: (panelKind: PanelKind, layoutIndex?: number) => void;
/** Section the picker opens on; omit → the untitled root / first section. */
defaultLayoutIndex?: number;
}
/** Fake loader shown on the confirm button before navigating to the editor. */
const CONFIRM_LOADER_MS = 500;
function PanelTypeSelectionModal({
open,
onClose,
onSelect,
defaultLayoutIndex,
}: PanelTypeSelectionModalProps): JSX.Element {
const sections = useDashboardSections();
const options = useMemo(() => buildSectionOptions(sections), [sections]);
const hasSectionPicker = options.length > 1;
const [selectedValue, setSelectedValue] = useState('');
const [selectedPanelKind, setSelectedPanelKind] = useState<PanelKind | null>(
null,
);
const [isSubmitting, setIsSubmitting] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = useCallback((): void => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
// Seed the target section on open; cancel a pending navigation on close.
useEffect(() => {
if (open) {
setSelectedValue(resolveDefaultSectionValue(options, defaultLayoutIndex));
setSelectedPanelKind(null);
setIsSubmitting(false);
} else {
clearTimer();
}
}, [open, options, defaultLayoutIndex, clearTimer]);
useEffect(() => clearTimer, [clearTimer]);
const handleConfirm = (): void => {
if (selectedPanelKind === null || isSubmitting) {
return;
}
setIsSubmitting(true);
const layoutIndex = selectedValue === '' ? undefined : Number(selectedValue);
timerRef.current = setTimeout(() => {
onSelect(selectedPanelKind, layoutIndex);
}, CONFIRM_LOADER_MS);
};
// Footer is always shown; the confirm button is disabled until a panel type is picked.
const footer = (
<div className={styles.footerActions}>
{hasSectionPicker && (
<div className={styles.footerPicker}>
<span className={styles.pickerLabel}>Add panel to</span>
<SectionPicker
options={options}
value={selectedValue}
onChange={setSelectedValue}
/>
</div>
)}
<Button
color="primary"
size="md"
disabled={selectedPanelKind === null}
loading={isSubmitting}
prefix={<Plus size={16} />}
onClick={handleConfirm}
testId="panel-type-confirm"
>
Add Panel
</Button>
</div>
);
return (
<Modal
<DialogWrapper
open={open}
title="Select a panel type"
onCancel={onClose}
footer={null}
destroyOnClose
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="New Panel"
footer={footer}
>
<div className={styles.grid}>
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
<Button
key={panelKind}
type="button"
variant="ghost"
className={styles.typeButton}
data-testid={`panel-type-${panelKind}`}
onClick={(): void => onSelect(panelKind)}
>
<Icon size={14} />
{label}
</Button>
))}
<div className={styles.panelTypeSection}>
<span className={styles.pickerLabel}>Select panel type</span>
<div className={styles.grid}>
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
<button
key={panelKind}
type="button"
className={cx(styles.panelTypeCard, {
[styles.panelTypeCardSelected]: panelKind === selectedPanelKind,
})}
data-testid={`panel-type-${panelKind}`}
aria-pressed={panelKind === selectedPanelKind}
onClick={(): void => setSelectedPanelKind(panelKind)}
>
<Icon size={24} color={Color.BG_ROBIN_400} />
{label}
</button>
))}
</div>
</div>
</Modal>
</DialogWrapper>
);
}

View File

@@ -0,0 +1,55 @@
.select {
width: 100%;
}
.dropdown {
min-width: 260px;
}
.rootIcon {
color: var(--bg-robin-400);
flex-shrink: 0;
}
.sectionIcon {
color: var(--l3-foreground);
flex-shrink: 0;
}
.triggerValue {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.triggerLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.optionRow {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0;
}
.optionText {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.optionLabel {
color: var(--l1-foreground);
line-height: 1.2;
}
.optionDescription {
color: var(--l3-foreground);
font-size: 12px;
line-height: 1.2;
}

View File

@@ -0,0 +1,65 @@
import { useMemo } from 'react';
// eslint-disable-next-line signoz/no-antd-components
import { Select } from 'antd';
import type { SectionOption } from './types';
import styles from './SectionPicker.module.scss';
interface SectionPickerProps {
options: SectionOption[];
value: string;
onChange: (value: string) => void;
}
function SectionPicker({
options,
value,
onChange,
}: SectionPickerProps): JSX.Element {
// `selectedLabel` (one line) shows in the trigger; `label` (two lines) in the list.
const selectOptions = useMemo(
() =>
options.map((option) => {
const iconClass = option.isRoot ? styles.rootIcon : styles.sectionIcon;
return {
value: option.value,
selectedLabel: (
<span className={styles.triggerValue}>
<option.Icon size={16} className={iconClass} />
<span className={styles.triggerLabel}>{option.label}</span>
</span>
),
label: (
<span
className={styles.optionRow}
data-testid={`panel-section-option-${option.layoutIndex}`}
>
<option.Icon size={16} className={iconClass} />
<span className={styles.optionText}>
<span className={styles.optionLabel}>{option.label}</span>
<span className={styles.optionDescription}>{option.description}</span>
</span>
</span>
),
};
}),
[options],
);
return (
<Select<string>
className={styles.select}
popupClassName={styles.dropdown}
value={value}
onChange={onChange}
data-testid="panel-section-select"
optionLabelProp="selectedLabel"
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement ?? document.body
}
options={selectOptions}
/>
);
}
export default SectionPicker;

View File

@@ -14,3 +14,16 @@ export interface PanelType {
/** Icon component — the consumer renders it and controls size/color/etc. */
Icon: ComponentType<IconProps>;
}
export interface SectionOption {
/** The section's `layoutIndex`, stringified for the Select value. */
value: string;
layoutIndex: number;
/** Section title, or "Dashboard (root)" for the untitled top-level layout. */
label: string;
/** Caption under the label. */
description: string;
/** Untitled top-level layout (has no section header). */
isRoot: boolean;
Icon: ComponentType<IconProps>;
}

View File

@@ -0,0 +1,41 @@
import { LayoutDashboard, Rows2 } from '@signozhq/icons';
import type { DashboardSection } from '../../../utils';
import type { SectionOption } from './types';
const ROOT_LABEL = 'Dashboard (root)';
const ROOT_DESCRIPTION = 'Top level — no section';
const SECTION_DESCRIPTION = 'Section';
/** Maps dashboard sections to section-picker options (untitled → "root"). */
export function buildSectionOptions(
sections: DashboardSection[],
): SectionOption[] {
return sections.map((section) => {
const isRoot = !section.title;
return {
value: String(section.layoutIndex),
layoutIndex: section.layoutIndex,
label: isRoot ? ROOT_LABEL : (section.title as string),
description: isRoot ? ROOT_DESCRIPTION : SECTION_DESCRIPTION,
isRoot,
Icon: isRoot ? LayoutDashboard : Rows2,
};
});
}
/**
* Picks the option the picker should open on: the section the "Add panel" was
* triggered from when present and still valid, otherwise the first option.
*/
export function resolveDefaultSectionValue(
options: SectionOption[],
defaultLayoutIndex: number | undefined,
): string {
const fallback = options[0]?.value ?? '';
if (defaultLayoutIndex === undefined) {
return fallback;
}
const target = String(defaultLayoutIndex);
return options.some((option) => option.value === target) ? target : fallback;
}

View File

@@ -29,8 +29,13 @@ interface SectionProps {
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const {
isPickerOpen,
openPicker,
closePicker,
createPanel,
targetLayoutIndex,
} = useCreatePanel();
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
@@ -141,6 +146,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
open={isPickerOpen}
onClose={closePicker}
onSelect={createPanel}
defaultLayoutIndex={targetLayoutIndex}
/>
<ConfirmDeleteDialog
open={isDeleteOpen}

View File

@@ -12,7 +12,10 @@ interface UseCreatePanelResult {
/** Pass the target section's layout index; omit → last/new section. */
openPicker: (layoutIndex?: number) => void;
closePicker: () => void;
createPanel: (panelKind: PanelKind) => void;
/** The section the picker was opened against — seeds its section dropdown. */
targetLayoutIndex: number | undefined;
/** `layoutIndex` overrides the opened-against target (the dropdown's choice). */
createPanel: (panelKind: PanelKind, layoutIndex?: number) => void;
}
/**
@@ -38,16 +41,23 @@ export function useCreatePanel(): UseCreatePanelResult {
}, []);
const createPanel = useCallback(
(panelKind: PanelKind): void => {
(panelKind: PanelKind, targetIndex?: number): void => {
setIsPickerOpen(false);
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId: NEW_PANEL_ID,
});
safeNavigate(`${path}${newPanelSearch(panelKind, layoutIndex)}`);
const target = targetIndex ?? layoutIndex;
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
},
[safeNavigate, dashboardId, layoutIndex],
);
return { isPickerOpen, openPicker, closePicker, createPanel };
return {
isPickerOpen,
openPicker,
closePicker,
targetLayoutIndex: layoutIndex,
createPanel,
};
}

View File

@@ -0,0 +1,21 @@
import { useMemo } from 'react';
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
import { useDashboardStore } from '../store/useDashboardStore';
import { type DashboardSection, layoutsToSections } from '../utils';
/**
* The current dashboard's sections, read from the already-loaded dashboard
* query. The page fetches via useGetDashboardV2 keyed by id, so this reuses that
* cache (no extra request) instead of prop-drilling the section list.
*/
export function useDashboardSections(): DashboardSection[] {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { data } = useGetDashboardV2({ id: dashboardId });
const spec = data?.data?.spec;
return useMemo(
() => layoutsToSections(spec?.layouts, spec?.panels),
[spec?.layouts, spec?.panels],
);
}

View File

@@ -7,8 +7,6 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
)
const DefaultMaxConcurrentQueries = 8
type SkipResourceFingerprint struct {
Enabled bool `yaml:"enabled" mapstructure:"enabled"`
// If count of fingerprint is above threshold, skip the fingerprint subquery and filter on main table instead.
@@ -39,7 +37,7 @@ func newConfig() factory.Config {
// Default values
CacheTTL: 168 * time.Hour,
FluxInterval: 5 * time.Minute,
MaxConcurrentQueries: DefaultMaxConcurrentQueries,
MaxConcurrentQueries: 4,
SkipResourceFingerprint: SkipResourceFingerprint{
Enabled: false,
Threshold: 100000,

View File

@@ -13,7 +13,6 @@ import (
"github.com/dustin/go-humanize"
"golang.org/x/exp/maps"
"golang.org/x/sync/errgroup"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
@@ -36,21 +35,20 @@ var (
)
type querier struct {
logger *slog.Logger
fl flagger.Flagger
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder
bucketCache BucketCache
liveDataRefresh time.Duration
builderConfig builderConfig
maxConcurrentQueries int
logger *slog.Logger
fl flagger.Flagger
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder
bucketCache BucketCache
liveDataRefresh time.Duration
builderConfig builderConfig
}
var _ Querier = (*querier)(nil)
@@ -69,30 +67,25 @@ func New(
bucketCache BucketCache,
flagger flagger.Flagger,
logTraceIDWindowPadding time.Duration,
maxConcurrentQueries int,
) *querier {
querierSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/querier")
if maxConcurrentQueries <= 0 {
maxConcurrentQueries = DefaultMaxConcurrentQueries
}
return &querier{
logger: querierSettings.Logger(),
fl: flagger,
telemetryStore: telemetryStore,
metadataStore: metadataStore,
promEngine: promEngine,
traceStmtBuilder: traceStmtBuilder,
logStmtBuilder: logStmtBuilder,
auditStmtBuilder: auditStmtBuilder,
metricStmtBuilder: metricStmtBuilder,
meterStmtBuilder: meterStmtBuilder,
traceOperatorStmtBuilder: traceOperatorStmtBuilder,
bucketCache: bucketCache,
liveDataRefresh: 5 * time.Second,
logger: querierSettings.Logger(),
fl: flagger,
telemetryStore: telemetryStore,
metadataStore: metadataStore,
promEngine: promEngine,
traceStmtBuilder: traceStmtBuilder,
logStmtBuilder: logStmtBuilder,
auditStmtBuilder: auditStmtBuilder,
metricStmtBuilder: metricStmtBuilder,
meterStmtBuilder: meterStmtBuilder,
traceOperatorStmtBuilder: traceOperatorStmtBuilder,
bucketCache: bucketCache,
liveDataRefresh: 5 * time.Second,
builderConfig: builderConfig{
logTraceIDWindowPaddingMS: uint64(logTraceIDWindowPadding.Milliseconds()),
},
maxConcurrentQueries: maxConcurrentQueries,
}
}
@@ -614,40 +607,30 @@ func (q *querier) run(
return false
}
names := maps.Keys(qs)
slices.Sort(names)
queryResults := make([]*qbtypes.Result, len(names))
// sem limits how many queries run at once for this request. The same
// limit covers the missing-range queries in executeWithCache. sem is held
// only while a query is running, never while waiting for other
// goroutines, so the two levels cannot deadlock.
sem := make(chan struct{}, q.maxConcurrentQueries)
eg, egCtx := errgroup.WithContext(ctx)
for i, name := range names {
query := qs[name]
eg.Go(func() error {
// Skip cache if NoCache is set, or if cache is not available
if req.NoCache || q.bucketCache == nil || query.Fingerprint() == "" {
if req.NoCache {
q.logger.DebugContext(egCtx, "NoCache flag set, bypassing cache", slog.String("query", name))
} else {
q.logger.InfoContext(egCtx, "no bucket cache or fingerprint, executing query", slog.String("fingerprint", query.Fingerprint()))
}
sem <- struct{}{}
result, err := query.Execute(egCtx)
<-sem
if err != nil {
return err
}
queryResults[i] = result
return nil
for name, query := range qs {
// Skip cache if NoCache is set, or if cache is not available
if req.NoCache || q.bucketCache == nil || query.Fingerprint() == "" {
if req.NoCache {
q.logger.DebugContext(ctx, "NoCache flag set, bypassing cache", slog.String("query", name))
} else {
q.logger.InfoContext(ctx, "no bucket cache or fingerprint, executing query", slog.String("fingerprint", query.Fingerprint()))
}
result, err := q.executeWithCache(egCtx, orgID, query, steps[name], sem)
result, err := query.Execute(ctx)
qbEvent.HasData = qbEvent.HasData || hasData(result)
if err != nil {
return err
return nil, err
}
results[name] = result.Value
warnings = append(warnings, result.Warnings...)
warningsDocURL = result.WarningsDocURL
stats.RowsScanned += result.Stats.RowsScanned
stats.BytesScanned += result.Stats.BytesScanned
stats.DurationMS += result.Stats.DurationMS
} else {
result, err := q.executeWithCache(ctx, orgID, query, steps[name], req.NoCache)
qbEvent.HasData = qbEvent.HasData || hasData(result)
if err != nil {
return nil, err
}
switch v := result.Value.(type) {
case *qbtypes.TimeSeriesData:
@@ -657,23 +640,14 @@ func (q *querier) run(
case *qbtypes.RawData:
v.QueryName = name
}
queryResults[i] = result
return nil
})
}
if err := eg.Wait(); err != nil {
return nil, err
}
for i, name := range names {
result := queryResults[i]
qbEvent.HasData = qbEvent.HasData || hasData(result)
results[name] = result.Value
warnings = append(warnings, result.Warnings...)
warningsDocURL = result.WarningsDocURL
stats.RowsScanned += result.Stats.RowsScanned
stats.BytesScanned += result.Stats.BytesScanned
stats.DurationMS += result.Stats.DurationMS
results[name] = result.Value
warnings = append(warnings, result.Warnings...)
warningsDocURL = result.WarningsDocURL
stats.RowsScanned += result.Stats.RowsScanned
stats.BytesScanned += result.Stats.BytesScanned
stats.DurationMS += result.Stats.DurationMS
}
}
gomaps.Copy(results, preseededResults)
@@ -733,9 +707,8 @@ func (q *querier) run(
return resp, nil
}
// executeWithCache executes a query using the bucket cache. sem limits how
// many queries run at once for the whole request.
func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query qbtypes.Query, step qbtypes.Step, sem chan struct{}) (*qbtypes.Result, error) {
// executeWithCache executes a query using the bucket cache.
func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query qbtypes.Query, step qbtypes.Step, _ bool) (*qbtypes.Result, error) {
// Get cached data and missing ranges
cachedResult, missingRanges := q.bucketCache.GetMissRanges(ctx, orgID, query, step)
@@ -748,9 +721,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
if cachedResult == nil && len(missingRanges) == 1 {
startMs, endMs := query.Window()
if missingRanges[0].From == startMs && missingRanges[0].To == endMs {
sem <- struct{}{}
result, err := query.Execute(ctx)
<-sem
if err != nil {
return nil, err
}
@@ -769,6 +740,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
slog.Int("missing_ranges_count", len(missingRanges)),
slog.Any("ranges", missingRanges))
sem := make(chan struct{}, 4)
var wg sync.WaitGroup
for i, timeRange := range missingRanges {
@@ -805,9 +777,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
if err != nil {
// If any query failed, fall back to full execution
q.logger.ErrorContext(ctx, "parallel query execution failed", errors.Attr(err))
sem <- struct{}{}
result, err := query.Execute(ctx)
<-sem
if err != nil {
return nil, err
}

View File

@@ -2,13 +2,11 @@ package querier
import (
"context"
"sync/atomic"
"testing"
"time"
cmock "github.com/SigNoz/clickhouse-go-mock"
"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/telemetrystore"
@@ -56,8 +54,7 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
0, // logTraceIDWindowPadding
0, // maxConcurrentQueries
0,
)
req := &qbtypes.QueryRangeRequest{
@@ -128,8 +125,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
0, // logTraceIDWindowPadding
0, // maxConcurrentQueries
0,
)
req := &qbtypes.QueryRangeRequest{
@@ -159,149 +155,3 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, resp)
}
type fakeQuery struct {
execute func(ctx context.Context) (*qbtypes.Result, error)
}
func (f *fakeQuery) Fingerprint() string { return "" }
func (f *fakeQuery) Window() (uint64, uint64) { return 0, 0 }
func (f *fakeQuery) Execute(ctx context.Context) (*qbtypes.Result, error) { return f.execute(ctx) }
func chQueryEnvelopes(names []string) []qbtypes.QueryEnvelope {
envelopes := make([]qbtypes.QueryEnvelope, 0, len(names))
for _, name := range names {
envelopes = append(envelopes, qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeClickHouseSQL,
Spec: qbtypes.ClickHouseQuery{Name: name},
})
}
return envelopes
}
func TestRunExecutesQueriesConcurrently(t *testing.T) {
names := []string{"A", "B", "C", "D", "E"}
numQueries := len(names)
q := &querier{
logger: instrumentationtest.New().Logger(),
maxConcurrentQueries: numQueries,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var started atomic.Int32
allStarted := make(chan struct{})
qs := make(map[string]qbtypes.Query, numQueries)
for _, name := range names {
qs[name] = &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
if int(started.Add(1)) == numQueries {
close(allStarted)
}
select {
case <-allStarted:
case <-ctx.Done():
return nil, ctx.Err()
}
return &qbtypes.Result{
Type: qbtypes.RequestTypeScalar,
Value: &qbtypes.ScalarData{QueryName: name},
Stats: qbtypes.ExecStats{RowsScanned: 1, BytesScanned: 2, DurationMS: 3},
}, nil
}}
}
req := &qbtypes.QueryRangeRequest{
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{Queries: chQueryEnvelopes(names)},
}
resp, err := q.run(ctx, valuer.GenerateUUID(), qs, req, nil, &qbtypes.QBEvent{}, nil)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Len(t, resp.Data.Results, numQueries)
assert.Equal(t, uint64(numQueries), resp.Meta.RowsScanned)
assert.Equal(t, uint64(2*numQueries), resp.Meta.BytesScanned)
assert.Equal(t, uint64(3*numQueries), resp.Meta.DurationMS)
}
func TestRunRespectsMaxConcurrentQueries(t *testing.T) {
const limit = 2
names := []string{"A", "B", "C", "D", "E", "F", "G", "H"}
q := &querier{
logger: instrumentationtest.New().Logger(),
maxConcurrentQueries: limit,
}
var running, maxRunning atomic.Int32
qs := make(map[string]qbtypes.Query, len(names))
for _, name := range names {
qs[name] = &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
cur := running.Add(1)
defer running.Add(-1)
for {
m := maxRunning.Load()
if cur <= m || maxRunning.CompareAndSwap(m, cur) {
break
}
}
time.Sleep(20 * time.Millisecond)
return &qbtypes.Result{
Type: qbtypes.RequestTypeScalar,
Value: &qbtypes.ScalarData{QueryName: name},
}, nil
}}
}
req := &qbtypes.QueryRangeRequest{
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{Queries: chQueryEnvelopes(names)},
}
resp, err := q.run(context.Background(), valuer.GenerateUUID(), qs, req, nil, &qbtypes.QBEvent{}, nil)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Len(t, resp.Data.Results, len(names))
assert.LessOrEqual(t, maxRunning.Load(), int32(limit), "running queries must not exceed maxConcurrentQueries")
}
func TestRunQueryErrorCancelsSiblings(t *testing.T) {
q := &querier{
logger: instrumentationtest.New().Logger(),
maxConcurrentQueries: 4,
}
bStarted := make(chan struct{})
var bCanceled atomic.Bool
qs := map[string]qbtypes.Query{
// fails once B is running.
"A": &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
select {
case <-bStarted:
case <-ctx.Done():
}
return nil, errors.NewInternalf(errors.CodeInternal, "query A failed")
}},
// blocks until its context is canceled by A's failure.
"B": &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
close(bStarted)
select {
case <-ctx.Done():
bCanceled.Store(true)
return nil, ctx.Err()
case <-time.After(10 * time.Second):
return nil, errors.NewInternalf(errors.CodeInternal, "query B was never canceled")
}
}},
}
req := &qbtypes.QueryRangeRequest{
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{Queries: chQueryEnvelopes([]string{"A", "B"})},
}
_, err := q.run(context.Background(), valuer.GenerateUUID(), qs, req, nil, &qbtypes.QBEvent{}, nil)
require.ErrorContains(t, err, "query A failed")
assert.True(t, bCanceled.Load(), "query B should be canceled once query A fails")
}

View File

@@ -193,6 +193,5 @@ func newProvider(
bucketCache,
flagger,
cfg.LogTraceIDWindowPadding,
cfg.MaxConcurrentQueries,
), nil
}

View File

@@ -56,7 +56,6 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
nil, // bucketCache
flagger,
0,
0, // maxConcurrentQueries (0 means default)
), metadataStore
}
@@ -111,7 +110,6 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
nil, // bucketCache
fl,
5*time.Minute, // logTraceIDWindowPadding
0, // maxConcurrentQueries (0 means default)
)
}
@@ -160,6 +158,5 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
nil, // bucketCache
fl,
0,
0, // maxConcurrentQueries (0 means default)
)
}