Compare commits

..

3 Commits

Author SHA1 Message Date
Naman Verma
a687c61919 chore: py fmt add new line 2026-04-06 11:10:04 +05:30
Naman Verma
4066425952 chore: lint fix by removing unused list 2026-04-06 10:34:43 +05:30
Naman Verma
2381cf1da0 fix: show warning for non-existent cost meter metrics 2026-04-06 09:58:24 +05:30
69 changed files with 965 additions and 3469 deletions

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
@@ -63,12 +64,12 @@ func NewAnomalyRule(
BaseRule: baseRule,
}
switch p.RuleCondition.Seasonality {
case ruletypes.SeasonalityHourly:
switch strings.ToLower(p.RuleCondition.Seasonality) {
case "hourly":
t.seasonality = anomaly.SeasonalityHourly
case ruletypes.SeasonalityDaily:
case "daily":
t.seasonality = anomaly.SeasonalityDaily
case ruletypes.SeasonalityWeekly:
case "weekly":
t.seasonality = anomaly.SeasonalityWeekly
default:
t.seasonality = anomaly.SeasonalityDaily

View File

@@ -67,7 +67,7 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
}},
},
SelectedQuery: "A",
Seasonality: ruletypes.SeasonalityDaily,
Seasonality: "daily",
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
@@ -170,7 +170,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
}},
},
SelectedQuery: "A",
Seasonality: ruletypes.SeasonalityDaily,
Seasonality: "daily",
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{

View File

@@ -13,9 +13,7 @@ export interface HostListPayload {
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
} | null;
start?: number;
end?: number;
};
}
export interface TimeSeriesValue {

View File

@@ -14,8 +14,6 @@ import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schem
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import './CreateServiceAccountModal.styles.scss';
@@ -30,8 +28,6 @@ function CreateServiceAccountModal(): JSX.Element {
parseAsBoolean.withDefault(false),
);
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const {
control,
handleSubmit,
@@ -58,10 +54,13 @@ function CreateServiceAccountModal(): JSX.Element {
await invalidateListServiceAccounts(queryClient);
},
onError: (err) => {
const errMessage = convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
);
showErrorModal(errMessage as APIError);
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
toast.error(`Failed to create service account: ${errMessage}`, {
richColors: true,
});
},
},
});
@@ -91,7 +90,7 @@ function CreateServiceAccountModal(): JSX.Element {
showCloseButton
width="narrow"
className="create-sa-modal"
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
<div className="create-sa-modal__content">
<form

View File

@@ -11,16 +11,6 @@ jest.mock('@signozhq/sonner', () => ({
const mockToast = jest.mocked(toast);
const showErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
__esModule: true,
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal,
isErrorModalVisible: false,
})),
}));
const SERVICE_ACCOUNTS_ENDPOINT = '*/api/v1/service_accounts';
function renderModal(): ReturnType<typeof render> {
@@ -102,13 +92,10 @@ describe('CreateServiceAccountModal', () => {
await user.click(submitBtn);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(
expect.objectContaining({
getErrorMessage: expect.any(Function),
}),
expect(mockToast.error).toHaveBeenCalledWith(
expect.stringMatching(/Failed to create service account/i),
expect.anything(),
);
const passedError = showErrorModal.mock.calls[0][0] as any;
expect(passedError.getErrorMessage()).toBe('Internal Server Error');
});
expect(

View File

@@ -83,6 +83,37 @@
opacity: 0.6;
}
&__role-select {
width: 100%;
.ant-select-selector {
background-color: var(--l2-background) !important;
border-color: var(--border) !important;
border-radius: 2px;
padding: var(--padding-1) var(--padding-2) !important;
display: flex;
align-items: center;
flex-wrap: wrap;
min-height: 32px;
height: auto !important;
}
.ant-select-selection-item {
font-size: var(--font-size-sm);
color: var(--l1-foreground);
line-height: 22px;
letter-spacing: -0.07px;
}
.ant-select-arrow {
color: var(--foreground);
}
&:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--foreground);
}
}
&__meta {
display: flex;
flex-direction: column;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
@@ -28,7 +28,6 @@ import {
useMemberRoleManager,
} from 'hooks/member/useMemberRoleManager';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
@@ -62,6 +61,10 @@ function toSaveApiError(err: unknown): APIError {
);
}
function areSortedArraysEqual(a: string[], b: string[]): boolean {
return JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
}
export interface EditMemberDrawerProps {
member: MemberRow | null;
open: boolean;
@@ -80,7 +83,7 @@ function EditMemberDrawer({
const { user: currentUser } = useAppContext();
const [localDisplayName, setLocalDisplayName] = useState('');
const [localRole, setLocalRole] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
@@ -91,11 +94,8 @@ function EditMemberDrawer({
const [linkType, setLinkType] = useState<'invite' | 'reset' | null>(null);
const isInvited = member?.status === MemberStatus.Invited;
const isDeleted = member?.status === MemberStatus.Deleted;
const isSelf = !!member?.id && member.id === currentUser?.id;
const { showErrorModal } = useErrorModal();
const {
data: fetchedUser,
isLoading: isFetchingUser,
@@ -115,45 +115,32 @@ function EditMemberDrawer({
refetch: refetchRoles,
} = useRoles();
const {
fetchedRoleIds,
isLoading: isMemberRolesLoading,
applyDiff,
} = useMemberRoleManager(member?.id ?? '', open && !!member?.id);
const { fetchedRoleIds, applyDiff } = useMemberRoleManager(
member?.id ?? '',
open && !!member?.id,
);
const fetchedDisplayName =
fetchedUser?.data?.displayName ?? member?.name ?? '';
const fetchedUserId = fetchedUser?.data?.id;
const fetchedUserDisplayName = fetchedUser?.data?.displayName;
const roleSessionRef = useRef<string | null>(null);
useEffect(() => {
if (fetchedUserId) {
setLocalDisplayName(fetchedUserDisplayName ?? member?.name ?? '');
}
setSaveErrors([]);
}, [fetchedUserId, fetchedUserDisplayName, member?.name]);
useEffect(() => {
if (fetchedUserId) {
setSaveErrors([]);
}
}, [fetchedUserId]);
useEffect(() => {
if (!member?.id) {
roleSessionRef.current = null;
} else if (member.id !== roleSessionRef.current && !isMemberRolesLoading) {
setLocalRole(fetchedRoleIds[0] ?? '');
roleSessionRef.current = member.id;
}
}, [member?.id, fetchedRoleIds, isMemberRolesLoading]);
setLocalRoles(fetchedRoleIds);
}, [fetchedRoleIds]);
const isDirty =
member !== null &&
fetchedUser != null &&
(localDisplayName !== fetchedDisplayName ||
localRole !== (fetchedRoleIds[0] ?? ''));
!areSortedArraysEqual(localRoles, fetchedRoleIds));
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
const { mutateAsync: updateUser } = useUpdateUser();
@@ -170,10 +157,17 @@ function EditMemberDrawer({
onClose();
},
onError: (err): void => {
const errMessage = convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
);
showErrorModal(errMessage as APIError);
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
const prefix = isInvited
? 'Failed to revoke invite'
: 'Failed to delete member';
toast.error(`${prefix}: ${errMessage}`, {
richColors: true,
position: 'top-right',
});
},
},
});
@@ -230,7 +224,7 @@ function EditMemberDrawer({
setIsSaving(true);
try {
const nameChanged = localDisplayName !== fetchedDisplayName;
const rolesChanged = localRole !== (fetchedRoleIds[0] ?? '');
const rolesChanged = !areSortedArraysEqual(localRoles, fetchedRoleIds);
const namePromise = nameChanged
? isSelf
@@ -243,9 +237,7 @@ function EditMemberDrawer({
const [nameResult, rolesResult] = await Promise.allSettled([
namePromise,
rolesChanged
? applyDiff([localRole].filter(Boolean), availableRoles)
: Promise.resolve([]),
rolesChanged ? applyDiff(localRoles, availableRoles) : Promise.resolve([]),
]);
const errors: SaveError[] = [];
@@ -263,10 +255,7 @@ function EditMemberDrawer({
context: 'Roles update',
apiError: toSaveApiError(rolesResult.reason),
onRetry: async (): Promise<void> => {
const failures = await applyDiff(
[localRole].filter(Boolean),
availableRoles,
);
const failures = await applyDiff(localRoles, availableRoles);
setSaveErrors((prev) => {
const rest = prev.filter((e) => e.context !== 'Roles update');
return [
@@ -314,7 +303,7 @@ function EditMemberDrawer({
isDirty,
isSelf,
localDisplayName,
localRole,
localRoles,
fetchedDisplayName,
fetchedRoleIds,
updateMyUser,
@@ -354,15 +343,15 @@ function EditMemberDrawer({
position: 'top-right',
});
}
} catch (err) {
const errMsg = convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
);
showErrorModal(errMsg as APIError);
} catch {
toast.error('Failed to generate password reset link', {
richColors: true,
position: 'top-right',
});
} finally {
setIsGeneratingLink(false);
}
}, [member, isInvited, onClose, showErrorModal]);
}, [member, isInvited, onClose]);
const [copyState, copyToClipboard] = useCopyToClipboard();
const handleCopyResetLink = useCallback((): void => {
@@ -429,7 +418,7 @@ function EditMemberDrawer({
}}
className="edit-member-drawer__input"
placeholder="Enter name"
disabled={isRootUser || isDeleted}
disabled={isRootUser}
/>
</Tooltip>
</div>
@@ -450,22 +439,21 @@ function EditMemberDrawer({
<label className="edit-member-drawer__label" htmlFor="member-role">
Roles
</label>
{isSelf || isRootUser || isDeleted ? (
{isSelf || isRootUser ? (
<Tooltip
title={
isRootUser
? ROOT_USER_TOOLTIP
: isDeleted
? undefined
: 'You cannot modify your own role'
}
title={isRootUser ? ROOT_USER_TOOLTIP : 'You cannot modify your own role'}
>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<div className="edit-member-drawer__disabled-roles">
{localRole ? (
<Badge color="vanilla">
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
</Badge>
{localRoles.length > 0 ? (
localRoles.map((roleId) => {
const role = availableRoles.find((r) => r.id === roleId);
return (
<Badge key={roleId} color="vanilla">
{role?.name ?? roleId}
</Badge>
);
})
) : (
<span className="edit-member-drawer__email-text"></span>
)}
@@ -476,14 +464,15 @@ function EditMemberDrawer({
) : (
<RolesSelect
id="member-role"
mode="multiple"
roles={availableRoles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={refetchRoles}
value={localRole}
onChange={(role): void => {
setLocalRole(role ?? '');
value={localRoles}
onChange={(roles): void => {
setLocalRoles(roles);
setSaveErrors((prev) =>
prev.filter(
(err) =>
@@ -491,8 +480,8 @@ function EditMemberDrawer({
),
);
}}
placeholder="Select role"
allowClear={false}
className="edit-member-drawer__role-select"
placeholder="Select roles"
/>
)}
</div>
@@ -504,10 +493,6 @@ function EditMemberDrawer({
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : member?.status === MemberStatus.Deleted ? (
<Badge color="cherry" variant="outline">
DELETED
</Badge>
) : (
<Badge color="amber" variant="outline">
INVITED
@@ -546,57 +531,55 @@ function EditMemberDrawer({
<div className="edit-member-drawer__layout">
<div className="edit-member-drawer__body">{drawerBody}</div>
{!isDeleted && (
<div className="edit-member-drawer__footer">
<div className="edit-member-drawer__footer-left">
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
</span>
</Tooltip>
<div className="edit-member-drawer__footer">
<div className="edit-member-drawer__footer-left">
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
</span>
</Tooltip>
<div className="edit-member-drawer__footer-divider" />
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser}
>
<RefreshCw size={12} />
{isGeneratingLink && 'Generating...'}
{!isGeneratingLink && isInvited && 'Copy Invite Link'}
{!isGeneratingLink && !isInvited && 'Generate Password Reset Link'}
</Button>
</span>
</Tooltip>
</div>
<div className="edit-member-drawer__footer-right">
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
</div>
<div className="edit-member-drawer__footer-divider" />
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser}
>
<RefreshCw size={12} />
{isGeneratingLink && 'Generating...'}
{!isGeneratingLink && isInvited && 'Copy Invite Link'}
{!isGeneratingLink && !isInvited && 'Generate Password Reset Link'}
</Button>
</span>
</Tooltip>
</div>
)}
<div className="edit-member-drawer__footer-right">
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
</div>
</div>
</div>
);

View File

@@ -5,6 +5,7 @@ import {
getResetPasswordToken,
useDeleteUser,
useGetUser,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
useUpdateMyUserV2,
useUpdateUser,
@@ -55,6 +56,7 @@ jest.mock('api/generated/services/users', () => ({
useUpdateUser: jest.fn(),
useUpdateMyUserV2: jest.fn(),
useSetRoleByUserID: jest.fn(),
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
getResetPasswordToken: jest.fn(),
}));
@@ -84,16 +86,6 @@ const ROLES_ENDPOINT = '*/api/v1/roles';
const mockDeleteMutate = jest.fn();
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
const showErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
__esModule: true,
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal,
isErrorModalVisible: false,
})),
}));
const mockFetchedUser = {
data: {
id: 'user-1',
@@ -157,7 +149,6 @@ function renderDrawer(
describe('EditMemberDrawer', () => {
beforeEach(() => {
jest.clearAllMocks();
showErrorModal.mockClear();
server.use(
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
@@ -180,6 +171,10 @@ describe('EditMemberDrawer', () => {
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useDeleteUser as jest.Mock).mockReturnValue({
mutate: mockDeleteMutate,
isLoading: false,
@@ -253,7 +248,7 @@ describe('EditMemberDrawer', () => {
expect(onClose).not.toHaveBeenCalled();
});
it('selecting a different role calls setRole with the new role name', async () => {
it('calls setRole when a new role is added', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
@@ -282,30 +277,32 @@ describe('EditMemberDrawer', () => {
});
});
it('does not call removeRole when the role is changed', async () => {
it('calls removeRole when an existing role is removed', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
const mockRemove = jest.fn().mockResolvedValue({});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: mockSet,
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
mutateAsync: mockRemove,
isLoading: false,
});
renderDrawer({ onComplete });
// Switch from signoz-admin to signoz-viewer using single-select
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-viewer'));
// Wait for the signoz-admin tag to appear, then click its remove button
const adminTag = await screen.findByTitle('signoz-admin');
const removeBtn = adminTag.querySelector(
'.ant-select-selection-item-remove',
) as Element;
await user.click(removeBtn);
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
data: { name: 'signoz-viewer' },
expect(mockRemove).toHaveBeenCalledWith({
pathParams: { id: 'user-1', roleId: managedRoles[0].id },
});
expect(onComplete).toHaveBeenCalled();
});
@@ -470,6 +467,7 @@ describe('EditMemberDrawer', () => {
it('shows API error message when deleteUser fails for active member', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
mutate: mockDeleteMutate.mockImplementation(() => {
@@ -487,20 +485,16 @@ describe('EditMemberDrawer', () => {
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(
expect.objectContaining({
getErrorMessage: expect.any(Function),
}),
);
const passedError = showErrorModal.mock.calls[0][0] as any;
expect(passedError.getErrorMessage()).toBe(
'Something went wrong on server',
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to delete member: Something went wrong on server',
expect.anything(),
);
});
});
it('shows API error message when deleteUser fails for invited member', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
mutate: mockDeleteMutate.mockImplementation(() => {
@@ -518,14 +512,9 @@ describe('EditMemberDrawer', () => {
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(
expect.objectContaining({
getErrorMessage: expect.any(Function),
}),
);
const passedError = showErrorModal.mock.calls[0][0] as any;
expect(passedError.getErrorMessage()).toBe(
'Something went wrong on server',
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to revoke invite: Something went wrong on server',
expect.anything(),
);
});
});

View File

@@ -10,12 +10,7 @@ import APIError from 'types/api/error';
import './ErrorContent.styles.scss';
interface ErrorContentProps {
error:
| APIError
| {
code: number;
message: string;
};
error: APIError;
icon?: ReactNode;
}
@@ -25,15 +20,7 @@ function ErrorContent({ error, icon }: ErrorContentProps): JSX.Element {
errors: errorMessages,
code: errorCode,
message: errorMessage,
} =
error && 'error' in error
? error?.error?.error || {}
: {
url: undefined,
errors: [],
code: error.code || 500,
message: error.message || 'Something went wrong',
};
} = error?.error?.error || {};
return (
<section className="error-content">
{/* Summary Header */}

View File

@@ -10,7 +10,6 @@ import { Select } from 'antd';
import inviteUsers from 'api/v1/invite/bulk/create';
import sendInvite from 'api/v1/invite/create';
import { cloneDeep, debounce } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { ROLES } from 'types/roles';
import { EMAIL_REGEX } from 'utils/app';
@@ -41,8 +40,6 @@ function InviteMembersModal({
onClose,
onComplete,
}: InviteMembersModalProps): JSX.Element {
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [rows, setRows] = useState<InviteRow[]>(() => [
EMPTY_ROW(),
EMPTY_ROW(),
@@ -207,11 +204,13 @@ function InviteMembersModal({
resetAndClose();
onComplete?.();
} catch (err) {
showErrorModal(err as APIError);
const apiErr = err as APIError;
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
toast.error(errorMessage, { richColors: true, position: 'top-right' });
} finally {
setIsSubmitting(false);
}
}, [validateAllUsers, rows, resetAndClose, onComplete, showErrorModal]);
}, [rows, onComplete, resetAndClose, validateAllUsers]);
const touchedRows = rows.filter(isRowTouched);
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
@@ -228,7 +227,7 @@ function InviteMembersModal({
showCloseButton
width="wide"
className="invite-members-modal"
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
<div className="invite-members-modal__content">
<div className="invite-members-modal__table">
@@ -330,7 +329,6 @@ function InviteMembersModal({
size="sm"
onClick={handleSubmit}
disabled={isSubmitDisabled}
loading={isSubmitting}
>
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
</Button>

View File

@@ -1,3 +1,4 @@
import { toast } from '@signozhq/sonner';
import inviteUsers from 'api/v1/invite/bulk/create';
import sendInvite from 'api/v1/invite/create';
import { StatusCodes } from 'http-status-codes';
@@ -21,16 +22,6 @@ jest.mock('@signozhq/sonner', () => ({
},
}));
const showErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
__esModule: true,
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal,
isErrorModalVisible: false,
})),
}));
const mockSendInvite = jest.mocked(sendInvite);
const mockInviteUsers = jest.mocked(inviteUsers);
@@ -43,7 +34,6 @@ const defaultProps = {
describe('InviteMembersModal', () => {
beforeEach(() => {
jest.clearAllMocks();
showErrorModal.mockClear();
mockSendInvite.mockResolvedValue({
httpStatusCode: 200,
data: { data: 'test', status: 'success' },
@@ -164,10 +154,9 @@ describe('InviteMembersModal', () => {
describe('error handling', () => {
it('shows BE message on single invite 409', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const error = makeApiError(
'An invite already exists for this email: single@signoz.io',
mockSendInvite.mockRejectedValue(
makeApiError('An invite already exists for this email: single@signoz.io'),
);
mockSendInvite.mockRejectedValue(error);
render(<InviteMembersModal {...defaultProps} />);
@@ -182,16 +171,18 @@ describe('InviteMembersModal', () => {
);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(error);
expect(toast.error).toHaveBeenCalledWith(
'An invite already exists for this email: single@signoz.io',
expect.anything(),
);
});
});
it('shows BE message on bulk invite 409', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const error = makeApiError(
'An invite already exists for this email: alice@signoz.io',
mockInviteUsers.mockRejectedValue(
makeApiError('An invite already exists for this email: alice@signoz.io'),
);
mockInviteUsers.mockRejectedValue(error);
render(<InviteMembersModal {...defaultProps} />);
@@ -210,17 +201,18 @@ describe('InviteMembersModal', () => {
);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(error);
expect(toast.error).toHaveBeenCalledWith(
'An invite already exists for this email: alice@signoz.io',
expect.anything(),
);
});
});
it('shows BE message on generic error', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const error = makeApiError(
'Internal server error',
StatusCodes.INTERNAL_SERVER_ERROR,
mockSendInvite.mockRejectedValue(
makeApiError('Internal server error', StatusCodes.INTERNAL_SERVER_ERROR),
);
mockSendInvite.mockRejectedValue(error);
render(<InviteMembersModal {...defaultProps} />);
@@ -235,7 +227,10 @@ describe('InviteMembersModal', () => {
);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(error);
expect(toast.error).toHaveBeenCalledWith(
'Internal server error',
expect.anything(),
);
});
});
});

View File

@@ -210,7 +210,7 @@ function MembersTable({
index % 2 === 0 ? 'members-table-row--tinted' : ''
}
onRow={(record): React.HTMLAttributes<HTMLElement> => {
const isClickable = !!onRowClick;
const isClickable = onRowClick && record.status !== MemberStatus.Deleted;
return {
onClick: (): void => {
if (isClickable) {

View File

@@ -86,7 +86,7 @@ describe('MembersTable', () => {
);
});
it('renders DELETED badge and calls onRowClick when a deleted member row is clicked', async () => {
it('renders DELETED badge and does not call onRowClick when a deleted member row is clicked', async () => {
const onRowClick = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const deletedMember: MemberRow = {
@@ -108,7 +108,7 @@ describe('MembersTable', () => {
expect(screen.getByText('DELETED')).toBeInTheDocument();
await user.click(screen.getByText('Dave Deleted'));
expect(onRowClick).toHaveBeenCalledWith(
expect(onRowClick).not.toHaveBeenCalledWith(
expect.objectContaining({ id: 'user-del' }),
);
});

View File

@@ -88,13 +88,3 @@
color: var(--destructive);
}
}
.roles-single-select {
.ant-select-selector {
min-height: 32px;
background-color: var(--l2-background) !important;
border: 1px solid var(--border) !important;
border-radius: 2px;
padding: 2px var(--padding-2) !important;
}
}

View File

@@ -85,8 +85,7 @@ interface BaseProps {
interface SingleProps extends BaseProps {
mode?: 'single';
value?: string;
onChange?: (role: string | undefined) => void;
allowClear?: boolean;
onChange?: (role: string) => void;
}
interface MultipleProps extends BaseProps {
@@ -155,15 +154,14 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
);
}
const { value, onChange, allowClear = true } = props as SingleProps;
const { value, onChange } = props as SingleProps;
return (
<Select
id={id}
value={value || undefined}
value={value}
onChange={onChange}
placeholder={placeholder}
allowClear={allowClear}
className={cx('roles-single-select', className)}
className={cx('roles-select', className)}
loading={loading}
notFoundContent={notFoundContent}
options={options}

View File

@@ -17,8 +17,6 @@ import { AxiosError } from 'axios';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import KeyCreatedPhase from './KeyCreatedPhase';
import KeyFormPhase from './KeyFormPhase';
@@ -29,7 +27,6 @@ import './AddKeyModal.styles.scss';
function AddKeyModal(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [isAddKeyOpen, setIsAddKeyOpen] = useQueryState(
SA_QUERY_PARAMS.ADD_KEY,
@@ -84,11 +81,11 @@ function AddKeyModal(): JSX.Element {
}
},
onError: (error) => {
showErrorModal(
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
) as APIError,
);
)?.getErrorMessage() || 'Failed to create key';
toast.error(errMessage, { richColors: true });
},
},
});
@@ -154,7 +151,7 @@ function AddKeyModal(): JSX.Element {
width="base"
className="add-key-modal"
showCloseButton
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
{phase === Phase.FORM && (
<KeyFormPhase

View File

@@ -16,12 +16,9 @@ import type {
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
function DeleteAccountModal(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [accountId, setAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [isDeleteOpen, setIsDeleteOpen] = useQueryState(
SA_QUERY_PARAMS.DELETE_SA,
@@ -48,11 +45,11 @@ function DeleteAccountModal(): JSX.Element {
await invalidateListServiceAccounts(queryClient);
},
onError: (error) => {
showErrorModal(
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
) as APIError,
);
)?.getErrorMessage() || 'Failed to delete service account';
toast.error(errMessage, { richColors: true });
},
},
});
@@ -82,7 +79,7 @@ function DeleteAccountModal(): JSX.Element {
width="narrow"
className="alert-dialog sa-delete-dialog"
showCloseButton={false}
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
<p className="sa-delete-dialog__body">
Are you sure you want to delete <strong>{accountName}</strong>? This action

View File

@@ -17,9 +17,7 @@ import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import dayjs from 'dayjs';
import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { RevokeKeyContent } from '../RevokeKeyModal';
import EditKeyForm from './EditKeyForm';
@@ -43,7 +41,6 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
const open = !!editKeyId && !!selectedAccountId;
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false);
const {
@@ -81,11 +78,11 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
}
},
onError: (error) => {
showErrorModal(
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
) as APIError,
);
)?.getErrorMessage() || 'Failed to update key';
toast.error(errMessage, { richColors: true });
},
},
});
@@ -105,13 +102,12 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
});
}
},
// eslint-disable-next-line sonarjs/no-identical-functions
onError: (error) => {
showErrorModal(
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
) as APIError,
);
)?.getErrorMessage() || 'Failed to revoke key';
toast.error(errMessage, { richColors: true });
},
},
});
@@ -164,7 +160,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal'
}
showCloseButton={!isRevokeConfirmOpen}
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
{isRevokeConfirmOpen ? (
<RevokeKeyContent

View File

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

View File

@@ -16,8 +16,6 @@ import type {
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
export interface RevokeKeyContentProps {
isRevoking: boolean;
@@ -58,7 +56,6 @@ export function RevokeKeyContent({
function RevokeKeyModal(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [revokeKeyId, setRevokeKeyId] = useQueryState(
SA_QUERY_PARAMS.REVOKE_KEY,
@@ -86,11 +83,11 @@ function RevokeKeyModal(): JSX.Element {
}
},
onError: (error) => {
showErrorModal(
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
) as APIError,
);
)?.getErrorMessage() || 'Failed to revoke key';
toast.error(errMessage, { richColors: true });
},
},
});
@@ -118,7 +115,7 @@ function RevokeKeyModal(): JSX.Element {
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
<RevokeKeyContent
isRevoking={isRevoking}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { Button } from '@signozhq/button';
import { DrawerWrapper } from '@signozhq/drawer';
@@ -8,9 +8,7 @@ import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Pagination, Skeleton } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getGetServiceAccountRolesQueryKey,
getListServiceAccountsQueryKey,
useDeleteServiceAccountRole,
useGetServiceAccount,
useListServiceAccountKeys,
useUpdateServiceAccount,
@@ -25,10 +23,7 @@ import {
ServiceAccountStatus,
toServiceAccountRow,
} from 'container/ServiceAccountsSettings/utils';
import {
RoleUpdateFailure,
useServiceAccountRoleManager,
} from 'hooks/serviceAccount/useServiceAccountRoleManager';
import { useServiceAccountRoleManager } from 'hooks/serviceAccount/useServiceAccountRoleManager';
import {
parseAsBoolean,
parseAsInteger,
@@ -54,13 +49,6 @@ export interface ServiceAccountDrawerProps {
const PAGE_SIZE = 15;
function toSaveApiError(err: unknown): APIError {
return (
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
toAPIError(err as AxiosError<RenderErrorResponseDTO>)
);
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function ServiceAccountDrawer({
onSuccess,
@@ -92,7 +80,7 @@ function ServiceAccountDrawer({
parseAsBoolean.withDefault(false),
);
const [localName, setLocalName] = useState('');
const [localRole, setLocalRole] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
@@ -115,35 +103,21 @@ function ServiceAccountDrawer({
[accountData],
);
const {
currentRoles,
isLoading: isRolesLoading,
applyDiff,
} = useServiceAccountRoleManager(selectedAccountId ?? '');
const roleSessionRef = useRef<string | null>(null);
const { currentRoles, applyDiff } = useServiceAccountRoleManager(
selectedAccountId ?? '',
);
useEffect(() => {
if (account?.id) {
setLocalName(account?.name ?? '');
setKeysPage(1);
}
setSaveErrors([]);
}, [account?.id, account?.name, setKeysPage]);
useEffect(() => {
if (account?.id) {
setSaveErrors([]);
}
}, [account?.id]);
useEffect(() => {
if (!account?.id) {
roleSessionRef.current = null;
} else if (account.id !== roleSessionRef.current && !isRolesLoading) {
setLocalRole(currentRoles[0]?.id ?? '');
roleSessionRef.current = account.id;
}
}, [account?.id, currentRoles, isRolesLoading]);
setLocalRoles(currentRoles.map((r) => r.id).filter(Boolean) as string[]);
}, [currentRoles]);
const isDeleted =
account?.status?.toUpperCase() === ServiceAccountStatus.Deleted;
@@ -151,7 +125,8 @@ function ServiceAccountDrawer({
const isDirty =
account !== null &&
(localName !== (account.name ?? '') ||
localRole !== (currentRoles[0]?.id ?? ''));
JSON.stringify([...localRoles].sort()) !==
JSON.stringify([...currentRoles.map((r) => r.id).filter(Boolean)].sort()));
const {
roles: availableRoles,
@@ -179,22 +154,12 @@ function ServiceAccountDrawer({
// the retry for this mutation is safe due to the api being idempotent on backend
const { mutateAsync: updateMutateAsync } = useUpdateServiceAccount();
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole();
const executeRolesOperation = useCallback(
async (accountId: string): Promise<RoleUpdateFailure[]> => {
if (localRole === '' && currentRoles[0]?.id) {
await deleteRole({
pathParams: { id: accountId, rid: currentRoles[0].id },
});
await queryClient.invalidateQueries(
getGetServiceAccountRolesQueryKey({ id: accountId }),
);
return [];
}
return applyDiff([localRole].filter(Boolean), availableRoles);
},
[localRole, currentRoles, availableRoles, applyDiff, deleteRole, queryClient],
const toSaveApiError = useCallback(
(err: unknown): APIError =>
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
toAPIError(err as AxiosError<RenderErrorResponseDTO>),
[],
);
const retryNameUpdate = useCallback(async (): Promise<void> => {
@@ -216,7 +181,14 @@ function ServiceAccountDrawer({
),
);
}
}, [account, localName, updateMutateAsync, refetchAccount, queryClient]);
}, [
account,
localName,
updateMutateAsync,
refetchAccount,
queryClient,
toSaveApiError,
]);
const handleNameChange = useCallback((name: string): void => {
setLocalName(name);
@@ -239,39 +211,26 @@ function ServiceAccountDrawer({
);
}
},
[],
);
const clearRoleErrors = useCallback((): void => {
setSaveErrors((prev) =>
prev.filter(
(e) => e.context !== 'Roles update' && !e.context.startsWith("Role '"),
),
);
}, []);
const failuresToSaveErrors = useCallback(
(failures: RoleUpdateFailure[]): SaveError[] =>
failures.map((f) => {
const ctx = `Role '${f.roleName}'`;
return {
context: ctx,
apiError: toSaveApiError(f.error),
onRetry: makeRoleRetry(ctx, f.onRetry),
};
}),
[makeRoleRetry],
[toSaveApiError],
);
const retryRolesUpdate = useCallback(async (): Promise<void> => {
try {
const failures = await executeRolesOperation(selectedAccountId ?? '');
const failures = await applyDiff(localRoles, availableRoles);
if (failures.length === 0) {
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Roles update'));
} else {
setSaveErrors((prev) => {
const rest = prev.filter((e) => e.context !== 'Roles update');
return [...rest, ...failuresToSaveErrors(failures)];
const roleErrors = failures.map((f) => {
const ctx = `Role '${f.roleName}'`;
return {
context: ctx,
apiError: toSaveApiError(f.error),
onRetry: makeRoleRetry(ctx, f.onRetry),
};
});
return [...rest, ...roleErrors];
});
}
} catch (err) {
@@ -281,7 +240,7 @@ function ServiceAccountDrawer({
),
);
}
}, [selectedAccountId, executeRolesOperation, failuresToSaveErrors]);
}, [localRoles, availableRoles, applyDiff, toSaveApiError, makeRoleRetry]);
const handleSave = useCallback(async (): Promise<void> => {
if (!account || !isDirty) {
@@ -300,7 +259,7 @@ function ServiceAccountDrawer({
const [nameResult, rolesResult] = await Promise.allSettled([
namePromise,
executeRolesOperation(account.id),
applyDiff(localRoles, availableRoles),
]);
const errors: SaveError[] = [];
@@ -320,7 +279,14 @@ function ServiceAccountDrawer({
onRetry: retryRolesUpdate,
});
} else {
errors.push(...failuresToSaveErrors(rolesResult.value));
for (const failure of rolesResult.value) {
const context = `Role '${failure.roleName}'`;
errors.push({
context,
apiError: toSaveApiError(failure.error),
onRetry: makeRoleRetry(context, failure.onRetry),
});
}
}
if (errors.length > 0) {
@@ -342,14 +308,17 @@ function ServiceAccountDrawer({
account,
isDirty,
localName,
localRoles,
availableRoles,
updateMutateAsync,
executeRolesOperation,
applyDiff,
refetchAccount,
onSuccess,
queryClient,
toSaveApiError,
retryNameUpdate,
makeRoleRetry,
retryRolesUpdate,
failuresToSaveErrors,
]);
const handleClose = useCallback((): void => {
@@ -441,11 +410,8 @@ function ServiceAccountDrawer({
account={account}
localName={localName}
onNameChange={handleNameChange}
localRole={localRole}
onRoleChange={(role): void => {
setLocalRole(role ?? '');
clearRoleErrors();
}}
localRoles={localRoles}
onRolesChange={setLocalRoles}
isDisabled={isDeleted}
availableRoles={availableRoles}
rolesLoading={rolesLoading}

View File

@@ -139,18 +139,18 @@ describe('ServiceAccountDrawer', () => {
});
});
it('changing roles enables Save; clicking Save sends role add request without delete', async () => {
it('changing roles enables Save; clicking Save sends updated roles in payload', async () => {
const updateSpy = jest.fn();
const roleSpy = jest.fn();
const deleteSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
roleSpy(await req.json());
rest.put(SA_ENDPOINT, async (req, res, ctx) => {
updateSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => {
deleteSpy();
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
roleSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
@@ -167,12 +167,12 @@ describe('ServiceAccountDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(updateSpy).not.toHaveBeenCalled();
expect(roleSpy).toHaveBeenCalledWith(
expect.objectContaining({
id: '019c24aa-2248-7585-a129-4188b3473c27',
}),
);
expect(deleteSpy).not.toHaveBeenCalled();
});
});
@@ -350,7 +350,7 @@ describe('ServiceAccountDrawer save-error UX', () => {
).toBeInTheDocument();
});
it('role add failure shows SaveErrorItem with the role name context', async () => {
it('role update failure shows SaveErrorItem with the role name context', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(

View File

@@ -838,20 +838,22 @@ function FormAlertRules({
>
<div className="overview-header">
<div className="alert-type-container">
<Typography.Title level={5} className="alert-type-title">
<BellDot size={14} />
{isNewRule && (
<Typography.Title level={5} className="alert-type-title">
<BellDot size={14} />
{alertDef.alertType === AlertTypes.ANOMALY_BASED_ALERT &&
'Anomaly Detection Alert'}
{alertDef.alertType === AlertTypes.METRICS_BASED_ALERT &&
'Metrics Based Alert'}
{alertDef.alertType === AlertTypes.LOGS_BASED_ALERT &&
'Logs Based Alert'}
{alertDef.alertType === AlertTypes.TRACES_BASED_ALERT &&
'Traces Based Alert'}
{alertDef.alertType === AlertTypes.EXCEPTIONS_BASED_ALERT &&
'Exceptions Based Alert'}
</Typography.Title>
{alertDef.alertType === AlertTypes.ANOMALY_BASED_ALERT &&
'Anomaly Detection Alert'}
{alertDef.alertType === AlertTypes.METRICS_BASED_ALERT &&
'Metrics Based Alert'}
{alertDef.alertType === AlertTypes.LOGS_BASED_ALERT &&
'Logs Based Alert'}
{alertDef.alertType === AlertTypes.TRACES_BASED_ALERT &&
'Traces Based Alert'}
{alertDef.alertType === AlertTypes.EXCEPTIONS_BASED_ALERT &&
'Exceptions Based Alert'}
</Typography.Title>
)}
</div>
<Button

View File

@@ -1,52 +1,41 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { VerticalAlignTopOutlined } from '@ant-design/icons';
import { Button, Tooltip, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import {
getHostLists,
HostListPayload,
HostListResponse,
} from 'api/infraMonitoring/getHostLists';
import { HostListPayload } from 'api/infraMonitoring/getHostLists';
import HostMetricDetail from 'components/HostMetricsDetail';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
useInfraMonitoringCurrentPage,
useInfraMonitoringFiltersHosts,
useInfraMonitoringOrderByHosts,
} from 'container/InfraMonitoringK8s/hooks';
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
import { useGetHostList } from 'hooks/infraMonitoring/useGetHostList';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { Filter } from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { useAppContext } from 'providers/App/App';
import { useGlobalTimeStore } from 'store/globalTime';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
IBuilderQuery,
Query,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { AppState } from 'store/reducers';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../constants/features';
import { useAppContext } from '../../providers/App/App';
import HostsListControls from './HostsListControls';
import HostsListTable from './HostsListTable';
import { getHostListsQuery, GetHostsQuickFiltersConfig } from './utils';
import './InfraMonitoring.styles.scss';
const defaultFilters: TagFilter = { items: [], op: 'and' };
const baseQuery = getHostListsQuery();
function HostsList(): JSX.Element {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
const [filters, setFilters] = useInfraMonitoringFiltersHosts();
const [orderBy, setOrderBy] = useInfraMonitoringOrderByHosts();
@@ -73,48 +62,56 @@ function HostsList(): JSX.Element {
const { pageSize, setPageSize } = usePageSize('hosts');
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const query = useMemo(() => {
const baseQuery = getHostListsQuery();
return {
...baseQuery,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters,
start: Math.floor(minTime / 1000000),
end: Math.floor(maxTime / 1000000),
orderBy,
};
}, [pageSize, currentPage, filters, minTime, maxTime, orderBy]);
const queryKey = useMemo(
() =>
getAutoRefreshQueryKey(
selectedTime,
REACT_QUERY_KEY.GET_HOST_LIST,
const queryKey = useMemo(() => {
if (selectedHostName) {
return [
'hostList',
String(pageSize),
String(currentPage),
JSON.stringify(filters),
JSON.stringify(orderBy),
),
[pageSize, currentPage, filters, orderBy, selectedTime],
);
];
}
return [
'hostList',
String(pageSize),
String(currentPage),
JSON.stringify(filters),
JSON.stringify(orderBy),
String(minTime),
String(maxTime),
];
}, [
pageSize,
currentPage,
filters,
orderBy,
selectedHostName,
minTime,
maxTime,
]);
const { data, isFetching, isLoading, isError } = useQuery<
SuccessResponse<HostListResponse> | ErrorResponse,
Error
>({
queryKey,
queryFn: ({ signal }) => {
const { minTime, maxTime } = getMinMaxTime();
const payload: HostListPayload = {
...baseQuery,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: filters ?? defaultFilters,
orderBy,
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
};
return getHostLists(payload, signal);
const { data, isFetching, isLoading, isError } = useGetHostList(
query as HostListPayload,
{
queryKey,
enabled: !!query,
keepPreviousData: true,
},
enabled: true,
keepPreviousData: true,
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
);
const hostMetricsData = useMemo(() => data?.payload?.data?.records || [], [
data,
@@ -230,7 +227,7 @@ function HostsList(): JSX.Element {
isError={isError}
tableData={data}
hostMetricsData={hostMetricsData}
filters={filters ?? defaultFilters}
filters={filters || { items: [], op: 'AND' }}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
onHostClick={handleHostClick}

View File

@@ -10,7 +10,6 @@ import {
} from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import logEvent from 'api/common/logEvent';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { InfraMonitoringEvents } from 'constants/events';
import { isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
@@ -27,40 +26,9 @@ import {
function EmptyOrLoadingView(
viewState: EmptyOrLoadingViewProps,
): React.ReactNode {
if (viewState.showTableLoadingState) {
return (
<div className="hosts-list-loading-state">
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
</div>
);
}
const { isError, data } = viewState;
if (isError || data?.error || (data?.statusCode || 0) >= 300) {
return (
<ErrorContent
error={{
code: data?.statusCode || 500,
message: data?.error || 'Something went wrong',
}}
/>
);
const { isError, errorMessage } = viewState;
if (isError) {
return <Typography>{errorMessage || 'Something went wrong'}</Typography>;
}
if (viewState.showHostsEmptyState) {
return (
@@ -108,6 +76,30 @@ function EmptyOrLoadingView(
</div>
);
}
if (viewState.showTableLoadingState) {
return (
<div className="hosts-list-loading-state">
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
</div>
);
}
return null;
}
@@ -198,8 +190,7 @@ export default function HostsListTable({
!isLoading &&
formattedHostMetricsData.length === 0 &&
(!sentAnyHostMetricsData || isSendingIncorrectK8SAgentMetrics) &&
!filters.items.length &&
!endTimeBeforeRetention;
!filters.items.length;
const showEndTimeBeforeRetentionMessage =
!isFetching &&
@@ -220,7 +211,7 @@ export default function HostsListTable({
const emptyOrLoadingView = EmptyOrLoadingView({
isError,
data,
errorMessage: data?.error ?? '',
showHostsEmptyState,
sentAnyHostMetricsData,
isSendingIncorrectK8SAgentMetrics,

View File

@@ -2,8 +2,8 @@ import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, waitFor } from '@testing-library/react';
import * as getHostListsApi from 'api/infraMonitoring/getHostLists';
import { render } from '@testing-library/react';
import * as useGetHostListHooks from 'hooks/infraMonitoring/useGetHostList';
import { withNuqsTestingAdapter } from 'nuqs/adapters/testing';
import * as appContextHooks from 'providers/App/App';
import * as timezoneHooks from 'providers/Timezone';
@@ -19,10 +19,6 @@ jest.mock('lib/getMinMax', () => ({
maxTime: 1713738000000,
isValidShortHandDateTimeFormat: jest.fn().mockReturnValue(true),
})),
getMinMaxForSelectedTime: jest.fn().mockReturnValue({
minTime: 1713734400000000000,
maxTime: 1713738000000000000,
}),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
@@ -45,13 +41,7 @@ jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
),
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const queryClient = new QueryClient();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
@@ -90,40 +80,27 @@ jest.spyOn(timezoneHooks, 'useTimezone').mockReturnValue({
offset: 0,
},
} as any);
jest.spyOn(getHostListsApi, 'getHostLists').mockResolvedValue({
statusCode: 200,
error: null,
message: 'Success',
payload: {
status: 'success',
data: {
type: 'list',
records: [
{
hostName: 'test-host',
active: true,
os: 'linux',
cpu: 0.75,
cpuTimeSeries: { labels: {}, labelsArray: [], values: [] },
memory: 0.65,
memoryTimeSeries: { labels: {}, labelsArray: [], values: [] },
wait: 0.03,
waitTimeSeries: { labels: {}, labelsArray: [], values: [] },
load15: 0.5,
load15TimeSeries: { labels: {}, labelsArray: [], values: [] },
},
],
groups: null,
total: 1,
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: false,
endTimeBeforeRetention: false,
jest.spyOn(useGetHostListHooks, 'useGetHostList').mockReturnValue({
data: {
payload: {
data: {
records: [
{
hostName: 'test-host',
active: true,
cpu: 0.75,
memory: 0.65,
wait: 0.03,
},
],
isSendingK8SAgentMetrics: false,
sentAnyHostMetricsData: true,
},
},
},
params: {} as any,
});
isLoading: false,
isError: false,
} as any);
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
@@ -151,11 +128,7 @@ jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
const Wrapper = withNuqsTestingAdapter({ searchParams: {} });
describe('HostsList', () => {
beforeEach(() => {
queryClient.clear();
});
it('renders hosts list table', async () => {
it('renders hosts list table', () => {
const { container } = render(
<Wrapper>
<QueryClientProvider client={queryClient}>
@@ -167,12 +140,10 @@ describe('HostsList', () => {
</QueryClientProvider>
</Wrapper>,
);
await waitFor(() => {
expect(container.querySelector('.hosts-list-table')).toBeInTheDocument();
});
expect(container.querySelector('.hosts-list-table')).toBeInTheDocument();
});
it('renders filters', async () => {
it('renders filters', () => {
const { container } = render(
<Wrapper>
<QueryClientProvider client={queryClient}>
@@ -184,8 +155,6 @@ describe('HostsList', () => {
</QueryClientProvider>
</Wrapper>,
);
await waitFor(() => {
expect(container.querySelector('.filters')).toBeInTheDocument();
});
expect(container.querySelector('.filters')).toBeInTheDocument();
});
});

View File

@@ -1,13 +1,8 @@
import { Dispatch, SetStateAction } from 'react';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import {
Progress,
TableColumnType as ColumnType,
Tag,
Tooltip,
Typography,
} from 'antd';
import { Progress, TabsProps, Tag, Tooltip, Typography } from 'antd';
import { TableColumnType as ColumnType } from 'antd';
import { SortOrder } from 'antd/lib/table/interface';
import {
HostData,
@@ -18,6 +13,8 @@ import {
FiltersType,
IQuickFiltersConfig,
} from 'components/QuickFilters/types';
import TabLabel from 'components/TabLabel';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { TriangleAlert } from 'lucide-react';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -25,6 +22,9 @@ import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { OrderBySchemaType } from '../InfraMonitoringK8s/schemas';
import HostsList from './HostsList';
import './InfraMonitoring.styles.scss';
export interface HostRowData {
key?: string;
@@ -112,10 +112,7 @@ export interface HostsListTableProps {
export interface EmptyOrLoadingViewProps {
isError: boolean;
data:
| ErrorResponse<string>
| SuccessResponse<HostListResponse, unknown>
| undefined;
errorMessage: string;
showHostsEmptyState: boolean;
sentAnyHostMetricsData: boolean;
isSendingIncorrectK8SAgentMetrics: boolean;
@@ -144,6 +141,14 @@ function mapOrderByToSortOrder(
: undefined;
}
export const getTabsItems = (): TabsProps['items'] => [
{
label: <TabLabel label="List View" isDisabled={false} tooltipText="" />,
key: PANEL_TYPES.LIST,
children: <HostsList />,
},
];
export const getHostsListColumns = (
orderBy: OrderBySchemaType,
): ColumnType<HostRowData>[] => [

View File

@@ -51,8 +51,6 @@ function MembersSettings(): JSX.Element {
if (filterMode === FilterMode.Invited) {
result = result.filter((m) => m.status === MemberStatus.Invited);
} else if (filterMode === FilterMode.Deleted) {
result = result.filter((m) => m.status === MemberStatus.Deleted);
}
if (searchQuery.trim()) {
@@ -91,9 +89,6 @@ function MembersSettings(): JSX.Element {
const pendingCount = allMembers.filter(
(m) => m.status === MemberStatus.Invited,
).length;
const deletedCount = allMembers.filter(
(m) => m.status === MemberStatus.Deleted,
).length;
const totalCount = allMembers.length;
const filterMenuItems: MenuProps['items'] = [
@@ -123,27 +118,12 @@ function MembersSettings(): JSX.Element {
setPage(1);
},
},
{
key: FilterMode.Deleted,
label: (
<div className="members-filter-option">
<span>Deleted {deletedCount}</span>
{filterMode === FilterMode.Deleted && <Check size={14} />}
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.Deleted);
setPage(1);
},
},
];
const filterLabel =
filterMode === FilterMode.All
? `All members ⎯ ${totalCount}`
: filterMode === FilterMode.Invited
? `Pending invites ⎯ ${pendingCount}`
: `Deleted ⎯ ${deletedCount}`;
: `Pending invites ⎯ ${pendingCount}`;
const handleInviteComplete = useCallback((): void => {
refetchUsers();

View File

@@ -117,14 +117,14 @@ describe('MembersSettings (integration)', () => {
await screen.findByText('Member Details');
});
it('opens EditMemberDrawer when a deleted member row is clicked', async () => {
it('does not open 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'));
expect(screen.queryByText('Member Details')).toBeInTheDocument();
expect(screen.queryByText('Member Details')).not.toBeInTheDocument();
});
it('opens InviteMembersModal when "Invite member" button is clicked', async () => {

View File

@@ -1,7 +1,6 @@
export enum FilterMode {
All = 'all',
Invited = 'invited',
Deleted = 'deleted',
}
export enum MemberStatus {

View File

@@ -1,72 +0,0 @@
import { toast } from '@signozhq/sonner';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import DisplayName from '../index';
jest.mock('@signozhq/sonner', () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
const ORG_ME_ENDPOINT = '*/api/v2/orgs/me';
const defaultProps = { index: 0, id: 'does-not-matter-id' };
describe('DisplayName', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
server.resetHandlers();
});
it('renders form pre-filled with org displayName from context', async () => {
render(<DisplayName {...defaultProps} />);
const input = await screen.findByRole('textbox');
expect(input).toHaveValue('Pentagon');
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
});
it('enables submit and calls PUT when display name is changed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(rest.put(ORG_ME_ENDPOINT, (_, res, ctx) => res(ctx.status(200))));
render(<DisplayName {...defaultProps} />);
const input = await screen.findByRole('textbox');
await user.clear(input);
await user.type(input, 'New Org Name');
const submitBtn = screen.getByRole('button', { name: /submit/i });
expect(submitBtn).toBeEnabled();
await user.click(submitBtn);
await waitFor(() => {
expect(toast.success).toHaveBeenCalled();
});
});
it('shows validation error when display name is cleared and submitted', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<DisplayName {...defaultProps} />);
const input = await screen.findByRole('textbox');
await user.clear(input);
// Submit button is disabled when empty, so trigger validation via Enter
await user.type(input, '{enter}');
await waitFor(() => {
expect(screen.getByText(/missing display name/i)).toBeInTheDocument();
});
});
});

View File

@@ -1,19 +1,12 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from '@signozhq/sonner';
import { Button, Form, Input } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
useGetMyOrganization,
useUpdateMyOrganization,
} from 'api/generated/services/orgs';
import { useUpdateMyOrganization } from 'api/generated/services/orgs';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { useAppContext } from 'providers/App/App';
import { IUser } from 'providers/App/types';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
@@ -21,25 +14,8 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
const orgName = Form.useWatch('displayName', form);
const { t } = useTranslation(['organizationsettings', 'common']);
const { showErrorModal } = useErrorModal();
const { org, updateOrg, user } = useAppContext();
const currentOrg = (org || [])[index];
const isAdmin = user.role === USER_ROLES.ADMIN;
const { data: orgData } = useGetMyOrganization({
query: {
enabled: isAdmin && !currentOrg?.displayName,
},
});
const displayName =
currentOrg?.displayName ?? orgData?.data?.displayName ?? '';
useEffect(() => {
if (displayName && !form.getFieldValue('displayName')) {
form.setFieldsValue({ displayName });
}
}, [displayName, form]);
const { org, updateOrg } = useAppContext();
const { displayName } = (org || [])[index];
const {
mutateAsync: updateMyOrganization,
@@ -54,8 +30,12 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
updateOrg(orgId, data.displayName ?? '');
},
onError: (error) => {
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
const apiError = convertToApiError(
error as AxiosError<RenderErrorResponseDTO>,
);
toast.error(
apiError?.getErrorMessage() ?? t('something_went_wrong', { ns: 'common' }),
{ richColors: true, position: 'top-right' },
);
},
},

View File

@@ -360,11 +360,7 @@ function DateTimeSelection({
const invalidateQueries = useGlobalTimeQueryInvalidate();
const onRefreshHandler = (): void => {
invalidateQueries();
onSelectHandler(
isModalTimeSelection && modalSelectedInterval
? modalSelectedInterval
: selectedTime,
);
onSelectHandler(selectedTime);
onLastRefreshHandler();
};
const handleReset = useCallback(() => {

View File

@@ -0,0 +1,42 @@
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import {
getHostLists,
HostListPayload,
HostListResponse,
} from 'api/infraMonitoring/getHostLists';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetHostList = (
requestData: HostListPayload,
options?: UseQueryOptions<
SuccessResponse<HostListResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<SuccessResponse<HostListResponse> | ErrorResponse, Error>;
export const useGetHostList: UseGetHostList = (
requestData,
options,
headers,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
if (options?.queryKey && typeof options.queryKey === 'string') {
return options.queryKey;
}
return [REACT_QUERY_KEY.GET_HOST_LIST, requestData];
}, [options?.queryKey, requestData]);
return useQuery<SuccessResponse<HostListResponse> | ErrorResponse, Error>({
queryFn: ({ signal }) => getHostLists(requestData, signal, headers),
...options,
queryKey,
});
};

View File

@@ -1,6 +1,10 @@
import { useCallback, useMemo } from 'react';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import { useGetUser, useSetRoleByUserID } from 'api/generated/services/users';
import {
useGetUser,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
} from 'api/generated/services/users';
export interface MemberRoleUpdateFailure {
roleName: string;
@@ -39,6 +43,7 @@ export function useMemberRoleManager(
);
const { mutateAsync: setRole } = useSetRoleByUserID();
const { mutateAsync: removeRole } = useRemoveUserRoleByUserIDAndRoleID();
const applyDiff = useCallback(
async (
@@ -48,12 +53,25 @@ export function useMemberRoleManager(
const currentRoleIdSet = new Set(fetchedRoleIds);
const desiredRoleIdSet = new Set(localRoleIds.filter(Boolean));
const toRemove = currentUserRoles.filter((ur) => {
const id = ur.role?.id ?? ur.roleId;
return id && !desiredRoleIdSet.has(id);
});
const toAdd = availableRoles.filter(
(r) => r.id && desiredRoleIdSet.has(r.id) && !currentRoleIdSet.has(r.id),
);
/// TODO: re-enable deletes once BE for this is streamlined
const allOps = [
...toRemove.map((ur) => ({
roleName: ur.role?.name ?? 'unknown',
run: (): ReturnType<typeof removeRole> =>
removeRole({
pathParams: {
id: userId,
roleId: ur.role?.id ?? ur.roleId ?? '',
},
}),
})),
...toAdd.map((role) => ({
roleName: role.name ?? 'unknown',
run: (): ReturnType<typeof setRole> =>
@@ -76,7 +94,7 @@ export function useMemberRoleManager(
return failures;
},
[userId, fetchedRoleIds, setRole],
[userId, fetchedRoleIds, currentUserRoles, setRole, removeRole],
);
return { fetchedRoleIds, isLoading, applyDiff };

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from 'react-query';
import {
getGetServiceAccountRolesQueryKey,
useCreateServiceAccountRole,
useDeleteServiceAccountRole,
useGetServiceAccountRoles,
} from 'api/generated/services/serviceaccount';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
@@ -35,6 +36,7 @@ export function useServiceAccountRoleManager(
// the retry for these mutations is safe due to being idempotent on backend
const { mutateAsync: createRole } = useCreateServiceAccountRole();
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole();
const invalidateRoles = useCallback(
() =>
@@ -60,23 +62,28 @@ export function useServiceAccountRoleManager(
(r) => r.id && desiredRoleIds.has(r.id) && !currentRoleIds.has(r.id),
);
// TODO: re-enable deletes once BE for this is streamlined
const removedRoles = currentRoles.filter(
(r) => r.id && !desiredRoleIds.has(r.id),
);
const allOperations = [
...addedRoles.map((role) => ({
role,
run: (): ReturnType<typeof createRole> =>
createRole({ pathParams: { id: accountId }, data: { id: role.id } }),
})),
...removedRoles.map((role) => ({
role,
run: (): ReturnType<typeof deleteRole> =>
deleteRole({ pathParams: { id: accountId, rid: role.id } }),
})),
];
const results = await Promise.allSettled(
allOperations.map((op) => op.run()),
);
const successCount = results.filter((r) => r.status === 'fulfilled').length;
if (successCount > 0) {
await invalidateRoles();
}
await invalidateRoles();
const failures: RoleUpdateFailure[] = [];
results.forEach((result, index) => {
@@ -95,7 +102,7 @@ export function useServiceAccountRoleManager(
return failures;
},
[accountId, currentRoles, createRole, invalidateRoles],
[accountId, currentRoles, createRole, deleteRole, invalidateRoles],
);
return {

View File

@@ -12,6 +12,7 @@ import {
import { useQuery } from 'react-query';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { useGetMyOrganization } from 'api/generated/services/orgs';
import { useGetMyUser } from 'api/generated/services/users';
import listOrgPreferences from 'api/v1/org/preferences/list';
import listUserPreferences from 'api/v1/user/preferences/list';
@@ -84,6 +85,14 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
query: { enabled: isLoggedIn },
});
const {
data: orgData,
isFetching: isFetchingOrgData,
error: orgFetchDataError,
} = useGetMyOrganization({
query: { enabled: isLoggedIn },
});
const {
permissions: permissionsResult,
isFetching: isFetchingPermissions,
@@ -93,8 +102,10 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
enabled: isLoggedIn,
});
const isFetchingUser = isFetchingUserData || isFetchingPermissions;
const userFetchError = userFetchDataError || errorOnPermissions;
const isFetchingUser =
isFetchingUserData || isFetchingOrgData || isFetchingPermissions;
const userFetchError =
userFetchDataError || orgFetchDataError || errorOnPermissions;
const userRole = useMemo(() => {
if (permissionsResult?.[IsAdminPermission]?.isGranted) {
@@ -134,40 +145,39 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
createdAt: toISOString(userData.data.createdAt) ?? prev.createdAt,
updatedAt: toISOString(userData.data.updatedAt) ?? prev.updatedAt,
}));
}
}, [userData, isFetchingUserData]);
// todo: we need to update the org name as well, we should have the [admin only role restriction on the get org api call] - BE input needed
setOrg((prev): any => {
useEffect(() => {
if (!isFetchingOrgData && orgData?.data) {
const { id: orgId, displayName: orgDisplayName } = orgData.data;
setOrg((prev) => {
if (!prev) {
return [
{
createdAt: 0,
id: userData.data.orgId,
},
];
return [{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' }];
}
const orgIndex = prev.findIndex((e) => e.id === userData.data.orgId);
const orgIndex = prev.findIndex((e) => e.id === orgId);
if (orgIndex === -1) {
return [
...prev,
{
createdAt: 0,
id: userData.data.orgId,
},
{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' },
];
}
return [
const updatedOrg: Organization[] = [
...prev.slice(0, orgIndex),
{
createdAt: 0,
id: userData.data.orgId,
},
{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' },
...prev.slice(orgIndex + 1),
];
return updatedOrg;
});
setDefaultUser((prev) => ({
...prev,
organization: orgDisplayName ?? prev.organization,
}));
}
}, [userData, isFetchingUserData]);
}, [orgData, isFetchingOrgData]);
// fetcher for licenses v3
const {

View File

@@ -281,6 +281,48 @@ describe('AppProvider user and org data from v2 APIs', () => {
);
});
it('populates org state from GET /api/v2/orgs/me', async () => {
server.use(
rest.get(MY_ORG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: {
id: 'org-abc',
displayName: 'My Org',
},
}),
),
),
rest.get(MY_USER_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: { id: 'u-default', email: 'default@signoz.io' } }),
),
),
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [false, false, false])),
);
}),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitFor(
() => {
expect(result.current.org).not.toBeNull();
const org = result.current.org?.[0];
expect(org?.id).toBe('org-abc');
expect(org?.displayName).toBe('My Org');
},
{ timeout: 2000 },
);
});
it('sets isFetchingUser false once both user and org calls complete', async () => {
server.use(
rest.get(MY_USER_URL, (_, res, ctx) =>

View File

@@ -14,7 +14,6 @@ import APIError from 'types/api/error';
interface ErrorModalContextType {
showErrorModal: (error: APIError) => void;
hideErrorModal: () => void;
isErrorModalVisible: boolean;
}
const ErrorModalContext = createContext<ErrorModalContextType | undefined>(
@@ -39,10 +38,10 @@ export function ErrorModalProvider({
setIsVisible(false);
}, []);
const value = useMemo(
() => ({ showErrorModal, hideErrorModal, isErrorModalVisible: isVisible }),
[showErrorModal, hideErrorModal, isVisible],
);
const value = useMemo(() => ({ showErrorModal, hideErrorModal }), [
showErrorModal,
hideErrorModal,
]);
return (
<ErrorModalContext.Provider value={value}>

View File

@@ -240,26 +240,6 @@ func (m *MockNotificationManager) DeleteAllRoutePoliciesByName(ctx context.Conte
return nil
}
func (m *MockNotificationManager) GetRoutePoliciesByChannel(ctx context.Context, orgID string, channelName string) ([]*alertmanagertypes.RoutePolicy, error) {
if orgID == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "orgID cannot be empty")
}
var matched []*alertmanagertypes.RoutePolicy
for _, route := range m.routes {
if route.OrgID != orgID {
continue
}
for _, ch := range route.Channels {
if ch == channelName {
matched = append(matched, route)
break
}
}
}
return matched, nil
}
func (m *MockNotificationManager) Match(ctx context.Context, orgID string, ruleID string, set model.LabelSet) ([]string, error) {
key := getKey(orgID, ruleID)
if err := m.errors[key]; err != nil {

View File

@@ -59,10 +59,6 @@ func (m *MockSQLRouteStore) DeleteRouteByName(ctx context.Context, orgID string,
return m.routeStore.DeleteRouteByName(ctx, orgID, name)
}
func (m *MockSQLRouteStore) GetAll(ctx context.Context, orgID string) ([]*alertmanagertypes.RoutePolicy, error) {
return m.routeStore.GetAll(ctx, orgID)
}
func (m *MockSQLRouteStore) ExpectGetByID(orgID, id string, route *alertmanagertypes.RoutePolicy) {
rows := sqlmock.NewRows([]string{"id", "org_id", "name", "expression", "kind", "description", "enabled", "tags", "channels", "created_at", "updated_at", "created_by", "updated_by"})

View File

@@ -83,18 +83,6 @@ func (store *store) GetAllByName(ctx context.Context, orgID string, name string)
return routes, nil
}
func (store *store) GetAll(ctx context.Context, orgID string) ([]*routeTypes.RoutePolicy, error) {
var routes []*routeTypes.RoutePolicy
err := store.sqlstore.BunDBCtx(ctx).NewSelect().Model(&routes).Where("org_id = ?", orgID).Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to fetch routing policies for orgID: %s", orgID)
}
return routes, nil
}
func (store *store) DeleteRouteByName(ctx context.Context, orgID string, name string) error {
_, err := store.sqlstore.BunDBCtx(ctx).NewDelete().Model((*routeTypes.RoutePolicy)(nil)).Where("org_id = ?", orgID).Where("name = ?", name).Exec(ctx)
if err != nil {

View File

@@ -23,10 +23,6 @@ type NotificationManager interface {
DeleteRoutePolicy(ctx context.Context, orgID string, routeID string) error
DeleteAllRoutePoliciesByName(ctx context.Context, orgID string, name string) error
// GetRoutePoliciesByChannel returns all route policies (both rule-based and policy-based)
// that reference the given channel name.
GetRoutePoliciesByChannel(ctx context.Context, orgID string, channelName string) ([]*alertmanagertypes.RoutePolicy, error)
// Route matching
Match(ctx context.Context, orgID string, ruleID string, set model.LabelSet) ([]string, error)
}

View File

@@ -155,28 +155,6 @@ func (r *provider) GetAllRoutePolicies(ctx context.Context, orgID string) ([]*al
return r.routeStore.GetAllByKind(ctx, orgID, alertmanagertypes.PolicyBasedExpression)
}
func (r *provider) GetRoutePoliciesByChannel(ctx context.Context, orgID string, channelName string) ([]*alertmanagertypes.RoutePolicy, error) {
if orgID == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "orgID cannot be empty")
}
allRoutes, err := r.routeStore.GetAll(ctx, orgID)
if err != nil {
return nil, err
}
var matched []*alertmanagertypes.RoutePolicy
for _, route := range allRoutes {
for _, ch := range route.Channels {
if ch == channelName {
matched = append(matched, route)
break
}
}
}
return matched, nil
}
func (r *provider) DeleteRoutePolicy(ctx context.Context, orgID string, routeID string) error {
if routeID == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "routeID cannot be empty")

View File

@@ -169,21 +169,6 @@ func (provider *provider) DeleteChannelByID(ctx context.Context, orgID string, c
return err
}
// Check if channel is referenced by any route policy (rule-based or policy-based)
policies, err := provider.notificationManager.GetRoutePoliciesByChannel(ctx, orgID, channel.Name)
if err != nil {
return err
}
if len(policies) > 0 {
names := make([]string, 0, len(policies))
for _, p := range policies {
names = append(names, p.Name)
}
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"channel %q cannot be deleted because it is used by the following routing policies: %v",
channel.Name, names)
}
config, err := provider.configStore.Get(ctx, orgID)
if err != nil {
return err

View File

@@ -32,7 +32,7 @@ func newConfig() factory.Config {
Domain: "signozserviceaccount.com",
},
Analytics: AnalyticsConfig{
Enabled: false,
Enabled: true,
},
}
}

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import (
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -67,8 +66,6 @@ type Module interface {
GetIdentity(context.Context, string) (*authtypes.Identity, error)
Config() Config
statsreporter.StatsCollector
}
type Handler interface {

View File

@@ -383,11 +383,6 @@ func (module *setter) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot self delete")
}
err = user.UpdateStatus(types.UserStatusDeleted)
if err != nil {
return err
}
userRoles, err := module.getter.GetRolesByUserID(ctx, user.ID)
if err != nil {
return err
@@ -411,8 +406,6 @@ func (module *setter) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return err
}
traitsOrProperties := types.NewTraitsFromUser(user)
module.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traitsOrProperties)
module.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Deleted", map[string]any{
"deleted_by": deletedBy,
})
@@ -575,13 +568,8 @@ func (module *setter) UpdatePasswordByResetPasswordToken(ctx context.Context, to
roleNames := roleNamesFromUserRoles(userRoles)
isPendingInviteUser := user.Status == types.UserStatusPendingInvite
// since grant is idempotent, multiple calls won't cause issues in case of retries
if isPendingInviteUser {
if err := user.UpdateStatus(types.UserStatusActive); err != nil {
return err
}
if user.Status == types.UserStatusPendingInvite {
if err = module.authz.Grant(
ctx,
user.OrgID,
@@ -592,14 +580,15 @@ func (module *setter) UpdatePasswordByResetPasswordToken(ctx context.Context, to
}
traitsOrProperties := types.NewTraitsFromUser(user)
module.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traitsOrProperties)
module.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Activated", traitsOrProperties)
}
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if isPendingInviteUser {
err := module.store.UpdateUser(ctx, user.OrgID, user)
if err != nil {
if user.Status == types.UserStatusPendingInvite {
if err := user.UpdateStatus(types.UserStatusActive); err != nil {
return err
}
if err := module.store.UpdateUser(ctx, user.OrgID, user); err != nil {
return err
}
}
@@ -828,7 +817,6 @@ func (module *setter) activatePendingUser(ctx context.Context, user *types.User,
}
traitsOrProperties := types.NewTraitsFromUser(user)
module.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traitsOrProperties)
module.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Activated", traitsOrProperties)
return nil
@@ -878,17 +866,16 @@ func (module *setter) AddUserRole(ctx context.Context, orgID, userID valuer.UUID
if err != nil {
return err
}
existingRoles := make([]string, len(existingUserRoles))
for idx, role := range existingUserRoles {
existingRoles[idx] = role.Role.Name
for _, userRole := range existingUserRoles {
if userRole.Role != nil && userRole.Role.Name == roleName {
return nil // role already assigned no-op
}
}
// grant via authz (idempotent)
if err := module.authz.ModifyGrant(
if err := module.authz.Grant(
ctx,
orgID,
existingRoles,
[]string{roleName},
authtypes.MustNewSubject(authtypes.TypeableUser, existingUser.ID.StringValue(), existingUser.OrgID, nil),
); err != nil {
@@ -897,20 +884,7 @@ func (module *setter) AddUserRole(ctx context.Context, orgID, userID valuer.UUID
// create user_role entry
userRoles := authtypes.NewUserRoles(userID, foundRoles)
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
err = module.userRoleStore.DeleteUserRoles(ctx, existingUser.ID)
if err != nil {
return err
}
err := module.userRoleStore.CreateUserRoles(ctx, userRoles)
if err != nil {
return err
}
return nil
})
if err != nil {
if err := module.userRoleStore.CreateUserRoles(ctx, userRoles); err != nil {
return err
}

View File

@@ -419,10 +419,12 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
}
nonExistentMetrics := []string{}
var dormantMetricsWarningMsg string
// question: should we maintain a list of all internal metrics, cuz what if a user defines a metric with these prefixes?
isInternalMetric := func(n string) bool { return strings.HasPrefix(n, "signoz.") || strings.HasPrefix(n, "signoz_") }
if len(missingMetrics) > 0 {
lastSeenInfo, _ := q.metadataStore.FetchLastSeenInfoMulti(ctx, missingMetrics...)
for _, missingMetricName := range missingMetrics {
if ts, ok := lastSeenInfo[missingMetricName]; ok && ts > 0 {
if ts, ok := lastSeenInfo[missingMetricName]; (ok && ts > 0) || isInternalMetric(missingMetricName) {
continue
}
nonExistentMetrics = append(nonExistentMetrics, missingMetricName)
@@ -437,7 +439,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
ago := humanize.RelTime(time.UnixMilli(ts), time.Now(), "ago", "from now")
return fmt.Sprintf("%s (last seen %s)", name, ago)
}
return name // this case won't come cuz lastSeenStr is never called for metrics in nonExistentMetrics
return name // this case will come only for internal metrics
}
if len(missingMetrics) == 1 {
dormantMetricsWarningMsg = fmt.Sprintf("no data found for the metric %s in the query time range", lastSeenStr(missingMetrics[0]))
@@ -450,7 +452,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
}
}
preseededResults := make(map[string]any)
for _, name := range missingMetricQueries { // at this point missing metrics will not have any non existent metrics, only normal ones
for _, name := range missingMetricQueries {
switch req.RequestType {
case qbtypes.RequestTypeTimeSeries:
preseededResults[name] = &qbtypes.TimeSeriesData{QueryName: name}

View File

@@ -139,6 +139,9 @@ func WithRuleStateHistoryModule(module rulestatehistory.Module) RuleOption {
}
func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, opts ...RuleOption) (*BaseRule, error) {
if p.RuleCondition == nil || !p.RuleCondition.IsValid() {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid rule condition")
}
threshold, err := p.RuleCondition.Thresholds.GetRuleThreshold()
if err != nil {
return nil, err

View File

@@ -320,38 +320,6 @@ func (m *Manager) Stop(_ context.Context) {
m.logger.Info("rule manager stopped")
}
// validateChannels checks that every channel referenced by the rule
// exists as a notification channel for the given org.
func (m *Manager) validateChannels(ctx context.Context, orgID string, rule *ruletypes.PostableRule) error {
channels := rule.Channels()
if len(channels) == 0 {
return nil
}
orgChannels, err := m.alertmanager.ListChannels(ctx, orgID)
if err != nil {
return err
}
known := make(map[string]struct{}, len(orgChannels))
for _, ch := range orgChannels {
known[ch.Name] = struct{}{}
}
var unknown []string
for _, name := range channels {
if _, ok := known[name]; !ok {
unknown = append(unknown, name)
}
}
if len(unknown) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"channels: the following channels do not exist: %v", unknown)
}
return nil
}
// EditRule writes the rule definition to the
// datastore and also updates the rule executor
func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error {
@@ -368,12 +336,7 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID)
if err != nil {
return err
}
if err := parsedRule.Validate(); err != nil {
return err
}
if err := m.validateChannels(ctx, claims.OrgID, &parsedRule); err != nil {
return err
}
existingRule, err := m.ruleStore.GetStoredRule(ctx, id)
if err != nil {
return err
@@ -570,12 +533,7 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
if err != nil {
return nil, err
}
if err := parsedRule.Validate(); err != nil {
return nil, err
}
if err := m.validateChannels(ctx, claims.OrgID, &parsedRule); err != nil {
return nil, err
}
now := time.Now()
storedRule := &ruletypes.Rule{
Identifiable: types.Identifiable{
@@ -962,12 +920,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
if err := storedRule.Validate(); err != nil {
return nil, err
}
if err := m.validateChannels(ctx, claims.OrgID, &storedRule); err != nil {
return nil, err
}
// deploy or un-deploy task according to patched (new) rule state
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
m.logger.ErrorContext(ctx, "failed to sync stored rule state with the task", slog.String("task.name", taskName), errors.Attr(err))
@@ -1018,12 +971,6 @@ func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleS
if err != nil {
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to unmarshal rule")
}
if err := parsedRule.Validate(); err != nil {
return 0, err
}
if err := m.validateChannels(ctx, orgID.StringValue(), &parsedRule); err != nil {
return 0, err
}
if !parsedRule.NotificationSettings.UsePolicy {
parsedRule.NotificationSettings.GroupBy = append(parsedRule.NotificationSettings.GroupBy, ruletypes.LabelThresholdName)
}

View File

@@ -437,7 +437,6 @@ func New(
tokenizer,
config,
modules.AuthDomain,
modules.ServiceAccount,
}
// Initialize stats reporter from the available stats reporter provider factories

View File

@@ -136,5 +136,4 @@ type RouteStore interface {
GetAllByKind(ctx context.Context, orgID string, kind ExpressionKind) ([]*RoutePolicy, error)
GetAllByName(ctx context.Context, orgID string, name string) ([]*RoutePolicy, error)
DeleteRouteByName(ctx context.Context, orgID string, name string) error
GetAll(ctx context.Context, orgID string) ([]*RoutePolicy, error)
}

View File

@@ -103,7 +103,7 @@ type RuleCondition struct {
MatchType MatchType `json:"matchType"`
TargetUnit string `json:"targetUnit,omitempty"`
Algorithm string `json:"algorithm,omitempty"`
Seasonality Seasonality `json:"seasonality,omitzero"`
Seasonality string `json:"seasonality,omitempty"`
SelectedQuery string `json:"selectedQueryName,omitempty"`
RequireMinPoints bool `json:"requireMinPoints,omitempty"`
RequiredNumPoints int `json:"requiredNumPoints,omitempty"`
@@ -158,6 +158,10 @@ func (rc *RuleCondition) SelectedQueryName() string {
return keys[len(keys)-1]
}
func (rc *RuleCondition) IsValid() bool {
return true
}
// ShouldEval checks if the further series should be evaluated at all for alerts.
func (rc *RuleCondition) ShouldEval(series *qbtypes.TimeSeries) bool {
return !rc.RequireMinPoints || len(series.Values) >= rc.RequiredNumPoints

View File

@@ -12,7 +12,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -26,8 +25,7 @@ const (
)
const (
DefaultSchemaVersion = "v1"
SchemaVersionV2Alpha1 = "v2alpha1"
DefaultSchemaVersion = "v1"
)
type RuleDataKind string
@@ -41,9 +39,9 @@ type PostableRule struct {
AlertName string `json:"alert"`
AlertType AlertType `json:"alertType,omitempty"`
Description string `json:"description,omitempty"`
RuleType RuleType `json:"ruleType,omitzero"`
EvalWindow valuer.TextDuration `json:"evalWindow,omitzero"`
Frequency valuer.TextDuration `json:"frequency,omitzero"`
RuleType RuleType `json:"ruleType,omitempty"`
EvalWindow valuer.TextDuration `json:"evalWindow,omitempty"`
Frequency valuer.TextDuration `json:"frequency,omitempty"`
RuleCondition *RuleCondition `json:"condition,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
@@ -66,7 +64,7 @@ type PostableRule struct {
type NotificationSettings struct {
GroupBy []string `json:"groupBy,omitempty"`
Renotify Renotify `json:"renotify,omitzero"`
Renotify Renotify `json:"renotify,omitempty"`
UsePolicy bool `json:"usePolicy,omitempty"`
// NewGroupEvalDelay is the grace period for new series to be excluded from alerts evaluation
NewGroupEvalDelay valuer.TextDuration `json:"newGroupEvalDelay,omitzero"`
@@ -95,28 +93,6 @@ func (ns *NotificationSettings) GetAlertManagerNotificationConfig() alertmanager
return alertmanagertypes.NewNotificationConfig(ns.GroupBy, renotifyInterval, noDataRenotifyInterval, ns.UsePolicy)
}
// Channels returns all unique channel names referenced by the rule's thresholds.
func (r *PostableRule) Channels() []string {
if r.RuleCondition == nil || r.RuleCondition.Thresholds == nil {
return nil
}
threshold, err := r.RuleCondition.Thresholds.GetRuleThreshold()
if err != nil {
return nil
}
seen := make(map[string]struct{})
var channels []string
for _, receiver := range threshold.GetRuleReceivers() {
for _, ch := range receiver.Channels {
if _, ok := seen[ch]; !ok {
seen[ch] = struct{}{}
channels = append(channels, ch)
}
}
}
return channels
}
func (r *PostableRule) GetRuleRouteRequest(ruleID string) ([]*alertmanagertypes.PostableRoutePolicy, error) {
threshold, err := r.RuleCondition.Thresholds.GetRuleThreshold()
if err != nil {
@@ -209,19 +185,15 @@ func (r *PostableRule) processRuleDefaults() {
r.SchemaVersion = DefaultSchemaVersion
}
// v2alpha1 uses the Evaluation envelope for window/frequency;
// only default top-level fields for v1.
if r.SchemaVersion != SchemaVersionV2Alpha1 {
if r.EvalWindow.IsZero() {
r.EvalWindow = valuer.MustParseTextDuration("5m")
}
if r.Frequency.IsZero() {
r.Frequency = valuer.MustParseTextDuration("1m")
}
if r.EvalWindow.IsZero() {
r.EvalWindow = valuer.MustParseTextDuration("5m")
}
if r.RuleCondition != nil && r.RuleCondition.CompositeQuery != nil {
if r.Frequency.IsZero() {
r.Frequency = valuer.MustParseTextDuration("1m")
}
if r.RuleCondition != nil {
switch r.RuleCondition.CompositeQuery.QueryType {
case QueryTypeBuilder:
if r.RuleType.IsZero() {
@@ -287,10 +259,6 @@ func (r *PostableRule) MarshalJSON() ([]byte, error) {
aux.SchemaVersion = ""
aux.NotificationSettings = nil
return json.Marshal(aux)
case SchemaVersionV2Alpha1:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
default:
copyStruct := *r
aux := Alias(copyStruct)
@@ -324,24 +292,23 @@ func isValidLabelValue(v string) bool {
return utf8.ValidString(v)
}
// validate runs during UnmarshalJSON (read + write path).
// Preserves the original pre-existing checks only so that stored rules
// continue to load without errors.
func (r *PostableRule) validate() error {
var errs []error
if r.RuleCondition == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
return errors.NewInvalidInputf(errors.CodeInvalidInput, "rule condition is required")
}
if r.Version != "v5" {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "only version v5 is supported, got %q", r.Version))
}
for k, v := range r.Labels {
if !isValidLabelName(k) {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label name: %s", k))
}
if !isValidLabelValue(v) {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label value: %s", v))
}
@@ -354,196 +321,7 @@ func (r *PostableRule) validate() error {
}
errs = append(errs, testTemplateParsing(r)...)
joined := errors.Join(errs...)
if joined != nil {
return errors.WrapInvalidInputf(joined, errors.CodeInvalidInput, "validation failed")
}
return nil
}
// Validate enforces all validation rules. For now, this is invoked on the write path
// (create, update, patch, test) before persisting. This is intentionally
// not called from UnmarshalJSON so that existing stored rules can always
// be loaded regardless of new validation rules.
func (r *PostableRule) Validate() error {
var errs []error
if r.AlertName == "" {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "alert: field is required"))
}
if r.RuleCondition == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
}
if r.Version != "v5" {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
}
if r.AlertType != "" {
switch r.AlertType {
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions:
default:
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"alertType: unsupported value %q; must be one of %q, %q, %q, %q",
r.AlertType, AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions))
}
}
if !r.RuleType.IsZero() {
if err := r.RuleType.Validate(); err != nil {
errs = append(errs, err)
}
}
if r.RuleType == RuleTypeAnomaly && !r.RuleCondition.Seasonality.IsZero() {
if err := r.RuleCondition.Seasonality.Validate(); err != nil {
errs = append(errs, err)
}
}
if r.RuleCondition.CompositeQuery == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery: field is required"))
} else {
if len(r.RuleCondition.CompositeQuery.Queries) == 0 {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.queries: must have at least one query"))
} else {
cq := &qbtypes.CompositeQuery{Queries: r.RuleCondition.CompositeQuery.Queries}
if err := cq.Validate(qbtypes.GetValidationOptions(qbtypes.RequestTypeTimeSeries)...); err != nil {
errs = append(errs, err)
}
}
}
if r.RuleCondition.SelectedQuery != "" && r.RuleCondition.CompositeQuery != nil && len(r.RuleCondition.CompositeQuery.Queries) > 0 {
found := false
for _, query := range r.RuleCondition.CompositeQuery.Queries {
if query.GetQueryName() == r.RuleCondition.SelectedQuery {
found = true
break
}
}
if !found {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.selectedQueryName: %q does not match any query in compositeQuery",
r.RuleCondition.SelectedQuery))
}
}
if r.RuleCondition.RequireMinPoints && r.RuleCondition.RequiredNumPoints <= 0 {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.requiredNumPoints: must be greater than 0 when requireMinPoints is enabled"))
}
errs = append(errs, r.validateSchemaVersion()...)
for k, v := range r.Labels {
if !isValidLabelName(k) {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label name: %s", k))
}
if !isValidLabelValue(v) {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label value: %s", v))
}
}
for k := range r.Annotations {
if !isValidLabelName(k) {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid annotation name: %s", k))
}
}
errs = append(errs, testTemplateParsing(r)...)
joined := errors.Join(errs...)
if joined != nil {
return errors.WrapInvalidInputf(joined, errors.CodeInvalidInput, "validation failed")
}
return nil
}
func (r *PostableRule) validateSchemaVersion() []error {
switch r.SchemaVersion {
case DefaultSchemaVersion:
return r.validateV1()
case SchemaVersionV2Alpha1:
return r.validateV2Alpha1()
default:
return []error{errors.NewInvalidInputf(errors.CodeInvalidInput,
"schemaVersion: unsupported value %q; must be one of %q, %q",
r.SchemaVersion, DefaultSchemaVersion, SchemaVersionV2Alpha1)}
}
}
func (r *PostableRule) validateV1() []error {
var errs []error
if r.RuleCondition.Target == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.target: field is required for schemaVersion %q", DefaultSchemaVersion))
}
if r.RuleCondition.CompareOperator.IsZero() {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.op: field is required for schemaVersion %q", DefaultSchemaVersion))
} else if err := r.RuleCondition.CompareOperator.Validate(); err != nil {
errs = append(errs, err)
}
if r.RuleCondition.MatchType.IsZero() {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.matchType: field is required for schemaVersion %q", DefaultSchemaVersion))
} else if err := r.RuleCondition.MatchType.Validate(); err != nil {
errs = append(errs, err)
}
return errs
}
func (r *PostableRule) validateV2Alpha1() []error {
var errs []error
// TODO(srikanthccv): reject v1-only fields?
// if r.RuleCondition.Target != nil {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "condition.target: field is not used in schemaVersion %q; set target in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// if !r.RuleCondition.CompareOperator.IsZero() {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "condition.op: field is not used in schemaVersion %q; set op in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// if !r.RuleCondition.MatchType.IsZero() {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "condition.matchType: field is not used in schemaVersion %q; set matchType in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// if len(r.PreferredChannels) > 0 {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "preferredChannels: field is not used in schemaVersion %q; set channels in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// Require v2alpha1-specific fields
if r.RuleCondition.Thresholds == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.thresholds: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
}
if r.Evaluation == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"evaluation: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
}
if r.NotificationSettings == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"notificationSettings: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
} else {
if r.NotificationSettings.Renotify.Enabled && !r.NotificationSettings.Renotify.ReNotifyInterval.IsPositive() {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"notificationSettings.renotify.interval: must be a positive duration when renotify is enabled"))
}
}
return errs
return errors.Join(errs...)
}
func testTemplateParsing(rl *PostableRule) (errs []error) {
@@ -615,10 +393,6 @@ func (g *GettableRule) MarshalJSON() ([]byte, error) {
aux.SchemaVersion = ""
aux.NotificationSettings = nil
return json.Marshal(aux)
case SchemaVersionV2Alpha1:
copyStruct := *g
aux := Alias(copyStruct)
return json.Marshal(aux)
default:
copyStruct := *g
aux := Alias(copyStruct)

View File

@@ -34,15 +34,15 @@ func TestParseIntoRule(t *testing.T) {
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
"stepInterval": "5m"
"builderQueries": {
"A": {
"expression": "A",
"disabled": false,
"aggregateAttribute": {
"key": "test_metric"
}
}
}]
}
},
"target": 10.0,
"matchType": "1",
@@ -77,15 +77,14 @@ func TestParseIntoRule(t *testing.T) {
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
"stepInterval": "5m"
"builderQueries": {
"A": {
"disabled": false,
"aggregateAttribute": {
"key": "test_metric"
}
}
}]
}
},
"target": 5.0,
"matchType": "1",
@@ -113,14 +112,12 @@ func TestParseIntoRule(t *testing.T) {
"condition": {
"compositeQuery": {
"queryType": "promql",
"queries": [{
"type": "promql",
"spec": {
"name": "A",
"promQueries": {
"A": {
"query": "rate(http_requests_total[5m])",
"disabled": false
}
}]
}
},
"target": 10.0,
"matchType": "1",
@@ -168,13 +165,12 @@ func TestParseIntoRule(t *testing.T) {
func TestParseIntoRuleSchemaVersioning(t *testing.T) {
tests := []struct {
name string
initRule PostableRule
content []byte
kind RuleDataKind
expectError bool // unmarshal error (read path)
expectValidateError bool // Validate() error (write path only)
validate func(*testing.T, *PostableRule)
name string
initRule PostableRule
content []byte
kind RuleDataKind
expectError bool
validate func(*testing.T, *PostableRule)
}{
{
name: "schema v1 - threshold name from severity label",
@@ -186,15 +182,13 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
"builderQueries": {
"A": {
"aggregateAttribute": {
"key": "cpu_usage"
}
}
}],
},
"unit": "percent"
},
"target": 85.0,
@@ -277,15 +271,13 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "memory_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
"builderQueries": {
"A": {
"aggregateAttribute": {
"key": "memory_usage"
}
}
}]
}
},
"target": 90.0,
"matchType": "1",
@@ -320,15 +312,13 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
"builderQueries": {
"A": {
"aggregateAttribute": {
"key": "cpu_usage"
}
}
}],
},
"unit": "percent"
},
"target": 80.0,
@@ -404,254 +394,50 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
},
},
{
name: "schema v2alpha1 - uses explicit thresholds and evaluation",
name: "schema v2 - does not populate thresholds and evaluation",
initRule: PostableRule{},
content: []byte(`{
"alert": "V2Alpha1Test",
"schemaVersion": "v2alpha1",
"alert": "V2Test",
"schemaVersion": "v2",
"version": "v5",
"ruleType": "threshold_rule",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
"stepInterval": "5m"
"builderQueries": {
"A": {
"aggregateAttribute": {
"key": "test_metric"
}
}
}]
}
},
"thresholds": {
"kind": "basic",
"spec": [{
"name": "critical",
"target": 100.0,
"matchType": "1",
"op": "1"
}]
}
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m",
"frequency": "1m"
}
},
"notificationSettings": {
"renotify": {
"enabled": true,
"interval": "4h",
"alertStates": ["firing"]
}
"target": 100.0,
"matchType": "1",
"op": "1"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.SchemaVersion != SchemaVersionV2Alpha1 {
t.Errorf("Expected schemaVersion %q, got %q", SchemaVersionV2Alpha1, rule.SchemaVersion)
if rule.SchemaVersion != "v2" {
t.Errorf("Expected schemaVersion 'v2', got '%s'", rule.SchemaVersion)
}
if rule.RuleCondition.Thresholds == nil {
t.Error("Expected Thresholds to be present for v2alpha1")
if rule.RuleCondition.Thresholds != nil {
t.Error("Expected Thresholds to be nil for v2")
}
if rule.Evaluation == nil {
t.Error("Expected Evaluation to be present for v2alpha1")
if rule.Evaluation != nil {
t.Error("Expected Evaluation to be nil for v2")
}
if rule.NotificationSettings == nil {
t.Error("Expected NotificationSettings to be present for v2alpha1")
if rule.EvalWindow.Duration() != 5*time.Minute {
t.Error("Expected default EvalWindow to be applied")
}
if rule.RuleType != RuleTypeThreshold {
t.Error("Expected RuleType to be auto-detected")
}
},
},
{
name: "schema v2alpha1 - rejects v1-only fields with suggestions",
initRule: PostableRule{},
content: []byte(`{
"alert": "MixedFieldsTest",
"schemaVersion": "v2alpha1",
"version": "v5",
"ruleType": "threshold_rule",
"preferredChannels": ["slack"],
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 100.0,
"matchType": "1",
"op": "1"
}
}`),
kind: RuleDataKindJson,
expectValidateError: true,
},
{
name: "schema v2alpha1 - requires evaluation",
initRule: PostableRule{},
content: []byte(`{
"alert": "MissingEvalTest",
"schemaVersion": "v2alpha1",
"version": "v5",
"ruleType": "threshold_rule",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"thresholds": {
"kind": "basic",
"spec": [{
"name": "critical",
"target": 100.0,
"matchType": "1",
"op": "1"
}]
}
},
"notificationSettings": {
"renotify": {
"enabled": true,
"interval": "4h",
"alertStates": ["firing"]
}
}
}`),
kind: RuleDataKindJson,
expectValidateError: true,
},
{
name: "schema v2alpha1 - requires notificationSettings",
initRule: PostableRule{},
content: []byte(`{
"alert": "MissingNotifTest",
"schemaVersion": "v2alpha1",
"version": "v5",
"ruleType": "threshold_rule",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"thresholds": {
"kind": "basic",
"spec": [{
"name": "critical",
"target": 100.0,
"matchType": "1",
"op": "1"
}]
}
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m",
"frequency": "1m"
}
}
}`),
kind: RuleDataKindJson,
expectValidateError: true,
},
{
name: "schema v2alpha1 - requires thresholds for non-promql rules",
initRule: PostableRule{},
content: []byte(`{
"alert": "MissingThresholdsTest",
"schemaVersion": "v2alpha1",
"version": "v5",
"ruleType": "threshold_rule",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
}
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m",
"frequency": "1m"
}
},
"notificationSettings": {
"renotify": {
"enabled": true,
"interval": "4h",
"alertStates": ["firing"]
}
}
}`),
kind: RuleDataKindJson,
expectValidateError: true,
},
{
name: "unsupported schema version",
initRule: PostableRule{},
content: []byte(`{
"alert": "BadSchemaTest",
"schemaVersion": "v3",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 100.0,
"matchType": "1",
"op": "1"
}
}`),
kind: RuleDataKindJson,
expectValidateError: true,
},
{
name: "default schema version - defaults to v1 behavior",
initRule: PostableRule{},
@@ -661,15 +447,13 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
"stepInterval": "5m"
"builderQueries": {
"A": {
"aggregateAttribute": {
"key": "test_metric"
}
}
}]
}
},
"target": 75.0,
"matchType": "1",
@@ -696,23 +480,13 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
rule := tt.initRule
err := json.Unmarshal(tt.content, &rule)
if tt.expectError {
if err == nil {
t.Errorf("Expected unmarshal error but got none")
}
return
if tt.expectError && err == nil {
t.Errorf("Expected error but got none")
}
if err != nil {
t.Errorf("Unexpected unmarshal error: %v", err)
return
if !tt.expectError && err != nil {
t.Errorf("Unexpected error: %v", err)
}
if tt.expectValidateError {
if err := rule.Validate(); err == nil {
t.Errorf("Expected Validate() error but got none")
}
return
}
if tt.validate != nil {
if tt.validate != nil && err == nil {
tt.validate(t, &rule)
}
})
@@ -726,15 +500,15 @@ func TestParseIntoRuleThresholdGeneration(t *testing.T) {
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "response_time", "spaceAggregation": "p50"}],
"stepInterval": "5m"
"builderQueries": {
"A": {
"expression": "A",
"disabled": false,
"aggregateAttribute": {
"key": "response_time"
}
}
}]
}
},
"target": 100.0,
"matchType": "1",
@@ -797,7 +571,7 @@ func TestParseIntoRuleThresholdGeneration(t *testing.T) {
func TestParseIntoRuleMultipleThresholds(t *testing.T) {
content := []byte(`{
"schemaVersion": "v2alpha1",
"schemaVersion": "v2",
"alert": "MultiThresholdAlert",
"ruleType": "threshold_rule",
"version": "v5",
@@ -805,16 +579,19 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
"compositeQuery": {
"queryType": "builder",
"unit": "%",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
"builderQueries": {
"A": {
"expression": "A",
"disabled": false,
"aggregateAttribute": {
"key": "cpu_usage"
}
}
}]
}
},
"target": 90.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A",
"thresholds": {
"kind": "basic",
@@ -839,20 +616,6 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
}
]
}
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m",
"frequency": "1m"
}
},
"notificationSettings": {
"renotify": {
"enabled": true,
"interval": "4h",
"alertStates": ["firing"]
}
}
}`)
rule := PostableRule{}

View File

@@ -54,29 +54,6 @@ func (CompareOperator) Enum() []any {
}
}
// Normalize returns the canonical (numeric) form of the operator.
// This ensures evaluation logic can use simple == checks against the canonical values.
func (c CompareOperator) Normalize() CompareOperator {
switch c {
case ValueIsAbove, ValueIsAboveLiteral, ValueIsAboveSymbol:
return ValueIsAbove
case ValueIsBelow, ValueIsBelowLiteral, ValueIsBelowSymbol:
return ValueIsBelow
case ValueIsEq, ValueIsEqLiteral, ValueIsEqLiteralShort, ValueIsEqSymbol:
return ValueIsEq
case ValueIsNotEq, ValueIsNotEqLiteral, ValueIsNotEqLiteralShort, ValueIsNotEqSymbol:
return ValueIsNotEq
case ValueAboveOrEq, ValueAboveOrEqLiteral, ValueAboveOrEqLiteralShort, ValueAboveOrEqSymbol:
return ValueAboveOrEq
case ValueBelowOrEq, ValueBelowOrEqLiteral, ValueBelowOrEqLiteralShort, ValueBelowOrEqSymbol:
return ValueBelowOrEq
case ValueOutsideBounds, ValueOutsideBoundsLiteral:
return ValueOutsideBounds
default:
return c
}
}
func (c CompareOperator) Validate() error {
switch c {
case ValueIsAbove,
@@ -93,18 +70,10 @@ func (c CompareOperator) Validate() error {
ValueIsNotEqLiteral,
ValueIsNotEqLiteralShort,
ValueIsNotEqSymbol,
ValueAboveOrEq,
ValueAboveOrEqLiteral,
ValueAboveOrEqLiteralShort,
ValueAboveOrEqSymbol,
ValueBelowOrEq,
ValueBelowOrEqLiteral,
ValueBelowOrEqLiteralShort,
ValueBelowOrEqSymbol,
ValueOutsideBounds,
ValueOutsideBoundsLiteral:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.op: unsupported value %q; must be one of above, below, equal, not_equal, above_or_equal, below_or_equal, outside_bounds", c.StringValue())
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown comparison operator, known values are: ")
}
}

View File

@@ -11,7 +11,7 @@ type MatchType struct {
var (
AtleastOnce = MatchType{valuer.NewString("1")}
AtleastOnceLiteral = MatchType{valuer.NewString("at_least_once")}
AtleastOnceLiteral = MatchType{valuer.NewString("atleast_once")}
AllTheTimes = MatchType{valuer.NewString("2")}
AllTheTimesLiteral = MatchType{valuer.NewString("all_the_times")}
@@ -38,24 +38,6 @@ func (MatchType) Enum() []any {
}
}
// Normalize returns the canonical (numeric) form of the match type.
func (m MatchType) Normalize() MatchType {
switch m {
case AtleastOnce, AtleastOnceLiteral:
return AtleastOnce
case AllTheTimes, AllTheTimesLiteral:
return AllTheTimes
case OnAverage, OnAverageLiteral, OnAverageShort:
return OnAverage
case InTotal, InTotalLiteral, InTotalShort:
return InTotal
case Last, LastLiteral:
return Last
default:
return m
}
}
func (m MatchType) Validate() error {
switch m {
case
@@ -73,6 +55,6 @@ func (m MatchType) Validate() error {
LastLiteral:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.matchType: unsupported value %q; must be one of at_least_once, all_the_times, on_average, in_total, last", m.StringValue())
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown match type operator, known values are")
}
}

View File

@@ -31,6 +31,6 @@ func (r RuleType) Validate() error {
RuleTypeAnomaly:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "ruleType: unsupported value %q; must be one of threshold_rule, promql_rule, anomaly_rule", r.StringValue())
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown rule type, known values are")
}
}

View File

@@ -1,35 +0,0 @@
package ruletypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Seasonality struct {
valuer.String
}
var (
SeasonalityHourly = Seasonality{valuer.NewString("hourly")}
SeasonalityDaily = Seasonality{valuer.NewString("daily")}
SeasonalityWeekly = Seasonality{valuer.NewString("weekly")}
)
func (Seasonality) Enum() []any {
return []any{
SeasonalityHourly,
SeasonalityDaily,
SeasonalityWeekly,
}
}
func (s Seasonality) Validate() error {
switch s {
case SeasonalityHourly, SeasonalityDaily, SeasonalityWeekly:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.seasonality: unsupported value %q; must be one of hourly, daily, weekly",
s.StringValue())
}
}

View File

@@ -113,9 +113,6 @@ func (r BasicRuleThresholds) GetRuleReceivers() []RuleReceivers {
}
func (r BasicRuleThresholds) Validate() error {
if len(r) == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.thresholds.spec: must have at least one threshold")
}
var errs []error
for _, basicThreshold := range r {
if err := basicThreshold.Validate(); err != nil {
@@ -192,7 +189,7 @@ func sortThresholds(thresholds []BasicRuleThreshold) {
targetI := thresholds[i].target(thresholds[i].TargetUnit) //for sorting we dont need rule unit
targetJ := thresholds[j].target(thresholds[j].TargetUnit)
switch thresholds[i].CompareOperator.Normalize() {
switch thresholds[i].CompareOperator {
case ValueIsAbove, ValueAboveOrEq, ValueOutsideBounds:
// For "above" operations, sort descending (higher values first)
return targetI > targetJ
@@ -237,11 +234,16 @@ func (b BasicRuleThreshold) Validate() error {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "target value cannot be nil"))
}
if err := b.CompareOperator.Validate(); err != nil {
errs = append(errs, err)
switch b.CompareOperator {
case ValueIsAbove, ValueIsBelow, ValueIsEq, ValueIsNotEq, ValueAboveOrEq, ValueBelowOrEq, ValueOutsideBounds:
default:
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid compare operation: %s", b.CompareOperator.StringValue()))
}
if err := b.MatchType.Validate(); err != nil {
errs = append(errs, err)
switch b.MatchType {
case AtleastOnce, AllTheTimes, OnAverage, InTotal, Last:
default:
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid match type: %s", b.MatchType.StringValue()))
}
return errors.Join(errs...)
@@ -266,33 +268,6 @@ func PrepareSampleLabelsForRule(seriesLabels []*qbtypes.Label, thresholdName str
return lb.Labels()
}
// matchesCompareOp checks if a value matches the compare operator against target.
func matchesCompareOp(op CompareOperator, value, target float64) bool {
switch op {
case ValueIsAbove:
return value > target
case ValueIsBelow:
return value < target
case ValueIsEq:
return value == target
case ValueIsNotEq:
return value != target
case ValueAboveOrEq:
return value >= target
case ValueBelowOrEq:
return value <= target
case ValueOutsideBounds:
return math.Abs(value) >= target
default:
return false
}
}
// negatesCompareOp checks if a value does NOT match the compare operator against target.
func negatesCompareOp(op CompareOperator, value, target float64) bool {
return !matchesCompareOp(op, value, target)
}
func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, target float64) (Sample, bool) {
var shouldAlert bool
var alertSmpl Sample
@@ -303,35 +278,63 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
return alertSmpl, false
}
// Normalize to canonical forms so evaluation uses simple == checks
op := b.CompareOperator.Normalize()
matchType := b.MatchType.Normalize()
switch matchType {
switch b.MatchType {
case AtleastOnce:
// If any sample matches the condition, the rule is firing.
for _, smpl := range series.Values {
if matchesCompareOp(op, smpl.Value, target) {
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
shouldAlert = true
break
if b.CompareOperator == ValueIsAbove {
for _, smpl := range series.Values {
if smpl.Value > target {
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
shouldAlert = true
break
}
}
} else if b.CompareOperator == ValueIsBelow {
for _, smpl := range series.Values {
if smpl.Value < target {
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
shouldAlert = true
break
}
}
} else if b.CompareOperator == ValueIsEq {
for _, smpl := range series.Values {
if smpl.Value == target {
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
shouldAlert = true
break
}
}
} else if b.CompareOperator == ValueIsNotEq {
for _, smpl := range series.Values {
if smpl.Value != target {
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
shouldAlert = true
break
}
}
} else if b.CompareOperator == ValueOutsideBounds {
for _, smpl := range series.Values {
if math.Abs(smpl.Value) >= target {
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
shouldAlert = true
break
}
}
}
case AllTheTimes:
// If all samples match the condition, the rule is firing.
shouldAlert = true
alertSmpl = Sample{Point: Point{V: target}, Metric: lbls}
for _, smpl := range series.Values {
if negatesCompareOp(op, smpl.Value, target) {
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
shouldAlert = false
break
if b.CompareOperator == ValueIsAbove {
for _, smpl := range series.Values {
if smpl.Value <= target {
shouldAlert = false
break
}
}
}
if shouldAlert {
switch op {
case ValueIsAbove, ValueAboveOrEq, ValueOutsideBounds:
// use min value from the series
// use min value from the series
if shouldAlert {
var minValue = math.Inf(1)
for _, smpl := range series.Values {
if smpl.Value < minValue {
@@ -339,8 +342,15 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
}
}
alertSmpl = Sample{Point: Point{V: minValue}, Metric: lbls}
case ValueIsBelow, ValueBelowOrEq:
// use max value from the series
}
} else if b.CompareOperator == ValueIsBelow {
for _, smpl := range series.Values {
if smpl.Value >= target {
shouldAlert = false
break
}
}
if shouldAlert {
var maxValue = math.Inf(-1)
for _, smpl := range series.Values {
if smpl.Value > maxValue {
@@ -348,8 +358,23 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
}
}
alertSmpl = Sample{Point: Point{V: maxValue}, Metric: lbls}
case ValueIsNotEq:
// use any non-inf and non-nan value from the series
}
} else if b.CompareOperator == ValueIsEq {
for _, smpl := range series.Values {
if smpl.Value != target {
shouldAlert = false
break
}
}
} else if b.CompareOperator == ValueIsNotEq {
for _, smpl := range series.Values {
if smpl.Value == target {
shouldAlert = false
break
}
}
// use any non-inf or nan value from the series
if shouldAlert {
for _, smpl := range series.Values {
if !math.IsInf(smpl.Value, 0) && !math.IsNaN(smpl.Value) {
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
@@ -357,6 +382,14 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
}
}
}
} else if b.CompareOperator == ValueOutsideBounds {
for _, smpl := range series.Values {
if math.Abs(smpl.Value) < target {
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
shouldAlert = false
break
}
}
}
case OnAverage:
// If the average of all samples matches the condition, the rule is firing.
@@ -370,10 +403,32 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
}
avg := sum / count
alertSmpl = Sample{Point: Point{V: avg}, Metric: lbls}
shouldAlert = matchesCompareOp(op, avg, target)
switch b.CompareOperator {
case ValueIsAbove:
if avg > target {
shouldAlert = true
}
case ValueIsBelow:
if avg < target {
shouldAlert = true
}
case ValueIsEq:
if avg == target {
shouldAlert = true
}
case ValueIsNotEq:
if avg != target {
shouldAlert = true
}
case ValueOutsideBounds:
if math.Abs(avg) >= target {
shouldAlert = true
}
}
case InTotal:
// If the sum of all samples matches the condition, the rule is firing.
var sum float64
for _, smpl := range series.Values {
if math.IsNaN(smpl.Value) || math.IsInf(smpl.Value, 0) {
continue
@@ -381,12 +436,50 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
sum += smpl.Value
}
alertSmpl = Sample{Point: Point{V: sum}, Metric: lbls}
shouldAlert = matchesCompareOp(op, sum, target)
switch b.CompareOperator {
case ValueIsAbove:
if sum > target {
shouldAlert = true
}
case ValueIsBelow:
if sum < target {
shouldAlert = true
}
case ValueIsEq:
if sum == target {
shouldAlert = true
}
case ValueIsNotEq:
if sum != target {
shouldAlert = true
}
case ValueOutsideBounds:
if math.Abs(sum) >= target {
shouldAlert = true
}
}
case Last:
// If the last sample matches the condition, the rule is firing.
lastValue := series.Values[len(series.Values)-1].Value
alertSmpl = Sample{Point: Point{V: lastValue}, Metric: lbls}
shouldAlert = matchesCompareOp(op, lastValue, target)
shouldAlert = false
alertSmpl = Sample{Point: Point{V: series.Values[len(series.Values)-1].Value}, Metric: lbls}
switch b.CompareOperator {
case ValueIsAbove:
if series.Values[len(series.Values)-1].Value > target {
shouldAlert = true
}
case ValueIsBelow:
if series.Values[len(series.Values)-1].Value < target {
shouldAlert = true
}
case ValueIsEq:
if series.Values[len(series.Values)-1].Value == target {
shouldAlert = true
}
case ValueIsNotEq:
if series.Values[len(series.Values)-1].Value != target {
shouldAlert = true
}
}
}
return alertSmpl, shouldAlert
}

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,6 @@ var (
ErrCodeAPIKeyAlreadyExists = errors.MustNewCode("api_key_already_exists")
ErrCodeAPIKeytNotFound = errors.MustNewCode("api_key_not_found")
ErrCodeAPIKeyExpired = errors.MustNewCode("api_key_expired")
errInvalidAPIKeyName = errors.New(errors.TypeInvalidInput, ErrCodeAPIKeyInvalidInput, "name must be 180 characters long and contain only lowercase letters (a-z) and hyphens (-)")
)
type FactorAPIKey struct {
@@ -113,7 +112,7 @@ func (key *PostableFactorAPIKey) UnmarshalJSON(data []byte) error {
}
if match := factorAPIKeyNameRegex.MatchString(temp.Name); !match {
return errInvalidAPIKeyName
return errors.Newf(errors.TypeInvalidInput, ErrCodeAPIKeyInvalidInput, "name must conform to the regex: %s", factorAPIKeyNameRegex.String())
}
if temp.ExpiresAt != 0 && time.Now().After(time.Unix(int64(temp.ExpiresAt), 0)) {
@@ -133,7 +132,7 @@ func (key *UpdatableFactorAPIKey) UnmarshalJSON(data []byte) error {
}
if match := factorAPIKeyNameRegex.MatchString(temp.Name); !match {
return errInvalidAPIKeyName
return errors.Newf(errors.TypeInvalidInput, ErrCodeAPIKeyInvalidInput, "name must conform to the regex: %s", factorAPIKeyNameRegex.String())
}
if temp.ExpiresAt != 0 && time.Now().After(time.Unix(int64(temp.ExpiresAt), 0)) {

View File

@@ -23,7 +23,6 @@ var (
ErrCodeServiceAccountNotFound = errors.MustNewCode("service_account_not_found")
ErrCodeServiceAccountRoleAlreadyExists = errors.MustNewCode("service_account_role_already_exists")
ErrCodeServiceAccountOperationUnsupported = errors.MustNewCode("service_account_operation_unsupported")
errInvalidServiceAccountName = errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name must be 150 characters long and contain only lowercase letters (a-z) and hyphens (-)")
)
var (
@@ -215,7 +214,7 @@ func (serviceAccount *PostableServiceAccount) UnmarshalJSON(data []byte) error {
}
if match := serviceAccountNameRegex.MatchString(temp.Name); !match {
return errInvalidServiceAccountName
return errors.Newf(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name must conform to the regex: %s", serviceAccountNameRegex.String())
}
*serviceAccount = PostableServiceAccount(temp)
@@ -244,7 +243,6 @@ type Store interface {
// Service Account Role
CreateServiceAccountRole(context.Context, *ServiceAccountRole) error
DeleteServiceAccountRoles(context.Context, valuer.UUID) error
DeleteServiceAccountRole(context.Context, valuer.UUID, valuer.UUID) error
// Service Account Factor API Key

View File

@@ -133,11 +133,11 @@ def test_get_user_roles(
assert "type" in role
def test_assign_role_replaces_previous(
def test_assign_additional_role(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
):
"""Verify POST /api/v2/users/{id}/roles replaces existing role."""
"""Verify POST /api/v2/users/{id}/roles assigns an additional role."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
@@ -166,8 +166,8 @@ def test_assign_role_replaces_previous(
assert response.status_code == HTTPStatus.OK
roles = response.json()["data"]
names = {r["name"] for r in roles}
assert "signoz-admin" in names
assert "signoz-editor" in names
assert "signoz-admin" not in names
def test_get_users_by_role(
@@ -253,7 +253,9 @@ def test_remove_role(
)
assert response.status_code == HTTPStatus.OK
roles_after = response.json()["data"]
assert len(roles_after) == 0
names = {r["name"] for r in roles_after}
assert "signoz-editor" not in names
assert "signoz-admin" in names
def test_user_with_roles_reflects_change(
@@ -280,7 +282,8 @@ def test_user_with_roles_reflects_change(
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
role_names = {ur["role"]["name"] for ur in data["userRoles"]}
assert len(role_names) == 0
assert "signoz-admin" in role_names
assert "signoz-editor" not in role_names
def test_admin_cannot_assign_role_to_self(

View File

@@ -658,3 +658,33 @@ def test_non_existent_metrics_returns_404(
get_error_message(response.json())
== "could not find the metric whatevergoennnsgoeshere"
)
def test_non_existent__internal_metrics_returns_warning(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
metric_name = "signoz_calls_total"
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query(
"A",
metric_name,
"doesnotreallymatter",
"sum",
)
end_ms = int(now.timestamp() * 1000)
start_2h = int((now - timedelta(hours=2)).timestamp() * 1000)
response = make_query_request(signoz, token, start_2h, end_ms, [query])
assert response.status_code == HTTPStatus.OK
data = response.json()
warnings = get_all_warnings(data)
assert len(warnings) == 1
assert warnings[0]["message"].startswith(
f"no data found for the metric {metric_name}"
)

View File

@@ -48,7 +48,7 @@ def test_assign_role_to_service_account(
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""POST /{id}/roles replaces existing role, verify via GET."""
"""POST /{id}/roles assigns a new role, verify via GET."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# create service account with viewer role
@@ -56,7 +56,7 @@ def test_assign_role_to_service_account(
signoz, token, "sa-assign-role", role="signoz-viewer"
)
# assign editor role (replaces viewer)
# assign editor role additionally
editor_role_id = find_role_by_name(signoz, token, "signoz-editor")
assign_resp = requests.post(
signoz.self.host_configs["8080"].get(
@@ -68,7 +68,7 @@ def test_assign_role_to_service_account(
)
assert assign_resp.status_code == HTTPStatus.NO_CONTENT, assign_resp.text
# verify only editor role is present (viewer was replaced)
# verify both roles are present
roles_resp = requests.get(
signoz.self.host_configs["8080"].get(
f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"
@@ -78,9 +78,8 @@ def test_assign_role_to_service_account(
)
assert roles_resp.status_code == HTTPStatus.OK, roles_resp.text
role_names = [r["name"] for r in roles_resp.json()["data"]]
assert len(role_names) == 1
assert "signoz-viewer" in role_names
assert "signoz-editor" in role_names
assert "signoz-viewer" not in role_names
def test_assign_role_idempotent(
@@ -88,7 +87,7 @@ def test_assign_role_idempotent(
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""POST same role twice succeeds (replace with same role is idempotent)."""
"""POST same role twice succeeds (store uses ON CONFLICT DO NOTHING)."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
service_account_id = create_service_account(
signoz, token, "sa-role-idempotent", role="signoz-viewer"
@@ -119,66 +118,6 @@ def test_assign_role_idempotent(
assert role_names.count("signoz-viewer") == 1
def test_assign_role_replaces_access(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""After role replacement, SA loses old permissions and gains new ones."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# create SA with viewer role and an API key
service_account_id, api_key = create_service_account_with_key(
signoz, token, "sa-role-replace-access", role="signoz-viewer"
)
# viewer should get 403 on admin-only endpoint
resp = requests.get(
signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.FORBIDDEN
), f"Expected 403 for viewer on admin endpoint, got {resp.status_code}: {resp.text}"
# assign admin role (replaces viewer)
admin_role_id = find_role_by_name(signoz, token, "signoz-admin")
assign_resp = requests.post(
signoz.self.host_configs["8080"].get(
f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"
),
json={"id": admin_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert assign_resp.status_code == HTTPStatus.NO_CONTENT, assign_resp.text
# SA should now have admin access
resp = requests.get(
signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.OK
), f"Expected 200 for admin on admin endpoint, got {resp.status_code}: {resp.text}"
# verify only admin role is present
roles_resp = requests.get(
signoz.self.host_configs["8080"].get(
f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"
),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert roles_resp.status_code == HTTPStatus.OK, roles_resp.text
role_names = [r["name"] for r in roles_resp.json()["data"]]
assert len(role_names) == 1
assert "signoz-admin" in role_names
assert "signoz-viewer" not in role_names
def test_remove_role_from_service_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument