mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-07 20:50:26 +01:00
Compare commits
1 Commits
main
...
has-value-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6516640c81 |
@@ -1,4 +1,4 @@
|
||||
import { ReactChild, useCallback, useMemo } from 'react';
|
||||
import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { matchPath, Redirect, useLocation } from 'react-router-dom';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
@@ -8,10 +8,12 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import history from 'lib/history';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
|
||||
import { OrgPreference } from 'types/api/preferences/preference';
|
||||
import { Organization } from 'types/api/user/getOrganization';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { routePermission } from 'utils/permission';
|
||||
|
||||
@@ -23,7 +25,6 @@ import routes, {
|
||||
SUPPORT_ROUTE,
|
||||
} from './routes';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
const location = useLocation();
|
||||
const { pathname } = location;
|
||||
@@ -56,12 +57,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
const currentRoute = mapRoutes.get('current');
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
const orgData = useMemo(() => {
|
||||
if (org && org.length > 0 && org[0].id !== undefined) {
|
||||
return org[0];
|
||||
}
|
||||
return undefined;
|
||||
}, [org]);
|
||||
const [orgData, setOrgData] = useState<Organization | undefined>(undefined);
|
||||
|
||||
const { data: usersData, isFetching: isFetchingUsers } = useListUsers({
|
||||
query: {
|
||||
@@ -79,7 +75,214 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
return remainingUsers.length === 1;
|
||||
}, [usersData?.data]);
|
||||
|
||||
// Handle old routes - redirect to new routes
|
||||
useEffect(() => {
|
||||
if (
|
||||
isCloudUserVal &&
|
||||
!isFetchingOrgPreferences &&
|
||||
orgPreferences &&
|
||||
!isFetchingUsers &&
|
||||
usersData &&
|
||||
usersData.data
|
||||
) {
|
||||
const isOnboardingComplete = orgPreferences?.find(
|
||||
(preference: OrgPreference) =>
|
||||
preference.name === ORG_PREFERENCES.ORG_ONBOARDING,
|
||||
)?.value;
|
||||
|
||||
// Don't redirect to onboarding if workspace has issues (blocked, suspended, or restricted)
|
||||
// User needs access to settings/billing to fix payment issues
|
||||
const isWorkspaceBlocked = trialInfo?.workSpaceBlock;
|
||||
const isWorkspaceSuspended = activeLicense?.state === LicenseState.DEFAULTED;
|
||||
const isWorkspaceAccessRestricted =
|
||||
activeLicense?.state === LicenseState.TERMINATED ||
|
||||
activeLicense?.state === LicenseState.EXPIRED ||
|
||||
activeLicense?.state === LicenseState.CANCELLED;
|
||||
|
||||
const hasWorkspaceIssue =
|
||||
isWorkspaceBlocked || isWorkspaceSuspended || isWorkspaceAccessRestricted;
|
||||
|
||||
if (hasWorkspaceIssue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstUser = checkFirstTimeUser();
|
||||
if (
|
||||
isFirstUser &&
|
||||
!isOnboardingComplete &&
|
||||
// if the current route is allowed to be overriden by org onboarding then only do the same
|
||||
!ROUTES_NOT_TO_BE_OVERRIDEN.includes(pathname)
|
||||
) {
|
||||
history.push(ROUTES.ONBOARDING);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
checkFirstTimeUser,
|
||||
isCloudUserVal,
|
||||
isFetchingOrgPreferences,
|
||||
isFetchingUsers,
|
||||
orgPreferences,
|
||||
usersData,
|
||||
pathname,
|
||||
trialInfo?.workSpaceBlock,
|
||||
activeLicense?.state,
|
||||
]);
|
||||
|
||||
const navigateToWorkSpaceBlocked = useCallback((): void => {
|
||||
const isRouteEnabledForWorkspaceBlockedState =
|
||||
isAdmin &&
|
||||
(pathname === ROUTES.SETTINGS ||
|
||||
pathname === ROUTES.ORG_SETTINGS ||
|
||||
pathname === ROUTES.MEMBERS_SETTINGS ||
|
||||
pathname === ROUTES.BILLING ||
|
||||
pathname === ROUTES.MY_SETTINGS);
|
||||
|
||||
if (
|
||||
pathname &&
|
||||
pathname !== ROUTES.WORKSPACE_LOCKED &&
|
||||
!isRouteEnabledForWorkspaceBlockedState
|
||||
) {
|
||||
history.push(ROUTES.WORKSPACE_LOCKED);
|
||||
}
|
||||
}, [isAdmin, pathname]);
|
||||
|
||||
const navigateToWorkSpaceAccessRestricted = useCallback((): void => {
|
||||
if (pathname && pathname !== ROUTES.WORKSPACE_ACCESS_RESTRICTED) {
|
||||
history.push(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingActiveLicense && activeLicense) {
|
||||
const isTerminated = activeLicense.state === LicenseState.TERMINATED;
|
||||
const isExpired = activeLicense.state === LicenseState.EXPIRED;
|
||||
const isCancelled = activeLicense.state === LicenseState.CANCELLED;
|
||||
|
||||
const isWorkspaceAccessRestricted = isTerminated || isExpired || isCancelled;
|
||||
|
||||
const { platform } = activeLicense;
|
||||
|
||||
if (isWorkspaceAccessRestricted && platform === LicensePlatform.CLOUD) {
|
||||
navigateToWorkSpaceAccessRestricted();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isFetchingActiveLicense,
|
||||
activeLicense,
|
||||
navigateToWorkSpaceAccessRestricted,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingActiveLicense) {
|
||||
const shouldBlockWorkspace = trialInfo?.workSpaceBlock;
|
||||
|
||||
if (
|
||||
shouldBlockWorkspace &&
|
||||
activeLicense?.platform === LicensePlatform.CLOUD
|
||||
) {
|
||||
navigateToWorkSpaceBlocked();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isFetchingActiveLicense,
|
||||
trialInfo?.workSpaceBlock,
|
||||
activeLicense?.platform,
|
||||
navigateToWorkSpaceBlocked,
|
||||
]);
|
||||
|
||||
const navigateToWorkSpaceSuspended = useCallback((): void => {
|
||||
if (pathname && pathname !== ROUTES.WORKSPACE_SUSPENDED) {
|
||||
history.push(ROUTES.WORKSPACE_SUSPENDED);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingActiveLicense && activeLicense) {
|
||||
const shouldSuspendWorkspace =
|
||||
activeLicense.state === LicenseState.DEFAULTED;
|
||||
|
||||
if (
|
||||
shouldSuspendWorkspace &&
|
||||
activeLicense.platform === LicensePlatform.CLOUD
|
||||
) {
|
||||
navigateToWorkSpaceSuspended();
|
||||
}
|
||||
}
|
||||
}, [isFetchingActiveLicense, activeLicense, navigateToWorkSpaceSuspended]);
|
||||
|
||||
useEffect(() => {
|
||||
if (org && org.length > 0 && org[0].id !== undefined) {
|
||||
setOrgData(org[0]);
|
||||
}
|
||||
}, [org]);
|
||||
|
||||
// if the feature flag is enabled and the current route is /get-started then redirect to /get-started-with-signoz-cloud
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentRoute?.path === ROUTES.GET_STARTED &&
|
||||
featureFlags?.find((e) => e.name === FeatureKeys.ONBOARDING_V3)?.active
|
||||
) {
|
||||
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
}
|
||||
}, [currentRoute, featureFlags]);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
useEffect(() => {
|
||||
// if it is an old route navigate to the new route
|
||||
if (isOldRoute) {
|
||||
// this will be handled by the redirect component below
|
||||
return;
|
||||
}
|
||||
|
||||
// if the current route is public dashboard then don't redirect to login
|
||||
const isPublicDashboard = currentRoute?.path === ROUTES.PUBLIC_DASHBOARD;
|
||||
|
||||
if (isPublicDashboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if the current route
|
||||
if (currentRoute) {
|
||||
const { isPrivate, key } = currentRoute;
|
||||
if (isPrivate) {
|
||||
if (isLoggedInState) {
|
||||
const route = routePermission[key];
|
||||
if (route && route.find((e) => e === user.role) === undefined) {
|
||||
history.push(ROUTES.UN_AUTHORIZED);
|
||||
}
|
||||
} else {
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
|
||||
history.push(ROUTES.LOGIN);
|
||||
}
|
||||
} else if (isLoggedInState) {
|
||||
const fromPathname = getLocalStorageApi(
|
||||
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
|
||||
);
|
||||
if (fromPathname) {
|
||||
history.push(fromPathname);
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
|
||||
} else if (pathname !== ROUTES.SOMETHING_WENT_WRONG) {
|
||||
history.push(ROUTES.HOME);
|
||||
}
|
||||
} else {
|
||||
// do nothing as the unauthenticated routes are LOGIN and SIGNUP and the LOGIN container takes care of routing to signup if
|
||||
// setup is not completed
|
||||
}
|
||||
} else if (isLoggedInState) {
|
||||
const fromPathname = getLocalStorageApi(
|
||||
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
|
||||
);
|
||||
if (fromPathname) {
|
||||
history.push(fromPathname);
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
|
||||
} else {
|
||||
history.push(ROUTES.HOME);
|
||||
}
|
||||
} else {
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
|
||||
history.push(ROUTES.LOGIN);
|
||||
}
|
||||
}, [isLoggedInState, pathname, user, isOldRoute, currentRoute, location]);
|
||||
|
||||
if (isOldRoute) {
|
||||
const redirectUrl = oldNewRoutesMapping[pathname];
|
||||
return (
|
||||
@@ -93,143 +296,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
// Public dashboard - no redirect needed
|
||||
const isPublicDashboard = currentRoute?.path === ROUTES.PUBLIC_DASHBOARD;
|
||||
if (isPublicDashboard) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Check for workspace access restriction (cloud only)
|
||||
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;
|
||||
|
||||
if (!isFetchingActiveLicense && activeLicense && isCloudPlatform) {
|
||||
const isTerminated = activeLicense.state === LicenseState.TERMINATED;
|
||||
const isExpired = activeLicense.state === LicenseState.EXPIRED;
|
||||
const isCancelled = activeLicense.state === LicenseState.CANCELLED;
|
||||
const isWorkspaceAccessRestricted = isTerminated || isExpired || isCancelled;
|
||||
|
||||
if (
|
||||
isWorkspaceAccessRestricted &&
|
||||
pathname !== ROUTES.WORKSPACE_ACCESS_RESTRICTED
|
||||
) {
|
||||
return <Redirect to={ROUTES.WORKSPACE_ACCESS_RESTRICTED} />;
|
||||
}
|
||||
|
||||
// Check for workspace suspended (DEFAULTED)
|
||||
const shouldSuspendWorkspace = activeLicense.state === LicenseState.DEFAULTED;
|
||||
if (shouldSuspendWorkspace && pathname !== ROUTES.WORKSPACE_SUSPENDED) {
|
||||
return <Redirect to={ROUTES.WORKSPACE_SUSPENDED} />;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for workspace blocked (trial expired)
|
||||
if (!isFetchingActiveLicense && isCloudPlatform && trialInfo?.workSpaceBlock) {
|
||||
const isRouteEnabledForWorkspaceBlockedState =
|
||||
isAdmin &&
|
||||
(pathname === ROUTES.SETTINGS ||
|
||||
pathname === ROUTES.ORG_SETTINGS ||
|
||||
pathname === ROUTES.MEMBERS_SETTINGS ||
|
||||
pathname === ROUTES.BILLING ||
|
||||
pathname === ROUTES.MY_SETTINGS);
|
||||
|
||||
if (
|
||||
pathname !== ROUTES.WORKSPACE_LOCKED &&
|
||||
!isRouteEnabledForWorkspaceBlockedState
|
||||
) {
|
||||
return <Redirect to={ROUTES.WORKSPACE_LOCKED} />;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for onboarding redirect (cloud users, first user, onboarding not complete)
|
||||
if (
|
||||
isCloudUserVal &&
|
||||
!isFetchingOrgPreferences &&
|
||||
orgPreferences &&
|
||||
!isFetchingUsers &&
|
||||
usersData &&
|
||||
usersData.data
|
||||
) {
|
||||
const isOnboardingComplete = orgPreferences?.find(
|
||||
(preference: OrgPreference) =>
|
||||
preference.name === ORG_PREFERENCES.ORG_ONBOARDING,
|
||||
)?.value;
|
||||
|
||||
// Don't redirect to onboarding if workspace has issues
|
||||
const isWorkspaceBlocked = trialInfo?.workSpaceBlock;
|
||||
const isWorkspaceSuspended = activeLicense?.state === LicenseState.DEFAULTED;
|
||||
const isWorkspaceAccessRestricted =
|
||||
activeLicense?.state === LicenseState.TERMINATED ||
|
||||
activeLicense?.state === LicenseState.EXPIRED ||
|
||||
activeLicense?.state === LicenseState.CANCELLED;
|
||||
|
||||
const hasWorkspaceIssue =
|
||||
isWorkspaceBlocked || isWorkspaceSuspended || isWorkspaceAccessRestricted;
|
||||
|
||||
if (!hasWorkspaceIssue) {
|
||||
const isFirstUser = checkFirstTimeUser();
|
||||
if (
|
||||
isFirstUser &&
|
||||
!isOnboardingComplete &&
|
||||
!ROUTES_NOT_TO_BE_OVERRIDEN.includes(pathname) &&
|
||||
pathname !== ROUTES.ONBOARDING
|
||||
) {
|
||||
return <Redirect to={ROUTES.ONBOARDING} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for GET_STARTED → GET_STARTED_WITH_CLOUD redirect (feature flag)
|
||||
if (
|
||||
currentRoute?.path === ROUTES.GET_STARTED &&
|
||||
featureFlags?.find((e) => e.name === FeatureKeys.ONBOARDING_V3)?.active
|
||||
) {
|
||||
return <Redirect to={ROUTES.GET_STARTED_WITH_CLOUD} />;
|
||||
}
|
||||
|
||||
// Main routing logic
|
||||
if (currentRoute) {
|
||||
const { isPrivate, key } = currentRoute;
|
||||
if (isPrivate) {
|
||||
if (isLoggedInState) {
|
||||
const route = routePermission[key];
|
||||
if (route && route.find((e) => e === user.role) === undefined) {
|
||||
return <Redirect to={ROUTES.UN_AUTHORIZED} />;
|
||||
}
|
||||
} else {
|
||||
// Save current path and redirect to login
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
|
||||
return <Redirect to={ROUTES.LOGIN} />;
|
||||
}
|
||||
} else if (isLoggedInState) {
|
||||
// Non-private route, but user is logged in
|
||||
const fromPathname = getLocalStorageApi(
|
||||
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
|
||||
);
|
||||
if (fromPathname) {
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
|
||||
return <Redirect to={fromPathname} />;
|
||||
}
|
||||
if (pathname !== ROUTES.SOMETHING_WENT_WRONG) {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
}
|
||||
}
|
||||
// Non-private route, user not logged in - let login/signup pages handle it
|
||||
} else if (isLoggedInState) {
|
||||
// Unknown route, logged in
|
||||
const fromPathname = getLocalStorageApi(
|
||||
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
|
||||
);
|
||||
if (fromPathname) {
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
|
||||
return <Redirect to={fromPathname} />;
|
||||
}
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
} else {
|
||||
// Unknown route, not logged in
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
|
||||
return <Redirect to={ROUTES.LOGIN} />;
|
||||
}
|
||||
|
||||
// NOTE: disabling this rule as there is no need to have div
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { IAppContext, IUser } from 'providers/App/types';
|
||||
import {
|
||||
@@ -21,6 +22,19 @@ import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
import PrivateRoute from '../Private';
|
||||
|
||||
// Mock history module
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
location: { pathname: '/', search: '', hash: '' },
|
||||
listen: jest.fn(),
|
||||
createHref: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockHistoryPush = history.push as jest.Mock;
|
||||
|
||||
// Mock localStorage APIs
|
||||
const mockLocalStorage: Record<string, string> = {};
|
||||
jest.mock('api/browser/localstorage/get', () => ({
|
||||
@@ -225,18 +239,20 @@ function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
|
||||
}
|
||||
|
||||
// Generic assertion helpers for navigation behavior
|
||||
// Using location-based assertions since Private.tsx now uses Redirect component
|
||||
// Using these allows easier refactoring when switching from history.push to Redirect component
|
||||
|
||||
async function assertRedirectsTo(targetRoute: string): Promise<void> {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('location-display')).toHaveTextContent(targetRoute);
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(targetRoute);
|
||||
});
|
||||
}
|
||||
|
||||
function assertStaysOnRoute(expectedRoute: string): void {
|
||||
expect(screen.getByTestId('location-display')).toHaveTextContent(
|
||||
expectedRoute,
|
||||
);
|
||||
function assertNoRedirect(): void {
|
||||
expect(mockHistoryPush).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
function assertDoesNotRedirectTo(targetRoute: string): void {
|
||||
expect(mockHistoryPush).not.toHaveBeenCalledWith(targetRoute);
|
||||
}
|
||||
|
||||
function assertRendersChildren(): void {
|
||||
@@ -334,7 +350,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
assertRendersChildren();
|
||||
assertStaysOnRoute('/public/dashboard/abc123');
|
||||
assertNoRedirect();
|
||||
});
|
||||
|
||||
it('should render children for public dashboard route when logged in without redirecting', () => {
|
||||
@@ -346,7 +362,7 @@ describe('PrivateRoute', () => {
|
||||
assertRendersChildren();
|
||||
// Critical: without the isPublicDashboard early return, logged-in users
|
||||
// would be redirected to HOME due to the non-private route handling
|
||||
assertStaysOnRoute('/public/dashboard/abc123');
|
||||
assertNoRedirect();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -404,7 +420,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
assertRendersChildren();
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertNoRedirect();
|
||||
});
|
||||
|
||||
it('should redirect to unauthorized when VIEWER tries to access admin-only route /alerts/new', async () => {
|
||||
@@ -513,7 +529,7 @@ describe('PrivateRoute', () => {
|
||||
appContext: { isLoggedIn: true },
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.SOMETHING_WENT_WRONG);
|
||||
assertDoesNotRedirectTo(ROUTES.HOME);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -525,7 +541,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
// Should not redirect - login page handles its own routing
|
||||
assertStaysOnRoute(ROUTES.LOGIN);
|
||||
assertNoRedirect();
|
||||
});
|
||||
|
||||
it('should not redirect when not logged in user visits signup page', () => {
|
||||
@@ -534,7 +550,7 @@ describe('PrivateRoute', () => {
|
||||
appContext: { isLoggedIn: false },
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.SIGN_UP);
|
||||
assertNoRedirect();
|
||||
});
|
||||
|
||||
it('should not redirect when not logged in user visits password reset page', () => {
|
||||
@@ -543,7 +559,7 @@ describe('PrivateRoute', () => {
|
||||
appContext: { isLoggedIn: false },
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.PASSWORD_RESET);
|
||||
assertNoRedirect();
|
||||
});
|
||||
|
||||
it('should not redirect when not logged in user visits forgot password page', () => {
|
||||
@@ -552,7 +568,7 @@ describe('PrivateRoute', () => {
|
||||
appContext: { isLoggedIn: false },
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.FORGOT_PASSWORD);
|
||||
assertNoRedirect();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -641,7 +657,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
// Admin should be able to access settings even when workspace is blocked
|
||||
assertStaysOnRoute(ROUTES.SETTINGS);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to access /settings/billing when workspace is blocked', () => {
|
||||
@@ -657,7 +673,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.BILLING);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to access /settings/org-settings when workspace is blocked', () => {
|
||||
@@ -673,7 +689,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.ORG_SETTINGS);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to access /settings/members when workspace is blocked', () => {
|
||||
@@ -689,7 +705,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.MEMBERS_SETTINGS);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to access /settings/my-settings when workspace is blocked', () => {
|
||||
@@ -705,7 +721,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.MY_SETTINGS);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should redirect VIEWER to workspace locked even when trying to access settings', async () => {
|
||||
@@ -816,7 +832,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.WORKSPACE_LOCKED);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should not redirect self-hosted users to workspace locked even when workSpaceBlock is true', () => {
|
||||
@@ -833,7 +849,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: false,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -903,7 +919,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
});
|
||||
|
||||
it('should not redirect self-hosted users to workspace access restricted when license is terminated', () => {
|
||||
@@ -920,7 +936,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: false,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
});
|
||||
|
||||
it('should not redirect when license is ACTIVE', () => {
|
||||
@@ -937,7 +953,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
});
|
||||
|
||||
it('should not redirect when license is EVALUATING', () => {
|
||||
@@ -954,7 +970,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -990,7 +1006,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.WORKSPACE_SUSPENDED);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_SUSPENDED);
|
||||
});
|
||||
|
||||
it('should not redirect self-hosted users to workspace suspended when license is defaulted', () => {
|
||||
@@ -1007,7 +1023,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: false,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_SUSPENDED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1027,11 +1043,6 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
// Wait for the users query to complete and trigger re-render
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
@@ -1047,7 +1058,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when onboarding is already complete', async () => {
|
||||
@@ -1073,7 +1084,7 @@ describe('PrivateRoute', () => {
|
||||
|
||||
// Critical: if isOnboardingComplete check is broken (always false),
|
||||
// this test would fail because all other conditions for redirect ARE met
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding for non-cloud users', () => {
|
||||
@@ -1088,7 +1099,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: false,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when on /workspace-locked route', () => {
|
||||
@@ -1103,7 +1114,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.WORKSPACE_LOCKED);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when on /workspace-suspended route', () => {
|
||||
@@ -1118,7 +1129,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.WORKSPACE_SUSPENDED);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when workspace is blocked and accessing billing', async () => {
|
||||
@@ -1145,7 +1156,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
// Should NOT redirect to onboarding - user needs to access billing to fix payment
|
||||
assertStaysOnRoute(ROUTES.BILLING);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when workspace is blocked and accessing settings', async () => {
|
||||
@@ -1169,7 +1180,7 @@ describe('PrivateRoute', () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.SETTINGS);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when workspace is suspended (DEFAULTED)', async () => {
|
||||
@@ -1196,7 +1207,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
// Should redirect to WORKSPACE_SUSPENDED, not ONBOARDING
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_SUSPENDED);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when workspace is access restricted (TERMINATED)', async () => {
|
||||
@@ -1223,7 +1234,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
// Should redirect to WORKSPACE_ACCESS_RESTRICTED, not ONBOARDING
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when workspace is access restricted (EXPIRED)', async () => {
|
||||
@@ -1249,7 +1260,7 @@ describe('PrivateRoute', () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1291,7 +1302,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED);
|
||||
assertDoesNotRedirectTo(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
});
|
||||
|
||||
it('should not redirect when on GET_STARTED and ONBOARDING_V3 feature flag is not present', () => {
|
||||
@@ -1303,7 +1314,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED);
|
||||
assertDoesNotRedirectTo(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
});
|
||||
|
||||
it('should not redirect when on different route even if ONBOARDING_V3 is active', () => {
|
||||
@@ -1323,7 +1334,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1339,7 +1350,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should not fetch users when org data is not available', () => {
|
||||
@@ -1382,7 +1393,9 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_SUSPENDED);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1423,40 +1436,22 @@ describe('PrivateRoute', () => {
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to access /services route', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.APPLICATION,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role: USER_ROLES.ADMIN as ROLES }),
|
||||
},
|
||||
it('should allow all roles to access /services route', () => {
|
||||
const roles = [USER_ROLES.ADMIN, USER_ROLES.EDITOR, USER_ROLES.VIEWER];
|
||||
|
||||
roles.forEach((role) => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.APPLICATION,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role: role as ROLES }),
|
||||
},
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.APPLICATION);
|
||||
});
|
||||
|
||||
it('should allow EDITOR to access /services route', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.APPLICATION,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role: USER_ROLES.EDITOR as ROLES }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.APPLICATION);
|
||||
});
|
||||
|
||||
it('should allow VIEWER to access /services route', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.APPLICATION,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role: USER_ROLES.VIEWER as ROLES }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.APPLICATION);
|
||||
});
|
||||
|
||||
it('should redirect VIEWER from /onboarding route (admin only)', async () => {
|
||||
@@ -1486,7 +1481,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
assertRendersChildren();
|
||||
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
|
||||
assertDoesNotRedirectTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
|
||||
it('should allow EDITOR to access /get-started route', () => {
|
||||
@@ -1498,7 +1493,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED);
|
||||
assertDoesNotRedirectTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
@@ -91,11 +90,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,39 +111,26 @@ 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]);
|
||||
setLocalRole(fetchedRoleIds[0] ?? '');
|
||||
}, [fetchedRoleIds]);
|
||||
|
||||
const isDirty =
|
||||
member !== null &&
|
||||
@@ -170,10 +153,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',
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -354,15 +344,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 +419,7 @@ function EditMemberDrawer({
|
||||
}}
|
||||
className="edit-member-drawer__input"
|
||||
placeholder="Enter name"
|
||||
disabled={isRootUser || isDeleted}
|
||||
disabled={isRootUser}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -450,15 +440,9 @@ 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">
|
||||
@@ -483,7 +467,7 @@ function EditMemberDrawer({
|
||||
onRefetch={refetchRoles}
|
||||
value={localRole}
|
||||
onChange={(role): void => {
|
||||
setLocalRole(role ?? '');
|
||||
setLocalRole(role);
|
||||
setSaveErrors((prev) =>
|
||||
prev.filter(
|
||||
(err) =>
|
||||
@@ -492,7 +476,6 @@ function EditMemberDrawer({
|
||||
);
|
||||
}}
|
||||
placeholder="Select role"
|
||||
allowClear={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -504,10 +487,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 +525,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>
|
||||
);
|
||||
|
||||
|
||||
@@ -84,16 +84,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 +147,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)),
|
||||
@@ -470,6 +459,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 +477,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 +504,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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,14 +154,13 @@ 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}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
allowClear={allowClear}
|
||||
className={cx('roles-single-select', className)}
|
||||
loading={loading}
|
||||
notFoundContent={notFoundContent}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,7 @@ interface OverviewTabProps {
|
||||
localName: string;
|
||||
onNameChange: (v: string) => void;
|
||||
localRole: string;
|
||||
onRoleChange: (v: string | undefined) => void;
|
||||
onRoleChange: (v: string) => void;
|
||||
isDisabled: boolean;
|
||||
availableRoles: AuthtypesRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
@@ -37,7 +32,7 @@ import {
|
||||
useQueryState,
|
||||
} from 'nuqs';
|
||||
import APIError from 'types/api/error';
|
||||
import { retryOn429, toAPIError } from 'utils/errorUtils';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
import DeleteAccountModal from './DeleteAccountModal';
|
||||
@@ -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,
|
||||
@@ -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]);
|
||||
setLocalRole(currentRoles[0]?.id ?? '');
|
||||
}, [currentRoles]);
|
||||
|
||||
const isDeleted =
|
||||
account?.status?.toUpperCase() === ServiceAccountStatus.Deleted;
|
||||
@@ -179,26 +153,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({
|
||||
mutation: {
|
||||
retry: retryOn429,
|
||||
},
|
||||
});
|
||||
|
||||
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> => {
|
||||
@@ -220,7 +180,14 @@ function ServiceAccountDrawer({
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [account, localName, updateMutateAsync, refetchAccount, queryClient]);
|
||||
}, [
|
||||
account,
|
||||
localName,
|
||||
updateMutateAsync,
|
||||
refetchAccount,
|
||||
queryClient,
|
||||
toSaveApiError,
|
||||
]);
|
||||
|
||||
const handleNameChange = useCallback((name: string): void => {
|
||||
setLocalName(name);
|
||||
@@ -243,39 +210,29 @@ 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(
|
||||
[localRole].filter(Boolean),
|
||||
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) {
|
||||
@@ -285,7 +242,7 @@ function ServiceAccountDrawer({
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [selectedAccountId, executeRolesOperation, failuresToSaveErrors]);
|
||||
}, [localRole, availableRoles, applyDiff, toSaveApiError, makeRoleRetry]);
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!account || !isDirty) {
|
||||
@@ -304,7 +261,7 @@ function ServiceAccountDrawer({
|
||||
|
||||
const [nameResult, rolesResult] = await Promise.allSettled([
|
||||
namePromise,
|
||||
executeRolesOperation(account.id),
|
||||
applyDiff([localRole].filter(Boolean), availableRoles),
|
||||
]);
|
||||
|
||||
const errors: SaveError[] = [];
|
||||
@@ -324,7 +281,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) {
|
||||
@@ -346,14 +310,17 @@ function ServiceAccountDrawer({
|
||||
account,
|
||||
isDirty,
|
||||
localName,
|
||||
localRole,
|
||||
availableRoles,
|
||||
updateMutateAsync,
|
||||
executeRolesOperation,
|
||||
applyDiff,
|
||||
refetchAccount,
|
||||
onSuccess,
|
||||
queryClient,
|
||||
toSaveApiError,
|
||||
retryNameUpdate,
|
||||
makeRoleRetry,
|
||||
retryRolesUpdate,
|
||||
failuresToSaveErrors,
|
||||
]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
@@ -446,10 +413,7 @@ function ServiceAccountDrawer({
|
||||
localName={localName}
|
||||
onNameChange={handleNameChange}
|
||||
localRole={localRole}
|
||||
onRoleChange={(role): void => {
|
||||
setLocalRole(role ?? '');
|
||||
clearRoleErrors();
|
||||
}}
|
||||
onRoleChange={setLocalRole}
|
||||
isDisabled={isDeleted}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
|
||||
@@ -390,42 +390,6 @@ describe('ServiceAccountDrawer – save-error UX', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('role add retries on 429 then succeeds without showing an error', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let roleAddCallCount = 0;
|
||||
|
||||
// First call → 429, second call → 200
|
||||
server.use(
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) => {
|
||||
roleAddCallCount += 1;
|
||||
if (roleAddCallCount === 1) {
|
||||
return res(ctx.status(429), ctx.json({ message: 'Too Many Requests' }));
|
||||
}
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
await user.click(screen.getByLabelText('Roles'));
|
||||
await user.click(await screen.findByTitle('signoz-viewer'));
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
// Retried after 429 — at least 2 calls, no error shown
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(roleAddCallCount).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(screen.queryByText(/role assign failed/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking Retry on a name-update error re-triggers the request; on success the error item is removed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import uPlot from 'uplot';
|
||||
|
||||
import { ChartProps } from '../types';
|
||||
|
||||
const TOOLTIP_WIDTH_PADDING = 120;
|
||||
const TOOLTIP_WIDTH_PADDING = 60;
|
||||
const TOOLTIP_MIN_WIDTH = 200;
|
||||
|
||||
export default function ChartWrapper({
|
||||
|
||||
@@ -264,22 +264,20 @@ export default function Home(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
{user?.role === USER_ROLES.ADMIN && (
|
||||
<PersistedAnnouncementBanner
|
||||
type="info"
|
||||
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
|
||||
action={{
|
||||
label: 'Go to Service Accounts',
|
||||
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<strong>API keys</strong> have been deprecated in favour of{' '}
|
||||
<strong>Service accounts</strong>. The existing API Keys have been
|
||||
migrated to service accounts.
|
||||
</>
|
||||
</PersistedAnnouncementBanner>
|
||||
)}
|
||||
<PersistedAnnouncementBanner
|
||||
type="info"
|
||||
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
|
||||
action={{
|
||||
label: 'Go to Service Accounts',
|
||||
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<strong>API keys</strong> have been deprecated in favour of{' '}
|
||||
<strong>Service accounts</strong>. The existing API Keys have been migrated
|
||||
to service accounts.
|
||||
</>
|
||||
</PersistedAnnouncementBanner>
|
||||
|
||||
<div className="sticky-header">
|
||||
<Header
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export enum FilterMode {
|
||||
All = 'all',
|
||||
Invited = 'invited',
|
||||
Deleted = 'deleted',
|
||||
}
|
||||
|
||||
export enum MemberStatus {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
.display-name-form {
|
||||
.form-field {
|
||||
margin-bottom: var(--spacing-8);
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--destructive);
|
||||
margin-top: var(--spacing-2);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import {
|
||||
fireEvent,
|
||||
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);
|
||||
|
||||
const form = input.closest('form') as HTMLFormElement;
|
||||
fireEvent.submit(form);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/missing display name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,57 +1,21 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Button, Input } from 'antd';
|
||||
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';
|
||||
|
||||
import './DisplayName.styles.scss';
|
||||
|
||||
function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
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 ?? '';
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
getValues,
|
||||
setValue,
|
||||
} = useForm<FormValues>({
|
||||
defaultValues: { displayName },
|
||||
});
|
||||
|
||||
const orgName = watch('displayName');
|
||||
|
||||
useEffect(() => {
|
||||
if (displayName && !getValues('displayName')) {
|
||||
setValue('displayName', displayName);
|
||||
}
|
||||
}, [displayName, getValues, setValue]);
|
||||
const { org, updateOrg } = useAppContext();
|
||||
const { displayName } = (org || [])[index];
|
||||
|
||||
const {
|
||||
mutateAsync: updateMyOrganization,
|
||||
@@ -66,16 +30,20 @@ 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' },
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: FormValues): Promise<void> => {
|
||||
const { displayName: name } = values;
|
||||
await updateMyOrganization({ data: { id: orgId, displayName: name } });
|
||||
const { displayName } = values;
|
||||
await updateMyOrganization({ data: { id: orgId, displayName } });
|
||||
};
|
||||
|
||||
if (!org) {
|
||||
@@ -85,34 +53,21 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
|
||||
const isDisabled = isLoading || orgName === displayName || !orgName;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="display-name-form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
<Form
|
||||
initialValues={{ displayName }}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
<div className="form-field">
|
||||
<label htmlFor="displayName">Display name</label>
|
||||
<Controller
|
||||
name="displayName"
|
||||
control={control}
|
||||
rules={{ required: requireErrorMessage('Display name') }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
{...field}
|
||||
id="displayName"
|
||||
size="large"
|
||||
placeholder={t('signoz')}
|
||||
status={fieldState.error ? 'error' : ''}
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<div className="field-error">{fieldState.error.message}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Form.Item
|
||||
name="displayName"
|
||||
label="Display name"
|
||||
rules={[{ required: true, message: requireErrorMessage('Display name') }]}
|
||||
>
|
||||
<Input size="large" placeholder={t('signoz')} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
disabled={isDisabled}
|
||||
@@ -121,8 +76,8 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetUser, useSetRoleByUserID } from 'api/generated/services/users';
|
||||
import { retryOn429 } from 'utils/errorUtils';
|
||||
|
||||
export interface MemberRoleUpdateFailure {
|
||||
roleName: string;
|
||||
@@ -39,9 +38,7 @@ export function useMemberRoleManager(
|
||||
[currentUserRoles],
|
||||
);
|
||||
|
||||
const { mutateAsync: setRole } = useSetRoleByUserID({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
const { mutateAsync: setRole } = useSetRoleByUserID();
|
||||
|
||||
const applyDiff = useCallback(
|
||||
async (
|
||||
|
||||
@@ -6,12 +6,6 @@ import {
|
||||
useGetServiceAccountRoles,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { retryOn429 } from 'utils/errorUtils';
|
||||
|
||||
const enum PromiseStatus {
|
||||
Fulfilled = 'fulfilled',
|
||||
Rejected = 'rejected',
|
||||
}
|
||||
|
||||
export interface RoleUpdateFailure {
|
||||
roleName: string;
|
||||
@@ -40,9 +34,7 @@ export function useServiceAccountRoleManager(
|
||||
]);
|
||||
|
||||
// the retry for these mutations is safe due to being idempotent on backend
|
||||
const { mutateAsync: createRole } = useCreateServiceAccountRole({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
const { mutateAsync: createRole } = useCreateServiceAccountRole();
|
||||
|
||||
const invalidateRoles = useCallback(
|
||||
() =>
|
||||
@@ -81,16 +73,11 @@ export function useServiceAccountRoleManager(
|
||||
allOperations.map((op) => op.run()),
|
||||
);
|
||||
|
||||
const successCount = results.filter(
|
||||
(r) => r.status === PromiseStatus.Fulfilled,
|
||||
).length;
|
||||
if (successCount > 0) {
|
||||
await invalidateRoles();
|
||||
}
|
||||
await invalidateRoles();
|
||||
|
||||
const failures: RoleUpdateFailure[] = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === PromiseStatus.Rejected) {
|
||||
if (result.status === 'rejected') {
|
||||
const { role, run } = allOperations[index];
|
||||
failures.push({
|
||||
roleName: role.name ?? 'unknown',
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
.uplot-tooltip-container {
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--bg-ink-300);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--bg-vanilla-100);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--bg-ink-100);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&.lightMode {
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--bg-ink-500);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.uplot-tooltip-list {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.uplot-tooltip-divider {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.uplot-tooltip-header-container {
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.uplot-tooltip-header {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.uplot-tooltip-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
.uplot-tooltip-list {
|
||||
// Virtuoso absolutely positions its item rows; left: 0 prevents accidental
|
||||
// horizontal offset when the scroller has padding or transform applied.
|
||||
div[data-viewport-type='element'] {
|
||||
left: 0;
|
||||
padding: 4px 8px 4px 16px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-100);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
.uplot-tooltip-container {
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--bg-ink-300);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--bg-vanilla-100);
|
||||
border-radius: 6px;
|
||||
padding: 1rem 0.5rem 0.5rem 1rem;
|
||||
border: 1px solid var(--bg-ink-100);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&.lightMode {
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--bg-ink-500);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.uplot-tooltip-list {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.uplot-tooltip-header {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.uplot-tooltip-list-container {
|
||||
overflow-y: auto;
|
||||
max-height: 330px;
|
||||
|
||||
.uplot-tooltip-list {
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-100);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.uplot-tooltip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.uplot-tooltip-item-marker {
|
||||
border-radius: 50%;
|
||||
border-width: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.uplot-tooltip-item-content {
|
||||
white-space: wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,12 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { TooltipProps } from '../types';
|
||||
import TooltipItem from './components/TooltipItem/TooltipItem';
|
||||
|
||||
import Styles from './Tooltip.module.scss';
|
||||
import './Tooltip.styles.scss';
|
||||
|
||||
// Fallback per-item height used for the initial size estimate before
|
||||
// Virtuoso reports the real total height via totalListHeightChanged.
|
||||
const TOOLTIP_LIST_MAX_HEIGHT = 330;
|
||||
const TOOLTIP_ITEM_HEIGHT = 38;
|
||||
const LIST_MAX_HEIGHT = 300;
|
||||
const TOOLTIP_LIST_PADDING = 10;
|
||||
|
||||
export default function Tooltip({
|
||||
uPlotInstance,
|
||||
@@ -23,26 +21,27 @@ export default function Tooltip({
|
||||
showTooltipHeader = true,
|
||||
}: TooltipProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [listHeight, setListHeight] = useState(0);
|
||||
const tooltipContent = content ?? [];
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const [totalListHeight, setTotalListHeight] = useState(0);
|
||||
|
||||
const tooltipContent = useMemo(() => content ?? [], [content]);
|
||||
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
const resolvedTimezone = useMemo(() => {
|
||||
if (!timezone) {
|
||||
return userTimezone.value;
|
||||
}
|
||||
return timezone.value;
|
||||
}, [timezone, userTimezone]);
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!showTooltipHeader) {
|
||||
return null;
|
||||
}
|
||||
const data = uPlotInstance.data;
|
||||
const cursorIdx = uPlotInstance.cursor.idx;
|
||||
if (cursorIdx == null) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = uPlotInstance.data[0]?.[cursorIdx];
|
||||
if (timestamp == null) {
|
||||
return null;
|
||||
}
|
||||
return dayjs(timestamp * 1000)
|
||||
return dayjs(data[0][cursorIdx] * 1000)
|
||||
.tz(resolvedTimezone)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
|
||||
}, [
|
||||
@@ -52,68 +51,60 @@ export default function Tooltip({
|
||||
showTooltipHeader,
|
||||
]);
|
||||
|
||||
const activeItem = useMemo(
|
||||
() => tooltipContent.find((item) => item.isActive) ?? null,
|
||||
[tooltipContent],
|
||||
);
|
||||
|
||||
// Use the measured height from Virtuoso when available; fall back to a
|
||||
// per-item estimate on the first render. Math.ceil prevents a 1 px
|
||||
// subpixel rounding gap from triggering a spurious scrollbar.
|
||||
const virtuosoHeight = useMemo(() => {
|
||||
return totalListHeight > 0
|
||||
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
|
||||
: Math.min(tooltipContent.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT);
|
||||
}, [totalListHeight, tooltipContent.length]);
|
||||
|
||||
const showHeader = showTooltipHeader || activeItem != null;
|
||||
// With a single series the active item is fully represented in the header —
|
||||
// hide the divider and list to avoid showing a duplicate row.
|
||||
const showList = tooltipContent.length > 1;
|
||||
const showDivider = showList && showHeader;
|
||||
const virtuosoStyle = useMemo(() => {
|
||||
return {
|
||||
height:
|
||||
listHeight > 0
|
||||
? Math.min(listHeight + TOOLTIP_LIST_PADDING, TOOLTIP_LIST_MAX_HEIGHT)
|
||||
: Math.min(
|
||||
tooltipContent.length * TOOLTIP_ITEM_HEIGHT,
|
||||
TOOLTIP_LIST_MAX_HEIGHT,
|
||||
),
|
||||
width: '100%',
|
||||
};
|
||||
}, [listHeight, tooltipContent.length]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.uplotTooltipContainer, !isDarkMode && Styles.lightMode)}
|
||||
className={cx(
|
||||
'uplot-tooltip-container',
|
||||
isDarkMode ? 'darkMode' : 'lightMode',
|
||||
)}
|
||||
data-testid="uplot-tooltip-container"
|
||||
>
|
||||
{showHeader && (
|
||||
<div className={Styles.uplotTooltipHeaderContainer}>
|
||||
{showTooltipHeader && headerTitle && (
|
||||
<div
|
||||
className={Styles.uplotTooltipHeader}
|
||||
data-testid="uplot-tooltip-header"
|
||||
>
|
||||
<span>{headerTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeItem && (
|
||||
<TooltipItem
|
||||
item={activeItem}
|
||||
isItemActive={true}
|
||||
containerTestId="uplot-tooltip-pinned"
|
||||
markerTestId="uplot-tooltip-pinned-marker"
|
||||
contentTestId="uplot-tooltip-pinned-content"
|
||||
/>
|
||||
)}
|
||||
{showTooltipHeader && (
|
||||
<div className="uplot-tooltip-header" data-testid="uplot-tooltip-header">
|
||||
<span>{headerTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDivider && <span className={Styles.uplotTooltipDivider} />}
|
||||
|
||||
{showList && (
|
||||
<Virtuoso
|
||||
className={Styles.uplotTooltipList}
|
||||
data-testid="uplot-tooltip-list"
|
||||
data={tooltipContent}
|
||||
style={{ height: virtuosoHeight, width: '100%' }}
|
||||
totalListHeightChanged={setTotalListHeight}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<TooltipItem item={item} isItemActive={false} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="uplot-tooltip-list-container">
|
||||
{tooltipContent.length > 0 ? (
|
||||
<Virtuoso
|
||||
className="uplot-tooltip-list"
|
||||
data-testid="uplot-tooltip-list"
|
||||
data={tooltipContent}
|
||||
style={virtuosoStyle}
|
||||
totalListHeightChanged={setListHeight}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<div className="uplot-tooltip-item" data-testid="uplot-tooltip-item">
|
||||
<div
|
||||
className="uplot-tooltip-item-marker"
|
||||
style={{ borderColor: item.color }}
|
||||
data-is-legend-marker={true}
|
||||
data-testid="uplot-tooltip-item-marker"
|
||||
/>
|
||||
<div
|
||||
className="uplot-tooltip-item-content"
|
||||
style={{ color: item.color, fontWeight: item.isActive ? 700 : 400 }}
|
||||
data-testid="uplot-tooltip-item-content"
|
||||
>
|
||||
{item.label}: {item.tooltipValue}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,30 +133,46 @@ describe('Tooltip', () => {
|
||||
expect(screen.queryByText(unexpectedTitle)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders single active item in header only, without a list', () => {
|
||||
it('renders lightMode class when dark mode is disabled', () => {
|
||||
const uPlotInstance = createUPlotInstance(null);
|
||||
const content = [createTooltipContent({ isActive: true })];
|
||||
mockUseIsDarkMode.mockReturnValue(false);
|
||||
|
||||
renderTooltip({ uPlotInstance, content });
|
||||
renderTooltip({ uPlotInstance });
|
||||
|
||||
// Active item is shown in the header, not duplicated in a list
|
||||
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
|
||||
expect(screen.getByTestId('uplot-tooltip-pinned')).toBeInTheDocument();
|
||||
const pinnedContent = screen.getByTestId('uplot-tooltip-pinned-content');
|
||||
expect(pinnedContent).toHaveTextContent('Series A');
|
||||
expect(pinnedContent).toHaveTextContent('10');
|
||||
const container = screen.getByTestId('uplot-tooltip-container');
|
||||
|
||||
expect(container).toHaveClass('lightMode');
|
||||
expect(container).not.toHaveClass('darkMode');
|
||||
});
|
||||
|
||||
it('renders list when multiple series are present', () => {
|
||||
it('renders darkMode class when dark mode is enabled', () => {
|
||||
const uPlotInstance = createUPlotInstance(null);
|
||||
const content = [
|
||||
createTooltipContent({ isActive: true }),
|
||||
createTooltipContent({ label: 'Series B', isActive: false }),
|
||||
];
|
||||
mockUseIsDarkMode.mockReturnValue(true);
|
||||
|
||||
renderTooltip({ uPlotInstance });
|
||||
|
||||
const container = screen.getByTestId('uplot-tooltip-container');
|
||||
|
||||
expect(container).toHaveClass('darkMode');
|
||||
expect(container).not.toHaveClass('lightMode');
|
||||
});
|
||||
|
||||
it('renders tooltip items when content is provided', () => {
|
||||
const uPlotInstance = createUPlotInstance(null);
|
||||
const content = [createTooltipContent()];
|
||||
|
||||
renderTooltip({ uPlotInstance, content });
|
||||
|
||||
expect(screen.getByTestId('uplot-tooltip-list')).toBeInTheDocument();
|
||||
const list = screen.queryByTestId('uplot-tooltip-list');
|
||||
|
||||
expect(list).not.toBeNull();
|
||||
|
||||
const marker = screen.getByTestId('uplot-tooltip-item-marker');
|
||||
const itemContent = screen.getByTestId('uplot-tooltip-item-content');
|
||||
|
||||
expect(marker).toHaveStyle({ borderColor: '#ff0000' });
|
||||
expect(itemContent).toHaveStyle({ color: '#ff0000', fontWeight: '700' });
|
||||
expect(itemContent).toHaveTextContent('Series A: 10');
|
||||
});
|
||||
|
||||
it('does not render tooltip list when content is empty', () => {
|
||||
@@ -176,7 +192,7 @@ describe('Tooltip', () => {
|
||||
renderTooltip({ uPlotInstance, content });
|
||||
|
||||
const list = screen.getByTestId('uplot-tooltip-list');
|
||||
expect(list).toHaveStyle({ height: '200px' });
|
||||
expect(list).toHaveStyle({ height: '210px' });
|
||||
});
|
||||
|
||||
it('sets tooltip list height based on content length when Virtuoso reports 0 height', () => {
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('Tooltip utils', () => {
|
||||
];
|
||||
}
|
||||
|
||||
it('builds tooltip content in series-index order with isActive flag set correctly', () => {
|
||||
it('builds tooltip content with active series first', () => {
|
||||
const data: AlignedData = [[0], [10], [20], [30]];
|
||||
const series = createSeriesConfig();
|
||||
const dataIndexes = [null, 0, 0, 0];
|
||||
@@ -206,21 +206,21 @@ describe('Tooltip utils', () => {
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// Series are returned in series-index order (A=index 1 before B=index 2)
|
||||
// Active (series index 2) should come first
|
||||
expect(result[0]).toMatchObject<Partial<TooltipContentItem>>({
|
||||
label: 'A',
|
||||
value: 10,
|
||||
tooltipValue: 'formatted-10',
|
||||
color: '#ff0000',
|
||||
isActive: false,
|
||||
});
|
||||
expect(result[1]).toMatchObject<Partial<TooltipContentItem>>({
|
||||
label: 'B',
|
||||
value: 20,
|
||||
tooltipValue: 'formatted-20',
|
||||
color: 'color-2',
|
||||
isActive: true,
|
||||
});
|
||||
expect(result[1]).toMatchObject<Partial<TooltipContentItem>>({
|
||||
label: 'A',
|
||||
value: 10,
|
||||
tooltipValue: 'formatted-10',
|
||||
color: '#ff0000',
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips series with null data index or non-finite values', () => {
|
||||
@@ -273,31 +273,5 @@ describe('Tooltip utils', () => {
|
||||
expect(result[0].value).toBe(30);
|
||||
expect(result[1].value).toBe(30);
|
||||
});
|
||||
|
||||
it('returns items in series-index order', () => {
|
||||
// Series values in non-sorted order: 3, 1, 4, 2
|
||||
const data: AlignedData = [[0], [3], [1], [4], [2]];
|
||||
const series: Series[] = [
|
||||
{ label: 'x', show: true } as Series,
|
||||
{ label: 'C', show: true, stroke: '#aaaaaa' } as Series,
|
||||
{ label: 'A', show: true, stroke: '#bbbbbb' } as Series,
|
||||
{ label: 'D', show: true, stroke: '#cccccc' } as Series,
|
||||
{ label: 'B', show: true, stroke: '#dddddd' } as Series,
|
||||
];
|
||||
const dataIndexes = [null, 0, 0, 0, 0];
|
||||
const u = createUPlotInstance();
|
||||
|
||||
const result = buildTooltipContent({
|
||||
data,
|
||||
series,
|
||||
dataIndexes,
|
||||
activeSeriesIndex: null,
|
||||
uPlotInstance: u,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.value)).toEqual([3, 1, 4, 2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
.uplot-tooltip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
|
||||
.uplot-tooltip-item-marker {
|
||||
border-radius: 50%;
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.uplot-tooltip-item-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
|
||||
.uplot-tooltip-item-label {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&-separator {
|
||||
flex: 1;
|
||||
border-width: 0.5px;
|
||||
border-style: dashed;
|
||||
min-width: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { TooltipContentItem } from '../../../types';
|
||||
|
||||
import Styles from './TooltipItem.module.scss';
|
||||
|
||||
interface TooltipItemProps {
|
||||
item: TooltipContentItem;
|
||||
isItemActive: boolean;
|
||||
containerTestId?: string;
|
||||
markerTestId?: string;
|
||||
contentTestId?: string;
|
||||
}
|
||||
|
||||
export default function TooltipItem({
|
||||
item,
|
||||
isItemActive,
|
||||
containerTestId = 'uplot-tooltip-item',
|
||||
markerTestId = 'uplot-tooltip-item-marker',
|
||||
contentTestId = 'uplot-tooltip-item-content',
|
||||
}: TooltipItemProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={Styles.uplotTooltipItem}
|
||||
style={{
|
||||
opacity: isItemActive ? 1 : 0.7,
|
||||
fontWeight: isItemActive ? 700 : 400,
|
||||
}}
|
||||
data-testid={containerTestId}
|
||||
>
|
||||
<div
|
||||
className={Styles.uplotTooltipItemMarker}
|
||||
style={{ borderColor: item.color }}
|
||||
data-is-legend-marker={true}
|
||||
data-testid={markerTestId}
|
||||
/>
|
||||
<div
|
||||
className={Styles.uplotTooltipItemContent}
|
||||
style={{ color: item.color }}
|
||||
data-testid={contentTestId}
|
||||
>
|
||||
<span className={Styles.uplotTooltipItemLabel}>{item.label}</span>
|
||||
<span
|
||||
className={Styles.uplotTooltipItemContentSeparator}
|
||||
style={{ borderColor: item.color }}
|
||||
/>
|
||||
<span>{item.tooltipValue}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,16 +38,16 @@ export function getTooltipBaseValue({
|
||||
// When series are hidden, we must use the next *visible* series, not index+1,
|
||||
// since hidden series keep raw values and would produce negative/wrong results.
|
||||
if (isStackedBarChart && baseValue !== null && series) {
|
||||
let nextVisibleSeriesIdx = -1;
|
||||
for (let seriesIdx = index + 1; seriesIdx < series.length; seriesIdx++) {
|
||||
if (series[seriesIdx]?.show) {
|
||||
nextVisibleSeriesIdx = seriesIdx;
|
||||
let nextVisibleIdx = -1;
|
||||
for (let j = index + 1; j < series.length; j++) {
|
||||
if (series[j]?.show) {
|
||||
nextVisibleIdx = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (nextVisibleSeriesIdx >= 1) {
|
||||
const nextStackedValue = data[nextVisibleSeriesIdx][dataIndex] ?? 0;
|
||||
baseValue = baseValue - nextStackedValue;
|
||||
if (nextVisibleIdx >= 1) {
|
||||
const nextValue = data[nextVisibleIdx][dataIndex] ?? 0;
|
||||
baseValue = baseValue - nextValue;
|
||||
}
|
||||
}
|
||||
return baseValue;
|
||||
@@ -72,15 +72,16 @@ export function buildTooltipContent({
|
||||
decimalPrecision?: PrecisionOption;
|
||||
isStackedBarChart?: boolean;
|
||||
}): TooltipContentItem[] {
|
||||
const items: TooltipContentItem[] = [];
|
||||
const active: TooltipContentItem[] = [];
|
||||
const rest: TooltipContentItem[] = [];
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex < series.length; seriesIndex += 1) {
|
||||
const seriesItem = series[seriesIndex];
|
||||
if (!seriesItem?.show) {
|
||||
for (let index = 1; index < series.length; index += 1) {
|
||||
const s = series[index];
|
||||
if (!s?.show) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dataIndex = dataIndexes[seriesIndex];
|
||||
const dataIndex = dataIndexes[index];
|
||||
// Skip series with no data at the current cursor position
|
||||
if (dataIndex === null) {
|
||||
continue;
|
||||
@@ -88,22 +89,30 @@ export function buildTooltipContent({
|
||||
|
||||
const baseValue = getTooltipBaseValue({
|
||||
data,
|
||||
index: seriesIndex,
|
||||
index,
|
||||
dataIndex,
|
||||
isStackedBarChart,
|
||||
series,
|
||||
});
|
||||
|
||||
const isActive = index === activeSeriesIndex;
|
||||
|
||||
if (Number.isFinite(baseValue) && baseValue !== null) {
|
||||
items.push({
|
||||
label: String(seriesItem.label ?? ''),
|
||||
const item: TooltipContentItem = {
|
||||
label: String(s.label ?? ''),
|
||||
value: baseValue,
|
||||
tooltipValue: getToolTipValue(baseValue, yAxisUnit, decimalPrecision),
|
||||
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
|
||||
isActive: seriesIndex === activeSeriesIndex,
|
||||
});
|
||||
color: resolveSeriesColor(s.stroke, uPlotInstance, index),
|
||||
isActive,
|
||||
};
|
||||
|
||||
if (isActive) {
|
||||
active.push(item);
|
||||
} else {
|
||||
rest.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
return [...active, ...rest];
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ const HOVER_DISMISS_DELAY_MS = 100;
|
||||
export default function TooltipPlugin({
|
||||
config,
|
||||
render,
|
||||
maxWidth = 450,
|
||||
maxHeight = 600,
|
||||
maxWidth = 300,
|
||||
maxHeight = 400,
|
||||
syncMode = DashboardCursorSync.None,
|
||||
syncKey = '_tooltip_sync_global_',
|
||||
pinnedTooltipElement,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { retryOn429 } from './errorUtils';
|
||||
|
||||
describe('retryOn429', () => {
|
||||
const make429 = (): AxiosError =>
|
||||
Object.assign(new AxiosError('Too Many Requests'), {
|
||||
response: { status: 429 },
|
||||
}) as AxiosError;
|
||||
|
||||
it('returns true on first failure (failureCount=0) for 429', () => {
|
||||
expect(retryOn429(0, make429())).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true on second failure (failureCount=1) for 429', () => {
|
||||
expect(retryOn429(1, make429())).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false on third failure (failureCount=2) for 429 — max retries reached', () => {
|
||||
expect(retryOn429(2, make429())).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-429 axios errors', () => {
|
||||
const err = Object.assign(new AxiosError('Server Error'), {
|
||||
response: { status: 500 },
|
||||
}) as AxiosError;
|
||||
expect(retryOn429(0, err)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for 401 axios errors', () => {
|
||||
const err = Object.assign(new AxiosError('Unauthorized'), {
|
||||
response: { status: 401 },
|
||||
}) as AxiosError;
|
||||
expect(retryOn429(0, err)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-axios errors', () => {
|
||||
expect(retryOn429(0, new Error('network error'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null/undefined errors', () => {
|
||||
expect(retryOn429(0, null)).toBe(false);
|
||||
expect(retryOn429(0, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ErrorResponseHandlerForGeneratedAPIs } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { AxiosError } from 'axios';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
/**
|
||||
@@ -67,10 +66,3 @@ export function handleApiError(
|
||||
showErrorFunction(apiError as APIError);
|
||||
}
|
||||
}
|
||||
|
||||
export const retryOn429 = (failureCount: number, error: unknown): boolean => {
|
||||
if (error instanceof AxiosError && error.response?.status === 429) {
|
||||
return failureCount < 2;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -97,9 +97,6 @@ export default defineConfig(
|
||||
javascriptEnabled: true,
|
||||
},
|
||||
},
|
||||
modules: {
|
||||
localsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
define: {
|
||||
// TODO: Remove this in favor of import.meta.env
|
||||
|
||||
@@ -150,7 +150,7 @@ func (provider *provider) Grant(ctx context.Context, orgID valuer.UUID, names []
|
||||
|
||||
err = provider.Write(ctx, tuples, nil)
|
||||
if err != nil {
|
||||
return errors.WithAdditionalf(err, "failed to grant roles: %v to subject: %s", names, subject)
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "failed to grant roles: %v to subject: %s", names, subject)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -188,7 +188,7 @@ func (provider *provider) Revoke(ctx context.Context, orgID valuer.UUID, names [
|
||||
|
||||
err = provider.Write(ctx, nil, tuples)
|
||||
if err != nil {
|
||||
return errors.WithAdditionalf(err, "failed to revoke roles: %v to subject: %s", names, subject)
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "failed to revoke roles: %v to subject: %s", names, subject)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -15,14 +15,12 @@ import (
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer"
|
||||
openfgapkgserver "github.com/openfga/openfga/pkg/server"
|
||||
openfgaerrors "github.com/openfga/openfga/pkg/server/errors"
|
||||
"github.com/openfga/openfga/pkg/storage"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
)
|
||||
|
||||
const (
|
||||
batchCheckItemErrorMessage = "::AUTHZ-CHECK-ERROR::"
|
||||
writeErrorMessage = "::AUTHZ-WRITE-ERROR::"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -250,19 +248,7 @@ func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey
|
||||
}(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
openfgaError := new(openfgaerrors.InternalError)
|
||||
ok := errors.As(err, openfgaError)
|
||||
if ok {
|
||||
server.settings.Logger().ErrorContext(ctx, writeErrorMessage, errors.Attr(openfgaError.Unwrap()))
|
||||
return errors.New(errors.TypeTooManyRequests, errors.CodeTooManyRequests, openfgaError.Error())
|
||||
}
|
||||
|
||||
server.settings.Logger().ErrorContext(ctx, writeErrorMessage, errors.Attr(err))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
|
||||
|
||||
@@ -18,7 +18,6 @@ var (
|
||||
CodeUnknown = Code{"unknown"}
|
||||
CodeFatal = Code{"fatal"}
|
||||
CodeLicenseUnavailable = Code{"license_unavailable"}
|
||||
CodeTooManyRequests = Code{"too_many_requests"}
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -12,9 +12,8 @@ var (
|
||||
TypeCanceled = typ{"canceled"}
|
||||
TypeTimeout = typ{"timeout"}
|
||||
TypeUnexpected = typ{"unexpected"} // Generic mismatch of expectations
|
||||
TypeFatal = typ{"fatal"} // Unrecoverable failure (e.g. panic)
|
||||
TypeFatal = typ{"fatal"} // Unrecoverable failure (e.g. panic)
|
||||
TypeLicenseUnavailable = typ{"license-unavailable"}
|
||||
TypeTooManyRequests = typ{"too-many-requests"}
|
||||
)
|
||||
|
||||
// Defines custom error types.
|
||||
|
||||
@@ -77,8 +77,6 @@ func ErrorTypeFromStatusCode(statusCode int) string {
|
||||
return errors.TypeTimeout.String()
|
||||
case http.StatusUnavailableForLegalReasons:
|
||||
return errors.TypeLicenseUnavailable.String()
|
||||
case http.StatusTooManyRequests:
|
||||
return errors.TypeTooManyRequests.String()
|
||||
default:
|
||||
return errors.TypeInternal.String()
|
||||
}
|
||||
@@ -110,8 +108,6 @@ func Error(rw http.ResponseWriter, cause error) {
|
||||
httpCode = http.StatusInternalServerError
|
||||
case errors.TypeLicenseUnavailable:
|
||||
httpCode = http.StatusUnavailableForLegalReasons
|
||||
case errors.TypeTooManyRequests:
|
||||
httpCode = http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
body, err := json.Marshal(&ErrorResponse{Status: StatusError.s, Error: errors.AsJSON(cause)})
|
||||
|
||||
@@ -211,8 +211,6 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
|
||||
case float64:
|
||||
// try to convert the string value to to number
|
||||
tblFieldName = castFloat(tblFieldName)
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
tblFieldName = castFloat(tblFieldName)
|
||||
case []any:
|
||||
if allFloats(v) {
|
||||
tblFieldName = castFloat(tblFieldName)
|
||||
@@ -279,18 +277,6 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
|
||||
tblFieldName, value = castString(tblFieldName), toStrings(v)
|
||||
}
|
||||
}
|
||||
case telemetrytypes.FieldDataTypeArrayDynamic:
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
tblFieldName = castString(tblFieldName)
|
||||
case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
tblFieldName = accurateCastFloat(tblFieldName)
|
||||
case bool:
|
||||
tblFieldName = castBool(tblFieldName)
|
||||
case []any:
|
||||
// dynamic array elements will be default casted to string
|
||||
tblFieldName, value = castString(tblFieldName), toStrings(v)
|
||||
}
|
||||
}
|
||||
return tblFieldName, value
|
||||
}
|
||||
@@ -298,10 +284,6 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
|
||||
func castFloat(col string) string { return fmt.Sprintf("toFloat64OrNull(%s)", col) }
|
||||
func castFloatHack(col string) string { return fmt.Sprintf("toFloat64(%s)", col) }
|
||||
func castString(col string) string { return fmt.Sprintf("toString(%s)", col) }
|
||||
func castBool(col string) string { return fmt.Sprintf("accurateCastOrNull(%s, 'Bool')", col) }
|
||||
func accurateCastFloat(col string) string {
|
||||
return fmt.Sprintf("accurateCastOrNull(%s, 'Float64')", col)
|
||||
}
|
||||
|
||||
func allFloats(in []any) bool {
|
||||
for _, x := range in {
|
||||
|
||||
@@ -818,9 +818,9 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
|
||||
case "has":
|
||||
cond = fmt.Sprintf("has(%s, %s)", fieldName, v.builder.Var(value[0]))
|
||||
case "hasAny":
|
||||
cond = fmt.Sprintf("hasAny(%s, %s)", fieldName, v.builder.Var(value))
|
||||
cond = fmt.Sprintf("hasAny(%s, %s)", fieldName, v.builder.Var(value[0]))
|
||||
case "hasAll":
|
||||
cond = fmt.Sprintf("hasAll(%s, %s)", fieldName, v.builder.Var(value))
|
||||
cond = fmt.Sprintf("hasAll(%s, %s)", fieldName, v.builder.Var(value[0]))
|
||||
}
|
||||
conds = append(conds, cond)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package telemetrylogs
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -34,34 +33,182 @@ func NewJSONConditionBuilder(key *telemetrytypes.TelemetryFieldKey, valueType te
|
||||
|
||||
// BuildCondition builds the full WHERE condition for body_v2 JSON paths.
|
||||
func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
baseCond, err := c.emitPlannedCondition(operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
conditions := []string{}
|
||||
for _, node := range c.key.JSONPlan {
|
||||
condition, err := c.emitPlannedCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, condition)
|
||||
}
|
||||
baseCond := sb.Or(conditions...)
|
||||
|
||||
// path index
|
||||
if operator.AddDefaultExistsFilter() {
|
||||
pathIndex := fmt.Sprintf(`has(%s, '%s')`, schemamigrator.JSONPathsIndexExpr(LogsV2BodyV2Column), c.key.ArrayParentPaths()[0])
|
||||
return sb.And(baseCond, pathIndex), nil
|
||||
}
|
||||
|
||||
return baseCond, nil
|
||||
}
|
||||
|
||||
func (c *jsonConditionBuilder) emitPlannedCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
// emitPlannedCondition handles paths with array traversal.
|
||||
func (c *jsonConditionBuilder) emitPlannedCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
// Build traversal + terminal recursively per-hop
|
||||
conditions := []string{}
|
||||
for _, node := range c.key.JSONPlan {
|
||||
condition, err := c.recurseArrayHops(node, operator, value, sb)
|
||||
compiled, err := c.recurseArrayHops(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return compiled, nil
|
||||
}
|
||||
|
||||
// buildTerminalCondition creates the innermost condition.
|
||||
func (c *jsonConditionBuilder) buildTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if node.TerminalConfig.ElemType.IsArray {
|
||||
conditions := []string{}
|
||||
// if the value type is not an array
|
||||
// TODO(piyush): Confirm the Query built for Array case and add testcases for it later
|
||||
if !c.valueType.IsArray {
|
||||
// if operator is a String search Operator, then we need to build one more String comparison condition along with the Strict match condition
|
||||
if operator.IsStringSearchOperator() {
|
||||
formattedValue := querybuilder.FormatValueForContains(value)
|
||||
arrayCond, err := c.buildArrayMembershipCondition(node, operator, formattedValue, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, arrayCond)
|
||||
}
|
||||
|
||||
// switch operator for array membership checks
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorContains:
|
||||
operator = qbtypes.FilterOperatorEqual
|
||||
case qbtypes.FilterOperatorNotContains:
|
||||
operator = qbtypes.FilterOperatorNotEqual
|
||||
}
|
||||
}
|
||||
|
||||
arrayCond, err := c.buildArrayMembershipCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, condition)
|
||||
conditions = append(conditions, arrayCond)
|
||||
// or the conditions together
|
||||
return sb.Or(conditions...), nil
|
||||
}
|
||||
|
||||
return sb.Or(conditions...), nil
|
||||
return c.buildPrimitiveTerminalCondition(node, operator, value, sb)
|
||||
}
|
||||
|
||||
// buildPrimitiveTerminalCondition builds the condition if the terminal node is a primitive type
|
||||
// it handles the data type collisions and utilizes indexes for the condition if available.
|
||||
func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
fieldPath := node.FieldPath()
|
||||
conditions := []string{}
|
||||
var formattedValue = value
|
||||
if operator.IsStringSearchOperator() {
|
||||
formattedValue = querybuilder.FormatValueForContains(value)
|
||||
}
|
||||
|
||||
elemType := node.TerminalConfig.ElemType
|
||||
fieldExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, elemType.StringValue())
|
||||
fieldExpr, formattedValue = querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, fieldExpr, operator)
|
||||
|
||||
// utilize indexes for the condition if available
|
||||
indexed := slices.ContainsFunc(node.TerminalConfig.Key.Indexes, func(index telemetrytypes.JSONDataTypeIndex) bool {
|
||||
return index.Type == elemType && index.ColumnExpression == fieldPath
|
||||
})
|
||||
if elemType.IndexSupported && indexed {
|
||||
indexedExpr := assumeNotNull(fieldPath, elemType)
|
||||
emptyValue := func() any {
|
||||
switch elemType {
|
||||
case telemetrytypes.String:
|
||||
return ""
|
||||
case telemetrytypes.Int64, telemetrytypes.Float64, telemetrytypes.Bool:
|
||||
return 0
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
// switch the operator and value for exists and not exists
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorExists:
|
||||
operator = qbtypes.FilterOperatorNotEqual
|
||||
value = emptyValue
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
operator = qbtypes.FilterOperatorEqual
|
||||
value = emptyValue
|
||||
default:
|
||||
// do nothing
|
||||
}
|
||||
|
||||
indexedExpr, indexedComparisonValue := querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, indexedExpr, operator)
|
||||
cond, err := c.applyOperator(sb, indexedExpr, operator, indexedComparisonValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// if qb has a definitive value, we can skip adding a condition to
|
||||
// check the existence of the path in the json column
|
||||
if value != emptyValue {
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
conditions = append(conditions, cond)
|
||||
// Switch operator to EXISTS since indexed paths on assumedNotNull, indexes will always have a default value
|
||||
// So we flip the operator to Exists and filter the rows that actually have the value
|
||||
operator = qbtypes.FilterOperatorExists
|
||||
}
|
||||
|
||||
cond, err := c.applyOperator(sb, fieldExpr, operator, formattedValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, cond)
|
||||
if len(conditions) > 1 {
|
||||
return sb.And(conditions...), nil
|
||||
}
|
||||
return conditions[0], nil
|
||||
}
|
||||
|
||||
// buildArrayMembershipCondition handles array membership checks.
|
||||
func (c *jsonConditionBuilder) buildArrayMembershipCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
arrayPath := node.FieldPath()
|
||||
localKeyCopy := *node.TerminalConfig.Key
|
||||
// create typed array out of a dynamic array
|
||||
filteredDynamicExpr := func() string {
|
||||
// Change the field data type from []dynamic to the value type
|
||||
// since we've filtered the value type out of the dynamic array, we need to change the field data corresponding to the value type
|
||||
localKeyCopy.FieldDataType = telemetrytypes.MappingJSONDataTypeToFieldDataType[telemetrytypes.ScalerTypeToArrayType[c.valueType]]
|
||||
|
||||
baseArrayDynamicExpr := fmt.Sprintf("dynamicElement(%s, 'Array(Dynamic)')", arrayPath)
|
||||
return fmt.Sprintf("arrayMap(x->dynamicElement(x, '%s'), arrayFilter(x->(dynamicType(x) = '%s'), %s))",
|
||||
c.valueType.StringValue(),
|
||||
c.valueType.StringValue(),
|
||||
baseArrayDynamicExpr)
|
||||
}
|
||||
typedArrayExpr := func() string {
|
||||
return fmt.Sprintf("dynamicElement(%s, '%s')", arrayPath, node.TerminalConfig.ElemType.StringValue())
|
||||
}
|
||||
|
||||
var arrayExpr string
|
||||
if node.TerminalConfig.ElemType == telemetrytypes.ArrayDynamic {
|
||||
arrayExpr = filteredDynamicExpr()
|
||||
} else {
|
||||
arrayExpr = typedArrayExpr()
|
||||
}
|
||||
|
||||
key := "x"
|
||||
fieldExpr, value := querybuilder.DataTypeCollisionHandledFieldName(&localKeyCopy, value, key, operator)
|
||||
op, err := c.applyOperator(sb, fieldExpr, operator, value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("arrayExists(%s -> %s, %s)", key, op, arrayExpr), nil
|
||||
}
|
||||
|
||||
// recurseArrayHops recursively builds array traversal conditions.
|
||||
func (c *jsonConditionBuilder) recurseArrayHops(current *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if current == nil {
|
||||
return "", errors.NewInternalf(CodeArrayNavigationFailed, "navigation failed, current node is nil")
|
||||
@@ -75,33 +222,6 @@ func (c *jsonConditionBuilder) recurseArrayHops(current *telemetrytypes.JSONAcce
|
||||
return terminalCond, nil
|
||||
}
|
||||
|
||||
// apply NOT at top level arrayExists so that any subsequent arrayExists fails we count it as true (matching log)
|
||||
yes, operator := applyNotCondition(operator)
|
||||
condition, err := c.buildAccessNodeBranches(current, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if yes {
|
||||
return sb.Not(condition), nil
|
||||
}
|
||||
|
||||
return condition, nil
|
||||
}
|
||||
|
||||
func applyNotCondition(operator qbtypes.FilterOperator) (bool, qbtypes.FilterOperator) {
|
||||
if operator.IsNegativeOperator() {
|
||||
return true, operator.Inverse()
|
||||
}
|
||||
return false, operator
|
||||
}
|
||||
|
||||
// buildAccessNodeBranches builds conditions for each branch of the access node.
|
||||
func (c *jsonConditionBuilder) buildAccessNodeBranches(current *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if current == nil {
|
||||
return "", errors.NewInternalf(CodeArrayNavigationFailed, "navigation failed, current node is nil")
|
||||
}
|
||||
|
||||
currAlias := current.Alias()
|
||||
fieldPath := current.FieldPath()
|
||||
// Determine availability of Array(JSON) and Array(Dynamic) at this hop
|
||||
@@ -136,200 +256,6 @@ func (c *jsonConditionBuilder) buildAccessNodeBranches(current *telemetrytypes.J
|
||||
return sb.Or(branches...), nil
|
||||
}
|
||||
|
||||
// buildTerminalCondition creates the innermost condition.
|
||||
func (c *jsonConditionBuilder) buildTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if node.TerminalConfig.ElemType.IsArray {
|
||||
// Note: here applyNotCondition will return true only if; top level path is an array; and operator is a negative operator
|
||||
// Otherwise this code will be triggered by buildAccessNodeBranches; Where operator would've been already inverted if needed.
|
||||
yes, operator := applyNotCondition(operator)
|
||||
cond, err := c.buildTerminalArrayCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if yes {
|
||||
return sb.Not(cond), nil
|
||||
}
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
return c.buildPrimitiveTerminalCondition(node, operator, value, sb)
|
||||
}
|
||||
|
||||
func getEmptyValue(elemType telemetrytypes.JSONDataType) any {
|
||||
switch elemType {
|
||||
case telemetrytypes.String:
|
||||
return ""
|
||||
case telemetrytypes.Int64, telemetrytypes.Float64, telemetrytypes.Bool:
|
||||
return 0
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *jsonConditionBuilder) terminalIndexedCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
fieldPath := node.FieldPath()
|
||||
if strings.Contains(fieldPath, telemetrytypes.ArraySepSuffix) {
|
||||
return "", errors.NewInternalf(CodeArrayNavigationFailed, "can not build index condition for array field %s", fieldPath)
|
||||
}
|
||||
|
||||
elemType := node.TerminalConfig.ElemType
|
||||
dynamicExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, elemType.StringValue())
|
||||
indexedExpr := assumeNotNull(dynamicExpr)
|
||||
|
||||
// switch the operator and value for exists and not exists
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorExists:
|
||||
operator = qbtypes.FilterOperatorNotEqual
|
||||
value = getEmptyValue(elemType)
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
operator = qbtypes.FilterOperatorEqual
|
||||
value = getEmptyValue(elemType)
|
||||
default:
|
||||
// do nothing
|
||||
}
|
||||
|
||||
indexedExpr, formattedValue := querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, value, indexedExpr, operator)
|
||||
cond, err := c.applyOperator(sb, indexedExpr, operator, formattedValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
// buildPrimitiveTerminalCondition builds the condition if the terminal node is a primitive type
|
||||
// it handles the data type collisions and utilizes indexes for the condition if available.
|
||||
func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
fieldPath := node.FieldPath()
|
||||
conditions := []string{}
|
||||
|
||||
// utilize indexes for the condition if available
|
||||
//
|
||||
// Note: Indexing code doesn't get executed for Array Nested fields because they can not be indexed
|
||||
indexed := slices.ContainsFunc(node.TerminalConfig.Key.Indexes, func(index telemetrytypes.JSONDataTypeIndex) bool {
|
||||
return index.Type == node.TerminalConfig.ElemType
|
||||
})
|
||||
if node.TerminalConfig.ElemType.IndexSupported && indexed {
|
||||
indexCond, err := c.terminalIndexedCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// if qb has a definitive value, we can skip adding a condition to
|
||||
// check the existence of the path in the json column
|
||||
if value != nil && value != getEmptyValue(node.TerminalConfig.ElemType) {
|
||||
return indexCond, nil
|
||||
}
|
||||
|
||||
conditions = append(conditions, indexCond)
|
||||
|
||||
// Switch operator to EXISTS except when operator is NOT EXISTS since
|
||||
// indexed paths on assumedNotNull, indexes will always have a default
|
||||
// value so we flip the operator to Exists and filter the rows that
|
||||
// actually have the value
|
||||
if operator != qbtypes.FilterOperatorNotExists {
|
||||
operator = qbtypes.FilterOperatorExists
|
||||
}
|
||||
}
|
||||
|
||||
var formattedValue = value
|
||||
if operator.IsStringSearchOperator() {
|
||||
formattedValue = querybuilder.FormatValueForContains(value)
|
||||
}
|
||||
|
||||
fieldExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, node.TerminalConfig.ElemType.StringValue())
|
||||
|
||||
// if operator is negative and has a value comparison i.e. excluding EXISTS and NOT EXISTS, we need to assume that the field exists everywhere
|
||||
//
|
||||
// Note: here applyNotCondition will return true only if; top level path is being queried and operator is a negative operator
|
||||
// Otherwise this code will be triggered by buildAccessNodeBranches; Where operator would've been already inverted if needed.
|
||||
if node.IsNonNestedPath() {
|
||||
yes, _ := applyNotCondition(operator)
|
||||
if yes {
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
// skip
|
||||
default:
|
||||
fieldExpr = assumeNotNull(fieldExpr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fieldExpr, formattedValue = querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, fieldExpr, operator)
|
||||
cond, err := c.applyOperator(sb, fieldExpr, operator, formattedValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, cond)
|
||||
if len(conditions) > 1 {
|
||||
return sb.And(conditions...), nil
|
||||
}
|
||||
return conditions[0], nil
|
||||
}
|
||||
|
||||
func (c *jsonConditionBuilder) buildTerminalArrayCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
conditions := []string{}
|
||||
// if operator is a String search Operator, then we need to build one more String comparison condition along with the Strict match condition
|
||||
if operator.IsStringSearchOperator() {
|
||||
formattedValue := querybuilder.FormatValueForContains(value)
|
||||
arrayCond, err := c.buildArrayMembershipCondition(node, operator, formattedValue, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, arrayCond)
|
||||
|
||||
// switch operator for array membership checks
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorContains:
|
||||
operator = qbtypes.FilterOperatorEqual
|
||||
case qbtypes.FilterOperatorNotContains:
|
||||
operator = qbtypes.FilterOperatorNotEqual
|
||||
}
|
||||
}
|
||||
|
||||
arrayCond, err := c.buildArrayMembershipCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, arrayCond)
|
||||
if len(conditions) > 1 {
|
||||
return sb.Or(conditions...), nil
|
||||
}
|
||||
|
||||
return conditions[0], nil
|
||||
}
|
||||
|
||||
// buildArrayMembershipCondition builds condition of the part where Arrays becomes primitive typed Arrays
|
||||
// e.g. [300, 404, 500], and value operations will work on the array elements.
|
||||
func (c *jsonConditionBuilder) buildArrayMembershipCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
arrayPath := node.FieldPath()
|
||||
// create typed array out of a dynamic array
|
||||
filteredDynamicExpr := func() string {
|
||||
baseArrayDynamicExpr := fmt.Sprintf("dynamicElement(%s, 'Array(Dynamic)')", arrayPath)
|
||||
return fmt.Sprintf("arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), %s)",
|
||||
baseArrayDynamicExpr)
|
||||
}
|
||||
typedArrayExpr := func() string {
|
||||
return fmt.Sprintf("dynamicElement(%s, '%s')", arrayPath, node.TerminalConfig.ElemType.StringValue())
|
||||
}
|
||||
|
||||
var arrayExpr string
|
||||
if node.TerminalConfig.ElemType == telemetrytypes.ArrayDynamic {
|
||||
arrayExpr = filteredDynamicExpr()
|
||||
} else {
|
||||
arrayExpr = typedArrayExpr()
|
||||
}
|
||||
|
||||
key := "x"
|
||||
fieldExpr, value := querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, value, key, operator)
|
||||
op, err := c.applyOperator(sb, fieldExpr, operator, value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("arrayExists(%s -> %s, %s)", key, op, arrayExpr), nil
|
||||
}
|
||||
|
||||
func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, fieldExpr string, operator qbtypes.FilterOperator, value any) (string, error) {
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorEqual:
|
||||
@@ -391,6 +317,6 @@ func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, field
|
||||
}
|
||||
}
|
||||
|
||||
func assumeNotNull(fieldExpr string) string {
|
||||
return fmt.Sprintf("assumeNotNull(%s)", fieldExpr)
|
||||
func assumeNotNull(column string, elemType telemetrytypes.JSONDataType) string {
|
||||
return fmt.Sprintf("assumeNotNull(dynamicElement(%s, '%s'))", column, elemType.StringValue())
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -113,29 +113,6 @@ const (
|
||||
FilterOperatorNotContains
|
||||
)
|
||||
|
||||
var operatorInverseMapping = map[FilterOperator]FilterOperator{
|
||||
FilterOperatorEqual: FilterOperatorNotEqual,
|
||||
FilterOperatorNotEqual: FilterOperatorEqual,
|
||||
FilterOperatorGreaterThan: FilterOperatorLessThanOrEq,
|
||||
FilterOperatorGreaterThanOrEq: FilterOperatorLessThan,
|
||||
FilterOperatorLessThan: FilterOperatorGreaterThanOrEq,
|
||||
FilterOperatorLessThanOrEq: FilterOperatorGreaterThan,
|
||||
FilterOperatorLike: FilterOperatorNotLike,
|
||||
FilterOperatorNotLike: FilterOperatorLike,
|
||||
FilterOperatorILike: FilterOperatorNotILike,
|
||||
FilterOperatorNotILike: FilterOperatorILike,
|
||||
FilterOperatorBetween: FilterOperatorNotBetween,
|
||||
FilterOperatorNotBetween: FilterOperatorBetween,
|
||||
FilterOperatorIn: FilterOperatorNotIn,
|
||||
FilterOperatorNotIn: FilterOperatorIn,
|
||||
FilterOperatorExists: FilterOperatorNotExists,
|
||||
FilterOperatorNotExists: FilterOperatorExists,
|
||||
FilterOperatorRegexp: FilterOperatorNotRegexp,
|
||||
FilterOperatorNotRegexp: FilterOperatorRegexp,
|
||||
FilterOperatorContains: FilterOperatorNotContains,
|
||||
FilterOperatorNotContains: FilterOperatorContains,
|
||||
}
|
||||
|
||||
// AddDefaultExistsFilter returns true if addl exists filter should be added to the query
|
||||
// For the negative predicates, we don't want to add the exists filter. Why?
|
||||
// Say for example, user adds a filter `service.name != "redis"`, we can't interpret it
|
||||
@@ -185,10 +162,6 @@ func (f FilterOperator) IsNegativeOperator() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (f FilterOperator) Inverse() FilterOperator {
|
||||
return operatorInverseMapping[f]
|
||||
}
|
||||
|
||||
func (f FilterOperator) IsComparisonOperator() bool {
|
||||
switch f {
|
||||
case FilterOperatorGreaterThan, FilterOperatorGreaterThanOrEq, FilterOperatorLessThan, FilterOperatorLessThanOrEq:
|
||||
|
||||
@@ -90,11 +90,6 @@ func (n *JSONAccessNode) FieldPath() string {
|
||||
return n.Parent.Alias() + "." + key
|
||||
}
|
||||
|
||||
// Returns true if the current node is a non-nested path.
|
||||
func (n *JSONAccessNode) IsNonNestedPath() bool {
|
||||
return !strings.Contains(n.FieldPath(), ArraySep)
|
||||
}
|
||||
|
||||
func (n *JSONAccessNode) BranchesInOrder() []JSONAccessBranchType {
|
||||
return slices.SortedFunc(maps.Keys(n.Branches), func(a, b JSONAccessBranchType) int {
|
||||
return strings.Compare(b.StringValue(), a.StringValue())
|
||||
|
||||
@@ -8,101 +8,65 @@ package telemetrytypes
|
||||
// This represents the type information available in the test JSON structure.
|
||||
func TestJSONTypeSet() (map[string][]JSONDataType, MetadataStore) {
|
||||
types := map[string][]JSONDataType{
|
||||
|
||||
// ── user (primitives) ─────────────────────────────────────────────
|
||||
"user.name": {String},
|
||||
"user.permissions": {ArrayString},
|
||||
"user.age": {Int64, String}, // Int64/String ambiguity
|
||||
"user.height": {Float64},
|
||||
"user.active": {Bool}, // Bool — not IndexSupported
|
||||
|
||||
// Deeper non-array nesting (a.b.c — no array hops)
|
||||
"user.address.zip": {Int64},
|
||||
|
||||
// ── education[] ───────────────────────────────────────────────────
|
||||
// Pattern: x[].y
|
||||
"education": {ArrayJSON},
|
||||
"education[].name": {String},
|
||||
"education[].type": {String, Int64},
|
||||
"education[].year": {Int64},
|
||||
"education[].scores": {ArrayInt64},
|
||||
"education[].parameters": {ArrayFloat64, ArrayDynamic},
|
||||
|
||||
// Pattern: x[].y[]
|
||||
"education[].awards": {ArrayDynamic, ArrayJSON},
|
||||
|
||||
// Pattern: x[].y[].z
|
||||
"education[].awards[].name": {String},
|
||||
"education[].awards[].type": {String},
|
||||
"education[].awards[].semester": {Int64},
|
||||
|
||||
// Pattern: x[].y[].z[]
|
||||
"education[].awards[].participated": {ArrayDynamic, ArrayJSON},
|
||||
|
||||
// Pattern: x[].y[].z[].w
|
||||
"education[].awards[].participated[].members": {ArrayString},
|
||||
|
||||
// Pattern: x[].y[].z[].w[]
|
||||
"education[].awards[].participated[].team": {ArrayJSON},
|
||||
|
||||
// Pattern: x[].y[].z[].w[].v
|
||||
"education[].awards[].participated[].team[].branch": {String},
|
||||
|
||||
// ── interests[] ───────────────────────────────────────────────────
|
||||
"interests": {ArrayJSON},
|
||||
"interests[].entities": {ArrayJSON},
|
||||
"interests[].entities[].reviews": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].name": {String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].ratings": {ArrayInt64, ArrayString},
|
||||
"http-events": {ArrayJSON},
|
||||
"http-events[].request-info.host": {String},
|
||||
"ids": {ArrayDynamic},
|
||||
|
||||
// ── top-level primitives ──────────────────────────────────────────
|
||||
"message": {String},
|
||||
"http-status": {Int64, String}, // hyphen in root key, ambiguous
|
||||
|
||||
// ── top-level nested objects (no array hops) ───────────────────────
|
||||
"response.time-taken": {Float64}, // hyphen inside nested key
|
||||
"user.name": {String},
|
||||
"user.permissions": {ArrayString},
|
||||
"user.age": {Int64, String},
|
||||
"user.height": {Float64},
|
||||
"education": {ArrayJSON},
|
||||
"education[].name": {String},
|
||||
"education[].type": {String, Int64},
|
||||
"education[].internal_type": {String},
|
||||
"education[].metadata.location": {String},
|
||||
"education[].parameters": {ArrayFloat64, ArrayDynamic},
|
||||
"education[].duration": {String},
|
||||
"education[].mode": {String},
|
||||
"education[].year": {Int64},
|
||||
"education[].field": {String},
|
||||
"education[].awards": {ArrayDynamic, ArrayJSON},
|
||||
"education[].awards[].name": {String},
|
||||
"education[].awards[].rank": {Int64},
|
||||
"education[].awards[].medal": {String},
|
||||
"education[].awards[].type": {String},
|
||||
"education[].awards[].semester": {Int64},
|
||||
"education[].awards[].participated": {ArrayDynamic, ArrayJSON},
|
||||
"education[].awards[].participated[].type": {String},
|
||||
"education[].awards[].participated[].field": {String},
|
||||
"education[].awards[].participated[].project_type": {String},
|
||||
"education[].awards[].participated[].project_name": {String},
|
||||
"education[].awards[].participated[].race_type": {String},
|
||||
"education[].awards[].participated[].team_based": {Bool},
|
||||
"education[].awards[].participated[].team_name": {String},
|
||||
"education[].awards[].participated[].team": {ArrayJSON},
|
||||
"education[].awards[].participated[].members": {ArrayString},
|
||||
"education[].awards[].participated[].team[].name": {String},
|
||||
"education[].awards[].participated[].team[].branch": {String},
|
||||
"education[].awards[].participated[].team[].semester": {Int64},
|
||||
"interests": {ArrayJSON},
|
||||
"interests[].type": {String},
|
||||
"interests[].entities": {ArrayJSON},
|
||||
"interests[].entities.application_date": {String},
|
||||
"interests[].entities[].reviews": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].given_by": {String},
|
||||
"interests[].entities[].reviews[].remarks": {String},
|
||||
"interests[].entities[].reviews[].weight": {Float64},
|
||||
"interests[].entities[].reviews[].passed": {Bool},
|
||||
"interests[].entities[].reviews[].type": {String},
|
||||
"interests[].entities[].reviews[].analysis_type": {Int64},
|
||||
"interests[].entities[].reviews[].entries": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].subject": {String},
|
||||
"interests[].entities[].reviews[].entries[].status": {String},
|
||||
"interests[].entities[].reviews[].entries[].metadata": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].company": {String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].experience": {Int64},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].unit": {String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].name": {String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].duration": {Int64, Float64},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].unit": {String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].ratings": {ArrayInt64, ArrayString},
|
||||
"message": {String},
|
||||
"tags": {ArrayString},
|
||||
}
|
||||
|
||||
return types, nil
|
||||
}
|
||||
|
||||
// TestIndexedPathEntry is a path + JSON type pair representing a field
|
||||
// backed by a ClickHouse skip index in the test data.
|
||||
//
|
||||
// Only non-array paths with IndexSupported types (String, Int64, Float64)
|
||||
// are valid entries — arrays and Bool cannot carry a skip index.
|
||||
//
|
||||
// The ColumnExpression for each entry is computed at test-setup time from
|
||||
// the access plan, since it depends on the column name (e.g. body_v2)
|
||||
// which is unknown to this package.
|
||||
type TestIndexedPathEntry struct {
|
||||
Path string
|
||||
Type JSONDataType
|
||||
}
|
||||
|
||||
// TestIndexedPaths lists path+type pairs from TestJSONTypeSet that are
|
||||
// backed by a JSON data type index. Test setup uses this to populate
|
||||
// key.Indexes after calling SetJSONAccessPlan.
|
||||
//
|
||||
// Intentionally excluded:
|
||||
// - user.active → Bool, IndexSupported=false
|
||||
var TestIndexedPaths = []TestIndexedPathEntry{
|
||||
// user primitives
|
||||
{Path: "user.name", Type: String},
|
||||
|
||||
// user.address — deeper non-array nesting
|
||||
{Path: "user.address.zip", Type: Int64},
|
||||
|
||||
// root-level with special characters
|
||||
{Path: "http-status", Type: Int64},
|
||||
{Path: "http-status", Type: String},
|
||||
|
||||
// root-level nested objects (no array hops)
|
||||
{Path: "response.time-taken", Type: Float64},
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ QUERY_TIMEOUT = 30 # seconds
|
||||
@dataclass
|
||||
class TelemetryFieldKey:
|
||||
name: str
|
||||
field_data_type: Optional[str] = None
|
||||
field_context: Optional[str] = None
|
||||
field_data_type: str
|
||||
field_context: str
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
|
||||
@@ -14,9 +14,7 @@ from fixtures.querier import (
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
from src.querier.util import (
|
||||
assert_identical_query_response,
|
||||
)
|
||||
from src.querier.util import assert_identical_query_response
|
||||
|
||||
|
||||
def test_logs_list(
|
||||
@@ -401,6 +399,174 @@ def test_logs_list(
|
||||
assert "d-001" in values
|
||||
|
||||
|
||||
def test_logs_list_with_corrupt_data(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[List[Logs]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Insert 2 logs with different attributes
|
||||
|
||||
Tests:
|
||||
1. Query logs for the last 10 seconds and check if the logs are returned in the correct order
|
||||
2. Query values of severity_text attribute from the autocomplete API
|
||||
3. Query values of severity_text attribute from the fields API
|
||||
4. Query values of code.file attribute from the autocomplete API
|
||||
5. Query values of code.file attribute from the fields API
|
||||
6. Query values of code.line attribute from the autocomplete API
|
||||
7. Query values of code.line attribute from the fields API
|
||||
"""
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=datetime.now(tz=timezone.utc) - timedelta(seconds=1),
|
||||
resources={
|
||||
"deployment.environment": "production",
|
||||
"service.name": "java",
|
||||
"os.type": "linux",
|
||||
"host.name": "linux-001",
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "001",
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
attributes={
|
||||
"log.iostream": "stdout",
|
||||
"logtag": "F",
|
||||
"code.file": "/opt/Integration.java",
|
||||
"code.function": "com.example.Integration.process",
|
||||
"code.line": 120,
|
||||
"telemetry.sdk.language": "java",
|
||||
"id": "1",
|
||||
},
|
||||
body="This is a log message, coming from a java application",
|
||||
severity_text="DEBUG",
|
||||
),
|
||||
Logs(
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
resources={
|
||||
"deployment.environment": "production",
|
||||
"service.name": "go",
|
||||
"os.type": "linux",
|
||||
"host.name": "linux-001",
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "001",
|
||||
"id": 2,
|
||||
},
|
||||
attributes={
|
||||
"log.iostream": "stdout",
|
||||
"logtag": "F",
|
||||
"code.file": "/opt/integration.go",
|
||||
"code.function": "com.example.Integration.process",
|
||||
"code.line": 120,
|
||||
"metric.domain_id": "d-001",
|
||||
"telemetry.sdk.language": "go",
|
||||
"timestamp": "invalid-timestamp",
|
||||
},
|
||||
body="This is a log message, coming from a go application",
|
||||
severity_text="INFO",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Query Logs for the last 10 seconds and check if the logs are returned in the correct order
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
|
||||
timeout=2,
|
||||
headers={
|
||||
"authorization": f"Bearer {token}",
|
||||
},
|
||||
json={
|
||||
"schemaVersion": "v1",
|
||||
"start": int(
|
||||
(datetime.now(tz=timezone.utc) - timedelta(seconds=10)).timestamp()
|
||||
* 1000
|
||||
),
|
||||
"end": int(datetime.now(tz=timezone.utc).timestamp() * 1000),
|
||||
"requestType": "raw",
|
||||
"compositeQuery": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"disabled": False,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"order": [
|
||||
{"key": {"name": "timestamp"}, "direction": "desc"},
|
||||
{"key": {"name": "id"}, "direction": "desc"},
|
||||
],
|
||||
"having": {"expression": ""},
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
assert len(results) == 1
|
||||
|
||||
rows = results[0]["rows"]
|
||||
assert len(rows) == 2
|
||||
|
||||
assert (
|
||||
rows[0]["data"]["body"] == "This is a log message, coming from a go application"
|
||||
)
|
||||
assert rows[0]["data"]["resources_string"] == {
|
||||
"cloud.account.id": "001",
|
||||
"cloud.provider": "integration",
|
||||
"deployment.environment": "production",
|
||||
"host.name": "linux-001",
|
||||
"os.type": "linux",
|
||||
"service.name": "go",
|
||||
"id": "2",
|
||||
}
|
||||
assert rows[0]["data"]["attributes_string"] == {
|
||||
"code.file": "/opt/integration.go",
|
||||
"code.function": "com.example.Integration.process",
|
||||
"log.iostream": "stdout",
|
||||
"logtag": "F",
|
||||
"metric.domain_id": "d-001",
|
||||
"telemetry.sdk.language": "go",
|
||||
"timestamp": "invalid-timestamp",
|
||||
}
|
||||
assert rows[0]["data"]["attributes_number"] == {"code.line": 120}
|
||||
|
||||
assert (
|
||||
rows[1]["data"]["body"]
|
||||
== "This is a log message, coming from a java application"
|
||||
)
|
||||
assert rows[1]["data"]["resources_string"] == {
|
||||
"cloud.account.id": "001",
|
||||
"cloud.provider": "integration",
|
||||
"deployment.environment": "production",
|
||||
"host.name": "linux-001",
|
||||
"os.type": "linux",
|
||||
"service.name": "java",
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
assert rows[1]["data"]["attributes_string"] == {
|
||||
"code.file": "/opt/Integration.java",
|
||||
"code.function": "com.example.Integration.process",
|
||||
"id": "1",
|
||||
"log.iostream": "stdout",
|
||||
"logtag": "F",
|
||||
"telemetry.sdk.language": "java",
|
||||
}
|
||||
assert rows[1]["data"]["attributes_number"] == {"code.line": 120}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"order_by_context,expected_order",
|
||||
####
|
||||
|
||||
@@ -1,774 +0,0 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http import HTTPStatus
|
||||
from typing import Any, Callable, List
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import (
|
||||
BuilderQuery,
|
||||
OrderBy,
|
||||
TelemetryFieldKey,
|
||||
make_query_request,
|
||||
)
|
||||
from src.querier.util import (
|
||||
generate_logs_with_corrupt_metadata,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,result",
|
||||
[
|
||||
# pytest.param(
|
||||
# BuilderQuery(
|
||||
# signal="logs",
|
||||
# name="A",
|
||||
# limit=1,
|
||||
# ),
|
||||
# lambda x: _flatten_log(x[2]),
|
||||
# id="no-select-no-order",
|
||||
# # Behaviour:
|
||||
# # Empty order results in consistent random order
|
||||
# ),
|
||||
# pytest.param(
|
||||
# BuilderQuery(
|
||||
# signal="logs",
|
||||
# name="A",
|
||||
# select_fields=[TelemetryFieldKey("timestamp")],
|
||||
# limit=1,
|
||||
# ),
|
||||
# lambda x: [x[2].id, x[2].timestamp],
|
||||
# id="select-timestamp-no-order",
|
||||
# # Behaviour:
|
||||
# # AdjustKeys no-op
|
||||
# # Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
|
||||
# # Select timestamp is mapped to top level field by field mapper
|
||||
# # Empty order results in consistent random order
|
||||
# ),
|
||||
# pytest.param(
|
||||
# BuilderQuery(
|
||||
# signal="logs",
|
||||
# name="A",
|
||||
# select_fields=[TelemetryFieldKey("log.timestamp")],
|
||||
# limit=1,
|
||||
# ),
|
||||
# lambda x: [x[2].id, x[2].timestamp],
|
||||
# id="select-log-timestamp-no-order",
|
||||
# # Behaviour:
|
||||
# # AdjustKeys no-op
|
||||
# # Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
|
||||
# # Select timestamp is mapped to top level field by field mapper
|
||||
# # Empty order results in consistent random order
|
||||
# ),
|
||||
# pytest.param(
|
||||
# BuilderQuery(
|
||||
# signal="logs",
|
||||
# name="A",
|
||||
# select_fields=[TelemetryFieldKey("attribute.timestamp")],
|
||||
# limit=1,
|
||||
# ),
|
||||
# lambda x: [x[2].id, x[2].timestamp],
|
||||
# id="select-attr-timestamp-no-order",
|
||||
# # Behaviour: [BUG - user didn't get what they expected]
|
||||
# # AdjustKeys no-op
|
||||
# # Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
|
||||
# # Select timestamp is mapped to top level field by field mapper
|
||||
# # Empty order results in consistent random order
|
||||
# ),
|
||||
# pytest.param(
|
||||
# BuilderQuery(
|
||||
# signal="logs",
|
||||
# name="A",
|
||||
# select_fields=[
|
||||
# TelemetryFieldKey("log.timestamp"),
|
||||
# TelemetryFieldKey("attribute.timestamp"),
|
||||
# ],
|
||||
# limit=1,
|
||||
# ),
|
||||
# lambda x: [x[2].id, x[2].timestamp],
|
||||
# id="select-log-timestamp-and-attr-timestamp-no-order",
|
||||
# # Behaviour: [BUG - user didn't get what they expected]
|
||||
# # AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
|
||||
# # AdjustKeys logic adjusts key "log.timestamp" to "timestamp"
|
||||
# # AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
|
||||
# # Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp fields
|
||||
# # Select timestamp is mapped to top level field by field mapper
|
||||
# # Empty order results in consistent random order
|
||||
# ),
|
||||
# pytest.param(
|
||||
# BuilderQuery(
|
||||
# signal="logs",
|
||||
# name="A",
|
||||
# select_fields=[
|
||||
# TelemetryFieldKey("timestamp"),
|
||||
# TelemetryFieldKey("attribute.timestamp"),
|
||||
# ],
|
||||
# limit=1,
|
||||
# ),
|
||||
# lambda x: [x[2].id, x[2].timestamp],
|
||||
# id="select-timestamp-and-attr-timestamp-no-order",
|
||||
# # Behaviour:
|
||||
# # AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
|
||||
# # AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
|
||||
# # Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp fields
|
||||
# # Select timestamp is mapped to top level field by field mapper
|
||||
# # Empty order results in consistent random order
|
||||
# ),
|
||||
# pytest.param(
|
||||
# BuilderQuery(
|
||||
# signal="logs",
|
||||
# name="A",
|
||||
# select_fields=[
|
||||
# TelemetryFieldKey("log.timestamp"),
|
||||
# TelemetryFieldKey("timestamp"),
|
||||
# ],
|
||||
# limit=1,
|
||||
# ),
|
||||
# lambda x: [x[2].id, x[2].timestamp],
|
||||
# id="select-log-timestamp-and-timestamp-no-order",
|
||||
# # Behaviour:
|
||||
# # AdjustKeys logic adjusts key "log.timestamp" to "timestamp"
|
||||
# # AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
|
||||
# # Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
|
||||
# # Select timestamp is mapped to top level field by field mapper
|
||||
# # Empty order results in consistent random order
|
||||
# ),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
limit=1,
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc")],
|
||||
),
|
||||
lambda x: _flatten_log(x[3]),
|
||||
id="no-select-order-timestamp-desc",
|
||||
# Behaviour:
|
||||
# AdjustKeys no-op
|
||||
# Order by timestamp is mapped to top level field by field mapper
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[TelemetryFieldKey("timestamp")],
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc")],
|
||||
limit=1,
|
||||
),
|
||||
lambda x: [x[3].id, x[3].timestamp],
|
||||
id="select-timestamp-order-timestamp-desc",
|
||||
# Behaviour:
|
||||
# AdjustKeys no-op
|
||||
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
|
||||
# Select and OrderBy both timestamp are mapped to top level field by field mapper
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[TelemetryFieldKey("log.timestamp")],
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc")],
|
||||
limit=1,
|
||||
),
|
||||
lambda x: [x[3].id, x[3].timestamp],
|
||||
id="select-log-timestamp-order-timestamp-desc",
|
||||
# Behaviour:
|
||||
# AdjustKeys logic adjusts key "log.timestamp" to "timestamp"
|
||||
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
|
||||
# Logs stmt builder by default adds timestamp field to select fields and ignores user input log.timestamp field
|
||||
# Select and OrderBy both timestamp are mapped to top level field by field mapper
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[TelemetryFieldKey("attribute.timestamp")],
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc")],
|
||||
limit=1,
|
||||
),
|
||||
lambda x: [x[3].id, x[3].timestamp],
|
||||
id="select-attr-timestamp-order-timestamp-desc",
|
||||
# Behaviour:
|
||||
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
|
||||
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
|
||||
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
|
||||
# Select and OrderBy both timestamp are mapped to top level field by field mapper
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[
|
||||
TelemetryFieldKey("log.timestamp"),
|
||||
TelemetryFieldKey("attribute.timestamp"),
|
||||
],
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc")],
|
||||
limit=1,
|
||||
),
|
||||
lambda x: [x[3].id, x[3].timestamp],
|
||||
id="select-log-timestamp-and-attr-timestamp-order-timestamp-desc",
|
||||
# Behaviour:
|
||||
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
|
||||
# AdjustKeys logic adjusts key "log.timestamp" to "timestamp"
|
||||
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
|
||||
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp fields
|
||||
# Select and OrderBy both timestamp are mapped to top level field by field mapper
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[
|
||||
TelemetryFieldKey("timestamp"),
|
||||
TelemetryFieldKey("attribute.timestamp"),
|
||||
],
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc")],
|
||||
limit=1,
|
||||
),
|
||||
lambda x: [x[3].id, x[3].timestamp],
|
||||
id="select-timestamp-and-attr-timestamp-order-timestamp-desc",
|
||||
# Behaviour:
|
||||
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
|
||||
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
|
||||
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp fields
|
||||
# Select and OrderBy both timestamp are mapped to top level field by field mapper
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[
|
||||
TelemetryFieldKey("log.timestamp"),
|
||||
TelemetryFieldKey("timestamp"),
|
||||
],
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc")],
|
||||
limit=1,
|
||||
),
|
||||
lambda x: [x[3].id, x[3].timestamp],
|
||||
id="select-log-timestamp-and-timestamp-order-timestamp-desc",
|
||||
# Behaviour:
|
||||
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
|
||||
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
|
||||
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp fields
|
||||
# Select and OrderBy both timestamp are mapped to top level field by field mapper
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
limit=1,
|
||||
order=[OrderBy(TelemetryFieldKey("attribute.timestamp"), "desc")],
|
||||
),
|
||||
lambda x: [],
|
||||
id="no-select-order-attr-timestamp-desc",
|
||||
# Behaviour: [BUG]
|
||||
# AdjustKeys logic adjusts key "attribute.timestamp" to "attribute.timestamp:string"
|
||||
# Because of aliasing bug, result is empty
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[TelemetryFieldKey("timestamp")],
|
||||
order=[OrderBy(TelemetryFieldKey("attribute.timestamp"), "desc")],
|
||||
limit=1,
|
||||
),
|
||||
lambda x: [x[3].id, x[3].timestamp],
|
||||
id="select-timestamp-order-attr-timestamp-desc",
|
||||
# Behaviour:
|
||||
# AdjustKeys adjusts key "attribute.timestamp" to "timestamp"
|
||||
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
|
||||
# Select and OrderBy both timestamp are mapped to top level field by field mapper
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[TelemetryFieldKey("log.timestamp")],
|
||||
order=[OrderBy(TelemetryFieldKey("attribute.timestamp"), "desc")],
|
||||
limit=1,
|
||||
),
|
||||
lambda x: [x[3].id, x[3].timestamp],
|
||||
id="select-log-timestamp-order-attr-timestamp-desc",
|
||||
# Behaviour: [BUG - user didn't get what they expected]
|
||||
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
|
||||
# AdjustKeys logic adjusts key "log.timestamp" to "timestamp"
|
||||
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
|
||||
# Select and OrderBy both timestamp are mapped to top level field by field mapper
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[TelemetryFieldKey("attribute.timestamp")],
|
||||
order=[OrderBy(TelemetryFieldKey("attribute.timestamp"), "desc")],
|
||||
limit=1,
|
||||
),
|
||||
lambda x: [], # Because of aliasing bug, this returns no data
|
||||
id="select-attr-timestamp-order-attr-timestamp-desc",
|
||||
# Behaviour [BUG - user didn't get what they expected]:
|
||||
# AdjustKeys logic adjusts key "attribute.timestamp" to "attribute.timestamp:string"
|
||||
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
|
||||
# Because of Logs stmt builder behaviour, we ran into aliasing bug, result is empty
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[
|
||||
TelemetryFieldKey("log.timestamp"),
|
||||
TelemetryFieldKey("attribute.timestamp"),
|
||||
],
|
||||
order=[OrderBy(TelemetryFieldKey("attribute.timestamp"), "desc")],
|
||||
limit=1,
|
||||
),
|
||||
lambda x: [x[3].id, x[3].timestamp],
|
||||
id="select-log-timestamp-and-attr-timestamp-order-attr-timestamp-desc",
|
||||
# Behaviour: [BUG - user didn't get what they expected]
|
||||
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
|
||||
# AdjustKeys logic adjusts key "log.timestamp" to "timestamp"
|
||||
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
|
||||
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
|
||||
# Select and OrderBy both timestamp are mapped to top level field by field mapper
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[
|
||||
TelemetryFieldKey("timestamp"),
|
||||
TelemetryFieldKey("attribute.timestamp"),
|
||||
],
|
||||
order=[OrderBy(TelemetryFieldKey("attribute.timestamp"), "desc")],
|
||||
limit=1,
|
||||
),
|
||||
lambda x: [x[3].id, x[3].timestamp],
|
||||
id="select-timestamp-and-attr-timestamp-order-attr-timestamp-desc",
|
||||
# Behaviour:
|
||||
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
|
||||
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
|
||||
# Select and OrderBy both timestamp are mapped to top level field by field mapper
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[
|
||||
TelemetryFieldKey("log.timestamp"),
|
||||
TelemetryFieldKey("timestamp"),
|
||||
],
|
||||
order=[OrderBy(TelemetryFieldKey("attribute.timestamp"), "desc")],
|
||||
limit=1,
|
||||
),
|
||||
lambda x: [x[3].id, x[3].timestamp],
|
||||
id="select-log-timestamp-and-timestamp-order-attr-timestamp-desc",
|
||||
# Behaviour:
|
||||
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
|
||||
# AdjustKeys logic adjusts key "log.timestamp" to "timestamp"
|
||||
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
|
||||
# Select and OrderBy both timestamp are mapped to top level field by field mapper
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_logs_list_query_timestamp_expectations(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[List[Logs]], None],
|
||||
query: BuilderQuery,
|
||||
result: Callable[[List[Logs]], List[Any]],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Insert logs with corrupt data
|
||||
|
||||
Tests:
|
||||
"""
|
||||
logs = generate_logs_with_corrupt_metadata()
|
||||
insert_logs(logs)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Query Logs for the last 1 minute and check if the logs are returned in the correct order
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int(
|
||||
(datetime.now(tz=timezone.utc) - timedelta(minutes=1)).timestamp() * 1000
|
||||
),
|
||||
end_ms=int(datetime.now(tz=timezone.utc).timestamp() * 1000),
|
||||
request_type="raw",
|
||||
queries=[query.to_dict()],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
if response.status_code == HTTPStatus.OK:
|
||||
if not result(logs):
|
||||
# No results expected
|
||||
assert response.json()["data"]["data"]["results"][0]["rows"] is None
|
||||
else:
|
||||
data = response.json()["data"]["data"]["results"][0]["rows"][0]["data"]
|
||||
for key, value in zip(list(data.keys()), result(logs)):
|
||||
assert data[key] == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,results",
|
||||
[
|
||||
# pytest.param(
|
||||
# BuilderQuery(
|
||||
# signal="logs",
|
||||
# name="A",
|
||||
# select_fields=[TelemetryFieldKey("trace_id")],
|
||||
# ),
|
||||
# lambda x: [
|
||||
# [x[2].id, x[2].timestamp, x[2].trace_id],
|
||||
# [x[1].id, x[1].timestamp, x[1].trace_id],
|
||||
# [x[0].id, x[0].timestamp, x[0].trace_id],
|
||||
# [x[3].id, x[3].timestamp, x[3].trace_id],
|
||||
# ],
|
||||
# id="select-trace-id-no-order",
|
||||
# # Justification (expected values and row order):
|
||||
# # Values: x[0].trace_id="1", x[1].trace_id="", x[2].trace_id="", x[3].trace_id=""
|
||||
# # Order: no explicit order → ClickHouse internal storage order
|
||||
# # Behaviour:
|
||||
# # AdjustKeys no-op
|
||||
# # Select trace_id is mapped to top level field by field mapper
|
||||
# # Empty order results in consistent random order
|
||||
# ),
|
||||
# pytest.param(
|
||||
# BuilderQuery(
|
||||
# signal="logs",
|
||||
# name="A",
|
||||
# select_fields=[TelemetryFieldKey("log.trace_id")],
|
||||
# ),
|
||||
# lambda x: [
|
||||
# [x[2].id, x[2].timestamp, x[2].trace_id],
|
||||
# [x[1].id, x[1].timestamp, x[1].trace_id],
|
||||
# [x[0].id, x[0].timestamp, x[0].trace_id],
|
||||
# [x[3].id, x[3].timestamp, x[3].trace_id],
|
||||
# ],
|
||||
# id="select-log-trace-id-no-order",
|
||||
# # Justification (expected values and row order):
|
||||
# # Values: x[0].trace_id="1", x[1].trace_id="", x[2].trace_id="", x[3].trace_id=""
|
||||
# # Order: no explicit order → ClickHouse internal storage order
|
||||
# # Behaviour:
|
||||
# # AdjustKeys adjusts log.trace_id to log.trace_id:string
|
||||
# # Select log.trace_id is mapped to top level field by field mapper
|
||||
# # Empty order results in consistent random order
|
||||
# ),
|
||||
# pytest.param(
|
||||
# BuilderQuery(
|
||||
# signal="logs",
|
||||
# name="A",
|
||||
# select_fields=[TelemetryFieldKey("body.trace_id")],
|
||||
# ),
|
||||
# lambda x: [
|
||||
# [x[2].id, x[2].timestamp, x[2].trace_id],
|
||||
# [x[1].id, x[1].timestamp, x[1].trace_id],
|
||||
# [x[0].id, x[0].timestamp, x[0].trace_id],
|
||||
# [x[3].id, x[3].timestamp, x[3].trace_id],
|
||||
# ],
|
||||
# id="select-body-trace-id-no-order",
|
||||
# # Justification (expected values and row order):
|
||||
# # Values: x[0].trace_id="1", x[1].trace_id="", x[2].trace_id="", x[3].trace_id=""
|
||||
# # Order: no explicit order → ClickHouse internal storage order
|
||||
# # Behaviour:
|
||||
# # AdjustKeys logic adjusts key "body.trace_id" to "log.trace_id:string"
|
||||
# ),
|
||||
# pytest.param(
|
||||
# BuilderQuery(
|
||||
# signal="logs",
|
||||
# name="A",
|
||||
# select_fields=[TelemetryFieldKey("attribute.trace_id")],
|
||||
# ),
|
||||
# lambda x: [
|
||||
# [x[2].id, x[2].timestamp, x[2].attributes_string.get("trace_id", "")],
|
||||
# [x[1].id, x[1].timestamp, x[1].attributes_string.get("trace_id", "")],
|
||||
# [x[0].id, x[0].timestamp, x[0].attributes_string.get("trace_id", "")],
|
||||
# [x[3].id, x[3].timestamp, x[3].attributes_string.get("trace_id", "")],
|
||||
# ],
|
||||
# id="select-attribute-trace-id-no-order",
|
||||
# # Justification (expected values and row order):
|
||||
# # Values: x[0]="", x[1]="2", x[2]="", x[3]="" (only x[1] has attribute.trace_id set)
|
||||
# # Order: no explicit order → ClickHouse internal storage order
|
||||
# # Behaviour:
|
||||
# # AdjustKeys no-op
|
||||
# ),
|
||||
# pytest.param(
|
||||
# BuilderQuery(
|
||||
# signal="logs",
|
||||
# name="A",
|
||||
# select_fields=[TelemetryFieldKey("resource.trace_id")],
|
||||
# ),
|
||||
# lambda x: [
|
||||
# [x[2].id, x[2].timestamp, x[2].resources_string.get("trace_id", "")],
|
||||
# [x[1].id, x[1].timestamp, x[1].resources_string.get("trace_id", "")],
|
||||
# [x[0].id, x[0].timestamp, x[0].resources_string.get("trace_id", "")],
|
||||
# [x[3].id, x[3].timestamp, x[3].resources_string.get("trace_id", "")],
|
||||
# ],
|
||||
# id="select-resource-trace-id-no-order",
|
||||
# # Justification (expected values and row order):
|
||||
# # Values: x[0]="", x[1]="", x[2]="3", x[3]="" (only x[2] has resource.trace_id set)
|
||||
# # Order: no explicit order → ClickHouse internal storage order
|
||||
# # Behaviour:
|
||||
# # AdjustKeys no-op
|
||||
# ),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
order=[OrderBy(TelemetryFieldKey("trace_id"), "desc")],
|
||||
),
|
||||
lambda x: [
|
||||
_flatten_log(x[0]),
|
||||
_flatten_log(x[2]),
|
||||
_flatten_log(x[1]),
|
||||
_flatten_log(x[3]),
|
||||
],
|
||||
id="no-select-trace-id-order",
|
||||
# Justification (expected values and row order):
|
||||
# Values: x[0].trace_id="1", x[1].trace_id="", x[2].trace_id="", x[3].trace_id=""
|
||||
# Order: trace_id DESC → x[0]("1") first, then x[2](""), x[1](""), x[3]("") in storage order
|
||||
# Behaviour:
|
||||
# AdjustKeys no-op
|
||||
# Order by trace_id is mapped to top level field by field mapper
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
order=[OrderBy(TelemetryFieldKey("attribute.trace_id"), "desc")],
|
||||
),
|
||||
lambda x: [
|
||||
[*_flatten_log(x[1])[:14], x[1].attributes_string.get("trace_id", "")],
|
||||
[*_flatten_log(x[2])[:14], x[2].attributes_string.get("trace_id", "")],
|
||||
[*_flatten_log(x[0])[:14], x[0].attributes_string.get("trace_id", "")],
|
||||
[*_flatten_log(x[3])[:14], x[3].attributes_string.get("trace_id", "")],
|
||||
],
|
||||
id="no-select-attribute-trace-id-order",
|
||||
# Justification (expected values and row order):
|
||||
# attribute.trace_id values: x[0]="", x[1]="2", x[2]="", x[3]=""
|
||||
# Behaviour: [BUG - user didn't get what they expected]
|
||||
# AdjustKeys adjusts "attribute.trace_id" to "attribute.trace_id:string"
|
||||
# Order by attribute.trace_id maps to attributes_string['trace_id']
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[TelemetryFieldKey("trace_id")],
|
||||
order=[OrderBy(TelemetryFieldKey("trace_id"), "desc")],
|
||||
),
|
||||
lambda x: [
|
||||
[x[0].id, x[0].timestamp, x[0].trace_id],
|
||||
[x[2].id, x[2].timestamp, x[2].trace_id],
|
||||
[x[1].id, x[1].timestamp, x[1].trace_id],
|
||||
[x[3].id, x[3].timestamp, x[3].trace_id],
|
||||
],
|
||||
id="select-trace-id-order-trace-id-desc",
|
||||
# Justification (expected values and row order):
|
||||
# Values: x[0].trace_id="1", x[1].trace_id="", x[2].trace_id="", x[3].trace_id=""
|
||||
# Order: trace_id DESC → x[0]("1") first, then x[2](""), x[1](""), x[3]("") in storage order
|
||||
# Behaviour:
|
||||
# AdjustKeys no-op
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[TelemetryFieldKey("attribute.trace_id")],
|
||||
order=[OrderBy(TelemetryFieldKey("attribute.trace_id"), "desc")],
|
||||
),
|
||||
lambda x: [
|
||||
[x[1].id, x[1].timestamp, x[1].attributes_string.get("trace_id", "")],
|
||||
[x[2].id, x[2].timestamp, x[2].attributes_string.get("trace_id", "")],
|
||||
[x[0].id, x[0].timestamp, x[0].attributes_string.get("trace_id", "")],
|
||||
[x[3].id, x[3].timestamp, x[3].attributes_string.get("trace_id", "")],
|
||||
],
|
||||
id="select-attribute-trace-id-order-attribute-trace-id-desc",
|
||||
# Justification (expected values and row order):
|
||||
# AdjustKeys: no-op for both select and order, "attribute.trace_id" is a valid attribute key
|
||||
# Field mapping: "attribute.trace_id" → attributes_string["trace_id"]
|
||||
# Values: x[0]="", x[1]="2", x[2]="", x[3]="" (only x[1] has attribute.trace_id set)
|
||||
# Order: attribute.trace_id DESC → x[1]("2") first, then x[2](""), x[0](""), x[3]("") in storage order
|
||||
# Behaviour:
|
||||
# AdjustKeys no-op
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[TelemetryFieldKey("resource.trace_id")],
|
||||
order=[OrderBy(TelemetryFieldKey("resource.trace_id"), "desc")],
|
||||
),
|
||||
lambda x: [
|
||||
[x[2].id, x[2].timestamp, x[2].resources_string.get("trace_id", "")],
|
||||
[x[1].id, x[1].timestamp, x[1].resources_string.get("trace_id", "")],
|
||||
[x[0].id, x[0].timestamp, x[0].resources_string.get("trace_id", "")],
|
||||
[x[3].id, x[3].timestamp, x[3].resources_string.get("trace_id", "")],
|
||||
],
|
||||
id="select-resource-trace-id-order-resource-trace-id-desc",
|
||||
# Justification (expected values and row order):
|
||||
# AdjustKeys: no-op for both select and order, "resource.trace_id" is a valid resource key
|
||||
# Field mapping: "resource.trace_id" → resources_string["trace_id"]
|
||||
# Values: x[0]="", x[1]="", x[2]="3", x[3]="" (only x[2] has resource.trace_id set)
|
||||
# Order: resource.trace_id DESC → x[2]("3") first, then x[1](""), x[0](""), x[3]("") in storage order
|
||||
# Behaviour:
|
||||
# AdjustKeys no-op
|
||||
),
|
||||
pytest.param(
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
select_fields=[TelemetryFieldKey("body.trace_id")],
|
||||
order=[OrderBy(TelemetryFieldKey("body.trace_id"), "desc")],
|
||||
),
|
||||
lambda x: [
|
||||
[x[0].id, x[0].timestamp, x[0].trace_id],
|
||||
[x[2].id, x[2].timestamp, x[2].trace_id],
|
||||
[x[1].id, x[1].timestamp, x[1].trace_id],
|
||||
[x[3].id, x[3].timestamp, x[3].trace_id],
|
||||
],
|
||||
id="select-body-trace-id-order-body-trace-id-desc",
|
||||
# Justification (expected values and row order):
|
||||
# AdjustKeys: adjusts "body.trace_id" to "log.trace_id:string" for both select and order
|
||||
# Field mapping: "log.trace_id:string" → top-level trace_id column
|
||||
# Values: x[0].trace_id="1", x[1].trace_id="", x[2].trace_id="", x[3].trace_id=""
|
||||
# Order: trace_id DESC → x[0]("1") first, then x[2](""), x[1](""), x[3]("") in storage order
|
||||
# Behaviour:
|
||||
# AdjustKeys logic adjusts key "body.trace_id" to "log.trace_id:string"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_logs_list_query_trace_id_expectations(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[List[Logs]], None],
|
||||
query: BuilderQuery,
|
||||
results: Callable[[List[Logs]], List[Any]],
|
||||
) -> None:
|
||||
"""
|
||||
Justification for expected rows and ordering:
|
||||
Four logs differ by where trace_id is set: top-level (x[0]), attribute (x[1]),
|
||||
resource (x[2]), or only in body text (x[3]). Each parametrized case documents
|
||||
which column AdjustKeys/field mapping reads and why DESC ties break in storage order.
|
||||
|
||||
Setup:
|
||||
Insert logs with corrupt trace_id
|
||||
|
||||
Tests:
|
||||
"""
|
||||
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
|
||||
|
||||
logs = [
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
body="POST /integration request received",
|
||||
severity_text="INFO",
|
||||
resources={
|
||||
"service.name": "http-service",
|
||||
"timestamp": "corrupt_data",
|
||||
},
|
||||
attributes={
|
||||
"severity_text": "corrupt_data",
|
||||
"timestamp": "corrupt_data",
|
||||
},
|
||||
trace_id="1",
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=3),
|
||||
body="SELECT query executed",
|
||||
severity_text="DEBUG",
|
||||
resources={
|
||||
"service.name": "http-service",
|
||||
"id": "corrupt_data",
|
||||
},
|
||||
attributes={
|
||||
"trace_id": "2",
|
||||
},
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=2),
|
||||
body="HTTP PATCH failed with 404",
|
||||
severity_text="WARN",
|
||||
resources={
|
||||
"service.name": "http-service",
|
||||
"body": "corrupt_data",
|
||||
"trace_id": "3",
|
||||
},
|
||||
attributes={
|
||||
"id": "1",
|
||||
},
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
body="{'trace_id': '4'}",
|
||||
severity_text="ERROR",
|
||||
resources={
|
||||
"service.name": "topic-service",
|
||||
},
|
||||
attributes={
|
||||
"body": "corrupt_data",
|
||||
"timestamp": "corrupt_data",
|
||||
},
|
||||
),
|
||||
]
|
||||
insert_logs(logs)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Query Logs for the last 1 minute and check if the logs are returned in the correct order
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int(
|
||||
(datetime.now(tz=timezone.utc) - timedelta(minutes=10)).timestamp() * 1000
|
||||
),
|
||||
end_ms=int(datetime.now(tz=timezone.utc).timestamp() * 1000),
|
||||
request_type="raw",
|
||||
queries=[query.to_dict()],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
if response.status_code == HTTPStatus.OK:
|
||||
if not results(logs):
|
||||
# No results expected
|
||||
assert response.json()["data"]["data"]["results"][0]["rows"] is None
|
||||
else:
|
||||
print(response.json())
|
||||
rows = response.json()["data"]["data"]["results"][0]["rows"]
|
||||
assert len(rows) == len(
|
||||
results(logs)
|
||||
), f"Expected {len(results(logs))} rows, got {len(rows)}"
|
||||
for row, expected_row in zip(rows, results(logs)):
|
||||
data = row["data"]
|
||||
keys = list(data.keys())
|
||||
for i, expected_value in enumerate(expected_row):
|
||||
assert (
|
||||
data[keys[i]] == expected_value
|
||||
), f"Row mismatch at key '{keys[i]}': expected {expected_value}, got {data[keys[i]]}"
|
||||
|
||||
|
||||
def _flatten_log(log: Logs) -> List[Any]:
|
||||
return [
|
||||
log.attributes_bool,
|
||||
log.attributes_number,
|
||||
log.attributes_string,
|
||||
log.body,
|
||||
log.id,
|
||||
log.resources_string,
|
||||
log.scope_name,
|
||||
log.scope_string,
|
||||
log.scope_version,
|
||||
log.severity_number,
|
||||
log.severity_text,
|
||||
log.span_id,
|
||||
log.timestamp,
|
||||
log.trace_flags,
|
||||
log.trace_id,
|
||||
]
|
||||
@@ -4,7 +4,6 @@ from typing import List
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
|
||||
@@ -39,101 +38,6 @@ def assert_identical_query_response(
|
||||
), "Response data do not match"
|
||||
|
||||
|
||||
def generate_logs_with_corrupt_metadata() -> List[Logs]:
|
||||
"""
|
||||
Specifically, entries with 'id', 'timestamp', 'severity_text', 'severity_number' and 'body' fields in metadata
|
||||
"""
|
||||
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
|
||||
|
||||
return [
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
body="POST /integration request received",
|
||||
severity_text="INFO",
|
||||
resources={
|
||||
"deployment.environment": "production",
|
||||
"service.name": "http-service",
|
||||
"os.type": "linux",
|
||||
"host.name": "linux-000",
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "000",
|
||||
"timestamp": "corrupt_data",
|
||||
},
|
||||
attributes={
|
||||
"net.transport": "IP.TCP",
|
||||
"http.scheme": "http",
|
||||
"http.user_agent": "Integration Test",
|
||||
"http.request.method": "POST",
|
||||
"http.response.status_code": "200",
|
||||
"severity_text": "corrupt_data",
|
||||
"timestamp": "corrupt_data",
|
||||
},
|
||||
trace_id="1",
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=3),
|
||||
body="SELECT query executed",
|
||||
severity_text="DEBUG",
|
||||
resources={
|
||||
"deployment.environment": "production",
|
||||
"service.name": "http-service",
|
||||
"os.type": "linux",
|
||||
"host.name": "linux-000",
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "000",
|
||||
"severity_number": "corrupt_data",
|
||||
"id": "corrupt_data",
|
||||
},
|
||||
attributes={
|
||||
"db.name": "integration",
|
||||
"db.operation": "SELECT",
|
||||
"db.statement": "SELECT * FROM integration",
|
||||
"trace_id": "2",
|
||||
},
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=2),
|
||||
body="HTTP PATCH failed with 404",
|
||||
severity_text="WARN",
|
||||
resources={
|
||||
"deployment.environment": "production",
|
||||
"service.name": "http-service",
|
||||
"os.type": "linux",
|
||||
"host.name": "linux-000",
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "000",
|
||||
"body": "corrupt_data",
|
||||
"trace_id": "3",
|
||||
},
|
||||
attributes={
|
||||
"http.request.method": "PATCH",
|
||||
"http.status_code": "404",
|
||||
"id": "1",
|
||||
},
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
body="{'trace_id': '4'}",
|
||||
severity_text="ERROR",
|
||||
resources={
|
||||
"deployment.environment": "production",
|
||||
"service.name": "topic-service",
|
||||
"os.type": "linux",
|
||||
"host.name": "linux-001",
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "001",
|
||||
},
|
||||
attributes={
|
||||
"message.type": "SENT",
|
||||
"messaging.operation": "publish",
|
||||
"messaging.message.id": "001",
|
||||
"body": "corrupt_data",
|
||||
"timestamp": "corrupt_data",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def generate_traces_with_corrupt_metadata() -> List[Traces]:
|
||||
"""
|
||||
Specifically, entries with 'id', 'timestamp', 'trace_id' and 'duration_nano' fields in metadata
|
||||
|
||||
Reference in New Issue
Block a user