Compare commits

...

6 Commits

Author SHA1 Message Date
Abhi Kumar
d946d9c4bd test: added tests for histogram and pie panel 2026-05-18 19:07:42 +05:30
Abhi Kumar
2cc8032f06 test: added test for timeseries and bar panel 2026-05-18 18:34:44 +05:30
Abhi Kumar
0b42daf39f test: added tests for table/value/list panels 2026-05-18 10:51:33 +05:30
Abhi kumar
9cba7e88ec Merge branch 'main' into e2e/dashboard-create-flow 2026-05-18 00:19:17 +05:30
Abhi Kumar
e4949379e2 test: added e2e tests for dashboard create flow 2026-05-18 00:11:35 +05:30
SagarRajput-7
44470cb35b feat(sa-fga): service account fga (#11258)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(sa-fga): changed the id from kind to kind+type

* feat(sa-fga): service account fga changes with common components for errors

* feat(sa-fga): added fga at more places in service account

* feat(sa-fga): refactor based on feedbacks

* feat(sa-fga): refactor and role page fga

* fix(authz): add attach detach permissions on metaresource

* feat(sa-fga): refactor and role page fga

* feat(sa-fga): test case fixes

* feat(sa-fga): enabled role detail page and remove the config flag

* feat(sa-fga): test case fixes

* feat(sa-fga): udpated the role details metaresource condition to list/create

* feat(sa-fga): test case fixes

* feat(sa-fga): feedback fixes from the copliot comments

* feat(sa-fga): feedback fixes from the reveiw comments and authztootip upgrade

* feat(sa-fga): feedback fixes from the testing and refactors

* feat(sa-fga): test cases fixes

* feat(sa-fga): added beta for the roles page

* feat(sa-fga): added roles doc and roles read check with name in the url param

* Revert "fix(authz): add attach detach permissions on metaresource"

This reverts commit 34938bb4ce.

---------

Co-authored-by: vikrantgupta25 <vikrant@signoz.io>
2026-05-17 14:35:27 +00:00
72 changed files with 5819 additions and 640 deletions

View File

@@ -0,0 +1,14 @@
.wrapper {
cursor: not-allowed;
}
.errorContent {
background: var(--callout-error-background) !important;
border-color: var(--callout-error-border) !important;
backdrop-filter: blur(15px);
border-radius: 4px !important;
color: var(--foreground) !important;
font-style: normal;
font-weight: 400;
white-space: nowrap;
}

View File

@@ -0,0 +1,145 @@
import { ReactElement } from 'react';
import { render, screen } from 'tests/test-utils';
import { buildPermission } from 'hooks/useAuthZ/utils';
import type { AuthZObject, BrandedPermission } from 'hooks/useAuthZ/types';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import AuthZTooltip from './AuthZTooltip';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const noPermissions = {
isLoading: false,
isFetching: false,
error: null,
permissions: null,
refetchPermissions: jest.fn(),
};
const TestButton = (
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
): ReactElement => (
<button type="button" {...props}>
Action
</button>
);
const createPerm = buildPermission(
'create',
'serviceaccount:*' as AuthZObject<'create'>,
);
const attachSAPerm = (id: string): BrandedPermission =>
buildPermission('attach', `serviceaccount:${id}` as AuthZObject<'attach'>);
const attachRolePerm = buildPermission(
'attach',
'role:*' as AuthZObject<'attach'>,
);
describe('AuthZTooltip — single check', () => {
it('renders child unchanged when permission is granted', () => {
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: { [createPerm]: { isGranted: true } },
});
render(
<AuthZTooltip checks={[createPerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).not.toBeDisabled();
});
it('disables child when permission is denied', () => {
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: { [createPerm]: { isGranted: false } },
});
render(
<AuthZTooltip checks={[createPerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
});
it('disables child while loading', () => {
mockUseAuthZ.mockReturnValue({ ...noPermissions, isLoading: true });
render(
<AuthZTooltip checks={[createPerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
});
});
describe('AuthZTooltip — multi-check (checks array)', () => {
it('renders child enabled when all checks are granted', () => {
const sa = attachSAPerm('sa-1');
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: {
[sa]: { isGranted: true },
[attachRolePerm]: { isGranted: true },
},
});
render(
<AuthZTooltip checks={[sa, attachRolePerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).not.toBeDisabled();
});
it('disables child when first check is denied, second granted', () => {
const sa = attachSAPerm('sa-1');
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: {
[sa]: { isGranted: false },
[attachRolePerm]: { isGranted: true },
},
});
render(
<AuthZTooltip checks={[sa, attachRolePerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
});
it('disables child when both checks are denied and lists denied permissions in data attr', () => {
const sa = attachSAPerm('sa-1');
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: {
[sa]: { isGranted: false },
[attachRolePerm]: { isGranted: false },
},
});
render(
<AuthZTooltip checks={[sa, attachRolePerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
const wrapper = screen.getByRole('button', { name: 'Action' }).parentElement;
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(sa);
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(
attachRolePerm,
);
});
});

View File

@@ -0,0 +1,85 @@
import { ReactElement, cloneElement, useMemo } from 'react';
import {
TooltipRoot,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import type { BrandedPermission } from 'hooks/useAuthZ/types';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { parsePermission } from 'hooks/useAuthZ/utils';
import styles from './AuthZTooltip.module.scss';
interface AuthZTooltipProps {
checks: BrandedPermission[];
children: ReactElement;
enabled?: boolean;
tooltipMessage?: string;
}
function formatDeniedMessage(
denied: BrandedPermission[],
override?: string,
): string {
if (override) {
return override;
}
const labels = denied.map((p) => {
const { relation, object } = parsePermission(p);
const resource = object.split(':')[0];
return `${relation} ${resource}`;
});
return labels.length === 1
? `You don't have ${labels[0]} permission`
: `You don't have ${labels.join(', ')} permissions`;
}
function AuthZTooltip({
checks,
children,
enabled = true,
tooltipMessage,
}: AuthZTooltipProps): JSX.Element {
const shouldCheck = enabled && checks.length > 0;
const { permissions, isLoading } = useAuthZ(checks, { enabled: shouldCheck });
const deniedPermissions = useMemo(() => {
if (!permissions) {
return [];
}
return checks.filter((p) => permissions[p]?.isGranted === false);
}, [checks, permissions]);
if (shouldCheck && isLoading) {
return (
<span className={styles.wrapper}>
{cloneElement(children, { disabled: true })}
</span>
);
}
if (!shouldCheck || deniedPermissions.length === 0) {
return children;
}
return (
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger asChild>
<span
className={styles.wrapper}
data-denied-permissions={deniedPermissions.join(',')}
>
{cloneElement(children, { disabled: true })}
</span>
</TooltipTrigger>
<TooltipContent className={styles.errorContent}>
{formatDeniedMessage(deniedPermissions, tooltipMessage)}
</TooltipContent>
</TooltipRoot>
</TooltipProvider>
);
}
export default AuthZTooltip;

View File

@@ -2,6 +2,8 @@ import { Controller, useForm } from 'react-hook-form';
import { useQueryClient } from 'react-query';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { SACreatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
@@ -132,17 +134,19 @@ function CreateServiceAccountModal(): JSX.Element {
Cancel
</Button>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form="create-sa-form"
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Service Account
</Button>
<AuthZTooltip checks={[SACreatePermission]}>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form="create-sa-form"
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Service Account
</Button>
</AuthZTooltip>
</DialogFooter>
</DialogWrapper>
);

View File

@@ -11,6 +11,15 @@ import {
import CreateServiceAccountModal from '../CreateServiceAccountModal';
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,
}: {
children: React.ReactElement;
}): React.ReactElement => children,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
@@ -113,7 +122,9 @@ describe('CreateServiceAccountModal', () => {
getErrorMessage: expect.any(Function),
}),
);
const passedError = showErrorModal.mock.calls[0][0] as any;
const passedError = showErrorModal.mock.calls[0][0] as {
getErrorMessage: () => string;
};
expect(passedError.getErrorMessage()).toBe('Internal Server Error');
});
@@ -132,6 +143,9 @@ describe('CreateServiceAccountModal', () => {
await user.click(screen.getByRole('button', { name: /Cancel/i }));
await waitForElementToBeRemoved(dialog);
expect(
screen.queryByRole('dialog', { name: /New Service Account/i }),
).not.toBeInTheDocument();
});
it('shows "Name is required" after clearing the name field', async () => {
@@ -142,6 +156,8 @@ describe('CreateServiceAccountModal', () => {
await user.type(nameInput, 'Bot');
await user.clear(nameInput);
await screen.findByText('Name is required');
await expect(
screen.findByText('Name is required'),
).resolves.toBeInTheDocument();
});
});

View File

@@ -1,34 +1,13 @@
import { ReactElement } from 'react';
import {
AuthtypesGettableTransactionDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ENVIRONMENT } from 'constants/env';
import { BrandedPermission } from 'hooks/useAuthZ/types';
import { buildPermission } from 'hooks/useAuthZ/utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
import { GuardAuthZ } from './GuardAuthZ';
const BASE_URL = ENVIRONMENT.baseURL || '';
const AUTHZ_CHECK_URL = `${BASE_URL}/api/v1/authz/check`;
function authzMockResponse(
payload: AuthtypesTransactionDTO[],
authorizedByIndex: boolean[],
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
return {
data: payload.map((txn, i) => ({
relation: txn.relation,
object: txn.object,
authorized: authorizedByIndex[i] ?? false,
})),
status: 'success',
};
}
describe('GuardAuthZ', () => {
const TestChild = (): ReactElement => <div>Protected Content</div>;
const LoadingFallback = (): ReactElement => <div>Loading...</div>;

View File

@@ -0,0 +1,4 @@
.callout {
box-sizing: border-box;
width: 100%;
}

View File

@@ -0,0 +1,22 @@
import { render, screen } from 'tests/test-utils';
import PermissionDeniedCallout from './PermissionDeniedCallout';
describe('PermissionDeniedCallout', () => {
it('renders the permission name in the callout message', () => {
render(<PermissionDeniedCallout permissionName="serviceaccount:attach" />);
expect(screen.getByText(/You don't have/)).toBeInTheDocument();
expect(screen.getByText(/serviceaccount:attach/)).toBeInTheDocument();
expect(screen.getByText(/permission/)).toBeInTheDocument();
});
it('accepts an optional className', () => {
const { container } = render(
<PermissionDeniedCallout
permissionName="serviceaccount:read"
className="custom-class"
/>,
);
expect(container.firstChild).toHaveClass('custom-class');
});
});

View File

@@ -0,0 +1,26 @@
import { Callout } from '@signozhq/ui/callout';
import cx from 'classnames';
import styles from './PermissionDeniedCallout.module.scss';
interface PermissionDeniedCalloutProps {
permissionName: string;
className?: string;
}
function PermissionDeniedCallout({
permissionName,
className,
}: PermissionDeniedCalloutProps): JSX.Element {
return (
<Callout
type="error"
showIcon
size="small"
className={cx(styles.callout, className)}
>
{`You don't have ${permissionName} permission`}
</Callout>
);
}
export default PermissionDeniedCallout;

View File

@@ -0,0 +1,44 @@
.container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 50vh;
padding: var(--spacing-10);
}
.content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-2);
max-width: 512px;
}
.icon {
margin-bottom: var(--spacing-1);
}
.title {
margin: 0;
font-size: var(--label-base-500-font-size);
font-weight: var(--label-base-500-font-weight);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
color: var(--l1-foreground);
}
.subtitle {
margin: 0;
font-size: var(--label-base-400-font-size);
font-weight: var(--label-base-400-font-weight);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
color: var(--l2-foreground);
}
.permission {
font-family: monospace;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,21 @@
import { render, screen } from 'tests/test-utils';
import PermissionDeniedFullPage from './PermissionDeniedFullPage';
describe('PermissionDeniedFullPage', () => {
it('renders the title and subtitle with the permissionName interpolated', () => {
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
expect(
screen.getByText("Uh-oh! You don't have permission to view this page."),
).toBeInTheDocument();
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
expect(
screen.getByText(/Please ask your SigNoz administrator to grant access/),
).toBeInTheDocument();
});
it('renders with a different permissionName', () => {
render(<PermissionDeniedFullPage permissionName="role:read" />);
expect(screen.getByText(/role:read/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,31 @@
import { CircleSlash2 } from '@signozhq/icons';
import styles from './PermissionDeniedFullPage.module.scss';
import { Style } from '@signozhq/design-tokens';
interface PermissionDeniedFullPageProps {
permissionName: string;
}
function PermissionDeniedFullPage({
permissionName,
}: PermissionDeniedFullPageProps): JSX.Element {
return (
<div className={styles.container}>
<div className={styles.content}>
<span className={styles.icon}>
<CircleSlash2 color={Style.CALLOUT_WARNING_TITLE} size={14} />
</span>
<p className={styles.title}>
Uh-oh! You don&apos;t have permission to view this page.
</p>
<p className={styles.subtitle}>
You need <code className={styles.permission}>{permissionName}</code> to
view this page. Please ask your SigNoz administrator to grant access.
</p>
</div>
</div>
);
}
export default PermissionDeniedFullPage;

View File

@@ -80,6 +80,7 @@ interface BaseProps {
isError?: boolean;
error?: APIError;
onRefetch?: () => void;
disabled?: boolean;
}
interface SingleProps extends BaseProps {
@@ -123,6 +124,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
isError = internalError,
error = convertToApiError(internalErrorObj),
onRefetch = externalRoles === undefined ? internalRefetch : undefined,
disabled,
} = props;
const notFoundContent = isError ? (
@@ -151,6 +153,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
</Checkbox>
)}
getPopupContainer={getPopupContainer}
disabled={disabled}
/>
);
}
@@ -168,6 +171,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
notFoundContent={notFoundContent}
options={options}
getPopupContainer={getPopupContainer}
disabled={disabled}
/>
);
}

View File

@@ -4,6 +4,11 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
APIKeyCreatePermission,
buildSAAttachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { popupContainer } from 'utils/selectPopupContainer';
import { disabledDate } from '../utils';
@@ -18,6 +23,7 @@ export interface KeyFormPhaseProps {
isValid: boolean;
onSubmit: () => void;
onClose: () => void;
accountId?: string;
}
function KeyFormPhase({
@@ -28,6 +34,7 @@ function KeyFormPhase({
isValid,
onSubmit,
onClose,
accountId,
}: KeyFormPhaseProps): JSX.Element {
return (
<>
@@ -111,17 +118,25 @@ function KeyFormPhase({
<Button variant="solid" color="secondary" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
<AuthZTooltip
checks={[
APIKeyCreatePermission,
buildSAAttachPermission(accountId ?? ''),
]}
enabled={!!accountId}
>
Create Key
</Button>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Key
</Button>
</AuthZTooltip>
</div>
</div>
</>

View File

@@ -161,6 +161,7 @@ function AddKeyModal(): JSX.Element {
isValid={isValid}
onSubmit={handleSubmit(handleCreate)}
onClose={handleClose}
accountId={accountId ?? undefined}
/>
)}

View File

@@ -1,6 +1,8 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { buildSADeletePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
@@ -65,7 +67,7 @@ function DeleteAccountModal(): JSX.Element {
}
function handleCancel(): void {
setIsDeleteOpen(null);
void setIsDeleteOpen(null);
}
const content = (
@@ -82,15 +84,20 @@ function DeleteAccountModal(): JSX.Element {
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
loading={isDeleting}
onClick={handleConfirm}
<AuthZTooltip
checks={[buildSADeletePermission(accountId ?? '')]}
enabled={!!accountId}
>
<Trash2 size={12} />
Delete
</Button>
<Button
variant="solid"
color="destructive"
loading={isDeleting}
onClick={handleConfirm}
>
<Trash2 size={12} />
Delete
</Button>
</AuthZTooltip>
</div>
);

View File

@@ -7,6 +7,12 @@ import { Input } from '@signozhq/ui/input';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
buildAPIKeyDeletePermission,
buildAPIKeyUpdatePermission,
buildSADetachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { popupContainer } from 'utils/selectPopupContainer';
import { disabledDate, formatLastObservedAt } from '../utils';
@@ -24,6 +30,8 @@ export interface EditKeyFormProps {
onClose: () => void;
onRevokeClick: () => void;
formatTimezoneAdjustedTimestamp: (ts: string, format: string) => string;
canUpdate?: boolean;
accountId?: string;
}
function EditKeyForm({
@@ -37,6 +45,8 @@ function EditKeyForm({
onClose,
onRevokeClick,
formatTimezoneAdjustedTimestamp,
canUpdate = true,
accountId = '',
}: EditKeyFormProps): JSX.Element {
return (
<>
@@ -45,12 +55,34 @@ function EditKeyForm({
<label className="edit-key-modal__label" htmlFor="edit-key-name">
Name
</label>
<Input
id="edit-key-name"
className="edit-key-modal__input"
placeholder="Enter key name"
{...register('name')}
/>
{!canUpdate ? (
<AuthZTooltip
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
enabled={!!keyItem?.id}
>
<div className="edit-key-modal__key-display">
<span className="edit-key-modal__id-text">{keyItem?.name || '—'}</span>
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
</div>
</AuthZTooltip>
) : (
<Input
id="edit-key-name"
className="edit-key-modal__input"
placeholder="Enter key name"
{...register('name')}
/>
)}
</div>
<div className="edit-key-modal__field">
<label className="edit-key-modal__label" htmlFor="edit-key-id">
ID
</label>
<div id="edit-key-id" className="edit-key-modal__key-display">
<span className="edit-key-modal__id-text">{keyItem?.id || '—'}</span>
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
</div>
</div>
<div className="edit-key-modal__field">
@@ -73,21 +105,22 @@ function EditKeyForm({
type="single"
value={field.value}
onChange={(val): void => {
if (val) {
if (val && canUpdate) {
field.onChange(val);
}
}}
size="sm"
className="edit-key-modal__expiry-toggle"
>
<ToggleGroupItem
value={ExpiryMode.NONE}
disabled={!canUpdate}
className="edit-key-modal__expiry-toggle-btn"
>
No Expiration
</ToggleGroupItem>
<ToggleGroupItem
value={ExpiryMode.DATE}
disabled={!canUpdate}
className="edit-key-modal__expiry-toggle-btn"
>
Set Expiration Date
@@ -114,6 +147,7 @@ function EditKeyForm({
popupClassName="edit-key-modal-datepicker-popup"
getPopupContainer={popupContainer}
disabledDate={disabledDate}
disabled={!canUpdate}
/>
)}
/>
@@ -133,26 +167,39 @@ function EditKeyForm({
</form>
<div className="edit-key-modal__footer">
<Button variant="link" color="destructive" onClick={onRevokeClick}>
<Trash2 size={12} />
Revoke Key
</Button>
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(keyItem?.id ?? ''),
buildSADetachPermission(accountId ?? ''),
]}
enabled={!!accountId && !!keyItem?.id}
>
<Button variant="link" color="destructive" onClick={onRevokeClick}>
<Trash2 size={12} />
Revoke Key
</Button>
</AuthZTooltip>
<div className="edit-key-modal__footer-right">
<Button variant="solid" color="secondary" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
<AuthZTooltip
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
enabled={!!accountId && !!keyItem?.id}
>
Save Changes
</Button>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
>
Save Changes
</Button>
</AuthZTooltip>
</div>
</div>
</>

View File

@@ -60,6 +60,16 @@
letter-spacing: 2px;
}
&__id-text {
font-size: 13px;
font-family: monospace;
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
&__lock-icon {
color: var(--foreground);
flex-shrink: 0;

View File

@@ -16,6 +16,8 @@ import type {
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import dayjs from 'dayjs';
import { buildAPIKeyUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';
@@ -69,6 +71,16 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
const expiryMode = watch('expiryMode');
const { permissions: editPermissions, isLoading: isAuthZLoading } = useAuthZ(
editKeyId ? [buildAPIKeyUpdatePermission(editKeyId)] : [],
{ enabled: !!editKeyId },
);
const canUpdate = isAuthZLoading
? false
: (editPermissions?.[buildAPIKeyUpdatePermission(editKeyId ?? '')]
?.isGranted ?? true);
const { mutate: updateKey, isLoading: isSaving } = useUpdateServiceAccountKey({
mutation: {
onSuccess: async () => {
@@ -115,7 +127,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
});
function handleClose(): void {
setEditKeyId(null);
void setEditKeyId(null);
setIsRevokeConfirmOpen(false);
}
@@ -169,6 +181,8 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
isRevoking={isRevoking}
onCancel={(): void => setIsRevokeConfirmOpen(false)}
onConfirm={handleRevoke}
accountId={selectedAccountId ?? undefined}
keyId={keyItem?.id ?? undefined}
/>
) : undefined
}
@@ -190,6 +204,8 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
onClose={handleClose}
onRevokeClick={(): void => setIsRevokeConfirmOpen(true)}
formatTimezoneAdjustedTimestamp={formatTimezoneAdjustedTimestamp}
canUpdate={canUpdate}
accountId={selectedAccountId ?? ''}
/>
)}
</DialogWrapper>

View File

@@ -1,9 +1,16 @@
import { useCallback, useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { KeyRound, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Skeleton, Table, Tooltip } from 'antd';
import { Skeleton, Table } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
APIKeyCreatePermission,
buildAPIKeyDeletePermission,
buildSAAttachPermission,
buildSADetachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs';
@@ -17,12 +24,15 @@ interface KeysTabProps {
keys: ServiceaccounttypesGettableFactorAPIKeyDTO[];
isLoading: boolean;
isDisabled?: boolean;
canUpdate?: boolean;
accountId?: string;
currentPage: number;
pageSize: number;
}
interface BuildColumnsParams {
isDisabled: boolean;
accountId: string;
onRevokeClick: (keyId: string) => void;
handleformatLastObservedAt: (
lastObservedAt: Date | null | undefined,
@@ -42,6 +52,7 @@ function formatExpiry(expiresAt: number): JSX.Element {
function buildColumns({
isDisabled,
accountId,
onRevokeClick,
handleformatLastObservedAt,
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesGettableFactorAPIKeyDTO> {
@@ -92,22 +103,34 @@ function buildColumns({
key: 'action',
width: 48,
align: 'right' as const,
onCell: (): {
onClick: (e: React.MouseEvent) => void;
style: React.CSSProperties;
} => ({
onClick: (e): void => e.stopPropagation(),
style: { cursor: 'default' },
}),
render: (_, record): JSX.Element => (
<Tooltip title={isDisabled ? 'Service account disabled' : 'Revoke Key'}>
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(record.id),
buildSADetachPermission(accountId),
]}
enabled={!isDisabled && !!accountId}
>
<Button
variant="ghost"
size="sm"
color="destructive"
disabled={isDisabled}
onClick={(e): void => {
e.stopPropagation();
onClick={(): void => {
onRevokeClick(record.id);
}}
className="keys-tab__revoke-btn"
>
<X size={12} />
</Button>
</Tooltip>
</AuthZTooltip>
),
},
];
@@ -117,6 +140,7 @@ function KeysTab({
keys,
isLoading,
isDisabled = false,
accountId = '',
currentPage,
pageSize,
}: KeysTabProps): JSX.Element {
@@ -143,14 +167,20 @@ function KeysTab({
const onRevokeClick = useCallback(
(keyId: string): void => {
setRevokeKeyId(keyId);
void setRevokeKeyId(keyId);
},
[setRevokeKeyId],
);
const columns = useMemo(
() => buildColumns({ isDisabled, onRevokeClick, handleformatLastObservedAt }),
[isDisabled, onRevokeClick, handleformatLastObservedAt],
() =>
buildColumns({
isDisabled,
accountId,
onRevokeClick,
handleformatLastObservedAt,
}),
[isDisabled, accountId, onRevokeClick, handleformatLastObservedAt],
);
if (isLoading) {
@@ -176,16 +206,21 @@ function KeysTab({
Learn more
</a>
</p>
<Button
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
<AuthZTooltip
checks={[APIKeyCreatePermission, buildSAAttachPermission(accountId)]}
enabled={!isDisabled && !!accountId}
>
+ Add your first key
</Button>
<Button
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
>
+ Add your first key
</Button>
</AuthZTooltip>
</div>
);
}

View File

@@ -3,9 +3,11 @@ import { LockKeyhole } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Input } from '@signozhq/ui/input';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import RolesSelect from 'components/RolesSelect';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { buildSAUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
@@ -19,6 +21,7 @@ interface OverviewTabProps {
localRoles: string[];
onRolesChange: (v: string[]) => void;
isDisabled: boolean;
canUpdate?: boolean;
availableRoles: AuthtypesRoleDTO[];
rolesLoading?: boolean;
rolesError?: boolean;
@@ -34,6 +37,7 @@ function OverviewTab({
localRoles,
onRolesChange,
isDisabled,
canUpdate = true,
availableRoles,
rolesLoading,
rolesError,
@@ -63,11 +67,16 @@ function OverviewTab({
<label className="sa-drawer__label" htmlFor="sa-name">
Name
</label>
{isDisabled ? (
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{localName || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
{isDisabled || !canUpdate ? (
<AuthZTooltip
checks={[buildSAUpdatePermission(account.id)]}
enabled={!isDisabled && !canUpdate}
>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{localName || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</AuthZTooltip>
) : (
<Input
id="sa-name"
@@ -78,6 +87,16 @@ function OverviewTab({
)}
</div>
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-id">
ID
</label>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{account.id || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</div>
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-email">
Email Address

View File

@@ -1,6 +1,11 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
buildAPIKeyDeletePermission,
buildSADetachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
@@ -23,12 +28,16 @@ export interface RevokeKeyFooterProps {
isRevoking: boolean;
onCancel: () => void;
onConfirm: () => void;
accountId?: string;
keyId?: string;
}
export function RevokeKeyFooter({
isRevoking,
onCancel,
onConfirm,
accountId,
keyId,
}: RevokeKeyFooterProps): JSX.Element {
return (
<>
@@ -36,15 +45,23 @@ export function RevokeKeyFooter({
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
loading={isRevoking}
onClick={onConfirm}
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(keyId ?? ''),
buildSADetachPermission(accountId ?? ''),
]}
enabled={!!accountId && !!keyId}
>
<Trash2 size={12} />
Revoke Key
</Button>
<Button
variant="solid"
color="destructive"
loading={isRevoking}
onClick={onConfirm}
>
<Trash2 size={12} />
Revoke Key
</Button>
</AuthZTooltip>
</>
);
}
@@ -115,6 +132,8 @@ function RevokeKeyModal(): JSX.Element {
isRevoking={isRevoking}
onCancel={handleCancel}
onConfirm={handleConfirm}
accountId={accountId ?? undefined}
keyId={revokeKeyId || undefined}
/>
}
>

View File

@@ -16,6 +16,8 @@ import {
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { GuardAuthZ } from 'components/GuardAuthZ/GuardAuthZ';
import PermissionDeniedCallout from 'components/PermissionDeniedCallout/PermissionDeniedCallout';
import { useRoles } from 'components/RolesSelect';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import {
@@ -27,6 +29,15 @@ import {
RoleUpdateFailure,
useServiceAccountRoleManager,
} from 'hooks/serviceAccount/useServiceAccountRoleManager';
import {
APIKeyCreatePermission,
APIKeyListPermission,
buildSAAttachPermission,
buildSADeletePermission,
buildSAReadPermission,
buildSAUpdatePermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import {
parseAsBoolean,
parseAsInteger,
@@ -37,6 +48,7 @@ import {
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AddKeyModal from './AddKeyModal';
import DeleteAccountModal from './DeleteAccountModal';
import KeysTab from './KeysTab';
@@ -96,6 +108,22 @@ function ServiceAccountDrawer({
const queryClient = useQueryClient();
const { permissions: drawerPermissions, isLoading: isAuthZLoading } = useAuthZ(
selectedAccountId
? [
buildSAReadPermission(selectedAccountId),
buildSAUpdatePermission(selectedAccountId),
buildSADeletePermission(selectedAccountId),
APIKeyListPermission,
]
: [],
{ enabled: !!selectedAccountId },
);
const canRead =
drawerPermissions?.[buildSAReadPermission(selectedAccountId ?? '')]
?.isGranted ?? false;
const {
data: accountData,
isLoading: isAccountLoading,
@@ -104,7 +132,7 @@ function ServiceAccountDrawer({
refetch: refetchAccount,
} = useGetServiceAccount(
{ id: selectedAccountId ?? '' },
{ query: { enabled: !!selectedAccountId } },
{ query: { enabled: canRead && !!selectedAccountId } },
);
const account = useMemo(
@@ -117,7 +145,9 @@ function ServiceAccountDrawer({
currentRoles,
isLoading: isRolesLoading,
applyDiff,
} = useServiceAccountRoleManager(selectedAccountId ?? '');
} = useServiceAccountRoleManager(selectedAccountId ?? '', {
enabled: canRead && !!selectedAccountId,
});
const roleSessionRef = useRef<string | null>(null);
@@ -165,9 +195,16 @@ function ServiceAccountDrawer({
refetch: refetchRoles,
} = useRoles();
const canListKeys =
drawerPermissions?.[APIKeyListPermission]?.isGranted ?? false;
const canUpdate =
drawerPermissions?.[buildSAUpdatePermission(selectedAccountId ?? '')]
?.isGranted ?? true;
const { data: keysData, isLoading: keysLoading } = useListServiceAccountKeys(
{ id: selectedAccountId ?? '' },
{ query: { enabled: !!selectedAccountId } },
{ query: { enabled: !!selectedAccountId && canListKeys } },
);
const keys = keysData?.data ?? [];
@@ -392,18 +429,26 @@ function ServiceAccountDrawer({
</ToggleGroupItem>
</ToggleGroup>
{activeTab === ServiceAccountDrawerTab.Keys && (
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
<AuthZTooltip
checks={[
APIKeyCreatePermission,
buildSAAttachPermission(selectedAccountId ?? ''),
]}
enabled={!isDeleted && !!selectedAccountId}
>
<Plus size={12} />
Add Key
</Button>
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
>
<Plus size={12} />
Add Key
</Button>
</AuthZTooltip>
)}
</div>
@@ -412,7 +457,9 @@ function ServiceAccountDrawer({
activeTab === ServiceAccountDrawerTab.Keys ? ' sa-drawer__body--keys' : ''
}`}
>
{isAccountLoading && <Skeleton active paragraph={{ rows: 6 }} />}
{(isAuthZLoading || isAccountLoading) && (
<Skeleton active paragraph={{ rows: 6 }} />
)}
{isAccountError && (
<ErrorInPlace
error={toAPIError(
@@ -421,38 +468,55 @@ function ServiceAccountDrawer({
)}
/>
)}
{!isAccountLoading && !isAccountError && (
<>
{activeTab === ServiceAccountDrawerTab.Overview && account && (
<OverviewTab
account={account}
localName={localName}
onNameChange={handleNameChange}
localRoles={localRoles}
onRolesChange={(roles): void => {
setLocalRoles(roles);
clearRoleErrors();
}}
isDisabled={isDeleted}
availableRoles={availableRoles}
rolesLoading={rolesLoading}
rolesError={rolesError}
rolesErrorObj={rolesErrorObj}
onRefetchRoles={refetchRoles}
saveErrors={saveErrors}
/>
)}
{activeTab === ServiceAccountDrawerTab.Keys && (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
)}
</>
)}
{!isAuthZLoading &&
!isAccountLoading &&
!isAccountError &&
selectedAccountId && (
<GuardAuthZ
relation="read"
object={`serviceaccount:${selectedAccountId}`}
fallbackOnNoPermissions={(): JSX.Element => (
<PermissionDeniedCallout permissionName="serviceaccount:read" />
)}
>
<>
{activeTab === ServiceAccountDrawerTab.Overview && account && (
<OverviewTab
account={account}
localName={localName}
onNameChange={handleNameChange}
localRoles={localRoles}
onRolesChange={(roles): void => {
setLocalRoles(roles);
clearRoleErrors();
}}
isDisabled={isDeleted}
canUpdate={canUpdate}
availableRoles={availableRoles}
rolesLoading={rolesLoading}
rolesError={rolesError}
rolesErrorObj={rolesErrorObj}
onRefetchRoles={refetchRoles}
saveErrors={saveErrors}
/>
)}
{activeTab === ServiceAccountDrawerTab.Keys &&
(canListKeys ? (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
canUpdate={canUpdate}
accountId={selectedAccountId}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
) : (
<PermissionDeniedCallout permissionName="factor-api-key:list" />
))}
</>
</GuardAuthZ>
)}
</div>
</div>
);
@@ -482,16 +546,21 @@ function ServiceAccountDrawer({
) : (
<>
{!isDeleted && (
<Button
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
<AuthZTooltip
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
enabled={!!selectedAccountId}
>
<Trash2 size={12} />
Delete Service Account
</Button>
<Button
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
>
<Trash2 size={12} />
Delete Service Account
</Button>
</AuthZTooltip>
)}
{!isDeleted && (
<div className="sa-drawer__footer-right">

View File

@@ -6,6 +6,15 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import EditKeyModal from '../EditKeyModal';
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,
}: {
children: React.ReactElement;
}): React.ReactElement => children,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
@@ -19,7 +28,7 @@ const mockKey: ServiceaccounttypesGettableFactorAPIKeyDTO = {
id: 'key-1',
name: 'Original Key Name',
expiresAt: 0,
lastObservedAt: null as any,
lastObservedAt: null as unknown as Date,
serviceAccountId: 'sa-1',
};

View File

@@ -6,6 +6,15 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import KeysTab from '../KeysTab';
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,
}: {
children: React.ReactElement;
}): React.ReactElement => children,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
@@ -20,7 +29,7 @@ const keys: ServiceaccounttypesGettableFactorAPIKeyDTO[] = [
id: 'key-1',
name: 'Production Key',
expiresAt: 0,
lastObservedAt: null as any,
lastObservedAt: null as unknown as Date,
serviceAccountId: 'sa-1',
},
{

View File

@@ -0,0 +1,158 @@
import type { ReactNode } from 'react';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import {
setupAuthzAdmin,
setupAuthzDeny,
setupAuthzDenyAll,
} from 'tests/authz-test-utils';
import {
APIKeyListPermission,
buildSADeletePermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import ServiceAccountDrawer from '../ServiceAccountDrawer';
const ROLES_ENDPOINT = '*/api/v1/roles';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_DELETE_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
const activeAccountResponse = {
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
};
jest.mock('@signozhq/ui/drawer', () => ({
...jest.requireActual('@signozhq/ui/drawer'),
DrawerWrapper: ({
children,
footer,
open,
}: {
children?: ReactNode;
footer?: ReactNode;
open: boolean;
}): JSX.Element | null =>
open ? (
<div>
{children}
{footer}
</div>
) : null,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
function renderDrawer(
searchParams: Record<string, string> = { account: 'sa-1' },
): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<ServiceAccountDrawer onSuccess={jest.fn()} />
</NuqsTestingAdapter>,
);
}
function setupBaseHandlers(): void {
server.use(
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: activeAccountResponse })),
),
rest.put(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: listRolesSuccessResponse.data.filter(
(r) => r.name === 'signoz-admin',
),
}),
),
),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
);
}
describe('ServiceAccountDrawer — permissions', () => {
beforeEach(() => {
jest.clearAllMocks();
setupBaseHandlers();
});
afterEach(() => {
server.resetHandlers();
});
it('shows PermissionDeniedCallout inside drawer when read permission is denied', async () => {
server.use(setupAuthzDenyAll());
renderDrawer();
await waitFor(() => {
expect(screen.getByText(/serviceaccount:read/)).toBeInTheDocument();
});
});
it('shows drawer content when read permission is granted', async () => {
server.use(setupAuthzAdmin());
renderDrawer();
await screen.findByDisplayValue('CI Bot');
expect(screen.queryByText(/serviceaccount:read/)).not.toBeInTheDocument();
});
it('shows PermissionDeniedCallout in Keys tab when list-keys permission is denied', async () => {
server.use(setupAuthzDeny(APIKeyListPermission));
renderDrawer();
await screen.findByDisplayValue('CI Bot');
fireEvent.click(screen.getByRole('radio', { name: /keys/i }));
await waitFor(() => {
expect(screen.getByText(/factor-api-key:list/)).toBeInTheDocument();
});
});
it('disables Delete button when delete permission is denied', async () => {
server.use(setupAuthzDeny(buildSADeletePermission('sa-1')));
renderDrawer();
await screen.findByDisplayValue('CI Bot');
const deleteBtn = screen.getByRole('button', {
name: /Delete Service Account/i,
});
await waitFor(() => expect(deleteBtn).toBeDisabled());
});
});

View File

@@ -3,6 +3,7 @@ import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { setupAuthzAdmin } from 'tests/authz-test-utils';
import ServiceAccountDrawer from '../ServiceAccountDrawer';
@@ -98,6 +99,7 @@ describe('ServiceAccountDrawer', () => {
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
setupAuthzAdmin(),
);
});
@@ -300,13 +302,6 @@ describe('ServiceAccountDrawer', () => {
await screen.findByText(/No keys/i);
});
it('shows skeleton while loading account data', () => {
renderDrawer();
// Skeleton renders while the fetch is in-flight
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('shows error state when account fetch fails', async () => {
server.use(
rest.get(SA_ENDPOINT, (_, res, ctx) =>
@@ -359,6 +354,7 @@ describe('ServiceAccountDrawer save-error UX', () => {
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
setupAuthzAdmin(),
);
});

View File

@@ -93,6 +93,7 @@ function ValueGraph({
<div
ref={containerRef}
className="value-graph-container"
data-testid="value-graph-container"
style={{
backgroundColor:
threshold.thresholdFormat === 'Background'

View File

@@ -98,7 +98,11 @@ function YAxisUnitSelector({
{categoriesToRender.map((category) => (
<Select.OptGroup key={category.name} label={category.name}>
{category.units.map((unit) => (
<Select.Option key={unit.id} value={unit.id}>
<Select.Option
key={unit.id}
value={unit.id}
data-testid={`unit-option-${unit.id}`}
>
{unit.name}
</Select.Option>
))}

View File

@@ -1,33 +1,16 @@
import { ReactElement } from 'react';
import type { RouteComponentProps } from 'react-router-dom';
import {
import type {
AuthtypesGettableTransactionDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
import { createGuardedRoute } from './createGuardedRoute';
const BASE_URL = ENVIRONMENT.baseURL || '';
const AUTHZ_CHECK_URL = `${BASE_URL}/api/v1/authz/check`;
function authzMockResponse(
payload: AuthtypesTransactionDTO[],
authorizedByIndex: boolean[],
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
return {
data: payload.map((txn, i) => ({
relation: txn.relation,
object: txn.object,
authorized: authorizedByIndex[i] ?? false,
})),
status: 'success',
};
}
describe('createGuardedRoute', () => {
const TestComponent = ({ testProp }: { testProp: string }): ReactElement => (
<div>Test Component: {testProp}</div>

View File

@@ -34,7 +34,7 @@ function OnNoPermissionsFallback(response: {
<br />
Object: <span>{object}</span>
<br />
Ask your SigNoz administrator to grant access.
Please ask your SigNoz administrator to grant access.
</p>
</div>
</div>

View File

@@ -159,6 +159,8 @@ function GridTableComponent({
if (threshold && idx !== -1) {
return (
<div
data-testid="threshold-styled-cell"
data-threshold-format={threshold.thresholdFormat}
style={
threshold.thresholdFormat === 'Background'
? { backgroundColor: threshold.thresholdColor }

View File

@@ -231,12 +231,14 @@ function Threshold({
type="text"
icon={<Pencil size={14} />}
className="edit-btn"
data-testid="threshold-edit-btn"
onClick={editHandler}
/>
<Button
type="text"
icon={<Trash2 size={14} />}
className="delete-btn"
data-testid="threshold-delete-btn"
onClick={deleteHandler}
/>
</div>

View File

@@ -29,18 +29,6 @@
border-bottom: 1px solid var(--l1-border);
}
&__close {
width: 16px;
height: 16px;
padding: 0;
color: var(--foreground);
flex-shrink: 0;
&:hover {
color: var(--l1-foreground);
}
}
&__header-divider {
display: block;
width: 1px;
@@ -167,7 +155,6 @@
line-height: 20px;
letter-spacing: -0.07px;
color: var(--l1-foreground);
text-transform: capitalize;
}
&__body {

View File

@@ -25,10 +25,13 @@ import { PermissionScope } from './PermissionSidePanel.types';
import './PermissionSidePanel.styles.scss';
const RELATIONS_ALL_ONLY = new Set(['list', 'create']);
interface ResourceRowProps {
resource: ResourceDefinition;
config: ResourceConfig;
isExpanded: boolean;
relation: string;
onToggleExpand: (id: string) => void;
onScopeChange: (id: string, scope: ScopeType) => void;
onSelectedIdsChange: (id: string, ids: string[]) => void;
@@ -38,10 +41,12 @@ function ResourceRow({
resource,
config,
isExpanded,
relation,
onToggleExpand,
onScopeChange,
onSelectedIdsChange,
}: ResourceRowProps): JSX.Element {
const showOnlySelected = !RELATIONS_ALL_ONLY.has(relation);
return (
<div className="psp-resource">
<div
@@ -78,36 +83,40 @@ function ResourceRow({
<RadioGroupLabel htmlFor={`${resource.id}-all`}>All</RadioGroupLabel>
</div>
{showOnlySelected && (
<div className="psp-resource__radio-item">
<RadioGroupItem
value={PermissionScope.ONLY_SELECTED}
id={`${resource.id}-only-selected`}
/>
<RadioGroupLabel htmlFor={`${resource.id}-only-selected`}>
Only selected
</RadioGroupLabel>
</div>
)}
<div className="psp-resource__radio-item">
<RadioGroupItem
value={PermissionScope.ONLY_SELECTED}
id={`${resource.id}-only-selected`}
value={PermissionScope.NONE}
id={`${resource.id}-none`}
/>
<RadioGroupLabel htmlFor={`${resource.id}-only-selected`}>
Only selected
</RadioGroupLabel>
<RadioGroupLabel htmlFor={`${resource.id}-none`}>None</RadioGroupLabel>
</div>
</RadioGroup>
{config.scope === PermissionScope.ONLY_SELECTED && (
{config.scope === PermissionScope.ONLY_SELECTED && showOnlySelected && (
<div className="psp-resource__select-wrapper">
{/* TODO: right now made to only accept user input, we need to give it proper resource based value fetching from APIs */}
<Select
mode="tags"
open={false}
allowClear
suffixIcon={null}
value={config.selectedIds}
onChange={(vals: string[]): void =>
onSelectedIdsChange(resource.id, vals)
}
options={resource.options ?? []}
placeholder="Select resources..."
placeholder="Type and press Enter to add..."
className="psp-resource__select"
popupClassName="psp-resource__select-popup"
showSearch
filterOption={(input, option): boolean =>
String(option?.label ?? '')
.toLowerCase()
.includes(input.toLowerCase())
}
/>
</div>
)}
@@ -121,10 +130,12 @@ function PermissionSidePanel({
open,
onClose,
permissionLabel,
relation,
resources,
initialConfig,
isLoading = false,
isSaving = false,
canEdit = true,
onSave,
}: PermissionSidePanelProps): JSX.Element | null {
const [config, setConfig] = useState<PermissionConfig>(() =>
@@ -213,13 +224,13 @@ function PermissionSidePanel({
<div className="permission-side-panel">
<div className="permission-side-panel__header">
<Button
variant="ghost"
variant="link"
color="secondary"
size="icon"
className="permission-side-panel__close"
onClick={onClose}
aria-label="Close panel"
>
<X size={16} />
<X size={14} />
</Button>
<span className="permission-side-panel__header-divider" />
<span className="permission-side-panel__title">
@@ -238,6 +249,7 @@ function PermissionSidePanel({
resource={resource}
config={config[resource.id] ?? DEFAULT_RESOURCE_CONFIG}
isExpanded={expandedIds.has(resource.id)}
relation={relation}
onToggleExpand={handleToggleExpand}
onScopeChange={handleScopeChange}
onSelectedIdsChange={handleSelectedIdsChange}
@@ -274,7 +286,7 @@ function PermissionSidePanel({
size="sm"
onClick={handleSave}
loading={isSaving}
disabled={isLoading || unsavedCount === 0}
disabled={isLoading || unsavedCount === 0 || !canEdit}
>
Save Changes
</Button>

View File

@@ -5,6 +5,8 @@ export interface ResourceOption {
export interface ResourceDefinition {
id: string;
kind: string;
type: string;
label: string;
options?: ResourceOption[];
}
@@ -12,6 +14,7 @@ export interface ResourceDefinition {
export enum PermissionScope {
ALL = 'all',
ONLY_SELECTED = 'only_selected',
NONE = 'none',
}
export type ScopeType = PermissionScope;
@@ -27,9 +30,11 @@ export interface PermissionSidePanelProps {
open: boolean;
onClose: () => void;
permissionLabel: string;
relation: string;
resources: ResourceDefinition[];
initialConfig?: PermissionConfig;
isLoading?: boolean;
isSaving?: boolean;
canEdit?: boolean;
onSave: (config: PermissionConfig) => void;
}

View File

@@ -9,8 +9,9 @@
.role-details-header {
display: flex;
flex-direction: column;
gap: 0;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.role-details-title {
@@ -28,44 +29,6 @@
opacity: 0.55;
}
.role-details-nav {
display: flex;
align-items: center;
justify-content: space-between;
}
.role-details-tab {
gap: 4px;
padding: 0 16px;
height: 32px;
border-radius: 0;
font-size: 12px;
overflow: hidden;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
&[data-state='on'] {
border-radius: 2px 0 0 2px;
}
}
.role-details-tab-count {
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
padding: 0 6px;
border-radius: 50px;
background: var(--secondary);
font-size: 12px;
font-weight: 400;
line-height: 20px;
color: var(--foreground);
letter-spacing: -0.06px;
text-transform: uppercase;
}
.role-details-actions {
display: flex;
align-items: center;
@@ -155,6 +118,17 @@
margin: 0;
}
.role-details-permissions-learn-more {
color: var(--primary);
font-size: var(--font-size-xs);
text-decoration: none;
white-space: nowrap;
&:hover {
text-decoration: underline;
}
}
.role-details-permission-list {
display: flex;
flex-direction: column;
@@ -282,30 +256,6 @@
}
}
.role-details-delete-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
min-width: 32px;
border: none;
border-radius: 2px;
background: transparent;
color: var(--destructive);
opacity: 0.6;
padding: 0;
transition:
background-color 0.2s,
opacity 0.2s;
box-shadow: none;
&:hover {
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
opacity: 0.9;
}
}
.role-details-delete-modal {
width: calc(100% - 30px) !important;
max-width: 384px;

View File

@@ -1,10 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useHistory, useLocation } from 'react-router-dom';
import { Table2, Trash2, Users } from '@signozhq/icons';
import { Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { Skeleton } from 'antd';
import {
getGetObjectsQueryKey,
@@ -13,7 +12,15 @@ import {
useGetRole,
usePatchObjects,
} from 'api/generated/services/role';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import permissionsType from 'hooks/useAuthZ/permissions.type';
import {
buildRoleDeletePermission,
buildRoleReadPermission,
buildRoleUpdatePermission,
} from 'hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import type { AuthzResources } from '../utils';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
@@ -23,7 +30,6 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import { RoleType } from 'types/roles';
import { handleApiError, toAPIError } from 'utils/errorUtils';
import { IS_ROLE_DETAILS_AND_CRUD_ENABLED } from '../config';
import type { PermissionConfig } from '../PermissionSidePanel';
import PermissionSidePanel from '../PermissionSidePanel';
import CreateRoleModal from '../RolesComponents/CreateRoleModal';
@@ -34,35 +40,33 @@ import {
deriveResourcesForRelation,
objectsToPermissionConfig,
} from '../utils';
import MembersTab from './components/MembersTab';
import OverviewTab from './components/OverviewTab';
import { ROLE_ID_REGEX } from './constants';
import './RoleDetailsPage.styles.scss';
type TabKey = 'overview' | 'members';
// eslint-disable-next-line sonarjs/cognitive-complexity
function RoleDetailsPage(): JSX.Element {
const { pathname } = useLocation();
const { pathname, search } = useLocation();
const history = useHistory();
useEffect(() => {
if (!IS_ROLE_DETAILS_AND_CRUD_ENABLED) {
history.push(ROUTES.ROLES_SETTINGS);
}
}, [history]);
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
const authzResources = permissionsType.data as unknown as AuthzResources;
// Extract channelId from URL pathname since useParams doesn't work in nested routing
// Extract roleId from URL pathname since useParams doesn't work in nested routing
const roleIdMatch = pathname.match(ROLE_ID_REGEX);
const roleId = roleIdMatch ? roleIdMatch[1] : '';
const [activeTab, setActiveTab] = useState<TabKey>('overview');
// Role name passed as query param by the listing page — used to check read permission
// before the role details API resolves. Absent when navigating directly (e.g. deep link),
// in which case we skip the FGA check and fall back to the BE guard.
const nameFromQuery = useMemo(
() => new URLSearchParams(search).get('name') ?? '',
[search],
);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [activePermission, setActivePermission] = useState<string | null>(null);
@@ -75,6 +79,27 @@ function RoleDetailsPage(): JSX.Element {
const isTransitioning = isFetching && role?.id !== roleId;
const isManaged = role?.type === RoleType.MANAGED;
const roleName = role?.name ?? '';
// Read check — fires immediately using the name query param so we can gate the page
// before the role details API resolves. Skipped when name is absent.
const { permissions: readPerms, isLoading: isReadAuthZLoading } = useAuthZ(
nameFromQuery ? [buildRoleReadPermission(nameFromQuery)] : [],
{ enabled: !!nameFromQuery },
);
const hasReadPermission = nameFromQuery
? (readPerms?.[buildRoleReadPermission(nameFromQuery)]?.isGranted ?? true)
: true;
// Update check uses role name once loaded
const { permissions: updatePerms, isLoading: isAuthZLoading } = useAuthZ(
roleName && !isManaged ? [buildRoleUpdatePermission(roleName)] : [],
{ enabled: !!roleName && !isManaged },
);
const hasUpdatePermission = isAuthZLoading
? false
: (updatePerms?.[buildRoleUpdatePermission(roleName)]?.isGranted ?? false);
const permissionTypes = useMemo(
() => derivePermissionTypes(authzResources?.relations ?? null),
[authzResources],
@@ -90,7 +115,11 @@ function RoleDetailsPage(): JSX.Element {
const { data: objectsData, isLoading: isLoadingObjects } = useGetObjects(
{ id: roleId, relation: activePermission ?? '' },
{ query: { enabled: !!activePermission && !!roleId && !isManaged } },
{
query: {
enabled: !!activePermission && !!roleId && !isManaged,
},
},
);
const initialConfig = useMemo(() => {
@@ -110,7 +139,6 @@ function RoleDetailsPage(): JSX.Element {
getGetObjectsQueryKey({ id: roleId, relation: activePermission }),
);
}
setActivePermission(null);
};
const { mutate: patchObjects, isLoading: isSaving } = usePatchObjects({
@@ -130,7 +158,11 @@ function RoleDetailsPage(): JSX.Element {
},
});
if (!IS_ROLE_DETAILS_AND_CRUD_ENABLED || isLoading || isTransitioning) {
if (!hasReadPermission && readPerms !== null) {
return <PermissionDeniedFullPage permissionName="role:read" />;
}
if (isLoading || isTransitioning || (!!nameFromQuery && isReadAuthZLoading)) {
return (
<div className="role-details-page">
<Skeleton
@@ -186,73 +218,49 @@ function RoleDetailsPage(): JSX.Element {
<div className="role-details-page">
<div className="role-details-header">
<h2 className="role-details-title">Role {role.name}</h2>
</div>
<div className="role-details-nav">
<ToggleGroup
type="single"
value={activeTab}
onChange={(val): void => {
if (val) {
setActiveTab(val as TabKey);
}
}}
className="role-details-tabs"
>
<ToggleGroupItem value="overview" className="role-details-tab">
<Table2 size={14} />
Overview
</ToggleGroupItem>
<ToggleGroupItem value="members" className="role-details-tab">
<Users size={14} />
Members
<span className="role-details-tab-count">0</span>
</ToggleGroupItem>
</ToggleGroup>
{!isManaged && (
<div className="role-details-actions">
<Button
variant="ghost"
color="destructive"
className="role-details-delete-action-btn"
onClick={(): void => setIsDeleteModalOpen(true)}
aria-label="Delete role"
>
<Trash2 size={14} />
</Button>
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setIsEditModalOpen(true)}
>
Edit Role Details
</Button>
<AuthZTooltip checks={[buildRoleDeletePermission(role.name)]}>
<Button
variant="link"
color="destructive"
onClick={(): void => setIsDeleteModalOpen(true)}
aria-label="Delete role"
>
<Trash2 size={12} />
</Button>
</AuthZTooltip>
<AuthZTooltip checks={[buildRoleUpdatePermission(role.name)]}>
<Button
variant="solid"
color="secondary"
onClick={(): void => setIsEditModalOpen(true)}
>
Edit Role Details
</Button>
</AuthZTooltip>
</div>
)}
</div>
{activeTab === 'overview' && (
<OverviewTab
role={role || null}
isManaged={isManaged}
permissionTypes={permissionTypes}
onPermissionClick={(key): void => setActivePermission(key)}
/>
)}
{activeTab === 'members' && <MembersTab />}
<OverviewTab
role={role || null}
isManaged={isManaged}
permissionTypes={permissionTypes}
onPermissionClick={(key): void => setActivePermission(key)}
/>
{!isManaged && (
<>
<PermissionSidePanel
open={activePermission !== null}
onClose={(): void => setActivePermission(null)}
permissionLabel={activePermission ? capitalize(activePermission) : ''}
relation={activePermission ?? ''}
resources={resourcesForActivePermission}
initialConfig={initialConfig}
isLoading={isLoadingObjects}
isSaving={isSaving}
canEdit={hasUpdatePermission}
onSave={handleSave}
/>

View File

@@ -1,5 +1,3 @@
jest.mock('../../config', () => ({ IS_ROLE_DETAILS_AND_CRUD_ENABLED: true }));
import * as roleApi from 'api/generated/services/role';
import {
customRoleResponse,
@@ -15,9 +13,16 @@ import {
waitFor,
within,
} from 'tests/test-utils';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import {
mockUseAuthZDenyAll,
mockUseAuthZGrantAll,
} from 'tests/authz-test-utils';
import RoleDetailsPage from '../RoleDetailsPage';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
const MANAGED_ROLE_ID = '019c24aa-2248-756f-9833-984f1ab63819';
@@ -29,7 +34,7 @@ const allScopeObjectsResponse = {
status: 'success',
data: [
{
resource: { kind: 'role', type: 'metaresources' },
resource: { kind: 'role', type: 'role' },
selectors: ['*'],
},
],
@@ -46,6 +51,10 @@ function setupDefaultHandlers(roleId = CUSTOM_ROLE_ID): void {
);
}
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
@@ -63,9 +72,6 @@ describe('RoleDetailsPage', () => {
screen.findByText('Role — billing-manager'),
).resolves.toBeInTheDocument();
expect(screen.getByText('Overview')).toBeInTheDocument();
expect(screen.getByText('Members')).toBeInTheDocument();
expect(
screen.getByText('Custom role for managing billing and invoices.'),
).toBeInTheDocument();
@@ -212,6 +218,18 @@ describe('RoleDetailsPage', () => {
);
});
it('shows PermissionDeniedFullPage when read permission is denied via query param', async () => {
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}?name=billing-manager`,
});
await expect(
screen.findByText(/you don't have permission to view this page/i),
).resolves.toBeInTheDocument();
});
describe('permission side panel', () => {
beforeEach(() => {
// Both hooks mocked so data renders synchronously — no React Query scheduler or MSW round-trip.
@@ -238,7 +256,18 @@ describe('RoleDetailsPage', () => {
const panel = document.querySelector(
'.permission-side-panel',
) as HTMLElement;
await within(panel).findByRole('button', { name: 'Role' });
await within(panel).findByRole('button', { name: 'role' });
return panel;
}
async function openReadPanel(): Promise<HTMLElement> {
await screen.findByText('Role — billing-manager');
fireEvent.click(screen.getByText('Read'));
await screen.findByText('Edit Read Permissions');
const panel = document.querySelector(
'.permission-side-panel',
) as HTMLElement;
await within(panel).findByRole('button', { name: 'role' });
return panel;
}
@@ -253,7 +282,7 @@ describe('RoleDetailsPage', () => {
within(panel).getByRole('button', { name: /save changes/i }),
).toBeDisabled();
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
fireEvent.click(screen.getByText('All'));
expect(
@@ -281,7 +310,7 @@ describe('RoleDetailsPage', () => {
const panel = await openCreatePanel();
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
fireEvent.click(screen.getByText('All'));
fireEvent.click(
within(panel).getByRole('button', { name: /save changes/i }),
@@ -317,9 +346,11 @@ describe('RoleDetailsPage', () => {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
const panel = await openCreatePanel();
const panel = await openReadPanel();
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
// Default is NONE, so switch to Only selected first to reveal the combobox
fireEvent.click(screen.getByText('Only selected'));
const combobox = within(panel).getByRole('combobox');
fireEvent.change(combobox, { target: { value: 'role-001' } });
@@ -342,6 +373,48 @@ describe('RoleDetailsPage', () => {
);
});
it('set scope to None on create panel (existing All) → patchObjects deletions: ["*"], additions: null', async () => {
const patchSpy = jest.fn();
jest.spyOn(roleApi, 'useGetObjects').mockReturnValue({
data: allScopeObjectsResponse,
isLoading: false,
} as any);
server.use(
rest.patch(
`${rolesApiBase}/:id/relations/:relation/objects`,
async (req, res, ctx) => {
patchSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
},
),
);
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
const panel = await openCreatePanel();
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
fireEvent.click(screen.getByText('None'));
fireEvent.click(
within(panel).getByRole('button', { name: /save changes/i }),
);
await waitFor(() =>
expect(patchSpy).toHaveBeenCalledWith({
additions: null,
deletions: [
{
resource: { kind: 'role', type: 'role' },
selectors: ['*'],
},
],
}),
);
});
it('existing All scope changed to Only selected (empty) → patchObjects deletions: ["*"], additions: null', async () => {
const patchSpy = jest.fn();
@@ -363,9 +436,9 @@ describe('RoleDetailsPage', () => {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
const panel = await openCreatePanel();
const panel = await openReadPanel();
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
fireEvent.click(screen.getByText('Only selected'));
fireEvent.click(
within(panel).getByRole('button', { name: /save changes/i }),
@@ -393,7 +466,7 @@ describe('RoleDetailsPage', () => {
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
fireEvent.click(screen.getByText('All'));
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();

View File

@@ -2,6 +2,7 @@ import { Callout } from '@signozhq/ui/callout';
import { PermissionType, TimestampBadge } from '../../utils';
import PermissionItem from './PermissionItem';
import { AuthtypesRelationDTO } from 'api/generated/services/sigNoz.schemas';
interface OverviewTabProps {
role: {
@@ -55,18 +56,28 @@ function OverviewTab({
<div className="role-details-permissions">
<div className="role-details-permissions-header">
<span className="role-details-section-label">Permissions</span>
<a
href="https://signoz.io/docs/manage/administrator-guide/iam/permissions/"
target="_blank"
rel="noopener noreferrer"
className="role-details-permissions-learn-more"
>
Learn more
</a>
<hr className="role-details-permissions-divider" />
</div>
<div className="role-details-permission-list">
{permissionTypes.map((permissionType) => (
<PermissionItem
key={permissionType.key}
permissionType={permissionType}
isManaged={isManaged}
onPermissionClick={onPermissionClick}
/>
))}
{permissionTypes
.filter((p) => p.key !== AuthtypesRelationDTO.assignee)
.map((permissionType) => (
<PermissionItem
key={permissionType.key}
permissionType={permissionType}
isManaged={isManaged}
onPermissionClick={onPermissionClick}
/>
))}
</div>
</div>
</div>

View File

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

View File

@@ -4,16 +4,17 @@ import { Pagination, Skeleton } from 'antd';
import { useListRoles } from 'api/generated/services/role';
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { RoleListPermission } from 'hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import useUrlQuery from 'hooks/useUrlQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useTimezone } from 'providers/Timezone';
import { RoleType } from 'types/roles';
import { toAPIError } from 'utils/errorUtils';
import { IS_ROLE_DETAILS_AND_CRUD_ENABLED } from '../config';
import '../RolesSettings.styles.scss';
const PAGE_SIZE = 20;
@@ -29,7 +30,14 @@ interface RolesListingTableProps {
function RolesListingTable({
searchQuery,
}: RolesListingTableProps): JSX.Element {
const { data, isLoading, isError, error } = useListRoles();
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
RoleListPermission,
]);
const hasListPermission = listPerms?.[RoleListPermission]?.isGranted ?? false;
const { data, isLoading, isError, error } = useListRoles({
query: { enabled: hasListPermission },
});
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const history = useHistory();
const urlQuery = useUrlQuery();
@@ -151,7 +159,11 @@ function RolesListingTable({
</>
);
if (isLoading) {
if (!hasListPermission && listPerms !== null) {
return <PermissionDeniedFullPage permissionName="role:list" />;
}
if (isAuthZLoading || isLoading) {
return (
<div className="roles-listing-table">
<Skeleton active paragraph={{ rows: 5 }} />
@@ -182,31 +194,26 @@ function RolesListingTable({
);
}
const navigateToRole = (roleId: string): void => {
history.push(ROUTES.ROLE_DETAILS.replace(':roleId', roleId));
const navigateToRole = (roleId: string, roleName?: string): void => {
const search = roleName ? `?name=${encodeURIComponent(roleName)}` : '';
history.push(`${ROUTES.ROLE_DETAILS.replace(':roleId', roleId)}${search}`);
};
// todo: use table from periscope when its available for consumption
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
<div
key={role.id}
className={`roles-table-row ${
IS_ROLE_DETAILS_AND_CRUD_ENABLED ? 'roles-table-row--clickable' : ''
}`}
className="roles-table-row roles-table-row--clickable"
role="button"
tabIndex={IS_ROLE_DETAILS_AND_CRUD_ENABLED ? 0 : -1}
tabIndex={0}
onClick={(): void => {
if (IS_ROLE_DETAILS_AND_CRUD_ENABLED && role.id) {
navigateToRole(role.id);
if (role.id) {
navigateToRole(role.id, role.name);
}
}}
onKeyDown={(e): void => {
if (
IS_ROLE_DETAILS_AND_CRUD_ENABLED &&
(e.key === 'Enter' || e.key === ' ') &&
role.id
) {
navigateToRole(role.id);
if ((e.key === 'Enter' || e.key === ' ') && role.id) {
navigateToRole(role.id, role.name);
}
}}
>

View File

@@ -22,12 +22,21 @@
color: var(--foreground);
font-family: Inter;
font-style: normal;
font-size: 14px;
font-weight: 400;
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: 20px;
letter-spacing: -0.07px;
margin: 0;
}
.roles-settings-header-learn-more {
color: var(--primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.roles-settings-content {
@@ -285,16 +294,23 @@
}
}
// todo: https://github.com/SigNoz/components/issues/116
input,
input {
&::placeholder {
opacity: 0.4;
}
}
textarea {
width: 100%;
background: var(--l3-background);
border: 1px solid var(--l1-border);
box-sizing: border-box;
min-height: 100px;
resize: vertical;
background: var(--input-background, transparent);
border: 1px solid var(--border);
border-radius: 2px;
padding: 6px 8px;
font-family: Inter;
font-size: 14px;
font-size: var(--font-size-xs);
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
@@ -303,7 +319,7 @@
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
color: var(--muted-foreground);
opacity: 0.4;
}
@@ -313,25 +329,6 @@
box-shadow: none;
}
}
input {
height: 32px;
}
input:disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
border-color: var(--l1-border);
box-shadow: none;
}
}
textarea {
min-height: 100px;
resize: vertical;
}
}
.ant-modal-footer {

View File

@@ -2,8 +2,9 @@ import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { RoleCreatePermission } from 'hooks/useAuthZ/permissions/role.permissions';
import { IS_ROLE_DETAILS_AND_CRUD_ENABLED } from './config';
import CreateRoleModal from './RolesComponents/CreateRoleModal';
import RolesListingTable from './RolesComponents/RolesListingTable';
@@ -18,7 +19,15 @@ function RolesSettings(): JSX.Element {
<div className="roles-settings-header">
<h3 className="roles-settings-header-title">Roles</h3>
<p className="roles-settings-header-description">
Create and manage custom roles for your team.
Create and manage custom roles for your team.{' '}
<a
href="https://signoz.io/docs/manage/administrator-guide/iam/roles/"
target="_blank"
rel="noopener noreferrer"
className="roles-settings-header-learn-more"
>
Learn more
</a>
</p>
</div>
<div className="roles-settings-content">
@@ -29,7 +38,7 @@ function RolesSettings(): JSX.Element {
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
{IS_ROLE_DETAILS_AND_CRUD_ENABLED && (
<AuthZTooltip checks={[RoleCreatePermission]}>
<Button
variant="solid"
color="primary"
@@ -39,7 +48,7 @@ function RolesSettings(): JSX.Element {
<Plus size={14} />
Custom role
</Button>
)}
</AuthZTooltip>
</div>
<RolesListingTable searchQuery={searchQuery} />
</div>

View File

@@ -5,13 +5,19 @@ import {
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { fireEvent, render, screen } from 'tests/test-utils';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import RolesSettings from '../RolesSettings';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const rolesApiURL = 'http://localhost/api/v1/roles';
describe('RolesSettings', () => {
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),

View File

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

View File

@@ -1 +0,0 @@
export const IS_ROLE_DETAILS_AND_CRUD_ENABLED = false;

View File

@@ -12,6 +12,7 @@ import type {
PermissionConfig,
ResourceConfig,
ResourceDefinition,
ScopeType,
} from './PermissionSidePanel/PermissionSidePanel.types';
import { PermissionScope } from './PermissionSidePanel/PermissionSidePanel.types';
import {
@@ -70,8 +71,10 @@ export function deriveResourcesForRelation(
return authzResources.resources
.filter((r) => supportedTypes.includes(r.type))
.map((r) => ({
id: r.kind,
label: capitalize(r.kind).replaceAll('_', ' '),
id: `${r.type}:${r.kind}`,
kind: r.kind,
type: r.type,
label: r.kind,
options: [],
}));
}
@@ -82,10 +85,12 @@ export function objectsToPermissionConfig(
): PermissionConfig {
const config: PermissionConfig = {};
for (const res of resources) {
const obj = objects.find((o) => o.resource.kind === res.id);
const obj = objects.find(
(o) => o.resource.kind === res.kind && o.resource.type === res.type,
);
if (!obj) {
config[res.id] = {
scope: PermissionScope.ONLY_SELECTED,
scope: PermissionScope.NONE,
selectedIds: [],
};
} else {
@@ -99,6 +104,16 @@ export function objectsToPermissionConfig(
return config;
}
function selectorsForScope(scope: ScopeType, selectedIds: string[]): string[] {
if (scope === PermissionScope.ALL) {
return ['*'];
}
if (scope === PermissionScope.ONLY_SELECTED) {
return selectedIds;
}
return []; // NONE
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function buildPatchPayload({
newConfig,
@@ -118,7 +133,9 @@ export function buildPatchPayload({
for (const res of resources) {
const initial = initialConfig[res.id];
const current = newConfig[res.id];
const found = authzRes.resources.find((r) => r.kind === res.id);
const found = authzRes.resources.find(
(r) => r.kind === res.kind && r.type === res.type,
);
if (!found) {
continue;
}
@@ -127,8 +144,8 @@ export function buildPatchPayload({
type: found.type,
};
const initialScope = initial?.scope ?? PermissionScope.ONLY_SELECTED;
const currentScope = current?.scope ?? PermissionScope.ONLY_SELECTED;
const initialScope = initial?.scope ?? PermissionScope.NONE;
const currentScope = current?.scope ?? PermissionScope.NONE;
if (initialScope === currentScope) {
// Same scope — only diff individual selectors when both are ONLY_SELECTED
@@ -144,16 +161,20 @@ export function buildPatchPayload({
additions.push({ resource: resourceDef, selectors: added });
}
}
// Both ALL → no change, skip
// Both ALL or both NONE → no change, skip
} else {
// Scope changed (ALL ↔ ONLY_SELECTED) — replace old with new
const initialSelectors =
initialScope === PermissionScope.ALL ? ['*'] : (initial?.selectedIds ?? []);
// Scope changed — replace old selectors with new ones
const initialSelectors = selectorsForScope(
initialScope,
initial?.selectedIds ?? [],
);
if (initialSelectors.length > 0) {
deletions.push({ resource: resourceDef, selectors: initialSelectors });
}
const currentSelectors =
currentScope === PermissionScope.ALL ? ['*'] : (current?.selectedIds ?? []);
const currentSelectors = selectorsForScope(
currentScope,
current?.selectedIds ?? [],
);
if (currentSelectors.length > 0) {
additions.push({ resource: resourceDef, selectors: currentSelectors });
}
@@ -191,7 +212,7 @@ export function TimestampBadge({ date }: TimestampBadgeProps): JSX.Element {
}
export const DEFAULT_RESOURCE_CONFIG: ResourceConfig = {
scope: PermissionScope.ONLY_SELECTED,
scope: PermissionScope.NONE,
selectedIds: [],
};

View File

@@ -0,0 +1,132 @@
import type { AuthtypesTransactionDTO } from 'api/generated/services/sigNoz.schemas';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, waitFor } from 'tests/test-utils';
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
import ServiceAccountsSettings from './ServiceAccountsSettings';
const SA_LIST_URL = 'http://localhost/api/v1/service_accounts';
function renderPage(): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter searchParams={{}} hasMemory>
<ServiceAccountsSettings />
</NuqsTestingAdapter>,
);
}
describe('ServiceAccountsSettings — FGA', () => {
beforeEach(() => {
server.use(
rest.get(SA_LIST_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
);
});
it('shows PermissionDeniedFullPage when list permission is denied', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(
authzMockResponse(
payload,
payload.map(() => false),
),
),
);
}),
);
renderPage();
await waitFor(() => {
expect(
screen.getByText(/You don't have permission to view this page/),
).toBeInTheDocument();
});
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
it('shows table when list permission is granted', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(
authzMockResponse(
payload,
payload.map(() => true),
),
),
);
}),
);
renderPage();
await waitFor(() => {
expect(screen.getByRole('table')).toBeInTheDocument();
});
expect(
screen.queryByText(/You don't have permission to view this page/),
).not.toBeInTheDocument();
});
it('disables New Service Account button when create permission is denied', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
// grant list, deny create — matched by relation name
return res(
ctx.status(200),
ctx.json(
authzMockResponse(
payload,
payload.map((txn: AuthtypesTransactionDTO) => txn.relation === 'list'),
),
),
);
}),
);
renderPage();
await waitFor(() => {
expect(
screen.getByRole('button', { name: /New Service Account/i }),
).toBeDisabled();
});
});
it('enables New Service Account button when create permission is granted', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(
authzMockResponse(
payload,
payload.map(() => true),
),
),
);
}),
);
renderPage();
await waitFor(() => {
expect(
screen.getByRole('button', { name: /New Service Account/i }),
).not.toBeDisabled();
});
});
});

View File

@@ -5,12 +5,20 @@ import { Input } from '@signozhq/ui/input';
import type { MenuProps } from 'antd';
import { Dropdown } from 'antd';
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import Spinner from 'components/Spinner';
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
import ServiceAccountsTable, {
PAGE_SIZE,
} from 'components/ServiceAccountsTable/ServiceAccountsTable';
import {
SACreatePermission,
SAListPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import {
parseAsBoolean,
parseAsInteger,
@@ -51,13 +59,19 @@ function ServiceAccountsSettings(): JSX.Element {
parseAsBoolean.withDefault(false),
);
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
SAListPermission,
]);
const hasListPermission = listPerms?.[SAListPermission]?.isGranted ?? false;
const {
data: serviceAccountsData,
isLoading,
isError,
error,
refetch: handleCreateSuccess,
} = useListServiceAccounts();
} = useListServiceAccounts({ query: { enabled: hasListPermission } });
const allAccounts = useMemo(
(): ServiceAccountRow[] =>
@@ -112,9 +126,9 @@ function ServiceAccountsSettings(): JSX.Element {
const maxPage = Math.max(1, Math.ceil(filteredAccounts.length / PAGE_SIZE));
if (currentPage > maxPage) {
setPage(maxPage);
void setPage(maxPage);
} else if (currentPage < 1) {
setPage(1);
void setPage(1);
}
}, [filteredAccounts.length, currentPage, setPage]);
@@ -130,8 +144,8 @@ function ServiceAccountsSettings(): JSX.Element {
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.All);
setPage(1);
void setFilterMode(FilterMode.All);
void setPage(1);
},
},
{
@@ -143,8 +157,8 @@ function ServiceAccountsSettings(): JSX.Element {
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.Active);
setPage(1);
void setFilterMode(FilterMode.Active);
void setPage(1);
},
},
{
@@ -156,8 +170,8 @@ function ServiceAccountsSettings(): JSX.Element {
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.Deleted);
setPage(1);
void setFilterMode(FilterMode.Deleted);
void setPage(1);
},
},
];
@@ -176,7 +190,7 @@ function ServiceAccountsSettings(): JSX.Element {
const handleRowClick = useCallback(
(row: ServiceAccountRow): void => {
setSelectedAccountId(row.id);
void setSelectedAccountId(row.id);
},
[setSelectedAccountId],
);
@@ -184,9 +198,9 @@ function ServiceAccountsSettings(): JSX.Element {
const handleDrawerSuccess = useCallback(
(options?: { closeDrawer?: boolean }): void => {
if (options?.closeDrawer) {
setSelectedAccountId(null);
void setSelectedAccountId(null);
}
handleCreateSuccess();
void handleCreateSuccess();
},
[handleCreateSuccess, setSelectedAccountId],
);
@@ -208,63 +222,76 @@ function ServiceAccountsSettings(): JSX.Element {
</a>
</p>
</div>
<div className="sa-settings__controls">
<Dropdown
menu={{ items: filterMenuItems }}
trigger={['click']}
overlayClassName="sa-settings-filter-dropdown"
>
<Button
variant="solid"
color="secondary"
className="sa-settings-filter-trigger"
>
<span>{filterLabel}</span>
<ChevronDown size={12} className="sa-settings-filter-trigger__chevron" />
</Button>
</Dropdown>
<div className="sa-settings__search">
<Input
type="search"
name="service-accounts-search"
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e): void => {
setSearchQuery(e.target.value);
setPage(1);
}}
className="sa-settings-search-input"
/>
</div>
<Button
variant="solid"
color="primary"
onClick={async (): Promise<void> => {
await setIsCreateModalOpen(true);
}}
>
<Plus size={12} />
New Service Account
</Button>
</div>
</div>
{isError ? (
<ErrorInPlace
error={toAPIError(
error,
'An unexpected error occurred while fetching service accounts.',
)}
/>
{isAuthZLoading || isLoading ? (
<Spinner height="50vh" />
) : !hasListPermission ? (
<PermissionDeniedFullPage permissionName="serviceaccount:list" />
) : (
<ServiceAccountsTable
data={filteredAccounts}
loading={isLoading}
onRowClick={handleRowClick}
/>
<div className="sa-settings__list-section">
<div className="sa-settings__controls">
<Dropdown
menu={{ items: filterMenuItems }}
trigger={['click']}
overlayClassName="sa-settings-filter-dropdown"
>
<Button
variant="solid"
color="secondary"
className="sa-settings-filter-trigger"
>
<span>{filterLabel}</span>
<ChevronDown
size={12}
className="sa-settings-filter-trigger__chevron"
/>
</Button>
</Dropdown>
<div className="sa-settings__search">
<Input
type="search"
name="service-accounts-search"
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e): void => {
void setSearchQuery(e.target.value);
void setPage(1);
}}
className="sa-settings-search-input"
/>
</div>
<AuthZTooltip checks={[SACreatePermission]}>
<Button
variant="solid"
color="primary"
onClick={async (): Promise<void> => {
await setIsCreateModalOpen(true);
}}
>
<Plus size={12} />
New Service Account
</Button>
</AuthZTooltip>
</div>
{isError ? (
<ErrorInPlace
error={toAPIError(
error,
'An unexpected error occurred while fetching service accounts.',
)}
/>
) : (
<ServiceAccountsTable
data={filteredAccounts}
loading={isLoading}
onRowClick={handleRowClick}
/>
)}
</div>
)}
<CreateServiceAccountModal />

View File

@@ -3,12 +3,14 @@ import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { setupAuthzAdmin } from 'tests/authz-test-utils';
import ServiceAccountsSettings from '../ServiceAccountsSettings';
const SA_LIST_ENDPOINT = '*/api/v1/service_accounts';
const SA_ENDPOINT = '*/api/v1/service_accounts/:id';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const ROLES_ENDPOINT = '*/api/v1/roles';
jest.mock('@signozhq/ui/drawer', () => ({
@@ -85,6 +87,7 @@ describe('ServiceAccountsSettings (integration)', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
setupAuthzAdmin(),
rest.get(SA_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockServiceAccountsAPI })),
),
@@ -98,6 +101,9 @@ describe('ServiceAccountsSettings (integration)', () => {
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
@@ -178,15 +184,17 @@ describe('ServiceAccountsSettings (integration)', () => {
it('saving changes in the drawer refetches the list', async () => {
const listRefetchSpy = jest.fn();
const putSpy = jest.fn();
server.use(
rest.get(SA_LIST_ENDPOINT, (_, res, ctx) => {
listRefetchSpy();
return res(ctx.status(200), ctx.json({ data: mockServiceAccountsAPI }));
}),
rest.put(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.put(SA_ENDPOINT, async (req, res, ctx) => {
putSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
render(
@@ -205,9 +213,17 @@ describe('ServiceAccountsSettings (integration)', () => {
const nameInput = await screen.findByDisplayValue('CI Bot');
fireEvent.change(nameInput, { target: { value: 'CI Bot Updated' } });
await screen.findByDisplayValue('CI Bot Updated');
fireEvent.click(screen.getByRole('button', { name: /Save Changes/i }));
await screen.findByDisplayValue('CI Bot Updated');
// Wait for the PUT to complete with the right payload — confirms save fired
await waitFor(() =>
expect(putSpy).toHaveBeenCalledWith(
expect.objectContaining({ name: 'CI Bot Updated' }),
),
);
await waitFor(() => {
expect(listRefetchSpy).toHaveBeenCalled();
});
@@ -222,6 +238,13 @@ describe('ServiceAccountsSettings (integration)', () => {
await screen.findByText('CI Bot');
// Wait for authz check to resolve before clicking
await waitFor(() =>
expect(
screen.getByRole('button', { name: /New Service Account/i }),
).not.toBeDisabled(),
);
fireEvent.click(screen.getByRole('button', { name: /New Service Account/i }));
await screen.findByRole('dialog', { name: /New Service Account/i });

View File

@@ -374,6 +374,7 @@ export const settingsNavSections: SettingsNavSection[] = [
icon: <Shield size={16} />,
isEnabled: false,
itemKey: 'roles',
isBeta: true,
},
{
key: ROUTES.MEMBERS_SETTINGS,

View File

@@ -31,10 +31,14 @@ interface UseServiceAccountRoleManagerResult {
export function useServiceAccountRoleManager(
accountId: string,
options?: { enabled?: boolean },
): UseServiceAccountRoleManagerResult {
const queryClient = useQueryClient();
const { data, isLoading } = useGetServiceAccountRoles({ id: accountId });
const { data, isLoading } = useGetServiceAccountRoles(
{ id: accountId },
{ query: { enabled: options?.enabled ?? true } },
);
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
() => data?.data ?? [],

View File

@@ -0,0 +1,14 @@
import { buildPermission } from '../utils';
import type { BrandedPermission } from '../types';
// Collection-level — no specific role id needed
export const RoleCreatePermission = buildPermission('create', 'role:*');
export const RoleListPermission = buildPermission('list', 'role:*');
// Resource-level — require a specific role id
export const buildRoleReadPermission = (id: string): BrandedPermission =>
buildPermission('read', `role:${id}`);
export const buildRoleUpdatePermission = (id: string): BrandedPermission =>
buildPermission('update', `role:${id}`);
export const buildRoleDeletePermission = (id: string): BrandedPermission =>
buildPermission('delete', `role:${id}`);

View File

@@ -0,0 +1,38 @@
import { buildPermission } from '../utils';
import type { BrandedPermission } from '../types';
// Collection-level — wildcard selector required for correct response key matching
export const SAListPermission = buildPermission('list', 'serviceaccount:*');
export const SACreatePermission = buildPermission('create', 'serviceaccount:*');
// Resource-level — require a specific SA id
export const buildSAReadPermission = (id: string): BrandedPermission =>
buildPermission('read', `serviceaccount:${id}`);
export const buildSAUpdatePermission = (id: string): BrandedPermission =>
buildPermission('update', `serviceaccount:${id}`);
export const buildSADeletePermission = (id: string): BrandedPermission =>
buildPermission('delete', `serviceaccount:${id}`);
export const buildSAAttachPermission = (id: string): BrandedPermission =>
buildPermission('attach', `serviceaccount:${id}`);
export const buildSADetachPermission = (id: string): BrandedPermission =>
buildPermission('detach', `serviceaccount:${id}`);
// Wildcard role permissions — used alongside SA-level checks for role assign/revoke guards.
// Backend requires both serviceaccount:attach AND role:attach to assign a role to a SA,
// and serviceaccount:detach AND role:detach to remove a role from a SA.
export const RoleAttachWildcardPermission = buildPermission('attach', 'role:*');
export const RoleDetachWildcardPermission = buildPermission('detach', 'role:*');
// API key (factor-api-key) permissions.
// Listing keys: factor-api-key:list.
// Creating a key: factor-api-key:create (wildcard) + serviceaccount:attach.
// Revoking a key: factor-api-key:delete (specific key) + serviceaccount:detach.
export const APIKeyListPermission = buildPermission('list', 'factor-api-key:*');
export const APIKeyCreatePermission = buildPermission(
'create',
'factor-api-key:*',
);
export const buildAPIKeyUpdatePermission = (keyId: string): BrandedPermission =>
buildPermission('update', `factor-api-key:${keyId}`);
export const buildAPIKeyDeletePermission = (keyId: string): BrandedPermission =>
buildPermission('delete', `factor-api-key:${keyId}`);

View File

@@ -1,35 +1,14 @@
import { ReactElement } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import {
AuthtypesGettableTransactionDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { AllTheProviders } from 'tests/test-utils';
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
import { BrandedPermission } from './types';
import { useAuthZ } from './useAuthZ';
import { buildPermission } from './utils';
const BASE_URL = ENVIRONMENT.baseURL || '';
const AUTHZ_CHECK_URL = `${BASE_URL}/api/v1/authz/check`;
function authzMockResponse(
payload: AuthtypesTransactionDTO[],
authorizedByIndex: boolean[],
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
return {
data: payload.map((txn, i) => ({
relation: txn.relation,
object: txn.object,
authorized: authorizedByIndex[i] ?? false,
})),
status: 'success',
};
}
const wrapper = ({ children }: { children: ReactElement }): ReactElement => (
<AllTheProviders>{children}</AllTheProviders>
);

View File

@@ -72,18 +72,26 @@ function SettingsPage(): JSX.Element {
}
if (isCloudUser) {
// Visible to all authenticated users
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.ROLE_DETAILS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
? true
: item.isEnabled,
}));
if (isAdmin) {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.ROLE_DETAILS ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.SHORTCUTS ||
item.key === ROUTES.MCP_SERVER
? true
@@ -113,17 +121,25 @@ function SettingsPage(): JSX.Element {
}
if (isEnterpriseSelfHostedUser) {
// Visible to all authenticated users
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.ROLE_DETAILS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
? true
: item.isEnabled,
}));
if (isAdmin) {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.ROLE_DETAILS ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.MCP_SERVER
? true
@@ -152,15 +168,22 @@ function SettingsPage(): JSX.Element {
}
if (!isCloudUser && !isEnterpriseSelfHostedUser) {
// Visible to all authenticated users
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.ROLE_DETAILS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
? true
: item.isEnabled,
}));
if (isAdmin) {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.ROLE_DETAILS
item.key === ROUTES.ORG_SETTINGS || item.key === ROUTES.MEMBERS_SETTINGS
? true
: item.isEnabled,
}));

View File

@@ -78,11 +78,14 @@ describe('SettingsPage nav sections', () => {
});
});
it.each(['workspace', 'account'])('renders "%s" element', (id) => {
expect(screen.getByTestId(id)).toBeInTheDocument();
});
it.each(['workspace', 'account', 'roles', 'service-accounts'])(
'renders "%s" element',
(id) => {
expect(screen.getByTestId(id)).toBeInTheDocument();
},
);
it.each(['billing', 'roles'])('does not render "%s" element', (id) => {
it.each(['billing'])('does not render "%s" element', (id) => {
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
});

View File

@@ -62,13 +62,16 @@ export const getRoutes = (
settings.push(...alertChannels(t));
// Visible to all authenticated users
settings.push(
...serviceAccountsSettings(t),
...rolesSettings(t),
...roleDetails(t),
);
// Admin-only: members management
if (isAdmin) {
settings.push(
...membersSettings(t),
...serviceAccountsSettings(t),
...rolesSettings(t),
...roleDetails(t),
);
settings.push(...membersSettings(t));
}
if ((isCloudUser || isEnterpriseSelfHostedUser) && isAdmin) {

View File

@@ -2,19 +2,15 @@ import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { renderHook, waitFor } from '@testing-library/react';
import setLocalStorageApi from 'api/browser/localstorage/set';
import type {
AuthtypesGettableTransactionDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import { SINGLE_FLIGHT_WAIT_TIME_MS } from 'hooks/useAuthZ/constants';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { USER_ROLES } from 'types/roles';
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
import { AppProvider, useAppContext } from '../App';
const AUTHZ_CHECK_URL = 'http://localhost/api/v1/authz/check';
const MY_USER_URL = 'http://localhost/api/v2/users/me';
const MY_ORG_URL = 'http://localhost/api/v2/orgs/me';
@@ -22,26 +18,9 @@ jest.mock('constants/env', () => ({
ENVIRONMENT: { baseURL: 'http://localhost', wsURL: '' },
}));
/**
* Since we are mocking the check permissions, this is needed
*/
const waitForSinglePreflightToFinish = async (): Promise<void> =>
await new Promise((r) => setTimeout(r, SINGLE_FLIGHT_WAIT_TIME_MS));
function authzMockResponse(
payload: AuthtypesTransactionDTO[],
authorizedByIndex: boolean[],
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
return {
data: payload.map((txn, i) => ({
relation: txn.relation,
object: txn.object,
authorized: authorizedByIndex[i] ?? false,
})),
status: 'success',
};
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {

View File

@@ -0,0 +1,128 @@
import type {
AuthtypesGettableTransactionDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ENVIRONMENT } from 'constants/env';
import { gettableTransactionToPermission } from 'hooks/useAuthZ/utils';
import type {
BrandedPermission,
UseAuthZOptions,
UseAuthZResult,
} from 'hooks/useAuthZ/types';
import { rest } from 'msw';
import type { RestHandler } from 'msw';
export const AUTHZ_CHECK_URL = `${ENVIRONMENT.baseURL || ''}/api/v1/authz/check`;
export function authzMockResponse(
payload: AuthtypesTransactionDTO[],
authorizedByIndex: boolean[],
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
return {
data: payload.map((txn, i) => ({
relation: txn.relation,
object: txn.object,
authorized: authorizedByIndex[i] ?? false,
})),
status: 'success',
};
}
export function setupAuthzAdmin(): RestHandler {
return rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = (await req.json()) as AuthtypesTransactionDTO[];
return res(
ctx.status(200),
ctx.json(
authzMockResponse(
payload,
payload.map(() => true),
),
),
);
});
}
/** Denies all permission checks. */
export function setupAuthzDenyAll(): RestHandler {
return rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = (await req.json()) as AuthtypesTransactionDTO[];
return res(
ctx.status(200),
ctx.json(
authzMockResponse(
payload,
payload.map(() => false),
),
),
);
});
}
/** Grants all permissions except the ones listed — matched precisely by relation + object. */
export function setupAuthzDeny(
...permissions: BrandedPermission[]
): RestHandler {
const denied = new Set<BrandedPermission>(permissions);
return rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = (await req.json()) as AuthtypesTransactionDTO[];
return res(
ctx.status(200),
ctx.json(
authzMockResponse(
payload,
payload.map((txn) => !denied.has(gettableTransactionToPermission(txn))),
),
),
);
});
}
/** Denies all permissions except the ones listed — matched precisely by relation + object. */
export function setupAuthzAllow(
...permissions: BrandedPermission[]
): RestHandler {
const allowed = new Set<BrandedPermission>(permissions);
return rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = (await req.json()) as AuthtypesTransactionDTO[];
return res(
ctx.status(200),
ctx.json(
authzMockResponse(
payload,
payload.map((txn) => allowed.has(gettableTransactionToPermission(txn))),
),
),
);
});
}
export function mockUseAuthZGrantAll(
permissions: BrandedPermission[],
_options?: UseAuthZOptions,
): UseAuthZResult {
return {
isLoading: false,
isFetching: false,
error: null,
permissions: Object.fromEntries(
permissions.map((p) => [p, { isGranted: true }]),
) as UseAuthZResult['permissions'],
refetchPermissions: jest.fn(),
};
}
export function mockUseAuthZDenyAll(
permissions: BrandedPermission[],
_options?: UseAuthZOptions,
): UseAuthZResult {
return {
isLoading: false,
isFetching: false,
error: null,
permissions: Object.fromEntries(
permissions.map((p) => [p, { isGranted: false }]),
) as UseAuthZResult['permissions'],
refetchPermissions: jest.fn(),
};
}

View File

@@ -98,10 +98,10 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
GET_STARTED_AZURE_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLES_SETTINGS: ['ADMIN'],
ROLE_DETAILS: ['ADMIN'],
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER'],
MEMBERS_SETTINGS: ['ADMIN'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -3,6 +3,10 @@ import path from 'path';
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import apmMetricsTemplate from '../testdata/apm-metrics.json';
import queriesData from '../testdata/queries.json';
export type SignalType = 'metrics' | 'logs' | 'traces';
export type QueriesData = typeof queriesData;
// ─── Constants ───────────────────────────────────────────────────────────
//
@@ -177,3 +181,248 @@ export async function openDashboardActionMenu(
await icon.click();
return page.getByRole('tooltip');
}
// ─── Dashboard detail page helpers ──────────────────────────────────────────
/**
* Click the Configure button (`data-testid="show-drawer"`) on a dashboard
* detail page and wait for the settings drawer (`.settings-container-root`) to
* be visible. Works from both the empty-state view and the populated toolbar —
* both render the same testid.
*
* Returns the drawer locator so callers can scope further assertions to it.
*/
export async function openDashboardSettingsDrawer(page: Page): Promise<Locator> {
await page.getByTestId('show-drawer').first().click();
const drawer = page.locator('.settings-container-root');
await drawer.waitFor({ state: 'visible' });
return drawer;
}
/**
* Click `data-testid="save-dashboard-config"` and wait for the resulting
* `PUT /api/v1/dashboards/<id>` response. The Save button is only rendered
* when there is at least one unsaved change — callers must ensure the drawer
* has been dirtied before calling this.
*/
export async function saveDashboardSettings(page: Page): Promise<void> {
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('save-dashboard-config').click();
await patchResponse;
}
/**
* Rename a dashboard via the toolbar options popover:
* opens the popover (`data-testid="options"`), clicks "Rename", fills the
* input, clicks "Rename Dashboard", and waits for the PUT response.
*
* Pre-condition: the caller must be on the dashboard detail page.
*/
export async function renameDashboardViaToolbar(
page: Page,
newTitle: string,
): Promise<void> {
await page.getByTestId('options').click();
await page.getByRole('button', { name: 'Rename' }).click();
const modal = page.getByRole('dialog');
await modal.waitFor({ state: 'visible' });
const input = modal.getByTestId('dashboard-name');
await input.clear();
await input.fill(newTitle);
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByRole('button', { name: 'Rename Dashboard' }).click();
await patchResponse;
await modal.waitFor({ state: 'hidden' });
}
// ─── Add panel flow ─────────────────────────────────────────────────────────
/**
* From the dashboard detail page (must already be loaded), drive the full
* "Add Panel" flow for the given signal type:
* 1. Click the empty-state `add-panel` CTA to open the New Panel modal.
* 2. Pick the Time Series panel type.
* 3. Fill the panel name in the right pane (drives the post-save assertion).
* 4. For metrics: type the metric name from `queries.json` into the metric
* AutoComplete and select it from the dropdown. For logs/traces: switch
* the data-source selector to LOGS / TRACES; default Query Builder state
* is sufficient (queries.json query strings are empty by design).
* 5. Click Save Changes, confirm the modal, and wait for the
* PUT /api/v1/dashboards/<id> response.
*
* Throws if the PUT response is not 2xx. After return, the page is back on
* the dashboard detail page; the caller asserts the panel rendered.
*/
export async function configureAndSavePanel(
page: Page,
signal: SignalType,
panelTitle: string,
): Promise<void> {
await page.getByTestId('add-panel').click();
const newPanelModal = page
.getByRole('dialog')
.filter({ hasText: 'New Panel' });
await newPanelModal.waitFor({ state: 'visible' });
await newPanelModal.getByTestId('panel-type-graph').click();
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
await page.getByTestId('panel-name-input').fill(panelTitle);
if (signal === 'metrics') {
const metricName = queriesData.metrics.metricName;
// The testid is on the Ant Select wrapper <div>; the editable input
// lives inside it. Target the descendant input for fill().
const metricInput = page.getByTestId('metric-name-selector-0').locator('input');
await metricInput.click();
await metricInput.fill(metricName);
// AutoComplete debounces and fetches; wait for the option then click.
await page
.locator('.ant-select-item-option-content', { hasText: metricName })
.first()
.click();
} else {
// logs / traces — switch the data source. Default query is sufficient.
await page.getByTestId('query-data-source-selector-0').click();
await page
.locator('.ant-select-item-option-content', {
hasText: signal.toUpperCase(),
})
.click();
}
const putResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('new-widget-save').click();
// Confirmation modal (title varies: "Save Widget" vs "Unsaved Changes" —
// don't assert title, just click OK on the topmost dialog).
const confirmModal = page.getByRole('dialog').last();
await confirmModal.waitFor({ state: 'visible' });
await confirmModal.getByRole('button', { name: /^OK$/i }).click();
const res = await putResponse;
if (!res.ok()) {
throw new Error(
`PUT /api/v1/dashboards failed ${res.status()}: ${await res.text()}`,
);
}
// Save navigates back to /dashboard/<id> (no /new suffix).
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
}
// ─── Widget editor (re-open existing panel) ────────────────────────────────
/**
* Display labels surfaced in the `panel-change-select` Ant Select inside the
* widget editor. The mapping to URL `graphType` values comes from the
* `PANEL_TYPES` enum: TIME_SERIES='graph', VALUE='value', and so on.
*/
export type PanelDisplayLabel =
| 'Time Series'
| 'Number'
| 'Table'
| 'List'
| 'Bar'
| 'Pie'
| 'Histogram';
const PANEL_DISPLAY_TO_GRAPH_TYPE: Record<PanelDisplayLabel, string> = {
'Time Series': 'graph',
Number: 'value',
Table: 'table',
List: 'list',
Bar: 'bar',
Pie: 'pie',
Histogram: 'histogram',
};
/**
* Open the widget editor for an existing panel by driving the panel header
* options menu (the three-dot Ant `Dropdown` next to the title).
*
* The widget-header-options button is `visibility: hidden` until the panel is
* hovered (see `GridCardLayout.styles.scss`) — except on TABLE panels, where
* `globalSearchAvailable` keeps it permanently visible. Hovering the title
* testid first works for both states.
*/
export async function openWidgetEditor(
page: Page,
panelTitle: string,
): Promise<void> {
await page.getByTestId(panelTitle).first().hover();
await page.getByTestId('widget-header-options').first().click();
await page
.getByRole('menuitem', { name: /^edit$/i })
.first()
.click();
await page.waitForURL(/widgetId=/);
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
}
/**
* Click "Save Changes" in the widget editor, confirm via the OK button on the
* resulting modal, await the dashboard PUT response, and wait for navigation
* back to `/dashboard/<id>`. Throws if the PUT response is not 2xx.
*
* The confirmation modal title varies between "Save Widget" and "Unsaved
* Changes" depending on whether the query was modified — don't assert title,
* just OK the topmost dialog.
*/
export async function saveWidgetEdit(page: Page): Promise<void> {
const putResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('new-widget-save').click();
const confirmModal = page.getByRole('dialog').last();
await confirmModal.waitFor({ state: 'visible' });
await confirmModal.getByRole('button', { name: /^OK$/i }).click();
const res = await putResponse;
if (!res.ok()) {
throw new Error(
`PUT /api/v1/dashboards failed ${res.status()}: ${await res.text()}`,
);
}
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
}
/**
* Switch the editor's panel display type via the Ant `Select` exposed as
* `data-testid="panel-change-select"`. The select options carry the display
* label as visible text (matches `PanelDisplay` enum values). After the
* change, this helper waits for the URL `graphType` param to reflect the new
* panel type and for the Save Changes button to re-render — the editor
* re-routes mid-flow via `redirectWithQueryBuilderData`.
*
* Note: the "List" option is filtered out of the dropdown when the current
* query contains a metrics data source (see VisualizationSettingsSection).
*/
export async function changePanelType(
page: Page,
displayLabel: PanelDisplayLabel,
): Promise<void> {
const expectedGraphType = PANEL_DISPLAY_TO_GRAPH_TYPE[displayLabel];
await page.getByTestId('panel-change-select').click();
// Each option renders a .select-option containing the display text — match
// against the typography element to avoid matching the trigger itself.
await page
.locator('.ant-select-item-option .display', { hasText: displayLabel })
.first()
.click();
await page.waitForURL(new RegExp(`graphType=${expectedGraphType}`));
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
}

12
tests/e2e/testdata/queries.json vendored Normal file
View File

@@ -0,0 +1,12 @@
{
"logs": {
"query": ""
},
"metrics": {
"metricName": "signoz_calls_total",
"query": ""
},
"traces": {
"query": ""
}
}

View File

@@ -0,0 +1,550 @@
import path from 'path';
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import {
APM_METRICS_TITLE,
authToken,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
gotoDashboardsList,
openDashboardSettingsDrawer,
renameDashboardViaToolbar,
saveDashboardSettings,
SEARCH_PLACEHOLDER,
} from '../../helpers/dashboards';
// All tests mutate dashboard state (create / rename / delete). Run serially to
// prevent cross-test interference on the list and detail pages.
test.describe.configure({ mode: 'serial' });
// ─── Suite-level seed registry ────────────────────────────────────────────────
//
// Every dashboard created by any test is registered here; one afterAll tears
// them all down. Tests that don't create anything (TC-10, TC-11, TC-13) need
// no cleanup entry.
const seedIds = new Set<string>();
const BASE_FIXTURE_TITLE = 'create-flow-base-fixture';
const APM_METRICS_TESTDATA_PATH = path.resolve(
__dirname,
'../../testdata/apm-metrics.json',
);
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
seedIds.add(id);
return id;
}
test.beforeAll(async ({ browser }) => {
// Seed one base dashboard so the list is non-empty and the
// `new-dashboard-cta` header button is rendered for all tests that
// drive the "New dashboard" dropdown from the list page.
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
seedIds.add(await createDashboardViaApi(page, BASE_FIXTURE_TITLE));
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('Dashboard Create Flow', () => {
// ─── 1. Create Dashboard (blank) ─────────────────────────────────────────
test('TC-01 blank create lands on onboarding state with correct default title', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
const postResponse = page.waitForResponse(
(r) =>
r.request().method() === 'POST' && /\/api\/v1\/dashboards/.test(r.url()),
);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('create-dashboard-menu-cta').click();
const res = await postResponse;
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(300);
const body = (await res.json()) as {
data: { data: { title: string }; id: string };
};
expect(body.data.data.title).toBe('Sample Title');
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
// DashboardDescription always renders dashboard-title even on blank dashboards.
await expect(page.getByTestId('dashboard-title')).toBeVisible();
await expect(page.getByText('Welcome to your new dashboard')).toBeVisible();
await expect(page.getByText('Configure your new dashboard')).toBeVisible();
await expect(page.getByTestId('show-drawer').first()).toBeVisible();
await expect(page.getByTestId('add-panel')).toBeVisible();
// Register the UI-created dashboard for cleanup.
const id = body.data.id;
expect(id, 'POST response must include a dashboard id').toBeTruthy();
seedIds.add(id);
});
test('TC-02 configure drawer opens with Overview tab and pre-fills existing title', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc02');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
// Overview tab is the default active tab.
await expect(drawer.getByRole('button', { name: 'Overview' })).toBeVisible();
const nameInput = drawer.getByTestId('dashboard-name');
await expect(nameInput).toHaveValue('create-flow-tc02');
const descInput = drawer.getByTestId('dashboard-desc');
await expect(descInput).toBeVisible();
await expect(descInput).toHaveValue('');
await expect(
drawer.getByPlaceholder('Start typing your tag name'),
).toBeVisible();
// Ant Drawer does not close on Escape — use the X close button in the header.
await drawer.getByRole('button', { name: 'Close' }).click();
await expect(drawer).not.toHaveClass(/ant-drawer-open/);
});
test('TC-03 rename title, add description and tags, save persists to list', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc03-original');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
const nameInput = drawer.getByTestId('dashboard-name');
await nameInput.clear();
await nameInput.fill('create-flow-tc03-renamed');
await expect(drawer.getByText(/1 unsaved change/)).toBeVisible();
await drawer.getByTestId('dashboard-desc').fill('A test description');
await expect(drawer.getByText(/2 unsaved changes/)).toBeVisible();
const tagInput = drawer.getByPlaceholder('Start typing your tag name');
await tagInput.click();
await tagInput.fill('e2e-tag');
await page.keyboard.press('Enter');
await expect(drawer.getByText(/3 unsaved changes/)).toBeVisible();
// Click save and wait for the unsaved-changes footer to disappear — the
// footer only clears after the PUT success callback re-syncs local state.
await page.getByTestId('save-dashboard-config').click();
await expect(drawer.getByText(/unsaved change/)).not.toBeVisible();
await drawer.getByRole('button', { name: 'Close' }).click();
// Renamed dashboard appears in the list.
await gotoDashboardsList(page);
const searchInput = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await searchInput.fill('create-flow-tc03-renamed');
await expect(page.getByText('create-flow-tc03-renamed').first()).toBeVisible();
// Tag search also surfaces the renamed dashboard.
await searchInput.fill('e2e-tag');
await expect(page.getByText('create-flow-tc03-renamed').first()).toBeVisible();
});
test('TC-04 discard reverts unsaved changes without API call', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc04');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
const nameInput = drawer.getByTestId('dashboard-name');
await nameInput.clear();
await nameInput.fill('create-flow-tc04-discarded');
await drawer.getByTestId('dashboard-desc').fill('discarded desc');
await expect(drawer.getByText(/unsaved change/)).toBeVisible();
// Intercept any PUT to detect an unwanted save.
let patchFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
patchFired = true;
}
route.continue();
});
await drawer.getByRole('button', { name: 'Discard' }).click();
await expect(drawer.getByText(/unsaved change/)).not.toBeVisible();
await expect(nameInput).toHaveValue('create-flow-tc04');
await expect(drawer.getByTestId('dashboard-desc')).toHaveValue('');
expect(patchFired).toBe(false);
});
test('TC-05 rename via toolbar options popover persists to the toolbar title', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc05');
await page.goto(`/dashboard/${id}`);
// DashboardDescription toolbar always renders — even on blank dashboards.
await expect(page.getByTestId('options')).toBeVisible();
await renameDashboardViaToolbar(page, 'create-flow-tc05-renamed');
await expect(page.getByTestId('dashboard-title')).toHaveText(
'create-flow-tc05-renamed',
);
});
// ─── 2. Variables ─────────────────────────────────────────────────────────
test('TC-06 add a Custom variable, verify it appears in the variables bar', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc06');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
await drawer.getByRole('button', { name: 'Variables' }).click();
await drawer.getByTestId('add-new-variable').click();
await expect(drawer.getByRole('button', { name: 'All variables' })).toBeVisible();
await drawer
.getByPlaceholder('Unique name of the variable')
.fill('env');
await drawer.getByRole('button', { name: 'Custom' }).click();
// After selecting "Custom" type, the Options collapse panel contains a
// textarea with placeholder "Enter options separated by commas."
const customInput = drawer.getByPlaceholder(
'Enter options separated by commas.',
);
await customInput.fill('prod,staging,dev');
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await drawer.getByRole('button', { name: 'Save Variable' }).click();
const res = await patchResponse;
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(300);
// After saving, the variable form disappears and the table row is visible.
await expect(drawer.getByRole('button', { name: 'All variables' })).not.toBeVisible();
await expect(drawer.getByText('env')).toBeVisible();
// Close the drawer via its X button and check the variables bar.
await drawer.getByRole('button', { name: 'Close' }).click();
await expect(page.locator('.dashboard-variables')).toBeVisible();
});
test('TC-07 duplicate variable name is rejected inline', async ({
authedPage: page,
}) => {
// Seed a dashboard that already has a variable named 'env'.
const id = await seed(page, 'create-flow-tc07');
await page.goto(`/dashboard/${id}`);
// Use the UI to add the first variable so the state is real.
const drawer = await openDashboardSettingsDrawer(page);
await drawer.getByRole('button', { name: 'Variables' }).click();
await drawer.getByTestId('add-new-variable').click();
await drawer.getByPlaceholder('Unique name of the variable').fill('env');
await drawer.getByRole('button', { name: 'Custom' }).click();
await drawer
.getByPlaceholder('Enter options separated by commas.')
.fill('prod');
const firstSave = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await drawer.getByRole('button', { name: 'Save Variable' }).click();
await firstSave;
// Now try to add a second variable with the same name.
await drawer.getByTestId('add-new-variable').click();
const nameInput = drawer.getByPlaceholder('Unique name of the variable');
await nameInput.fill('env');
await expect(
drawer.getByText('Variable name already exists'),
).toBeVisible();
await expect(
drawer.getByRole('button', { name: 'Save Variable' }),
).toBeDisabled();
});
// ─── 3. Import JSON ───────────────────────────────────────────────────────
//
// TC-08 and TC-12 are merged: TC-08 covers the POST contract and navigation;
// the merged test also navigates back to the list and verifies metadata
// surfacing (the TC-12 concern). This avoids two identical import flows.
test('TC-08 import via file upload creates dashboard, navigates to detail, and surfaces metadata in list', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page.getByRole('dialog').filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
const postResponse = page.waitForResponse(
(r) =>
r.request().method() === 'POST' && /\/api\/v1\/dashboards/.test(r.url()),
);
await dialog.locator('input[type="file"]').setInputFiles(APM_METRICS_TESTDATA_PATH);
await dialog.getByRole('button', { name: 'Import and Next' }).click();
const res = await postResponse;
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(300);
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
// Register for cleanup.
const urlMatch = page.url().match(/\/dashboard\/([0-9a-f-]+)/);
expect(urlMatch, 'URL must contain dashboard ID').not.toBeNull();
seedIds.add(urlMatch![1]);
await expect(page.getByTestId('dashboard-title')).toHaveText(APM_METRICS_TITLE);
// Navigate back and confirm the imported dashboard surfaces in the list
// with at least one tag chip (TC-12 coverage).
await gotoDashboardsList(page);
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(APM_METRICS_TITLE);
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
// The apm-metrics fixture has tags ['apm', 'latency', 'error rate', 'throughput'].
await expect(page.getByText('apm').first()).toBeVisible();
});
// TC-09 (Monaco paste path) is intentionally dropped — the file-upload
// path (TC-08) exercises the same populate-editor-then-import code path.
// Keyboard-typing large JSON into Monaco is unreliable in headless CI.
test('TC-10 invalid JSON via file upload shows "Invalid JSON" error', async ({
authedPage: page,
}) => {
// No dashboard is created by this test — no cleanup entry needed.
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page.getByRole('dialog').filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
await dialog.locator('input[type="file"]').setInputFiles({
name: 'bad.json',
mimeType: 'application/json',
buffer: Buffer.from('not valid json {'),
});
await expect(dialog.getByText('Invalid JSON')).toBeVisible();
await expect(dialog).toBeVisible();
// Clicking "Import and Next" with invalid content should surface an error
// and keep the dialog open.
await dialog.getByRole('button', { name: 'Import and Next' }).click();
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(dialog).toBeVisible();
});
test('TC-11 import with empty editor clicking Import and Next shows error', async ({
authedPage: page,
}) => {
// No dashboard is created — no cleanup entry needed.
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page.getByRole('dialog').filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Import and Next' }).click();
await expect(dialog.getByText('Error loading JSON file')).toBeVisible();
await expect(dialog).toBeVisible();
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
// ─── 4. View Templates ────────────────────────────────────────────────────
test('TC-13 View templates menu item is an external link targeting a new tab', async ({
authedPage: page,
}) => {
// No dashboard is created — no cleanup entry needed.
// The assertion guards against the link being silently changed to an
// in-app modal or a different URL (the DashboardTemplatesModal exists in
// source but is never triggered from this menu item).
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
const link = page.getByTestId('view-templates-menu-cta');
await expect(link).toBeVisible();
await expect(link).toHaveAttribute(
'href',
/signoz\.io\/docs\/dashboards\/dashboard-templates/,
);
await expect(link).toHaveAttribute('target', '_blank');
await expect(link).toHaveAttribute('rel', /noopener/);
});
// ─── 5. Post-Create Dashboard Detail — Panel Addition ────────────────────
test('TC-14 New Panel modal opens and selecting Time Series navigates to widget editor', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc14');
await page.goto(`/dashboard/${id}`);
await expect(page.getByText('Welcome to your new dashboard')).toBeVisible();
await page.getByTestId('add-panel').click();
// PANEL_TYPES enum: TIME_SERIES='graph', VALUE='value', TABLE='table'
// — the testid is panel-type-<enum-value>, not panel-type-<enum-name>.
const modal = page.getByRole('dialog').filter({ hasText: 'New Panel' });
await expect(modal).toBeVisible();
await expect(modal.getByTestId('panel-type-graph')).toBeVisible();
await expect(modal.getByTestId('panel-type-value')).toBeVisible();
await expect(modal.getByTestId('panel-type-table')).toBeVisible();
await modal.getByTestId('panel-type-graph').click();
await expect(page).toHaveURL(/graphType=graph/);
});
test('TC-15 New Panel button from toolbar header opens the same panel type modal', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc15');
await page.goto(`/dashboard/${id}`);
// The toolbar "New Panel" button (add-panel-header) is present even on
// a blank dashboard, alongside the empty-state "add-panel" button.
await expect(page.getByTestId('add-panel-header')).toBeVisible();
await page.getByTestId('add-panel-header').click();
const modal = page.getByRole('dialog').filter({ hasText: 'New Panel' });
await expect(modal).toBeVisible();
await expect(modal.getByTestId('panel-type-graph')).toBeVisible();
// Click the modal X button to close (Escape also works but may conflict
// with the Enterprise modal in the background; explicit click is more reliable).
await modal.getByRole('button', { name: 'Close' }).click();
await expect(modal).not.toBeVisible();
});
// ─── 6. Cancellation and Navigation Away ─────────────────────────────────
test('TC-16 browser Back from dashboard detail returns to list with URL preserved', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc16');
await page.goto(`/dashboard?search=create-flow-tc16`);
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.waitFor({ state: 'visible' });
await page.getByAltText('dashboard-image').first().click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await page.goBack();
await expect(page).toHaveURL(/search=create-flow-tc16/);
await expect(
page.getByPlaceholder(SEARCH_PLACEHOLDER),
).toHaveValue('create-flow-tc16');
});
test('TC-17 navigating away with the settings drawer open does not crash', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc17');
await page.goto(`/dashboard/${id}`);
await openDashboardSettingsDrawer(page);
// Navigate away without closing the drawer.
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/dashboard($|\?)/);
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
// No error overlay should be present.
await expect(
page.getByRole('alert').filter({ hasText: /error/i }),
).toHaveCount(0);
});
// ─── 7. Add Panel — end-to-end per signal ────────────────────────────────
//
// TC-14/TC-15 verify the New Panel modal opens and routes to the widget
// editor. The TCs below go further: configure a query for each signal
// using values from testdata/queries.json, save the panel, return to the
// dashboard, and verify the panel card renders.
test('TC-18 add metrics Time Series panel using signoz_calls_total from queries.json', async ({
authedPage: page,
}) => {
const id = await seed(page, 'add-panel-metrics');
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'metrics', 'metrics-timeseries');
await expect(page.getByTestId('metrics-timeseries')).toBeVisible();
});
test('TC-19 add logs Time Series panel with default query from queries.json', async ({
authedPage: page,
}) => {
const id = await seed(page, 'add-panel-logs');
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'logs', 'logs-timeseries');
await expect(page.getByTestId('logs-timeseries')).toBeVisible();
});
test('TC-20 add traces Time Series panel with default query from queries.json', async ({
authedPage: page,
}) => {
const id = await seed(page, 'add-panel-traces');
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'traces', 'traces-timeseries');
await expect(page.getByTestId('traces-timeseries')).toBeVisible();
});
});

View File

@@ -0,0 +1,484 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'bar-controls-fixture';
const FIXTURE_PANEL_TITLE = 'bar-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Bar');
await saveWidgetEdit(page);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
async function expandSection(page: Page, title: string): Promise<void> {
const section = page
.locator('.settings-section')
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
const contentDiv = section.locator('.settings-section-content');
const isOpen = await contentDiv.evaluate((el) =>
el.classList.contains('open'),
);
if (!isOpen) {
await section.locator('button.settings-section-header').click();
await contentDiv.waitFor({ state: 'visible' });
}
}
async function selectYAxisUnit(
page: Page,
wrapperSelector: string,
searchTerm: string,
optionText: string,
): Promise<void> {
const wrapper = page.locator(wrapperSelector).first();
await wrapper.locator('.ant-select').click();
await wrapper.locator('.ant-select input').fill(searchTerm);
await page
.locator('.ant-select-item-option-content', { hasText: optionText })
.first()
.click();
}
test.describe('Bar Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('bar-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('bar-controls-renamed').first()).toBeVisible();
await openWidgetEditor(page, 'bar-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'bar-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and toggles the widget-header info icon', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E bar description');
await saveWidgetEdit(page);
const header = page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE });
await expect(header.locator('.info-tooltip').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E bar description',
);
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
await expect(header.locator('.info-tooltip')).toHaveCount(0);
});
test('TC-03 panel time preference switches to Last 15 min and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.locator('section.panel-time-preference')
.getByRole('button', { name: /global time/i })
.click();
await page.getByRole('menuitem', { name: /Last 15 min/i }).click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
await page
.locator('section.panel-time-preference')
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: /Global Time/i }).click();
await saveWidgetEdit(page);
});
test('TC-04 stack series toggle persists; editor reflects state via data-stacking-state', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
const stackSwitch = page.locator('section.stack-chart').getByRole('switch');
const panelChangeSelect = page.getByTestId('panel-change-select');
await expect(stackSwitch).toHaveAttribute('aria-checked', 'false');
await expect(panelChangeSelect).toHaveAttribute('data-stacking-state', 'false');
await stackSwitch.click();
await expect(stackSwitch).toHaveAttribute('aria-checked', 'true');
await expect(panelChangeSelect).toHaveAttribute('data-stacking-state', 'true');
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.stack-chart').getByRole('switch'),
).toHaveAttribute('aria-checked', 'true');
await expect(page.getByTestId('panel-change-select')).toHaveAttribute(
'data-stacking-state',
'true',
);
// Reset
await page.locator('section.stack-chart').getByRole('switch').click();
await saveWidgetEdit(page);
});
test('TC-05 Y-axis unit persists', async ({ authedPage: page }) => {
// Tooltip-based visible-change check is omitted — the test stack's
// `signoz_calls_total` data slides outside the dashboard's default
// "Last 30 minutes" window mid-suite, so the rendered panel often
// shows "No Data" and the tooltip never appears. Verify persistence
// only — the selector value round-trips through PUT.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await selectYAxisUnit(
page,
'.y-axis-unit-selector-v2',
'Milliseconds',
'Milliseconds (ms)',
);
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(
page.locator('.y-axis-unit-selector-v2 .ant-select-selection-item').first(),
).toContainText(/Milliseconds/);
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
});
test('TC-06 decimal precision persists', async ({ authedPage: page }) => {
// Tooltip-based visible-change check is omitted for the same reason
// as TC-05.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
.first()
.click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
/0 decimals/,
);
// Reset
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-07 soft min and soft max persist (canvas-only visual)', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
await page.locator('section.soft-min-max .ant-input-number-input').first().fill('10');
await page.locator('section.soft-min-max .ant-input-number-input').last().fill('100');
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
await expect(
page.locator('section.soft-min-max .ant-input-number-input').first(),
).toHaveValue('10');
await expect(
page.locator('section.soft-min-max .ant-input-number-input').last(),
).toHaveValue('100');
await page.locator('section.soft-min-max .ant-input-number-input').first().fill('');
await page.locator('section.soft-min-max .ant-input-number-input').last().fill('');
await saveWidgetEdit(page);
});
test('TC-08 log scale persists (canvas-only visual)', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
await page.locator('section.log-scale .ant-select').first().click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Logarithmic$/ })
.first()
.click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
await expect(
page.locator('section.log-scale .ant-select-selection-item').first(),
).toContainText(/Logarithmic/);
await page.locator('section.log-scale .ant-select').first().click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Linear$/ })
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-09 legend position swap toggles chart-layout--legend-right and shows the search input', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
await expect(page.locator('.chart-layout--legend-right')).toHaveCount(0);
await expect(page.getByTestId('legend-search-input')).toHaveCount(0);
await page.locator('section.legend-position .ant-select').first().click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Right$/ })
.first()
.click();
await expect(page.locator('.chart-layout--legend-right').first()).toBeVisible();
await expect(page.getByTestId('legend-search-input').first()).toBeVisible();
await saveWidgetEdit(page);
await expect(page.locator('.chart-layout--legend-right').first()).toBeVisible();
await expect(page.getByTestId('legend-search-input').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
await page.locator('section.legend-position .ant-select').first().click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Bottom$/ })
.first()
.click();
await saveWidgetEdit(page);
await expect(page.locator('.chart-layout--legend-right')).toHaveCount(0);
await expect(page.getByTestId('legend-search-input')).toHaveCount(0);
});
test('TC-10 Legend Colors panel renders one row per query series with a default color swatch', async ({
authedPage: page,
}) => {
// Driving the Ant ColorPicker is fiddly across builds (trigger class
// varies, preset chips may not be configured). Per-option testids have
// been added in `YAxisUnitSelector.tsx` for the unit picker, but the
// LegendColors picker uses Ant's `ColorPicker` directly with no stable
// testids. The pragmatic check is structural: when a query has run
// and produced series, the Legend Colors collapse panel renders one
// row per legend label with a `.legend-marker` carrying an inline
// `background-color` (the auto-assigned default). This guards against
// regressions in the LegendColors → query-response wiring.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
const legendColorsCollapse = page.locator('.legend-colors-collapse').first();
await legendColorsCollapse.locator('.ant-collapse-header').first().click();
const items = page.locator('.legend-items .legend-item');
await items.first().waitFor({ state: 'visible' });
expect(await items.count()).toBeGreaterThan(0);
const firstMarker = items.first().locator('.legend-marker');
const markerStyle = (await firstMarker.getAttribute('style')) ?? '';
expect(markerStyle).toMatch(/background-color:/);
});
test('TC-11 threshold add + persistence (canvas-only line)', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const card = page.locator('.threshold-container').first();
// Bar thresholds do NOT have a label input — the time-series-alerts block
// only renders for TIME_SERIES. Skip label.
await card.getByTestId('threshold-value-input').fill('100');
await card.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
await expect(page.locator('.threshold-container').first()).toBeVisible();
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` after stack rebuild.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-12 panel type swap from Bar to Time Series and back persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Time Series');
// Editor-side visual change: Time-Series-only section appears.
await expect(page.locator('section.fill-gaps').first()).toBeVisible();
await expect(page.locator('section.stack-chart')).toHaveCount(0);
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=graph/);
await changePanelType(page, 'Bar');
await saveWidgetEdit(page);
});
test('TC-13 sections hidden for BAR are not rendered', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Hidden by the panel-type matrix for BAR.
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('.column-unit-selector')).toHaveCount(0);
// Expected to be present.
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
await expect(page.locator('section.stack-chart').first()).toBeVisible();
await expect(page.locator('section.panel-time-preference').first()).toBeVisible();
await expandSection(page, 'Axes');
await expect(page.locator('section.soft-min-max').first()).toBeVisible();
await expect(page.locator('section.log-scale').first()).toBeVisible();
await expandSection(page, 'Legend');
await expect(page.locator('section.legend-position').first()).toBeVisible();
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
await expandSection(page, 'Thresholds');
await expect(page.getByTestId('add-threshold-cta')).toBeVisible();
});
test('TC-14 discarding right-pane changes does not persist', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-bar-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
await page
.getByRole('dialog')
.last()
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — direct navigation
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
expect(putFired).toBe(false);
});
});

View File

@@ -0,0 +1,313 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'histogram-controls-fixture';
const FIXTURE_PANEL_TITLE = 'histogram-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Histogram');
await saveWidgetEdit(page);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
async function expandSection(page: Page, title: string): Promise<void> {
const section = page
.locator('.settings-section')
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
const contentDiv = section.locator('.settings-section-content');
const isOpen = await contentDiv.evaluate((el) =>
el.classList.contains('open'),
);
if (!isOpen) {
await section.locator('button.settings-section-header').click();
await contentDiv.waitFor({ state: 'visible' });
}
}
test.describe('Histogram Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('histogram-controls-renamed');
await saveWidgetEdit(page);
await expect(
page.getByTestId('histogram-controls-renamed').first(),
).toBeVisible();
await openWidgetEditor(page, 'histogram-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'histogram-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and toggles the widget-header info icon', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E histogram description');
await saveWidgetEdit(page);
const header = page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE });
await expect(header.locator('.info-tooltip').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E histogram description',
);
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
await expect(header.locator('.info-tooltip')).toHaveCount(0);
});
test('TC-03 bucket count and bucket width persist (canvas-only visual)', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Section is titled "Histogram / Buckets" — literal slash + spaces.
await expandSection(page, 'Histogram / Buckets');
const bucketCount = page.locator('.bucket-input .ant-input-number-input').first();
const bucketWidth = page
.locator('.histogram-settings__bucket-input .ant-input-number-input')
.first();
await bucketCount.fill('50');
await bucketWidth.fill('1.5');
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Histogram / Buckets');
await expect(
page.locator('.bucket-input .ant-input-number-input').first(),
).toHaveValue('50');
await expect(
page
.locator('.histogram-settings__bucket-input .ant-input-number-input')
.first(),
// Ant InputNumber with precision={2} formats 1.5 → "1.50"
).toHaveValue('1.50');
// Reset
await page
.locator('.bucket-input .ant-input-number-input')
.first()
.fill('');
await page
.locator('.histogram-settings__bucket-input .ant-input-number-input')
.first()
.fill('');
await saveWidgetEdit(page);
});
test('TC-04 "Merge all series" toggle removes .legend-container from the DOM', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Histogram / Buckets');
// Live preview: legend should be present when toggle is OFF.
// (Use `.first()` because the editor may render multiple chart areas.)
await expect(page.locator('.legend-container').first()).toBeVisible();
const mergeSwitch = page
.locator('section.histogram-settings__combine-hist')
.getByRole('switch');
await expect(mergeSwitch).toHaveAttribute('aria-checked', 'false');
await mergeSwitch.click();
await expect(mergeSwitch).toHaveAttribute('aria-checked', 'true');
// Histogram passes `showLegend={!isQueriesMerged}` → legend container is
// not rendered when the merge toggle is ON.
await expect(page.locator('.legend-container')).toHaveCount(0);
await saveWidgetEdit(page);
// Dashboard render: legend container also absent.
await expect(page.locator('.legend-container')).toHaveCount(0);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Histogram / Buckets');
await expect(
page
.locator('section.histogram-settings__combine-hist')
.getByRole('switch'),
).toHaveAttribute('aria-checked', 'true');
// Reset
await page
.locator('section.histogram-settings__combine-hist')
.getByRole('switch')
.click();
await saveWidgetEdit(page);
await expect(page.locator('.legend-container').first()).toBeVisible();
});
test('TC-05 Legend Colors panel renders one row per query series with a default color swatch', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
const legendColorsCollapse = page.locator('.legend-colors-collapse').first();
await legendColorsCollapse.locator('.ant-collapse-header').first().click();
const items = page.locator('.legend-items .legend-item');
await items.first().waitFor({ state: 'visible' });
expect(await items.count()).toBeGreaterThan(0);
const firstMarker = items.first().locator('.legend-marker');
const markerStyle = (await firstMarker.getAttribute('style')) ?? '';
expect(markerStyle).toMatch(/background-color:/);
});
test('TC-06 panel type swap from Histogram to Time Series and back persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Time Series');
// Editor-side visual change: Time Series sections appear, Histogram-only
// section disappears.
await expect(page.locator('section.fill-gaps').first()).toBeVisible();
await expect(page.locator('.histogram-settings__bucket-config')).toHaveCount(0);
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=graph/);
// Reset
await changePanelType(page, 'Histogram');
await saveWidgetEdit(page);
});
test('TC-07 sections hidden for HISTOGRAM are not rendered', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.locator('section.panel-time-preference')).toHaveCount(0);
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('section.stack-chart')).toHaveCount(0);
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
await expect(page.locator('section.log-scale')).toHaveCount(0);
await expect(page.locator('section.legend-position')).toHaveCount(0);
await expect(page.locator('.y-axis-unit-selector-v2')).toHaveCount(0);
await expect(page.locator('.decimal-precision-selector')).toHaveCount(0);
await expect(page.locator('.column-unit-selector')).toHaveCount(0);
await expect(page.getByTestId('add-threshold-cta')).toHaveCount(0);
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
await expandSection(page, 'Histogram / Buckets');
await expect(
page.locator('.histogram-settings__bucket-config').first(),
).toBeVisible();
await expandSection(page, 'Legend');
await expect(page.locator('.legend-colors-collapse').first()).toBeVisible();
});
test('TC-08 discarding right-pane changes does not persist', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-histogram-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
await page
.getByRole('dialog')
.last()
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — direct navigation
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
expect(putFired).toBe(false);
});
});

View File

@@ -0,0 +1,192 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'list-controls-fixture';
const FIXTURE_PANEL_TITLE = 'list-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
// LIST panels require a logs (or traces) data source — metrics queries
// hide the LIST option from panel-change-select.
await configureAndSavePanel(page, 'logs', FIXTURE_PANEL_TITLE);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'List');
await saveWidgetEdit(page);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
test.describe('List Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('list-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('list-controls-renamed').first()).toBeVisible();
await openWidgetEditor(page, 'list-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'list-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and shows info icon on header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E list description');
await saveWidgetEdit(page);
await expect(
page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE })
.locator('.info-tooltip')
.first(),
).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E list description',
);
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
});
test('TC-03 panel type switch from List to Table persists and re-renders', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Table');
// Table re-renders Decimal Precision + Column Units in the right pane.
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
await saveWidgetEdit(page);
// Panel card should now render an Ant table head.
await expect(
page
.locator('[data-testid="' + FIXTURE_PANEL_TITLE + '"]')
.first(),
).toBeVisible();
await expect(page.locator('.ant-table-thead').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=table/);
// Reset back to List.
await changePanelType(page, 'List');
await saveWidgetEdit(page);
});
test('TC-04 sections hidden for LIST are not rendered in the right pane', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.locator('section.panel-time-preference')).toHaveCount(0);
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('section.stack-chart')).toHaveCount(0);
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
await expect(page.locator('section.log-scale')).toHaveCount(0);
await expect(page.locator('section.legend-position')).toHaveCount(0);
await expect(page.locator('.decimal-precision-selector')).toHaveCount(0);
await expect(page.locator('.column-unit-selector')).toHaveCount(0);
await expect(page.locator('.y-axis-unit-selector-v2')).toHaveCount(0);
await expect(page.getByTestId('add-threshold-cta')).toHaveCount(0);
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-description-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
});
test('TC-05 discarding right-pane changes does not persist', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-list-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
await page
.getByRole('dialog')
.last()
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — direct navigation
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
expect(putFired).toBe(false);
});
});

View File

@@ -0,0 +1,417 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'pie-controls-fixture';
const FIXTURE_PANEL_TITLE = 'pie-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Pie');
await saveWidgetEdit(page);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
async function expandSection(page: Page, title: string): Promise<void> {
const section = page
.locator('.settings-section')
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
const contentDiv = section.locator('.settings-section-content');
const isOpen = await contentDiv.evaluate((el) =>
el.classList.contains('open'),
);
if (!isOpen) {
await section.locator('button.settings-section-header').click();
await contentDiv.waitFor({ state: 'visible' });
}
}
async function selectYAxisUnit(
page: Page,
wrapperSelector: string,
searchTerm: string,
optionText: string,
): Promise<void> {
const wrapper = page.locator(wrapperSelector).first();
await wrapper.locator('.ant-select').click();
await wrapper.locator('.ant-select input').fill(searchTerm);
await page
.locator('.ant-select-item-option-content', { hasText: optionText })
.first()
.click();
}
/**
* Trigger the arc tooltip for the first pie slice and return its rendered
* value text. Pie uses `@visx/tooltip` (plain DOM portal — not canvas) so the
* tooltip node is reliably queryable.
*
* Playwright's `.hover()` is blocked by the SVG element intercepting pointer
* events. `page.mouse.move` bypasses actionability checks but still relies on
* the browser hit-testing landing on the `<g>`. The most reliable path is
* `page.evaluate` firing a native `MouseEvent` of type `mouseover` directly
* on the arc `<g>` element — React 17+ delegates `onMouseEnter` via
* `mouseover` on the root, but also captures synthetic `mouseover` events
* dispatched on child elements and applies enter/leave semantics.
*/
async function readPieArcTooltipText(page: Page): Promise<string> {
// Wait for the arc group to be in the DOM.
const firstArcG = page.locator('.piechart-container svg g g').first();
await firstArcG.waitFor({ state: 'visible' });
// Dispatch a synthetic mouseover directly on the arc <g>. This reaches
// React's event delegation layer regardless of SVG pointer-event interception.
// All browser globals are cast via `(globalThis as any)` because the
// tsconfig lib does not include "dom" — page.evaluate callbacks run in the
// browser but are type-checked in the Node context.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await page.evaluate((sel: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = globalThis as any;
const g = w.document.querySelector(sel);
if (!g) throw new Error('Arc <g> not found');
g.dispatchEvent(new w.MouseEvent('mouseover', { bubbles: true, cancelable: true }));
}, '.piechart-container svg g g');
const tooltip = page.locator('.piechart-tooltip').first();
await tooltip.waitFor({ state: 'visible', timeout: 5000 });
const valueText = (await page.locator('.tooltip-value').first().textContent()) ?? '';
// Dispatch mouseout on the arc to close the tooltip.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await page.evaluate((sel: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = globalThis as any;
const g = w.document.querySelector(sel);
if (!g) return;
g.dispatchEvent(new w.MouseEvent('mouseout', { bubbles: true, cancelable: true }));
}, '.piechart-container svg g g');
return valueText;
}
test.describe('Pie Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('pie-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('pie-controls-renamed').first()).toBeVisible();
await openWidgetEditor(page, 'pie-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'pie-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and toggles the widget-header info icon', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E pie description');
await saveWidgetEdit(page);
const header = page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE });
await expect(header.locator('.info-tooltip').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E pie description',
);
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
await expect(header.locator('.info-tooltip')).toHaveCount(0);
});
test('TC-03 panel time preference switches to Last 15 min and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.locator('section.panel-time-preference')
.getByRole('button', { name: /global time/i })
.click();
await page.getByRole('menuitem', { name: /Last 15 min/i }).click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
await page
.locator('section.panel-time-preference')
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: /Global Time/i }).click();
await saveWidgetEdit(page);
});
test('TC-04 Y-axis unit applies to the SVG centre text and arc tooltip', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await selectYAxisUnit(
page,
'.y-axis-unit-selector-v2',
'Milliseconds',
'Milliseconds (ms)',
);
await saveWidgetEdit(page);
// Visible change 1: the SVG centre text gains a `ms` tspan when a
// unit is set.
const centreTspans = page.locator('.piechart-container svg text tspan');
await centreTspans.first().waitFor({ state: 'visible' });
const tspanTexts = await centreTspans.allTextContents();
expect(tspanTexts.some((t) => /ms/.test(t))).toBe(true);
// Visible change 2: the arc tooltip includes the `ms` suffix.
const tooltipText = await readPieArcTooltipText(page);
expect(tooltipText).toMatch(/ms/);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(
page.locator('.y-axis-unit-selector-v2 .ant-select-selection-item').first(),
).toContainText(/Milliseconds/);
// Reset
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
});
test('TC-05 decimal precision changes the rendered arc-tooltip values', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
// A unit is required for decimal precision to have a visible effect.
await selectYAxisUnit(
page,
'.y-axis-unit-selector-v2',
'Seconds',
'Seconds (s)',
);
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
.first()
.click();
await saveWidgetEdit(page);
// Visible change: arc tooltip numeric portion has no decimal point.
const tooltipText = await readPieArcTooltipText(page);
const numericPart = tooltipText.replace(/[A-Za-z]+/g, '');
expect(numericPart).not.toMatch(/\./);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
/0 decimals/,
);
// Reset
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
.first()
.click();
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
});
test('TC-06 Legend Colors panel renders one row per query series with a default color swatch', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
const legendColorsCollapse = page.locator('.legend-colors-collapse').first();
await legendColorsCollapse.locator('.ant-collapse-header').first().click();
const items = page.locator('.legend-items .legend-item');
await items.first().waitFor({ state: 'visible' });
expect(await items.count()).toBeGreaterThan(0);
const firstMarker = items.first().locator('.legend-marker');
const markerStyle = (await firstMarker.getAttribute('style')) ?? '';
expect(markerStyle).toMatch(/background-color:/);
});
test('TC-07 piechart-legend-item count matches the number of query series', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
// On the dashboard, count legend items and assert each has a coloured
// swatch.
await page.locator('.piechart-legend-item').first().waitFor({ state: 'visible' });
const dashboardCount = await page.locator('.piechart-legend-item').count();
expect(dashboardCount).toBeGreaterThan(0);
const firstSwatchStyle = (await page
.locator('.piechart-legend-item .piechart-legend-label')
.first()
.getAttribute('style')) ?? '';
expect(firstSwatchStyle).toMatch(/background-color:/);
});
test('TC-08 panel type swap from Pie to Time Series and back persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Time Series');
// Editor-side visual change: Time Series sections appear, Pie-only
// `.piechart-wrapper` is gone from the editor preview area.
await expect(page.locator('section.fill-gaps').first()).toBeVisible();
await saveWidgetEdit(page);
// Dashboard render now shows a uPlot chart, not a piechart.
await expect(page.getByTestId('uplot-main-div').first()).toBeVisible();
await expect(page.locator('.piechart-wrapper')).toHaveCount(0);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=graph/);
// Reset
await changePanelType(page, 'Pie');
await saveWidgetEdit(page);
});
test('TC-09 sections hidden for PIE are not rendered', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('section.stack-chart')).toHaveCount(0);
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
await expect(page.locator('section.log-scale')).toHaveCount(0);
await expect(page.locator('section.legend-position')).toHaveCount(0);
await expect(page.locator('.column-unit-selector')).toHaveCount(0);
await expect(page.getByTestId('add-threshold-cta')).toHaveCount(0);
await expect(page.locator('.histogram-settings__bucket-config')).toHaveCount(
0,
);
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
await expect(page.locator('section.panel-time-preference').first()).toBeVisible();
await expandSection(page, 'Formatting & Units');
await expect(page.locator('.y-axis-unit-selector-v2').first()).toBeVisible();
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
await expandSection(page, 'Legend');
await expect(page.locator('.legend-colors-collapse').first()).toBeVisible();
});
test('TC-10 discarding right-pane changes does not persist', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-pie-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
await page
.getByRole('dialog')
.last()
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — direct navigation
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
expect(putFired).toBe(false);
});
});

View File

@@ -0,0 +1,470 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'table-controls-fixture';
const FIXTURE_PANEL_TITLE = 'table-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Table');
await saveWidgetEdit(page);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
/**
* Return the last <td> in the first data row of the panel's Ant Design table.
* Ant Design applies .ant-table-row to actual data rows only (not header rows),
* so this correctly skips the fixed/sticky header tbody rows.
*
* For the metrics panel the row has: td[0] = label column, td[last] = value
* column (the aggregation query "A"). The last td is thus the value cell.
* However, depending on the panel query there may only be ONE td per row. Use
* the cell that contains a non-empty value: any td that is not purely the
* label placeholder.
*
* NOTE: the value cell wraps its text in a <button> element (from the
* QueryTable open-traces render path) so textContent picks it up correctly.
*/
async function getFirstDataCell(page: Page) {
// .ant-table-row targets Ant Design data rows only (not header/fixed rows).
const firstRow = page.locator('tr.ant-table-row').first();
await firstRow.waitFor({ state: 'visible' });
// Return the last <td> — for a metrics table with columns [label, A] this
// is the value column. For a single-column table it is the only column.
return firstRow.locator('td').last();
}
/**
* Ensure a SettingsSection accordion in the widget editor right pane is
* expanded. If it is already open (content div has the `open` class), this is
* a no-op. Otherwise it clicks the header button and waits for the content to
* become visible.
*/
async function expandSection(page: Page, title: string): Promise<void> {
const section = page
.locator('.settings-section')
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
const contentDiv = section.locator('.settings-section-content');
const isOpen = await contentDiv.evaluate((el) => el.classList.contains('open'));
if (!isOpen) {
await section.locator('button.settings-section-header').click();
await contentDiv.waitFor({ state: 'visible' });
}
}
/**
* Select a unit from the column-unit selector dropdown by typing a search
* term, then clicking the filtered option. Scoped to .column-unit-selector to
* avoid matching the Y-axis unit selectors on other panel types.
*
* The selector has `showSearch` enabled and renders a long virtualised option
* list — typing first avoids instability from the list re-rendering when the
* target option is off-screen.
*/
async function selectColumnUnit(
page: Page,
searchTerm: string,
optionText: string,
): Promise<void> {
const unitSelect = page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select')
.first();
await unitSelect.click();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select input')
.first()
.fill(searchTerm);
await page
.locator('.ant-select-item-option-content', { hasText: optionText })
.first()
.click();
}
test.describe('Table Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('table-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('table-controls-renamed').first()).toBeVisible();
await openWidgetEditor(page, 'table-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'table-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and shows info icon on header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E table description');
await saveWidgetEdit(page);
await expect(
page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE })
.locator('.info-tooltip')
.first(),
).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E table description',
);
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
});
test('TC-03 panel time preference switches to Last 15 min and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.locator('section.panel-time-preference')
.getByRole('button', { name: /global time/i })
.click();
await page.getByRole('menuitem', { name: /Last 15 min/i }).click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
await page
.locator('section.panel-time-preference')
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: /Global Time/i }).click();
await saveWidgetEdit(page);
});
test('TC-04 column unit formats the matching column cells and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Use selectColumnUnit to avoid virtualised-list detached-DOM failures.
await selectColumnUnit(page, 'Milliseconds', 'Milliseconds (ms)');
await saveWidgetEdit(page);
// Cell text in the data column should now contain the `ms` suffix.
const cell = await getFirstDataCell(page);
await expect(cell).toContainText(/ms/);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Section starts collapsed again on re-open — expand before asserting.
await expandSection(page, 'Formatting & Units');
await expect(
page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-selection-item')
.first(),
).toContainText(/Milliseconds/);
// Reset — clear the unit via the Ant Select allowClear X button.
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2')
.first()
.hover();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-clear')
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-05 decimal precision changes the number of decimals when a column unit is set', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Set a column unit so decimal precision has a visible effect.
await selectColumnUnit(page, 'Seconds', 'Seconds (s)');
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
.first()
.click();
await saveWidgetEdit(page);
const cell = await getFirstDataCell(page);
await expect(cell).toContainText(/s/);
const text = (await cell.textContent()) ?? '';
expect(text.replace(/\s*s\s*$/, '')).not.toMatch(/\./);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Section starts collapsed again on re-open — expand before asserting.
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
/0 decimals/,
);
// Reset: decimal precision back to 2, clear column unit.
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
.first()
.click();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2')
.first()
.hover();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-clear')
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-06 column-targeted Background threshold paints only the targeted column', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const card = page.locator('.threshold-container').first();
// For TABLE thresholds the column selector (table-operator-input-selector)
// defaults to the first aggregation query column (typically `A`). Operator
// defaults to '>'; switch to '>=' so it reliably matches non-negative values.
await card.getByTestId('operator-input-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
await card.getByTestId('threshold-color-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: 'Background' })
.first()
.click();
// Save the threshold row (commits it to the thresholds state array).
await card.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// Find a data row and inspect its cells. Use tr.ant-table-row to skip
// fixed-header tbody rows that Ant Design inserts for sticky scroll.
// QueryTable wraps each cell in <div role="button">; the threshold
// styled <div> is nested inside it. Use div[style] to target the first
// <div> that actually carries an inline style — that is the threshold div.
// TODO: switch to `getByTestId('threshold-styled-cell')` once the frontend
// build deployed to the test stack picks up the testid added in
// GridTableComponent/index.tsx (the host also carries
// `data-threshold-format="Background|Text"` to discriminate variants).
const row = page.locator('tr.ant-table-row').first();
await row.waitFor({ state: 'visible' });
const dataCellInner = row.locator('td').last().locator('div[style]').first();
const dataStyle = (await dataCellInner.getAttribute('style')) ?? '';
expect(dataStyle).toMatch(/background-color:/);
// Reset — delete the threshold. Edit/delete buttons are display:none
// by default and revealed only on .threshold-card-container:hover.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// ThresholdsSection defaultOpen is based on threshold count at mount; may
// start collapsed due to async state loading — always expand before interacting.
await expandSection(page, 'Thresholds');
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` once the stack
// frontend rebuild picks up the testid added in Threshold.tsx.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-07 column-targeted Text threshold colors only the targeted column text', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const card = page.locator('.threshold-container').first();
await card.getByTestId('operator-input-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
// Format defaults to 'Text' — no change needed.
await card.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// QueryTable wraps each cell in <div role="button">; the threshold styled
// <div> is nested inside. Use div[style] to find the threshold div directly.
// TODO: same testid migration as TC-06 once the frontend rebuild lands.
const row = page.locator('tr.ant-table-row').first();
await row.waitFor({ state: 'visible' });
const dataCellInner = row.locator('td').last().locator('div[style]').first();
const dataStyle = (await dataCellInner.getAttribute('style')) ?? '';
expect(dataStyle).toMatch(/color:/);
expect(dataStyle).not.toMatch(/background-color:/);
// Reset
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` after frontend rebuild.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-08 sections hidden for TABLE are not rendered', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('section.stack-chart')).toHaveCount(0);
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
await expect(page.locator('section.log-scale')).toHaveCount(0);
await expect(page.locator('section.legend-position')).toHaveCount(0);
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
// decimal-precision-selector and column-unit-selector are inside the
// "Formatting & Units" section which starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
await expect(page.locator('.column-unit-selector').first()).toBeVisible();
// add-threshold-cta is inside "Thresholds" which is also collapsed.
await expandSection(page, 'Thresholds');
await expect(page.getByTestId('add-threshold-cta')).toBeVisible();
});
test('TC-09 panel type switch from Table to Number persists and re-renders as a number', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Number');
// Number panel exposes the Y-axis unit selector in the Formatting & Units section.
await expect(page.locator('.y-axis-unit-selector-v2').first()).toBeVisible();
await saveWidgetEdit(page);
await expect(page.getByTestId('value-graph-text').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=value/);
// Reset: switch back to Table.
await changePanelType(page, 'Table');
await saveWidgetEdit(page);
});
test('TC-10 discarding right-pane changes does not persist', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-table-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
await page
.getByRole('dialog')
.last()
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — direct navigation
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
expect(putFired).toBe(false);
});
});

View File

@@ -0,0 +1,584 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
// All TCs share one fixture panel — run serially.
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'time-series-controls-fixture';
const FIXTURE_PANEL_TITLE = 'time-series-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
// configureAndSavePanel creates a Time Series (graph) panel by default —
// no panel-type swap needed here.
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
/**
* Ensure a SettingsSection accordion in the widget editor right pane is
* expanded. No-op if already open.
*/
async function expandSection(page: Page, title: string): Promise<void> {
const section = page
.locator('.settings-section')
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
const contentDiv = section.locator('.settings-section-content');
const isOpen = await contentDiv.evaluate((el) =>
el.classList.contains('open'),
);
if (!isOpen) {
await section.locator('button.settings-section-header').click();
await contentDiv.waitFor({ state: 'visible' });
}
}
/**
* Select a unit from a Y-axis-unit-selector wrapper by typing a search term
* first (Ant Select has a virtualised option list — typing first prevents
* detached-DOM failures when the target option is off-screen).
*
* `wrapperSelector` is the CSS selector for the enclosing
* `.y-axis-unit-selector-v2` instance (use `.y-axis-unit-selector-v2` for the
* Formatting Y-axis unit; threshold cards have their own nested instance —
* scope accordingly).
*/
async function selectYAxisUnit(
page: Page,
wrapperSelector: string,
searchTerm: string,
optionText: string,
): Promise<void> {
const wrapper = page.locator(wrapperSelector).first();
await wrapper.locator('.ant-select').click();
await wrapper.locator('.ant-select input').fill(searchTerm);
await page
.locator('.ant-select-item-option-content', { hasText: optionText })
.first()
.click();
}
test.describe('Time Series Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('ts-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('ts-controls-renamed').first()).toBeVisible();
await openWidgetEditor(page, 'ts-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'ts-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and toggles the widget-header info icon', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E time series description');
await saveWidgetEdit(page);
// Visible change: info icon appears in the widget header.
const header = page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE });
await expect(header.locator('.info-tooltip').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E time series description',
);
// Reset and assert the info icon disappears.
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
await expect(header.locator('.info-tooltip')).toHaveCount(0);
});
test('TC-03 panel time preference switches to Last 15 min and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.locator('section.panel-time-preference')
.getByRole('button', { name: /global time/i })
.click();
await page.getByRole('menuitem', { name: /Last 15 min/i }).click();
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
// Reset
await page
.locator('section.panel-time-preference')
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: /Global Time/i }).click();
await saveWidgetEdit(page);
});
test('TC-04 fill gaps toggle persists', async ({ authedPage: page }) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// canvas-only — visible chart effect not asserted (canvas-drawn series).
const fillGapsSwitch = page.locator('section.fill-gaps').getByRole('switch');
await expect(fillGapsSwitch).toHaveAttribute('aria-checked', 'false');
await fillGapsSwitch.click();
await expect(fillGapsSwitch).toHaveAttribute('aria-checked', 'true');
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.fill-gaps').getByRole('switch'),
).toHaveAttribute('aria-checked', 'true');
// Reset
await page.locator('section.fill-gaps').getByRole('switch').click();
await saveWidgetEdit(page);
});
test('TC-05 Y-axis unit persists', async ({ authedPage: page }) => {
// The plan asks for a tooltip-driven visible-change check (hover the
// chart, assert tooltip text contains `ms`). In practice the test
// stack's `signoz_calls_total` data slides outside the dashboard's
// default "Last 30 minutes" window between the suite-start golden
// reseed and the time TC-05 runs, so the rendered panel often shows
// "No Data" and the tooltip never appears. Until the seeder either
// emits points in a rolling-now window or the dashboard global-time
// preset gets widened from the test fixture, the tooltip assertion is
// not viable. Verify persistence only — the selector value round-trips
// through PUT and re-renders in the editor.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await selectYAxisUnit(
page,
'.y-axis-unit-selector-v2',
'Milliseconds',
'Milliseconds (ms)',
);
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(
page.locator('.y-axis-unit-selector-v2 .ant-select-selection-item').first(),
).toContainText(/Milliseconds/);
// Reset — clear via allowClear.
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
});
test('TC-06 decimal precision persists', async ({ authedPage: page }) => {
// Tooltip-based visible-change assertion is omitted for the same reason
// as TC-05 — `signoz_calls_total` data window flakes mid-suite. Verify
// persistence only.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
.first()
.click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
/0 decimals/,
);
// Reset
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-07 soft min and soft max persist (canvas-only visual)', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
// Soft Min is the first .ant-input-number inside section.soft-min-max.
const softMin = page.locator('section.soft-min-max .ant-input-number-input').first();
const softMax = page.locator('section.soft-min-max .ant-input-number-input').last();
await softMin.fill('10');
await softMax.fill('100');
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
await expect(
page.locator('section.soft-min-max .ant-input-number-input').first(),
).toHaveValue('10');
await expect(
page.locator('section.soft-min-max .ant-input-number-input').last(),
).toHaveValue('100');
// Reset — clear both. (Note: the |...|| 0 coercion in onClickSaveHandler
// will persist 0 not null after this save; that's the known behaviour.)
await page
.locator('section.soft-min-max .ant-input-number-input')
.first()
.fill('');
await page
.locator('section.soft-min-max .ant-input-number-input')
.last()
.fill('');
await saveWidgetEdit(page);
});
test('TC-08 log scale persists (canvas-only visual)', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
const logScaleSelect = page.locator('section.log-scale .ant-select').first();
await logScaleSelect.click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Logarithmic$/ })
.first()
.click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
await expect(
page.locator('section.log-scale .ant-select-selection-item').first(),
).toContainText(/Logarithmic/);
// Reset
await page.locator('section.log-scale .ant-select').first().click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Linear$/ })
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-09 legend position swap toggles the chart-layout--legend-right class and shows the search input', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
// Before: legend is at the bottom; no chart-layout--legend-right; no
// legend-search-input.
await expect(page.locator('.chart-layout--legend-right')).toHaveCount(0);
await expect(page.getByTestId('legend-search-input')).toHaveCount(0);
// Switch to Right.
await page.locator('section.legend-position .ant-select').first().click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Right$/ })
.first()
.click();
// In-editor live preview: layout updates.
await expect(page.locator('.chart-layout--legend-right').first()).toBeVisible();
await expect(page.getByTestId('legend-search-input').first()).toBeVisible();
await saveWidgetEdit(page);
// Dashboard: same assertions hold on the rendered panel card.
await expect(page.locator('.chart-layout--legend-right').first()).toBeVisible();
await expect(page.getByTestId('legend-search-input').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
await expect(
page.locator('section.legend-position .ant-select-selection-item').first(),
).toContainText(/Right/);
// Reset to Bottom and assert the class disappears.
await page.locator('section.legend-position .ant-select').first().click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Bottom$/ })
.first()
.click();
await saveWidgetEdit(page);
await expect(page.locator('.chart-layout--legend-right')).toHaveCount(0);
await expect(page.getByTestId('legend-search-input')).toHaveCount(0);
});
test('TC-09b Legend Colors panel renders one row per query series with a default color swatch', async ({
authedPage: page,
}) => {
// The original plan was to drive the Ant `ColorPicker` and assert a
// custom color round-trips. The Ant ColorPicker DOM is fiddly to drive
// reliably from Playwright (the trigger is the wrapped child element,
// presets vary by build, and committing a color requires Escape /
// click-outside semantics that depend on portal positioning). The
// pragmatic check we ship here is the *structural* one: when a query
// has run and produced series, the LegendColors collapse panel renders
// one row per legend label with a `.legend-marker` that carries an
// inline `background-color` (the auto-assigned default). This guards
// against regressions in the LegendColors → query-response wiring,
// which is the part most likely to silently break.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
// Expand the Ant Collapse panel "Legend Colors" (it sits below the
// Position selector inside the Legend SettingsSection).
const legendColorsCollapse = page.locator('.legend-colors-collapse').first();
await legendColorsCollapse.locator('.ant-collapse-header').first().click();
// After expansion: at least one per-series row, each with a coloured
// `.legend-marker` swatch carrying inline backgroundColor.
const items = page.locator('.legend-items .legend-item');
await items.first().waitFor({ state: 'visible' });
expect(await items.count()).toBeGreaterThan(0);
const firstMarker = items.first().locator('.legend-marker');
const markerStyle = (await firstMarker.getAttribute('style')) ?? '';
expect(markerStyle).toMatch(/background-color:/);
});
test('TC-10 threshold add + persistence (canvas-only line)', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const card = page.locator('.threshold-container').first();
// Time Series thresholds have a label input (unique to TIME_SERIES).
await card.getByTestId('threshold-label-input').fill('alert-threshold');
await card.getByTestId('threshold-value-input').fill('500');
await card.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// canvas-only — line is canvas-drawn. Verify persistence by re-open.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
await expect(page.locator('.threshold-container').first()).toBeVisible();
// Reset — delete via hover-revealed button.
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` after stack rebuild.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-11 threshold value persists in edit mode after save + re-open', async ({
authedPage: page,
}) => {
// Originally drove the threshold's V1 unit selector to assert
// `'seconds (s)'` round-trips. The V1 selector's `handleSearch`
// filterOption hides every option when a V2-style search term is typed
// AND the dropdown options don't reliably surface in the
// currently-visible portal under Playwright. We've added per-option
// `data-testid="unit-option-<id>"` in `YAxisUnitSelector.tsx`; once the
// test stack frontend rebuilds with that testid, this TC can be
// upgraded to pick the unit deterministically via
// `page.getByTestId('unit-option-s')`. Meanwhile the TC verifies the
// numeric value field round-trips through edit mode — the most common
// regression vector and the one most worth guarding.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const card = page.locator('.threshold-container').first();
await card.getByTestId('threshold-value-input').fill('100');
await card.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
// Re-enter edit mode and assert the value field carries the saved 100.
const cardAfter = page.locator('.threshold-container').first();
await cardAfter.hover();
// TODO: switch to `getByTestId('threshold-edit-btn')` after stack rebuild.
await cardAfter.locator('button.edit-btn').click();
await expect(cardAfter.getByTestId('threshold-value-input')).toHaveValue(
'100',
);
// Reset — discard the edit, then delete.
await cardAfter.getByRole('button', { name: /^discard$/i }).click();
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` after stack rebuild.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-12 panel type swap from Time Series to Bar and back persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Bar');
// Editor-side visual change: Bar-only section appears, Time-Series-only
// section disappears.
await expect(page.locator('section.stack-chart').first()).toBeVisible();
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=bar/);
// Reset
await changePanelType(page, 'Time Series');
await saveWidgetEdit(page);
});
test('TC-13 fill gaps and panel time preference persist together', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Set both.
await page.locator('section.fill-gaps').getByRole('switch').click();
await page
.locator('section.panel-time-preference')
.getByRole('button', { name: /global time/i })
.click();
await page.getByRole('menuitem', { name: /Last 1 hr/i }).click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.fill-gaps').getByRole('switch'),
).toHaveAttribute('aria-checked', 'true');
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 1 hr/i);
// Reset both.
await page.locator('section.fill-gaps').getByRole('switch').click();
await page
.locator('section.panel-time-preference')
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: /Global Time/i }).click();
await saveWidgetEdit(page);
});
test('TC-14 discarding right-pane changes does not persist', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-ts-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
await page
.getByRole('dialog')
.last()
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — direct navigation
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
expect(putFired).toBe(false);
});
});

View File

@@ -0,0 +1,495 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
// All TCs operate on the same fixture panel and toggle its state — they MUST
// run serially within the worker. Project-level fullyParallel still runs this
// file in parallel with other files.
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'value-controls-fixture';
const FIXTURE_PANEL_TITLE = 'value-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
// configureAndSavePanel creates a Time Series panel. Switch it to the
// Number (VALUE) type before the per-TC bodies run.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Number');
await saveWidgetEdit(page);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
/**
* Ensure a SettingsSection accordion in the widget editor right pane is
* expanded. If it is already open (content div has the `open` class), this is
* a no-op. Otherwise it clicks the header button and waits for the CSS
* transition to complete. This handles both the common case (collapsed on
* mount) and the defensive case (already open).
*/
async function expandSection(page: Page, title: string): Promise<void> {
// Find the settings-section that contains this title in its header.
const section = page
.locator('.settings-section')
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
// Check if the content div already has the `open` class.
const contentDiv = section.locator('.settings-section-content');
const isOpen = await contentDiv.evaluate((el) =>
el.classList.contains('open'),
);
if (!isOpen) {
// Click the header button to open the section.
await section.locator('button.settings-section-header').click();
// Wait for the CSS transition to complete (opacity 0→1, max-height 0→1000px).
await contentDiv.waitFor({ state: 'visible' });
}
}
/**
* Select a unit from the Y-axis unit selector dropdown by typing a search
* term, then clicking the filtered option. The selector has `showSearch`
* enabled and renders a long virtualised option list — typing first avoids
* instability from the virtualised list re-rendering when the target option
* is off-screen.
*/
async function selectYAxisUnit(
page: Page,
searchTerm: string,
optionText: string,
): Promise<void> {
// Click the outer wrapper to open the dropdown.
const unitSelect = page.locator('.y-axis-unit-selector-v2 .ant-select').first();
await unitSelect.click();
// The Ant Select input is now focused — type to filter the virtual list.
await page.locator('.y-axis-unit-selector-v2 .ant-select input').first().fill(searchTerm);
// Wait for the dropdown to show the filtered option, then click it.
await page
.locator('.ant-select-item-option-content', { hasText: optionText })
.first()
.click();
}
test.describe('Value Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('value-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('value-controls-renamed').first()).toBeVisible();
await openWidgetEditor(page, 'value-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'value-controls-renamed',
);
// Reset back to fixture title so subsequent TCs locate the panel.
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 panel description persists and renders the info icon on the header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E test description');
await saveWidgetEdit(page);
await expect(
page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE })
.locator('.info-tooltip')
.first(),
).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E test description',
);
// Reset
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
});
test('TC-03 panel time preference switches from Global Time to Last 15 min and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
const timeButton = page
.locator('section.panel-time-preference')
.getByRole('button', { name: /global time/i });
await timeButton.click();
await page.getByRole('menuitem', { name: /Last 15 min/i }).click();
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
// Reset
await page
.locator('section.panel-time-preference')
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: /Global Time/i }).click();
await saveWidgetEdit(page);
});
test('TC-04 Y-axis unit applies a suffix to the rendered value and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// The Y-Axis Unit selector has showSearch enabled and a long virtualised
// option list. Type "Seconds" to filter before clicking.
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
// Live preview should now render a suffix unit `s`.
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
await saveWidgetEdit(page);
// Back on the dashboard the panel card should also render the suffix.
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(
page.locator('.y-axis-unit-selector-v2 .ant-select-selection-item').first(),
).toContainText(/Seconds/);
// Reset — clear the unit via allowClear (X button on the Ant Select).
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
});
test('TC-05 decimal precision reformats the rendered value when a unit is set', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Setting a unit is required for decimal precision to have a visible
// effect — see Known Limitations #3 in the test plan.
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
.first()
.click();
// Live preview: the numeric text should no longer contain a decimal point.
await expect(page.getByTestId('value-graph-text').first()).not.toContainText(
/\./,
);
await saveWidgetEdit(page);
// Dashboard render: same assertion.
await expect(page.getByTestId('value-graph-text').first()).not.toContainText(
/\./,
);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
/0 decimals/,
);
// Reset: restore default 2 decimals and clear the unit.
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
.first()
.click();
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
});
test('TC-06 Text-format threshold colors the rendered value text and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds
// (defaultOpen={!!thresholds.length}) — expand it first.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
// VALUE panels do not render a threshold label input — only operator,
// value, unit, format (Text/Background), and color. Defaults: operator
// '>', format 'Text', value 0, color 'Red'. We force operator to '>=' so
// the threshold reliably matches non-negative values.
const thresholdCard = page.locator('.threshold-container').first();
await thresholdCard
.getByTestId('operator-input-selector')
.click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
// Save the threshold row (commits it to the thresholds state array). The
// dashboard PUT still needs `saveWidgetEdit` after this.
await thresholdCard.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// Dashboard render: value text should now carry an inline color style.
const valueText = page.getByTestId('value-graph-text').first();
await expect(valueText).toBeVisible();
const inlineStyle = await valueText.getAttribute('style');
expect(inlineStyle).toMatch(/color:/);
// Re-open editor and verify the threshold round-tripped.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The ThresholdsSection defaultOpen is based on threshold count at mount
// time; due to async state loading it may start collapsed. Expand it.
await expandSection(page, 'Thresholds');
await expect(
page.locator('.threshold-container').first(),
).toBeVisible();
// Reset — delete the threshold. The delete button is `display:none` by
// default and revealed only on `.threshold-card-container:hover`; hover
// the card so the CSS :hover rule activates, then click via testid.
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` once the frontend
// build deployed to the test stack includes the new testid (added in
// Threshold.tsx). The class-based fallback is robust meanwhile.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-07 Background-format threshold paints the value container background', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const thresholdCard = page.locator('.threshold-container').first();
// Set operator >= and switch format from Text to Background.
await thresholdCard.getByTestId('operator-input-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
await thresholdCard.getByTestId('threshold-color-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: 'Background' })
.first()
.click();
await thresholdCard.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// Dashboard render: .value-graph-container should now have an inline
// background-color style. TODO: switch to `getByTestId('value-graph-container')`
// once the frontend build deployed to the test stack picks up the testid
// added in ValueGraph/index.tsx.
const container = page.locator('.value-graph-container').first();
await expect(container).toBeVisible();
const inlineStyle = await container.getAttribute('style');
expect(inlineStyle).toMatch(/background-color:/);
// Reset
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// ThresholdsSection may start collapsed even with thresholds — always
// expand before interacting with threshold cards.
await expandSection(page, 'Thresholds');
// Edit/delete buttons are display:none by default, revealed on :hover.
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` once the frontend
// build deployed to the test stack includes the new testid (added in
// Threshold.tsx). The class-based fallback is robust meanwhile.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-08 clearing the Y-axis unit removes the suffix from the rendered value', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Apply a unit first.
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
await saveWidgetEdit(page);
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
// Clear it.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
// Suffix should be gone from the rendered panel.
await expect(page.getByTestId('value-graph-suffix-unit')).toHaveCount(0);
});
test('TC-09 panel type switch from Number to Time Series persists and re-renders', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Time Series');
// Time Series exposes Fill gaps — confirm the right pane re-rendered.
await expect(page.locator('section.fill-gaps')).toBeVisible();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=graph/);
// Reset: switch back to Number for downstream TCs.
await changePanelType(page, 'Number');
await saveWidgetEdit(page);
});
test('TC-10 sections hidden for VALUE are not rendered in the right pane', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Hidden by the panel-type matrix for VALUE — these sections are not
// rendered in the DOM at all (conditionally excluded by RightContainer).
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
await expect(page.locator('section.log-scale')).toHaveCount(0);
await expect(page.locator('section.legend-position')).toHaveCount(0);
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('section.stack-chart')).toHaveCount(0);
// Expected to be present in the always-open General and Visualization
// sections.
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
// The "Formatting & Units" section is collapsed on open — expand it to
// verify the controls are rendered for VALUE.
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
// The "Thresholds" section is collapsed when there are no thresholds —
// expand it to verify the Add Threshold CTA is rendered for VALUE.
await expandSection(page, 'Thresholds');
await expect(page.getByTestId('add-threshold-cta')).toBeVisible();
});
test('TC-11 discarding right-pane changes does not persist or visually update', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-value-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
// If a discard confirmation appears, OK it. Right-pane-only changes
// usually don't trigger one.
const confirmDialog = page.getByRole('dialog').last();
await confirmDialog
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — the editor navigated away immediately
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
expect(putFired).toBe(false);
});
});