Compare commits

..

9 Commits

Author SHA1 Message Date
SagarRajput-7
d35db1b02b chore(test): refactored custom domain and generel settings tests 2026-05-14 06:55:07 +05:30
SagarRajput-7
0556e67739 chore(test): refactored memebrs and sa tests 2026-05-14 06:36:46 +05:30
SagarRajput-7
79ea20b371 chore(test): fixes in roledetailspage test 2026-05-14 05:45:13 +05:30
SagarRajput-7
2bbd11c181 chore(test): fix, unskip and refactor platform surfaces tests 2026-05-14 05:38:06 +05:30
SagarRajput-7
0f9891d1c1 chore(test): fix, unskip and refactor platform surfaces tests 2026-05-14 04:33:46 +05:30
Ashutosh Sharma
b236a29a99 fix(time-picker): disable browser autofill on time selection input (#11247)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
Browser autofill was interfering with the custom time selection input.
Added autoComplete='off' to prevent browser from suggesting values.

Fixes #5875
2026-05-13 20:24:35 +00:00
Jatinderjit Singh
828459ab30 fix missing icon for nodata alerts (#11292) 2026-05-13 19:44:58 +00:00
Jatinderjit Singh
b572e30045 fix(alerts): invalidate rule cache after disable/enable toggle (#11295)
* fix(alerts): invalidate rule cache after disable/enable toggle

The toggle calls patchRulePartial but never invalidated the
useGetRuleByID react-query cache. With refetchOnMount disabled on
the edit page, saving the form sent back the stale disabled value
and reverted the toggle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* reuse existing function to invalidate rule cache

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 19:42:36 +00:00
Piyush Singariya
f1ce804629 fix: remove returnSpansFrom from rawexport e2e (#11278)
* fix: remove returnSpansFrom from rawexportE2E

* fix: make returnSpansFrom optional

* fix: test assertion
2026-05-13 13:27:50 +00:00
33 changed files with 233 additions and 485 deletions

View File

@@ -596,6 +596,7 @@ function CustomTimePicker({
>
<Input
ref={inputRef}
autoComplete="off"
className={cx(
'timeSelection-input',
inputStatus === CustomTimePickerInputStatus.ERROR ? 'error' : '',

View File

@@ -50,7 +50,6 @@ import {
import { JsonView } from 'periscope/components/JsonView';
import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -218,17 +217,20 @@ function LogDetailInner({
const logBody = useMemo(() => {
if (!isBodyJsonQueryEnabled) {
return (log?.body as string) ?? '';
return log?.body || '';
}
// Feature enabled: body is always a map; message is always a string
const bodyObj = log?.body as ILogBody;
if (!bodyObj) {
return '';
try {
const json = JSON.parse(log?.body || '');
if (typeof json?.message === 'string' && json.message !== '') {
return json.message;
}
return log?.body || '';
} catch {
return log?.body || '';
}
if (bodyObj.message) {
return bodyObj.message;
}
return JSON.stringify(bodyObj);
}, [isBodyJsonQueryEnabled, log?.body]);
const htmlBody = useMemo(

View File

@@ -9,10 +9,7 @@ import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
// hooks
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -102,7 +99,7 @@ function RawLogView({
// Check if body is selected
const showBody = selectedFields.some((field) => field.name === 'body');
if (showBody) {
parts.push(`${attributesText} ${getBodyDisplayString(data.body)}`);
parts.push(`${attributesText} ${data.body}`);
} else {
parts.push(attributesText);
}

View File

@@ -2,10 +2,7 @@ import type { ReactElement } from 'react';
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
@@ -90,7 +87,7 @@ export function useLogsTableColumns({
? {
id: 'body',
header: 'Body',
accessorFn: (log): string => getBodyDisplayString(log.body),
accessorFn: (log): string => log.body,
canBeHidden: false,
width: { default: '100%', min: 300 },
cell: ({ value, isActive }): ReactElement => (

View File

@@ -1,6 +1,6 @@
import { GetHosts200 } from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import CustomDomainSettings from '../CustomDomainSettings';
@@ -44,18 +44,20 @@ const mockHostsResponse: GetHosts200 = {
};
describe('CustomDomainSettings', () => {
beforeEach(() => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
);
});
afterEach(() => {
server.resetHandlers();
mockToastCustom.mockClear();
});
it('renders active host URL in the trigger button', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
);
render(<CustomDomainSettings />);
// The active host is the non-default one (custom-host)
@@ -63,20 +65,11 @@ describe('CustomDomainSettings', () => {
});
it('opens edit modal when clicking the edit button', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/custom-host\.test\.cloud/i);
await user.click(
screen.getByRole('button', { name: /edit workspace link/i }),
);
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
expect(
screen.getByRole('dialog', { name: /edit workspace link/i }),
@@ -89,28 +82,20 @@ describe('CustomDomainSettings', () => {
let capturedBody: Record<string, unknown> = {};
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
rest.put(ZEUS_HOSTS_ENDPOINT, async (req, res, ctx) => {
capturedBody = await req.json<Record<string, unknown>>();
return res(ctx.status(200), ctx.json({}));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/custom-host\.test\.cloud/i);
await user.click(
screen.getByRole('button', { name: /edit workspace link/i }),
);
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
// The input is inside the modal — find it by its role
const input = screen.getByRole('textbox');
await user.clear(input);
await user.type(input, 'myteam');
await user.click(screen.getByRole('button', { name: /apply changes/i }));
fireEvent.change(input, { target: { value: 'myteam' } });
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
await waitFor(() => {
expect(capturedBody).toStrictEqual({ name: 'myteam' });
@@ -119,9 +104,6 @@ describe('CustomDomainSettings', () => {
it('shows contact support option when domain update returns 409', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
rest.put(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(409),
@@ -130,18 +112,14 @@ describe('CustomDomainSettings', () => {
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/custom-host\.test\.cloud/i);
await user.click(
screen.getByRole('button', { name: /edit workspace link/i }),
);
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
const input = screen.getByRole('textbox');
await user.clear(input);
await user.type(input, 'myteam');
await user.click(screen.getByRole('button', { name: /apply changes/i }));
fireEvent.change(input, { target: { value: 'myteam' } });
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
await expect(
screen.findByRole('button', { name: /contact support/i }),
@@ -149,24 +127,14 @@ describe('CustomDomainSettings', () => {
});
it('shows validation error when subdomain is less than 3 characters', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/custom-host\.test\.cloud/i);
await user.click(
screen.getByRole('button', { name: /edit workspace link/i }),
);
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
const input = screen.getByRole('textbox');
await user.clear(input);
await user.type(input, 'ab');
await user.click(screen.getByRole('button', { name: /apply changes/i }));
fireEvent.change(input, { target: { value: 'ab' } });
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
expect(
screen.getByText(/minimum 3 characters required/i),
@@ -174,19 +142,12 @@ describe('CustomDomainSettings', () => {
});
it('shows all workspace URLs as links in the dropdown', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/custom-host\.test\.cloud/i);
// Open the URL dropdown
await user.click(
fireEvent.click(
screen.getByRole('button', { name: /custom-host\.test\.cloud/i }),
);
@@ -207,26 +168,19 @@ describe('CustomDomainSettings', () => {
it('calls toast.custom with new URL after successful domain update', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
rest.put(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({})),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/custom-host\.test\.cloud/i);
await user.click(
screen.getByRole('button', { name: /edit workspace link/i }),
);
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
const input = screen.getByRole('textbox');
await user.clear(input);
await user.type(input, 'myteam');
await user.click(screen.getByRole('button', { name: /apply changes/i }));
fireEvent.change(input, { target: { value: 'myteam' } });
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
// Verify toast.custom was called
await waitFor(() => {
@@ -243,12 +197,6 @@ describe('CustomDomainSettings', () => {
describe('Workspace Name rendering', () => {
it('renders org displayName when available from appContext', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
);
render(<CustomDomainSettings />, undefined, {
appContextOverrides: {
org: [{ id: 'xyz', displayName: 'My Org Name', createdAt: 0 }],
@@ -259,12 +207,6 @@ describe('CustomDomainSettings', () => {
});
it('falls back to customDomainSubdomain when org displayName is missing', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
);
render(<CustomDomainSettings />, undefined, {
appContextOverrides: { org: [] },
});

View File

@@ -1,12 +1,6 @@
import setRetentionApiV2 from 'api/settings/setRetentionV2';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { IDiskType } from 'types/api/disks/getDisks';
import {
PayloadPropsLogs,
@@ -115,8 +109,6 @@ describe('GeneralSettings - S3 Logs Retention', () => {
describe('Test 1: S3 Enabled - Only Days in Dropdown', () => {
it('should show only Days option for S3 retention and send correct API payload', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<GeneralSettings
metricsTtlValuesPayload={mockMetricsRetention}
@@ -159,8 +151,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
fireEvent.click(document.body);
// Change S3 retention value to 5 days
await user.clear(s3Input);
await user.type(s3Input, '5');
fireEvent.change(s3Input, { target: { value: '5' } });
// Find the save button in the Logs row
const saveButton = logsRow.querySelector(
@@ -217,8 +208,6 @@ describe('GeneralSettings - S3 Logs Retention', () => {
describe('Test 2: S3 Disabled - Field Hidden', () => {
it('should hide S3 retention field and send empty S3 values to API', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<GeneralSettings
metricsTtlValuesPayload={mockMetricsRetention}
@@ -245,7 +234,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
const totalDropdown = logsRow.querySelector(
'.ant-select-selector',
) as HTMLElement;
await user.click(totalDropdown);
fireEvent.mouseDown(totalDropdown);
// Wait for dropdown options to appear
await waitFor(() => {
@@ -259,11 +248,10 @@ describe('GeneralSettings - S3 Logs Retention', () => {
opt.textContent?.includes('Days'),
);
expect(daysOption).toBeInTheDocument();
await user.click(daysOption as HTMLElement);
fireEvent.click(daysOption as HTMLElement);
// Now change the value
await user.clear(totalInput);
await user.type(totalInput, '60');
fireEvent.change(totalInput, { target: { value: '60' } });
// Find the save button
const saveButton = logsRow.querySelector(
@@ -277,14 +265,14 @@ describe('GeneralSettings - S3 Logs Retention', () => {
});
// Click save button
await user.click(saveButton);
fireEvent.click(saveButton);
// Wait for modal to appear
const okButton = await screen.findByRole('button', { name: /ok/i });
expect(okButton).toBeInTheDocument();
// Click OK button
await user.click(okButton);
fireEvent.click(okButton);
// Verify API was called with empty S3 values (60 days)
await waitFor(() => {
@@ -333,8 +321,6 @@ describe('GeneralSettings - S3 Logs Retention', () => {
describe('Test 4: Save Button State with S3 Disabled', () => {
it('should disable save button when cold_storage_ttl_days is -1 and no changes made', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<GeneralSettings
metricsTtlValuesPayload={mockMetricsRetention}
@@ -365,8 +351,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
const totalInput = inputs[0] as HTMLInputElement;
// Change total retention value to trigger button enable
await user.clear(totalInput);
await user.type(totalInput, '60');
fireEvent.change(totalInput, { target: { value: '60' } });
// Button should now be enabled after change
await waitFor(() => {
@@ -374,8 +359,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
});
// Revert to original value (30 days displays as 1 Month)
await user.clear(totalInput);
await user.type(totalInput, '1');
fireEvent.change(totalInput, { target: { value: '1' } });
// Button should be disabled again (back to original state)
await waitFor(() => {

View File

@@ -19,7 +19,6 @@ import {
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { isArray } from 'lodash-es';
import { getBodyDisplayString } from 'container/LogDetailedView/utils';
import {
ChevronDown,
ChevronLeft,
@@ -179,7 +178,7 @@ export default function Events({
(event): EventDataType => ({
timestamp: event.timestamp,
severity: event.data.severity_text,
body: getBodyDisplayString(event.data.body),
body: event.data.body,
id: event.data.id,
key: event.data.id,
resources_string: event.data.resources_string,

View File

@@ -1,6 +1,8 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { useQueryClient } from 'react-query';
import { patchRulePartial } from 'api/alerts/patchRulePartial';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { invalidateGetRuleByID } from 'api/generated/services/rules';
import type {
RenderErrorResponseDTO,
RuletypesRuleDTO,
@@ -28,6 +30,7 @@ function ToggleAlertState({
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const queryClient = useQueryClient();
const onToggleHandler = async (
id: string,
@@ -60,6 +63,9 @@ function ToggleAlertState({
loading: false,
payload: updatedRule,
}));
invalidateGetRuleByID(queryClient, { id });
notifications.success({
message: 'Success',
});

View File

@@ -14,7 +14,7 @@ import { ILog } from 'types/api/logs/log';
import { ActionItemProps } from './ActionItem';
import TableView from './TableView';
import { getBodyDisplayString, removeEscapeCharacters } from './utils';
import { removeEscapeCharacters } from './utils';
import './Overview.styles.scss';
@@ -112,7 +112,7 @@ function Overview({
children: (
<div className="logs-body-content">
<MEditor
value={removeEscapeCharacters(getBodyDisplayString(logData.body))}
value={removeEscapeCharacters(logData.body)}
language="json"
options={options}
onChange={(): void => {}}

View File

@@ -106,20 +106,10 @@ function TableView({
isListViewPanel,
]);
// When USE_JSON_BODY is enabled, body arrives as a pre-parsed object. Serialize it
// back to a string so flattenObject keeps `body` as a single table row instead of
// recursively expanding it into dotted sub-keys (body.message, body.foo.bar, …),
// which would break the tree view in BodyContent that relies on record.field === 'body'.
const flattenLogData: Record<string, string> | null = useMemo(() => {
if (!logData) {
return null;
}
const normalizedLog =
typeof logData.body === 'object' && logData.body !== null
? { ...logData, body: JSON.stringify(logData.body) }
: logData;
return flattenObject(normalizedLog);
}, [logData]);
const flattenLogData: Record<string, string> | null = useMemo(
() => (logData ? flattenObject(logData) : null),
[logData],
);
const handleClick = (
operator: string,

View File

@@ -10,7 +10,7 @@ const MAX_BODY_BYTES = 100 * 1024; // 100 KB
// Hook for async JSON processing
const useAsyncJSONProcessing = (
value: string | Record<string, unknown>,
value: string,
shouldProcess: boolean,
handleChangeSelectedView?: ChangeViewFunctionType,
): {
@@ -40,17 +40,11 @@ const useAsyncJSONProcessing = (
return (): void => {};
}
// When value is already a parsed object skip the size check and JSON parsing
const parseBody = (): Record<string, unknown> | null => {
if (typeof value === 'object' && value !== null) {
return value as Record<string, unknown>;
}
const byteSize = new Blob([value as string]).size;
if (byteSize > MAX_BODY_BYTES) {
return null;
}
return recursiveParseJSON(value as string);
};
// Avoid processing if the json is too large
const byteSize = new Blob([value]).size;
if (byteSize > MAX_BODY_BYTES) {
return (): void => {};
}
processingRef.current = true;
setJsonState({ isLoading: true, treeData: null, error: null });
@@ -59,8 +53,8 @@ const useAsyncJSONProcessing = (
const processAsync = (): void => {
setTimeout(() => {
try {
const parsedBody = parseBody();
if (parsedBody && !isEmpty(parsedBody)) {
const parsedBody = recursiveParseJSON(value);
if (!isEmpty(parsedBody)) {
const treeData = jsonToDataNodes(parsedBody, {
isBodyJsonQueryEnabled,
handleChangeSelectedView,
@@ -88,8 +82,8 @@ const useAsyncJSONProcessing = (
// eslint-disable-next-line sonarjs/no-identical-functions
(): void => {
try {
const parsedBody = parseBody();
if (parsedBody && !isEmpty(parsedBody)) {
const parsedBody = recursiveParseJSON(value);
if (!isEmpty(parsedBody)) {
const treeData = jsonToDataNodes(parsedBody, {
isBodyJsonQueryEnabled,
handleChangeSelectedView,

View File

@@ -4,11 +4,7 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { MetricsType } from 'container/MetricsApplication/constant';
import dompurify from 'dompurify';
import { uniqueId } from 'lodash-es';
import {
ILog,
ILogAggregateAttributesResources,
ILogBody,
} from 'types/api/logs/log';
import { ILog, ILogAggregateAttributesResources } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FORBID_DOM_PURIFY_ATTR, FORBID_DOM_PURIFY_TAGS } from 'utils/app';
@@ -437,24 +433,3 @@ export const getSanitizedLogBody = (
return '{}';
}
};
// Returns a plain string for display contexts (Monaco editor, table cells, raw log row).
export function getBodyDisplayString(body: string | ILogBody): string {
return typeof body === 'string' ? body : JSON.stringify(body as ILogBody);
}
// Returns the primary "message" text for compact log row previews.
export function getBodyMessage(
body: string | ILogBody,
isBodyJsonEnabled: boolean,
): string {
if (!isBodyJsonEnabled) {
return (body as string) ?? '';
}
// Feature enabled: body is always a map; message is always a string
const msg = (body as ILogBody).message;
if (msg) {
return msg;
}
return JSON.stringify(body);
}

View File

@@ -1,6 +1,6 @@
import type { TypesUserDTO } from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
import { fireEvent, render, screen } from 'tests/test-utils';
import MembersSettings from '../MembersSettings';
@@ -76,32 +76,27 @@ describe('MembersSettings (integration)', () => {
});
it('filters to pending invites via the filter dropdown', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MembersSettings />);
await screen.findByText('Alice Smith');
await user.click(screen.getByRole('button', { name: /all members/i }));
fireEvent.click(screen.getByRole('button', { name: /all members/i }));
const pendingOption = await screen.findByText(/pending invites/i);
await user.click(pendingOption);
fireEvent.click(pendingOption);
await screen.findByText('charlie@signoz.io');
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();
});
it('filters members by name using the search input', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MembersSettings />);
await screen.findByText('Alice Smith');
await user.type(
screen.getByPlaceholderText(/Search by name or email/i),
'bob',
);
fireEvent.change(screen.getByPlaceholderText(/Search by name or email/i), {
target: { value: 'bob' },
});
await screen.findByText('Bob Jones');
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();
@@ -109,31 +104,25 @@ describe('MembersSettings (integration)', () => {
});
it('opens EditMemberDrawer when an active member row is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MembersSettings />);
await user.click(await screen.findByText('Alice Smith'));
fireEvent.click(await screen.findByText('Alice Smith'));
await screen.findByText('Member Details');
});
it('opens EditMemberDrawer when a deleted member row is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MembersSettings />);
await user.click(await screen.findByText('Dave Deleted'));
fireEvent.click(await screen.findByText('Dave Deleted'));
expect(screen.queryByText('Member Details')).toBeInTheDocument();
});
it('opens InviteMembersModal when "Invite member" button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MembersSettings />);
await user.click(screen.getByRole('button', { name: /invite member/i }));
fireEvent.click(screen.getByRole('button', { name: /invite member/i }));
await expect(
screen.findAllByPlaceholderText('john@signoz.io'),

View File

@@ -117,8 +117,7 @@ describe('CreateEdit Modal', () => {
});
});
// Todo: to fixed properly - failing with - due to timeout > 5000ms
describe.skip('Form Validation', () => {
describe('Form Validation', () => {
it('shows validation error when submitting without required fields', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -127,7 +126,7 @@ describe('CreateEdit Modal', () => {
const configureButtons = await screen.findAllByRole('button', {
name: /configure/i,
});
await user.click(configureButtons[0]);
fireEvent.click(configureButtons[0]);
const saveButton = await screen.findByRole('button', {
name: /save changes/i,
@@ -338,11 +337,8 @@ describe('CreateEdit Modal', () => {
});
});
// Todo: to fixed properly - failing with - due to timeout > 5000ms
describe.skip('Modal Actions', () => {
it('calls onClose when cancel button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
describe('Modal Actions', () => {
it('calls onClose when cancel button is clicked', () => {
render(
<CreateEdit
isCreate={false}
@@ -352,7 +348,7 @@ describe('CreateEdit Modal', () => {
);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
fireEvent.click(cancelButton);
expect(mockOnClose).toHaveBeenCalled();
});

View File

@@ -2,7 +2,6 @@ import { Expand } from '@signozhq/icons';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getBodyDisplayString } from 'container/LogDetailedView/utils';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useTimezone } from 'providers/Timezone';
import { ILog } from 'types/api/logs/log';
@@ -27,9 +26,7 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
DATE_TIME_FORMATS.UTC_MONTH_SHORT,
)}
</div>
<div className="logs-preview-list-item-body">
{getBodyDisplayString(log.body)}
</div>
<div className="logs-preview-list-item-body">{log.body}</div>
<div
className="logs-preview-list-item-expand"
onClick={makeLogDetailsHandler(log)}

View File

@@ -1,13 +1,20 @@
// Ungate feature flag for all tests in this file
jest.mock('../../config', () => ({ IS_ROLE_DETAILS_AND_CRUD_ENABLED: true }));
import * as roleApi from 'api/generated/services/role';
import {
customRoleResponse,
managedRoleResponse,
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
within,
} from 'tests/test-utils';
import RoleDetailsPage from '../RoleDetailsPage';
@@ -22,7 +29,7 @@ const allScopeObjectsResponse = {
status: 'success',
data: [
{
resource: { name: 'dashboard', type: 'dashboard' },
resource: { kind: 'role', type: 'metaresources' },
selectors: ['*'],
},
],
@@ -44,8 +51,7 @@ afterEach(() => {
server.resetHandlers();
});
// Todo: to fixed properly - failing with - due to timeout > 5000ms
describe.skip('RoleDetailsPage', () => {
describe('RoleDetailsPage', () => {
it('renders custom role header, tabs, description, permissions, and action buttons', async () => {
setupDefaultHandlers();
@@ -57,20 +63,16 @@ describe.skip('RoleDetailsPage', () => {
screen.findByText('Role — billing-manager'),
).resolves.toBeInTheDocument();
// Tab navigation
expect(screen.getByText('Overview')).toBeInTheDocument();
expect(screen.getByText('Members')).toBeInTheDocument();
// Role description (OverviewTab)
expect(
screen.getByText('Custom role for managing billing and invoices.'),
).toBeInTheDocument();
// Permission items derived from mocked authz relations
expect(screen.getByText('Create')).toBeInTheDocument();
expect(screen.getByText('Read')).toBeInTheDocument();
// Action buttons present for custom role
expect(
screen.getByRole('button', { name: /edit role details/i }),
).toBeInTheDocument();
@@ -96,14 +98,13 @@ describe.skip('RoleDetailsPage', () => {
),
).toBeInTheDocument();
// Action buttons absent for managed role
expect(screen.queryByText('Edit Role Details')).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /delete role/i }),
).not.toBeInTheDocument();
});
it('edit flow: modal opens pre-filled and calls PATCH on save and verify', async () => {
it('edit flow: modal opens pre-filled and calls PATCH on save', async () => {
const patchSpy = jest.fn();
let description = customRoleResponse.data.description;
server.use(
@@ -138,21 +139,16 @@ describe.skip('RoleDetailsPage', () => {
await screen.findByText('Role — billing-manager');
// Open the edit modal
await user.click(screen.getByRole('button', { name: /edit role details/i }));
await expect(
screen.findByText('Edit Role Details', {
selector: '.ant-modal-title',
}),
screen.findByText('Edit Role Details', { selector: '.ant-modal-title' }),
).resolves.toBeInTheDocument();
// Name field is disabled in edit mode (role rename is not allowed)
const nameInput = screen.getByPlaceholderText(
'Enter role name e.g. : Service Owner',
);
expect(nameInput).toBeDisabled();
// Update description and save
const descField = screen.getByPlaceholderText(
'A helpful description of the role',
);
@@ -168,9 +164,7 @@ describe.skip('RoleDetailsPage', () => {
await waitFor(() =>
expect(
screen.queryByText('Edit Role Details', {
selector: '.ant-modal-title',
}),
screen.queryByText('Edit Role Details', { selector: '.ant-modal-title' }),
).not.toBeInTheDocument(),
);
@@ -219,58 +213,61 @@ describe.skip('RoleDetailsPage', () => {
});
describe('permission side panel', () => {
async function openCreatePanel(
user: ReturnType<typeof userEvent.setup>,
): Promise<void> {
beforeEach(() => {
// Both hooks mocked so data renders synchronously — no React Query scheduler or MSW round-trip.
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isFetching: false,
isError: false,
error: null,
} as any);
jest
.spyOn(roleApi, 'useGetObjects')
.mockReturnValue({ data: emptyObjectsResponse, isLoading: false } as any);
});
afterEach(() => {
jest.restoreAllMocks();
});
async function openCreatePanel(): Promise<HTMLElement> {
await screen.findByText('Role — billing-manager');
await user.click(screen.getByText('Create'));
fireEvent.click(screen.getByText('Create'));
await screen.findByText('Edit Create Permissions');
await screen.findByRole('button', { name: /dashboard/i });
const panel = document.querySelector(
'.permission-side-panel',
) as HTMLElement;
await within(panel).findByRole('button', { name: 'Role' });
return panel;
}
it('Save Changes is disabled until a resource scope is changed', async () => {
setupDefaultHandlers();
server.use(
rest.get(
`${rolesApiBase}/:id/relation/:relation/objects`,
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
await openCreatePanel(user);
// No change yet — config matches initial, unsavedCount = 0
expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled();
// Expand Dashboard and flip to All — now Save is enabled
await user.click(screen.getByRole('button', { name: /dashboard/i }));
await user.click(screen.getByText('All'));
const panel = await openCreatePanel();
expect(
screen.getByRole('button', { name: /save changes/i }),
).not.toBeDisabled();
within(panel).getByRole('button', { name: /save changes/i }),
).toBeDisabled();
// check for what shown now - unsavedCount = 1
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
fireEvent.click(screen.getByText('All'));
expect(
within(panel).getByRole('button', { name: /save changes/i }),
).not.toBeDisabled();
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
});
it('set scope to All → patchObjects additions: ["*"], deletions: null', async () => {
const patchSpy = jest.fn();
setupDefaultHandlers();
server.use(
rest.get(
`${rolesApiBase}/:id/relation/:relation/objects`,
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
),
rest.patch(
`${rolesApiBase}/:id/relation/:relation/objects`,
`${rolesApiBase}/:id/relations/:relation/objects`,
async (req, res, ctx) => {
patchSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
@@ -278,23 +275,23 @@ describe.skip('RoleDetailsPage', () => {
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
await openCreatePanel(user);
const panel = await openCreatePanel();
await user.click(screen.getByRole('button', { name: /dashboard/i }));
await user.click(screen.getByText('All'));
await user.click(screen.getByRole('button', { name: /save changes/i }));
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
fireEvent.click(screen.getByText('All'));
fireEvent.click(
within(panel).getByRole('button', { name: /save changes/i }),
);
await waitFor(() =>
expect(patchSpy).toHaveBeenCalledWith({
additions: [
{
resource: { name: 'dashboard', type: 'dashboard' },
resource: { kind: 'role', type: 'metaresources' },
selectors: ['*'],
},
],
@@ -306,14 +303,9 @@ describe.skip('RoleDetailsPage', () => {
it('set scope to Only selected with IDs → patchObjects additions contain those IDs', async () => {
const patchSpy = jest.fn();
setupDefaultHandlers();
server.use(
rest.get(
`${rolesApiBase}/:id/relation/:relation/objects`,
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
),
rest.patch(
`${rolesApiBase}/:id/relation/:relation/objects`,
`${rolesApiBase}/:id/relations/:relation/objects`,
async (req, res, ctx) => {
patchSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
@@ -327,23 +319,25 @@ describe.skip('RoleDetailsPage', () => {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
await openCreatePanel(user);
const panel = await openCreatePanel();
await user.click(screen.getByRole('button', { name: /dashboard/i }));
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
const combobox = screen.getByRole('combobox');
await user.click(combobox);
await user.type(combobox, 'dash-1');
const combobox = within(panel).getByRole('combobox');
await user.type(combobox, 'role-001');
await user.keyboard('{Enter}');
await user.click(screen.getByRole('button', { name: /save changes/i }));
// user.click waits for the tag-addition state to settle before clicking.
await user.click(
within(panel).getByRole('button', { name: /save changes/i }),
);
await waitFor(() =>
expect(patchSpy).toHaveBeenCalledWith({
additions: [
{
resource: { name: 'dashboard', type: 'dashboard' },
selectors: ['dash-1'],
resource: { kind: 'role', type: 'metaresources' },
selectors: ['role-001'],
},
],
deletions: null,
@@ -354,15 +348,13 @@ describe.skip('RoleDetailsPage', () => {
it('existing All scope changed to Only selected (empty) → patchObjects deletions: ["*"], additions: null', async () => {
const patchSpy = jest.fn();
setupDefaultHandlers();
jest.spyOn(roleApi, 'useGetObjects').mockReturnValue({
data: allScopeObjectsResponse,
isLoading: false,
} as any);
server.use(
rest.get(
`${rolesApiBase}/:id/relation/:relation/objects`,
(_req, res, ctx) =>
res(ctx.status(200), ctx.json(allScopeObjectsResponse)),
),
rest.patch(
`${rolesApiBase}/:id/relation/:relation/objects`,
`${rolesApiBase}/:id/relations/:relation/objects`,
async (req, res, ctx) => {
patchSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
@@ -370,26 +362,24 @@ describe.skip('RoleDetailsPage', () => {
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
await openCreatePanel(user);
const panel = await openCreatePanel();
await user.click(screen.getByRole('button', { name: /dashboard/i }));
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
fireEvent.click(screen.getByText('Only selected'));
fireEvent.click(
within(panel).getByRole('button', { name: /save changes/i }),
);
await user.click(screen.getByText('Only selected'));
await user.click(screen.getByRole('button', { name: /save changes/i }));
// Should delete the '*' selector and add nothing
await waitFor(() =>
expect(patchSpy).toHaveBeenCalledWith({
additions: null,
deletions: [
{
resource: { name: 'dashboard', type: 'dashboard' },
resource: { kind: 'role', type: 'metaresources' },
selectors: ['*'],
},
],
@@ -398,36 +388,25 @@ describe.skip('RoleDetailsPage', () => {
});
it('unsaved changes counter shown on scope change, Discard resets it', async () => {
setupDefaultHandlers();
server.use(
rest.get(
`${rolesApiBase}/:id/relation/:relation/objects`,
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
await openCreatePanel(user);
const panel = await openCreatePanel();
// No unsaved changes indicator yet
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
// Change dashboard scope to "All"
await user.click(screen.getByRole('button', { name: /dashboard/i }));
await user.click(screen.getByText('All'));
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
fireEvent.click(screen.getByText('All'));
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
// Discard reverts to initial config — counter disappears, Save re-disabled
await user.click(screen.getByRole('button', { name: /discard/i }));
fireEvent.click(within(panel).getByRole('button', { name: /discard/i }));
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled();
expect(
within(panel).getByRole('button', { name: /save changes/i }),
).toBeDisabled();
});
});
});

View File

@@ -4,24 +4,27 @@ import {
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent } from 'tests/test-utils';
import { fireEvent, render, screen } from 'tests/test-utils';
import RolesSettings from '../RolesSettings';
const rolesApiURL = 'http://localhost/api/v1/roles';
describe('RolesSettings', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders the header and search input', () => {
beforeEach(() => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
it('renders the header and search input', () => {
render(<RolesSettings />);
expect(screen.getByText('Roles')).toBeInTheDocument();
@@ -34,12 +37,6 @@ describe('RolesSettings', () => {
});
it('displays roles grouped by managed and custom sections', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
@@ -68,20 +65,13 @@ describe('RolesSettings', () => {
});
it('filters roles by search query on name', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.type(searchInput, 'billing');
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
target: { value: 'billing' },
});
await expect(
screen.findByText('billing-manager'),
@@ -92,20 +82,13 @@ describe('RolesSettings', () => {
});
it('filters roles by search query on description', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.type(searchInput, 'read-only');
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
target: { value: 'read-only' },
});
await expect(screen.findByText('signoz-viewer')).resolves.toBeInTheDocument();
expect(screen.queryByText('signoz-admin')).not.toBeInTheDocument();
@@ -113,20 +96,13 @@ describe('RolesSettings', () => {
});
it('shows empty state when search matches nothing', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.type(searchInput, 'nonexistentrole');
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
target: { value: 'nonexistentrole' },
});
await expect(
screen.findByText('No roles match your search.'),
@@ -183,12 +159,6 @@ describe('RolesSettings', () => {
});
it('renders descriptions for all roles', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();

View File

@@ -2,7 +2,7 @@ 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 { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import ServiceAccountsSettings from '../ServiceAccountsSettings';
@@ -123,8 +123,6 @@ describe('ServiceAccountsSettings (integration)', () => {
});
it('filter dropdown to "Active" hides DISABLED accounts', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<NuqsTestingAdapter>
<ServiceAccountsSettings />
@@ -133,18 +131,16 @@ describe('ServiceAccountsSettings (integration)', () => {
await screen.findByText('CI Bot');
await user.click(screen.getByRole('button', { name: /All accounts/i }));
fireEvent.click(screen.getByRole('button', { name: /All accounts/i }));
const activeOption = await screen.findByText(/Active ⎯/i);
await user.click(activeOption);
fireEvent.click(activeOption);
await screen.findByText('CI Bot');
expect(screen.queryByText('Legacy Bot')).not.toBeInTheDocument();
});
it('search by name filters accounts in real-time', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<NuqsTestingAdapter>
<ServiceAccountsSettings />
@@ -153,10 +149,9 @@ describe('ServiceAccountsSettings (integration)', () => {
await screen.findByText('CI Bot');
await user.type(
screen.getByPlaceholderText(/Search by name or email/i),
'legacy',
);
fireEvent.change(screen.getByPlaceholderText(/Search by name or email/i), {
target: { value: 'legacy' },
});
await screen.findByText('Legacy Bot');
expect(screen.queryByText('CI Bot')).not.toBeInTheDocument();
@@ -164,15 +159,13 @@ describe('ServiceAccountsSettings (integration)', () => {
});
it('clicking a row opens the drawer with account details visible', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<NuqsTestingAdapter hasMemory>
<ServiceAccountsSettings />
</NuqsTestingAdapter>,
);
await user.click(
fireEvent.click(
await screen.findByRole('button', {
name: /View service account CI Bot/i,
}),
@@ -184,7 +177,6 @@ describe('ServiceAccountsSettings (integration)', () => {
});
it('saving changes in the drawer refetches the list', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const listRefetchSpy = jest.fn();
server.use(
@@ -206,15 +198,14 @@ describe('ServiceAccountsSettings (integration)', () => {
await screen.findByText('CI Bot');
listRefetchSpy.mockClear();
await user.click(
fireEvent.click(
await screen.findByRole('button', { name: /View service account CI Bot/i }),
);
const nameInput = await screen.findByDisplayValue('CI Bot');
await user.clear(nameInput);
await user.type(nameInput, 'CI Bot Updated');
fireEvent.change(nameInput, { target: { value: 'CI Bot Updated' } });
await user.click(screen.getByRole('button', { name: /Save Changes/i }));
fireEvent.click(screen.getByRole('button', { name: /Save Changes/i }));
await screen.findByDisplayValue('CI Bot Updated');
await waitFor(() => {
@@ -223,8 +214,6 @@ describe('ServiceAccountsSettings (integration)', () => {
});
it('"New Service Account" button opens the Create Service Account modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<NuqsTestingAdapter hasMemory>
<ServiceAccountsSettings />
@@ -233,9 +222,7 @@ describe('ServiceAccountsSettings (integration)', () => {
await screen.findByText('CI Bot');
await user.click(
screen.getByRole('button', { name: /New Service Account/i }),
);
fireEvent.click(screen.getByRole('button', { name: /New Service Account/i }));
await screen.findByRole('dialog', { name: /New Service Account/i });
expect(screen.getByPlaceholderText('Enter a name')).toBeInTheDocument();

View File

@@ -17,7 +17,7 @@ export default function AlertState({
let label;
const isDarkMode = useIsDarkMode();
switch (state) {
case 'no-data':
case 'nodata':
icon = (
<CircleOff
size={18}

View File

@@ -12,6 +12,7 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
deleteRuleByID,
invalidateGetRuleByID,
updateRuleByID,
useGetRuleByID,
useListRules,
@@ -408,6 +409,7 @@ export const useAlertRuleStatusToggle = ({
{
onSuccess: (data) => {
setAlertRuleState(data.data.state);
invalidateGetRuleByID(queryClient, { id: ruleId });
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
notifications.success({
message: `Alert has been ${
@@ -416,6 +418,7 @@ export const useAlertRuleStatusToggle = ({
});
},
onError: (error) => {
invalidateGetRuleByID(queryClient, { id: ruleId });
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,

View File

@@ -109,7 +109,8 @@ export type AlertRuleTimelineTableResponsePayload = {
labels: AlertLabelsProps['labels'];
};
};
type AlertState = 'firing' | 'normal' | 'no-data' | 'muted';
type AlertState = 'firing' | 'normal' | 'nodata' | 'muted';
export interface AlertRuleTimelineGraphResponse {
start: number;

View File

@@ -1,8 +1,3 @@
export interface ILogBody {
message?: string | null;
[key: string]: unknown;
}
export interface ILog {
date: string;
timestamp: number | string;
@@ -13,7 +8,7 @@ export interface ILog {
traceFlags: number;
severityText: string;
severityNumber: number;
body: string | ILogBody;
body: string;
resources_string: Record<string, never>;
scope_string: Record<string, never>;
attributesString: Record<string, never>;

2
go.mod
View File

@@ -11,7 +11,6 @@ require (
github.com/SigNoz/signoz-otel-collector v0.144.3
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/bytedance/sonic v1.14.1
github.com/cespare/xxhash/v2 v2.3.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/dgraph-io/ristretto/v2 v2.3.0
@@ -113,6 +112,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect

View File

@@ -12,10 +12,8 @@ import (
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/bytedance/sonic"
)
var (
@@ -24,8 +22,6 @@ var (
// written clickhouse query. The column alias indcate which value is
// to be considered as final result (or target).
legacyReservedColumnTargetAliases = []string{"__result", "__value", "result", "res", "value"}
CodeFailUnmarshalJSONColumn = errors.MustNewCode("fail_unmarshal_json_column")
)
// consume reads every row and shapes it into the payload expected for the
@@ -397,16 +393,11 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
// de-reference the typed pointer to any
val := reflect.ValueOf(cellPtr).Elem().Interface()
// Post-process JSON columns: unmarshal bytes into map[string]any
// Post-process JSON columns: normalize into String value
if strings.HasPrefix(strings.ToUpper(colTypes[i].DatabaseTypeName()), "JSON") {
switch x := val.(type) {
case []byte:
var m map[string]any
err := sonic.Unmarshal(x, &m)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailUnmarshalJSONColumn, "failed to unmarshal JSON column %s", name)
}
val = m
val = string(x)
default:
// already a structured type (map[string]any, []any, etc.)
}

View File

@@ -12,12 +12,9 @@ import (
"github.com/SigNoz/govaluate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// queryInfo holds common query properties.
@@ -53,7 +50,7 @@ func getQueryName(spec any) string {
return getqueryInfo(spec).Name
}
func (q *querier) postProcessResults(ctx context.Context, orgID valuer.UUID, results map[string]any, req *qbtypes.QueryRangeRequest) (map[string]any, error) {
func (q *querier) postProcessResults(ctx context.Context, results map[string]any, req *qbtypes.QueryRangeRequest) (map[string]any, error) {
// Convert results to typed format for processing
typedResults := make(map[string]*qbtypes.Result)
for name, result := range results {
@@ -72,7 +69,6 @@ func (q *querier) postProcessResults(ctx context.Context, orgID valuer.UUID, res
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if result, ok := typedResults[spec.Name]; ok {
result = postProcessBuilderQuery(q, result, spec, req)
result = q.postProcessLogBody(ctx, orgID, result, req)
typedResults[spec.Name] = result
}
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
@@ -1049,33 +1045,3 @@ func (q *querier) calculateFormulaStep(expression string, req *qbtypes.QueryRang
return result
}
// postProcessLogBody removes the "message" key from the body map when it is empty.
// Only runs for raw list queries with the use_json_body feature enabled.
func (q *querier) postProcessLogBody(ctx context.Context, orgID valuer.UUID, result *qbtypes.Result, req *qbtypes.QueryRangeRequest) *qbtypes.Result {
if req.RequestType != qbtypes.RequestTypeRaw {
return result
}
if !q.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
return result
}
rawData, ok := result.Value.(*qbtypes.RawData)
if !ok {
return result
}
for _, row := range rawData.Rows {
bodyMap, ok := row.Data["body"].(map[string]any)
if !ok {
continue
}
if msg, exists := bodyMap["message"]; exists {
switch v := msg.(type) {
case string:
if v == "" {
delete(bodyMap, "message")
}
}
}
}
return result
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"github.com/SigNoz/signoz/pkg/querybuilder"
@@ -36,7 +35,6 @@ var (
type querier struct {
logger *slog.Logger
fl flagger.Flagger
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
@@ -64,12 +62,10 @@ func New(
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder,
bucketCache BucketCache,
flagger flagger.Flagger,
) *querier {
querierSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/querier")
return &querier{
logger: querierSettings.Logger(),
fl: flagger,
telemetryStore: telemetryStore,
metadataStore: metadataStore,
promEngine: promEngine,
@@ -688,7 +684,7 @@ func (q *querier) run(
}
gomaps.Copy(results, preseededResults)
processedResults, err := q.postProcessResults(ctx, orgID, results, req)
processedResults, err := q.postProcessResults(ctx, results, req)
if err != nil {
return nil, err
}

View File

@@ -7,7 +7,6 @@ import (
cmock "github.com/srikanthccv/ClickHouse-go-mock"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
@@ -45,15 +44,14 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
providerSettings,
nil, // telemetryStore
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
nil, // prometheus
nil, // traceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
)
req := &qbtypes.QueryRangeRequest{
@@ -118,7 +116,6 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
)
req := &qbtypes.QueryRangeRequest{

View File

@@ -186,6 +186,5 @@ func newProvider(
meterStmtBuilder,
traceOperatorStmtBuilder,
bucketCache,
flagger,
), nil
}

View File

@@ -134,7 +134,7 @@ type RuleStateHistory struct {
// One of ["normal", "firing"]
OverallState AlertState `json:"overallState" ch:"overall_state"`
OverallStateChanged bool `json:"overallStateChanged" ch:"overall_state_changed"`
// One of ["normal", "firing", "no_data", "muted"]
// One of ["normal", "firing", "nodata", "muted"]
State AlertState `json:"state" ch:"state"`
StateChanged bool `json:"stateChanged" ch:"state_changed"`
UnixMilli int64 `json:"unixMilli" ch:"unix_milli"`

View File

@@ -53,7 +53,6 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flagger,
), metadataStore
}
@@ -103,7 +102,6 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
)
}
@@ -148,6 +146,5 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
)
}

View File

@@ -69,7 +69,7 @@ class BuilderQuery:
class TraceOperatorQuery:
name: str
expression: str
return_spans_from: str
return_spans_from: str | None = None
limit: int | None = None
order: list[OrderBy] | None = None
@@ -77,8 +77,9 @@ class TraceOperatorQuery:
spec: dict[str, Any] = {
"name": self.name,
"expression": self.expression,
"returnSpansFrom": self.return_spans_from,
}
if self.return_spans_from is not None:
spec["returnSpansFrom"] = self.return_spans_from
if self.limit is not None:
spec["limit"] = self.limit
if self.order:

View File

@@ -20,7 +20,7 @@ from fixtures.querier import (
def _get_bodies(response: requests.Response) -> list[dict[str, Any]]:
return [row["data"]["body"] for row in get_rows(response)]
return [json.loads(row["data"]["body"]) for row in get_rows(response)]
def _run_query_case(signoz: types.SigNoz, token: str, now: datetime, case: dict[str, Any]) -> None:
@@ -1183,7 +1183,7 @@ def test_message_searches(
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
def _body_messages(response: requests.Response) -> list[str]:
return [row["data"]["body"].get("message", "") for row in get_rows(response)]
return [json.loads(row["data"]["body"]).get("message", "") for row in get_rows(response)]
payment_messages = {
"Payment processed successfully",

View File

@@ -625,7 +625,6 @@ def test_export_traces_with_composite_query_trace_operator(
query_c = TraceOperatorQuery(
name="C",
expression="A => B",
return_spans_from="A",
limit=1000,
order=[OrderBy(TelemetryFieldKey("timestamp", "string", "span"), "desc")],
)
@@ -652,17 +651,15 @@ def test_export_traces_with_composite_query_trace_operator(
# Parse JSONL content
jsonl_lines = response.text.strip().split("\n")
assert len(jsonl_lines) == 1, f"Expected at least 1 line, got {len(jsonl_lines)}"
assert len(jsonl_lines) >= 1, f"Expected at least 1 line, got {len(jsonl_lines)}"
# Verify all returned spans belong to the matched trace
# Verify all returned spans belong to the matched trace.
# The direct-descendant JOIN emits one row per matching child, so the parent
# span may appear more than once (once per child that satisfies the condition).
json_objects = [json.loads(line) for line in jsonl_lines]
trace_ids = [obj.get("trace_id") for obj in json_objects]
assert all(tid == parent_trace_id for tid in trace_ids)
# Verify the parent span (returnSpansFrom = "A") is present
span_names = [obj.get("name") for obj in json_objects]
assert "parent-operation" in span_names
def test_export_traces_with_select_fields(
signoz: types.SigNoz,