mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-07 12:40:26 +01:00
Compare commits
11 Commits
fix/toolti
...
fix/hosts-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95e5a7732b | ||
|
|
9785fd3909 | ||
|
|
7b5a0a1d05 | ||
|
|
e37f481472 | ||
|
|
9230e06d3e | ||
|
|
ee5accefaa | ||
|
|
f7441d525b | ||
|
|
630a87c6b0 | ||
|
|
136374d69a | ||
|
|
92c51176e7 | ||
|
|
c1fd8e53f0 |
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
126
frontend/src/__tests__/query_range_v5.util.ts
Normal file
126
frontend/src/__tests__/query_range_v5.util.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { rest, RestRequest } from 'msw';
|
||||
|
||||
import { ENVIRONMENT } from '../constants/env';
|
||||
import { server } from '../mocks-server/server';
|
||||
import { MetricRangePayloadV5 } from '../types/api/v5/queryRange';
|
||||
|
||||
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
|
||||
|
||||
export type MockLogsOptions = {
|
||||
offset?: number;
|
||||
pageSize?: number;
|
||||
hasMore?: boolean;
|
||||
delay?: number;
|
||||
onReceiveRequest?: (
|
||||
req: RestRequest,
|
||||
) =>
|
||||
| undefined
|
||||
| void
|
||||
| Omit<MockLogsOptions, 'onReceiveRequest'>
|
||||
| Promise<Omit<MockLogsOptions, 'onReceiveRequest'>>
|
||||
| Promise<void>;
|
||||
};
|
||||
|
||||
const createLogsResponse = ({
|
||||
offset = 0,
|
||||
pageSize = 100,
|
||||
hasMore = true,
|
||||
}: MockLogsOptions): MetricRangePayloadV5 => {
|
||||
const itemsForThisPage = hasMore ? pageSize : pageSize / 2;
|
||||
|
||||
return {
|
||||
data: {
|
||||
type: 'raw',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
rows: Array.from({ length: itemsForThisPage }, (_, index) => {
|
||||
const cumulativeIndex = offset + index;
|
||||
const baseTimestamp = new Date('2024-02-15T21:20:22Z').getTime();
|
||||
const currentTimestamp = new Date(
|
||||
baseTimestamp - cumulativeIndex * 1000,
|
||||
);
|
||||
const timestampString = currentTimestamp.toISOString();
|
||||
const id = `log-id-${cumulativeIndex}`;
|
||||
const logLevel = ['INFO', 'WARN', 'ERROR'][cumulativeIndex % 3];
|
||||
const service = ['frontend', 'backend', 'database'][cumulativeIndex % 3];
|
||||
|
||||
return {
|
||||
timestamp: timestampString,
|
||||
data: {
|
||||
attributes_bool: {},
|
||||
attributes_float64: {},
|
||||
attributes_int64: {},
|
||||
attributes_string: {
|
||||
host_name: 'test-host',
|
||||
log_level: logLevel,
|
||||
service,
|
||||
},
|
||||
body: `${timestampString} ${logLevel} ${service} Log message ${cumulativeIndex}`,
|
||||
id,
|
||||
resources_string: {
|
||||
'host.name': 'test-host',
|
||||
},
|
||||
severity_number: [9, 13, 17][cumulativeIndex % 3],
|
||||
severity_text: logLevel,
|
||||
span_id: `span-${cumulativeIndex}`,
|
||||
trace_flags: 0,
|
||||
trace_id: `trace-${cumulativeIndex}`,
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
bytesScanned: 0,
|
||||
durationMs: 0,
|
||||
rowsScanned: 0,
|
||||
stepIntervals: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export function mockQueryRangeV5WithLogsResponse({
|
||||
hasMore = true,
|
||||
offset = 0,
|
||||
pageSize = 100,
|
||||
delay = 0,
|
||||
onReceiveRequest,
|
||||
}: MockLogsOptions = {}): void {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, async (req, res, ctx) =>
|
||||
res(
|
||||
...(delay ? [ctx.delay(delay)] : []),
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
createLogsResponse(
|
||||
(await onReceiveRequest?.(req)) ?? {
|
||||
hasMore,
|
||||
pageSize,
|
||||
offset,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function mockQueryRangeV5WithError(
|
||||
error: string,
|
||||
statusCode = 500,
|
||||
): void {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(statusCode),
|
||||
ctx.json({
|
||||
error,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import {
|
||||
Button,
|
||||
@@ -21,8 +20,6 @@ import {
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getFiltersFromParams } from 'container/InfraMonitoringK8s/commonUtils';
|
||||
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from 'container/InfraMonitoringK8s/constants';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
@@ -52,15 +49,22 @@ import {
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import {
|
||||
useInfraMonitoringTracesFilters,
|
||||
useInfraMonitoringView,
|
||||
} from '../../container/InfraMonitoringK8s/hooks';
|
||||
import { convertFiltersToExpression } from '../QueryBuilderV2/utils';
|
||||
import { VIEW_TYPES, VIEWS } from './constants';
|
||||
import Containers from './Containers/Containers';
|
||||
import { HostDetailProps } from './HostMetricDetail.interfaces';
|
||||
import HostMetricLogsDetailedView from './HostMetricsLogs/HostMetricLogsDetailedView';
|
||||
import { useInfraMonitoringHostLogsExpression } from './HostMetricsLogs/hooks';
|
||||
import HostMetricsLogs from './HostMetricsLogs/HostMetricsLogs';
|
||||
import HostMetricTraces from './HostMetricTraces/HostMetricTraces';
|
||||
import Metrics from './Metrics/Metrics';
|
||||
import Processes from './Processes/Processes';
|
||||
|
||||
import './HostMetricsDetail.styles.scss';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function HostMetricsDetails({
|
||||
host,
|
||||
@@ -71,8 +75,6 @@ function HostMetricsDetails({
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const startMs = useMemo(() => Math.floor(Number(minTime) / 1000000000), [
|
||||
minTime,
|
||||
]);
|
||||
@@ -95,23 +97,14 @@ function HostMetricsDetails({
|
||||
: (selectedTime as Time),
|
||||
);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<VIEWS>(
|
||||
(searchParams.get('view') as VIEWS) || VIEWS.METRICS,
|
||||
);
|
||||
const [selectedView, setSelectedView] = useInfraMonitoringView();
|
||||
const [traceFilters, setTraceFilters] = useInfraMonitoringTracesFilters();
|
||||
const [, setHostMetricLogExpression] = useInfraMonitoringHostLogsExpression();
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const initialFilters = useMemo(() => {
|
||||
const urlView = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW);
|
||||
const queryKey =
|
||||
urlView === VIEW_TYPES.LOGS
|
||||
? INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS
|
||||
: INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS;
|
||||
const filters = getFiltersFromParams(searchParams, queryKey);
|
||||
if (filters) {
|
||||
return filters;
|
||||
}
|
||||
|
||||
return {
|
||||
const [initialFilters] = useState(
|
||||
traceFilters || {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
@@ -126,11 +119,7 @@ function HostMetricsDetails({
|
||||
value: host?.hostName || '',
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [host?.hostName, searchParams]);
|
||||
|
||||
const [logFilters, setLogFilters] = useState<IBuilderQuery['filters']>(
|
||||
initialFilters,
|
||||
},
|
||||
);
|
||||
|
||||
const [tracesFilters, setTracesFilters] = useState<IBuilderQuery['filters']>(
|
||||
@@ -147,7 +136,6 @@ function HostMetricsDetails({
|
||||
}, [host]);
|
||||
|
||||
useEffect(() => {
|
||||
setLogFilters(initialFilters);
|
||||
setTracesFilters(initialFilters);
|
||||
}, [initialFilters]);
|
||||
|
||||
@@ -169,12 +157,7 @@ function HostMetricsDetails({
|
||||
setSelectedView(e.target.value);
|
||||
if (host?.hostName) {
|
||||
setSelectedView(e.target.value);
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: e.target.value,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(null),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(null),
|
||||
});
|
||||
setTraceFilters(null);
|
||||
}
|
||||
logEvent(InfraMonitoringEvents.TabChanged, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
@@ -210,52 +193,32 @@ function HostMetricsDetails({
|
||||
[],
|
||||
);
|
||||
|
||||
const handleChangeLogFilters = useCallback(
|
||||
(value: IBuilderQuery['filters'], view: VIEWS) => {
|
||||
setLogFilters((prevFilters) => {
|
||||
const hostNameFilter = prevFilters?.items?.find(
|
||||
(item) => item.key?.key === 'host.name',
|
||||
);
|
||||
const paginationFilter = value?.items?.find(
|
||||
(item) => item.key?.key === 'id',
|
||||
);
|
||||
const newFilters = value?.items?.filter(
|
||||
(item) => item.key?.key !== 'id' && item.key?.key !== 'host.name',
|
||||
);
|
||||
|
||||
if (newFilters && newFilters?.length > 0) {
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
view: InfraMonitoringEvents.LogsView,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedFilters = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
hostNameFilter,
|
||||
...(newFilters || []),
|
||||
...(paginationFilter ? [paginationFilter] : []),
|
||||
].filter((item): item is TagFilterItem => item !== undefined),
|
||||
};
|
||||
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(
|
||||
updatedFilters,
|
||||
),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view,
|
||||
});
|
||||
return updatedFilters;
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
const initialLogsExpression = useMemo(
|
||||
() =>
|
||||
convertFiltersToExpression({
|
||||
items: [
|
||||
{
|
||||
id: uuidv4(),
|
||||
key: {
|
||||
key: 'host.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: 'host.name--string--resource--false',
|
||||
},
|
||||
op: '=',
|
||||
value: host?.hostName || '',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
}).expression,
|
||||
[host?.hostName],
|
||||
);
|
||||
|
||||
const [hostMetricLogsExpr] = useInfraMonitoringHostLogsExpression();
|
||||
|
||||
const handleChangeTracesFilters = useCallback(
|
||||
(value: IBuilderQuery['filters'], view: VIEWS) => {
|
||||
setSelectedView(view);
|
||||
setTracesFilters((prevFilters) => {
|
||||
const hostNameFilter = prevFilters?.items?.find(
|
||||
(item) => item.key?.key === 'host.name',
|
||||
@@ -269,27 +232,16 @@ function HostMetricsDetails({
|
||||
});
|
||||
}
|
||||
|
||||
const updatedFilters = {
|
||||
return {
|
||||
op: 'AND',
|
||||
items: [
|
||||
hostNameFilter,
|
||||
...(value?.items?.filter((item) => item.key?.key !== 'host.name') || []),
|
||||
].filter((item): item is TagFilterItem => item !== undefined),
|
||||
};
|
||||
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(
|
||||
updatedFilters,
|
||||
),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view,
|
||||
});
|
||||
|
||||
return updatedFilters;
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
[setSelectedView],
|
||||
);
|
||||
|
||||
const handleExplorePagesRedirect = (): void => {
|
||||
@@ -308,11 +260,6 @@ function HostMetricsDetails({
|
||||
});
|
||||
|
||||
if (selectedView === VIEW_TYPES.LOGS) {
|
||||
const filtersWithoutPagination = {
|
||||
...logFilters,
|
||||
items: logFilters?.items?.filter((item) => item.key?.key !== 'id') || [],
|
||||
};
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
@@ -322,7 +269,11 @@ function HostMetricsDetails({
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
filters: filtersWithoutPagination,
|
||||
filter: { expression: hostMetricLogsExpr },
|
||||
expression: hostMetricLogsExpr,
|
||||
having: {
|
||||
expression: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -362,7 +313,9 @@ function HostMetricsDetails({
|
||||
const handleClose = (): void => {
|
||||
setSelectedInterval(selectedTime as Time);
|
||||
lastSelectedInterval.current = null;
|
||||
setSearchParams({});
|
||||
setSelectedView(null);
|
||||
setTraceFilters(null);
|
||||
setHostMetricLogExpression(null);
|
||||
|
||||
if (selectedTime !== 'custom') {
|
||||
const { maxTime, minTime } = GetMinMax(selectedTime);
|
||||
@@ -564,12 +517,11 @@ function HostMetricsDetails({
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.LOGS && (
|
||||
<HostMetricLogsDetailedView
|
||||
<HostMetricsLogs
|
||||
timeRange={modalTimeRange}
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
handleTimeChange={handleTimeChange}
|
||||
handleChangeLogFilters={handleChangeLogFilters}
|
||||
logFilters={logFilters}
|
||||
initialExpression={initialLogsExpression}
|
||||
selectedInterval={selectedInterval}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--spacing-4) 0px;
|
||||
border-radius: 3px;
|
||||
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.logs {
|
||||
border: 1px solid var(--border);
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.listContainer {
|
||||
flex: 1;
|
||||
height: calc(100vh - 278px) !important;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
:global(.raw-log-content) {
|
||||
width: 100%;
|
||||
text-wrap: inherit;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.listCard {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.logsLoadingSkeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
|
||||
:global(.ant-skeleton-input-sm) {
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.noLogsFound {
|
||||
height: 50vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
|
||||
p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
.host-metrics-logs-container {
|
||||
margin-top: 1rem;
|
||||
|
||||
.filter-section {
|
||||
flex: 1;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400) !important;
|
||||
background-color: var(--bg-ink-300) !important;
|
||||
|
||||
input {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-tag .ant-typography {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.host-metrics-logs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
padding: 12px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
|
||||
.host-metrics-logs {
|
||||
margin-top: 1rem;
|
||||
|
||||
.virtuoso-list {
|
||||
overflow-y: hidden !important;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
height: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-300);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-slate-200);
|
||||
}
|
||||
|
||||
.ant-row {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-container {
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.host-metrics-logs-list-container {
|
||||
flex: 1;
|
||||
height: calc(100vh - 272px) !important;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
.raw-log-content {
|
||||
width: 100%;
|
||||
text-wrap: inherit;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.host-metrics-logs-list-card {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
|
||||
.ant-card-body {
|
||||
padding: 0;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.logs-loading-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
|
||||
.ant-skeleton-input-sm {
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-logs-found {
|
||||
height: 50vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.ant-typography {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.filter-section {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.ant-select-selector {
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
background-color: var(--bg-vanilla-100) !important;
|
||||
color: var(--bg-ink-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { VIEWS } from '../constants';
|
||||
import HostMetricsLogs from './HostMetricsLogs';
|
||||
|
||||
import './HostMetricLogs.styles.scss';
|
||||
|
||||
interface Props {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
handleChangeLogFilters: (value: IBuilderQuery['filters'], view: VIEWS) => void;
|
||||
logFilters: IBuilderQuery['filters'];
|
||||
selectedInterval: Time;
|
||||
}
|
||||
|
||||
function HostMetricLogsDetailedView({
|
||||
timeRange,
|
||||
isModalTimeSelection,
|
||||
handleTimeChange,
|
||||
handleChangeLogFilters,
|
||||
logFilters,
|
||||
selectedInterval,
|
||||
}: Props): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const updatedCurrentQuery = useMemo(
|
||||
() => ({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
...currentQuery.builder.queryData[0].aggregateAttribute,
|
||||
},
|
||||
filters: {
|
||||
items:
|
||||
logFilters?.items?.filter((item) => item.key?.key !== 'host.name') ||
|
||||
[],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
[currentQuery, logFilters?.items],
|
||||
);
|
||||
|
||||
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
|
||||
|
||||
return (
|
||||
<div className="host-metrics-logs-container">
|
||||
<div className="host-metrics-logs-header">
|
||||
<div className="filter-section">
|
||||
{query && (
|
||||
<QueryBuilderSearch
|
||||
query={query as IBuilderQuery}
|
||||
onChange={(value): void => handleChangeLogFilters(value, VIEWS.LOGS)}
|
||||
disableNavigationShortcuts
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="datetime-section">
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HostMetricsLogs timeRange={timeRange} filters={logFilters} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HostMetricLogsDetailedView;
|
||||
@@ -1,87 +1,165 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import RawLogView from 'components/Logs/RawLogView';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import LogsError from 'container/LogsError/LogsError';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
|
||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
import { getHostLogsQueryPayload } from './constants';
|
||||
import {
|
||||
useInfiniteHostMetricLogs,
|
||||
useInfraMonitoringHostLogsExpression,
|
||||
} from './hooks';
|
||||
import NoLogsContainer from './NoLogsContainer';
|
||||
import { getHostLogsQueryPayload } from './utils';
|
||||
|
||||
import './HostMetricLogs.styles.scss';
|
||||
import styles from './HostMetricLogs.module.scss';
|
||||
|
||||
interface Props {
|
||||
initialExpression: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
filters: IBuilderQuery['filters'];
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
selectedInterval: Time;
|
||||
}
|
||||
|
||||
function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
|
||||
const EXPRESSION_DEBOUNCE_TIME_MS = 300;
|
||||
|
||||
function HostMetricsLogs({
|
||||
initialExpression,
|
||||
timeRange,
|
||||
isModalTimeSelection,
|
||||
handleTimeChange,
|
||||
selectedInterval,
|
||||
}: Props): JSX.Element {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const [
|
||||
filterExpression,
|
||||
setFilterExpression,
|
||||
] = useInfraMonitoringHostLogsExpression();
|
||||
|
||||
const [inputExpression, setInputExpression] = useState(
|
||||
filterExpression || initialExpression,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// If expression is present in the URL, prefer it and don't override it.
|
||||
// Otherwise, initialize URL state from the host's default expression.
|
||||
if (filterExpression) {
|
||||
setInputExpression(filterExpression);
|
||||
return;
|
||||
}
|
||||
|
||||
setInputExpression(initialExpression);
|
||||
setFilterExpression(initialExpression);
|
||||
}, [filterExpression, initialExpression, setFilterExpression]);
|
||||
|
||||
const debouncedFilterExpression = useDebounce(
|
||||
filterExpression?.trim() || initialExpression,
|
||||
EXPRESSION_DEBOUNCE_TIME_MS,
|
||||
);
|
||||
|
||||
const {
|
||||
activeLog,
|
||||
onAddToQuery,
|
||||
selectedTab,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
|
||||
const basePayload = getHostLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
filters,
|
||||
const onAddToQuery = useCallback(
|
||||
(fieldKey: string, fieldValue: string, operator: string): void => {
|
||||
handleCloseLogDetail();
|
||||
|
||||
const partExpression = generateFilterQuery({
|
||||
fieldKey,
|
||||
fieldValue,
|
||||
type: getOldLogsOperatorFromNew(operator),
|
||||
});
|
||||
|
||||
const newExpression = inputExpression.trim()
|
||||
? `${inputExpression} AND ${partExpression}`
|
||||
: partExpression;
|
||||
|
||||
setInputExpression(newExpression);
|
||||
setFilterExpression(newExpression);
|
||||
},
|
||||
[inputExpression, setFilterExpression, handleCloseLogDetail],
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback((expression: string): void => {
|
||||
setInputExpression(expression);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
logs,
|
||||
hasReachedEndOfLogs,
|
||||
isPaginating,
|
||||
currentPage,
|
||||
setIsPaginating,
|
||||
handleNewData,
|
||||
loadMoreLogs,
|
||||
queryPayload,
|
||||
} = useHandleLogsPagination({
|
||||
timeRange,
|
||||
filters,
|
||||
excludeFilterKeys: ['host.name'],
|
||||
basePayload,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useInfiniteHostMetricLogs({
|
||||
expression: debouncedFilterExpression,
|
||||
startTime: timeRange.startTime,
|
||||
endTime: timeRange.endTime,
|
||||
});
|
||||
|
||||
const { data, isLoading, isFetching, isError } = useQuery({
|
||||
queryKey: [
|
||||
'hostMetricsLogs',
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
filters,
|
||||
currentPage,
|
||||
],
|
||||
queryFn: () => GetMetricQueryRange(queryPayload, DEFAULT_ENTITY_VERSION),
|
||||
enabled: !!queryPayload,
|
||||
keepPreviousData: isPaginating,
|
||||
});
|
||||
const handleRunQuery = useCallback(
|
||||
(updatedExpression?: string): void => {
|
||||
const validation = validateQuery(updatedExpression || inputExpression);
|
||||
if (validation.isValid) {
|
||||
setFilterExpression(updatedExpression || inputExpression);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload?.data?.newResult?.data?.result) {
|
||||
handleNewData(data.payload.data.newResult.data.result);
|
||||
}
|
||||
}, [data, handleNewData]);
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
view: InfraMonitoringEvents.LogsView,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsPaginating(false);
|
||||
}, [data, setIsPaginating]);
|
||||
refetch();
|
||||
}
|
||||
},
|
||||
[inputExpression, refetch, setFilterExpression],
|
||||
);
|
||||
|
||||
const queryData = useMemo(
|
||||
() =>
|
||||
getHostLogsQueryPayload({
|
||||
start: timeRange.startTime,
|
||||
end: timeRange.endTime,
|
||||
// this should use inputExpression to show suggestions correctly
|
||||
// while we don't accept the final expression yet
|
||||
expression: inputExpression,
|
||||
}).queryData,
|
||||
[timeRange.startTime, timeRange.endTime, inputExpression],
|
||||
);
|
||||
|
||||
const handleScrollToLog = useScrollToLog({
|
||||
logs,
|
||||
@@ -122,22 +200,21 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
|
||||
const renderFooter = useCallback(
|
||||
(): JSX.Element | null => (
|
||||
<>
|
||||
{isFetching ? (
|
||||
<div className="logs-loading-skeleton"> Loading more logs ... </div>
|
||||
) : hasReachedEndOfLogs ? (
|
||||
<div className="logs-loading-skeleton"> *** End *** </div>
|
||||
{isFetchingNextPage ? (
|
||||
<div className={styles.logsLoadingSkeleton}> Loading more logs ... </div>
|
||||
) : !hasNextPage && logs.length > 0 ? (
|
||||
<div className={styles.logsLoadingSkeleton}> *** End *** </div>
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
[isFetching, hasReachedEndOfLogs],
|
||||
[isFetchingNextPage, hasNextPage, logs.length],
|
||||
);
|
||||
|
||||
const renderContent = useMemo(
|
||||
() => (
|
||||
<Card bordered={false} className="host-metrics-logs-list-card">
|
||||
<Card bordered={false} className={styles.listCard}>
|
||||
<OverlayScrollbar isVirtuoso>
|
||||
<Virtuoso
|
||||
className="host-metrics-logs-virtuoso"
|
||||
key="host-metrics-logs-virtuoso"
|
||||
ref={virtuosoRef}
|
||||
data={logs}
|
||||
@@ -155,32 +232,60 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
|
||||
[logs, loadMoreLogs, getItemContent, renderFooter],
|
||||
);
|
||||
|
||||
const showInitialLoading = isLoading || (isFetching && logs.length === 0);
|
||||
|
||||
return (
|
||||
<div className="host-metrics-logs">
|
||||
{isLoading && <LogsLoading />}
|
||||
{!isLoading && !isError && logs.length === 0 && <NoLogsContainer />}
|
||||
{isError && !isLoading && <LogsError />}
|
||||
{!isLoading && !isError && logs.length > 0 && (
|
||||
<div
|
||||
className="host-metrics-logs-list-container"
|
||||
data-log-detail-ignore="true"
|
||||
>
|
||||
{renderContent}
|
||||
</div>
|
||||
)}
|
||||
{selectedTab && activeLog && (
|
||||
<LogDetail
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
logs={logs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
selectedTab={selectedTab}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
onScrollToLog={handleScrollToLog}
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<RunQueryBtn
|
||||
isLoadingQueries={isLoading || isFetching}
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<QuerySearch
|
||||
queryData={queryData}
|
||||
onChange={handleFilterChange}
|
||||
dataSource={DataSource.LOGS}
|
||||
onRun={handleRunQuery}
|
||||
/>
|
||||
|
||||
<div className={styles.logs}>
|
||||
{showInitialLoading && <LogsLoading />}
|
||||
{!showInitialLoading && !isError && logs.length === 0 && (
|
||||
<NoLogsContainer />
|
||||
)}
|
||||
{isError && !showInitialLoading && <LogsError />}
|
||||
{!showInitialLoading && !isError && logs.length > 0 && (
|
||||
<div className={styles.listContainer} data-log-detail-ignore="true">
|
||||
{renderContent}
|
||||
</div>
|
||||
)}
|
||||
{selectedTab && activeLog && (
|
||||
<LogDetail
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
logs={logs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
selectedTab={selectedTab}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
onScrollToLog={handleScrollToLog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Typography } from 'antd';
|
||||
import { Ghost } from 'lucide-react';
|
||||
|
||||
const { Text } = Typography;
|
||||
import styles from './HostMetricLogs.module.scss';
|
||||
|
||||
export default function NoLogsContainer(): React.ReactElement {
|
||||
return (
|
||||
<div className="no-logs-found">
|
||||
<Text type="secondary">
|
||||
<div className={styles.noLogsFound}>
|
||||
<p>
|
||||
<Ghost size={24} color={Color.BG_AMBER_500} /> No logs found for this host
|
||||
in the selected time range.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,899 @@
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { act, render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { mockQueryRangeV5WithLogsResponse } from '../../../../__tests__/query_range_v5.util';
|
||||
import HostMetricsLogs from '../HostMetricsLogs';
|
||||
|
||||
jest.mock('react-virtuoso', () => {
|
||||
const actual = jest.requireActual('react-virtuoso');
|
||||
return {
|
||||
...actual,
|
||||
Virtuoso: ({
|
||||
data,
|
||||
itemContent,
|
||||
endReached,
|
||||
components,
|
||||
className,
|
||||
}: {
|
||||
data?: any[];
|
||||
itemContent?: (index: number, item: any) => React.ReactNode;
|
||||
endReached?: (index: number) => void;
|
||||
components?: { Footer?: React.ComponentType };
|
||||
className?: string;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="virtuoso-mock" className={className}>
|
||||
{Array.isArray(data) &&
|
||||
data.map((item, index) => (
|
||||
<div key={item?.id ?? index} data-testid={`virtuoso-item-${index}`}>
|
||||
{itemContent?.(index, item)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="virtuoso-end-reached"
|
||||
onClick={(): void => endReached?.((data?.length || 0) - 1)}
|
||||
>
|
||||
endReached
|
||||
</button>
|
||||
{components?.Footer ? <components.Footer /> : null}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
|
||||
const FIELDS_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/fields/keys`;
|
||||
const FIELDS_VALUES_URL = `${ENVIRONMENT.baseURL}/api/v1/fields/values`;
|
||||
|
||||
const defaultProps = {
|
||||
initialExpression: 'host_name = "test-host"',
|
||||
timeRange: {
|
||||
startTime: 1708000000,
|
||||
endTime: 1708003600,
|
||||
},
|
||||
isModalTimeSelection: false,
|
||||
handleTimeChange: jest.fn(),
|
||||
selectedInterval: '15m' as const,
|
||||
};
|
||||
|
||||
// Mock OverlayScrollbar to avoid scroll behavior issues in tests
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onTimeChange,
|
||||
}: {
|
||||
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<div className="datetime-section" data-testid="datetime-selection">
|
||||
<button
|
||||
data-testid="time-picker-btn"
|
||||
onClick={(): void => {
|
||||
onTimeChange?.('5m');
|
||||
}}
|
||||
>
|
||||
Select Time
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const createFieldKeysResponse = (): any => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: {},
|
||||
},
|
||||
});
|
||||
|
||||
const createFieldValuesResponse = (): any => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
stringValues: [],
|
||||
numberValues: [],
|
||||
boolValues: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderComponent = (
|
||||
props = defaultProps,
|
||||
searchParams?: Record<string, string>,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 600, itemHeight: 50 }}
|
||||
>
|
||||
<HostMetricsLogs {...props} />
|
||||
</VirtuosoMockContext.Provider>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
describe('HostMetricsLogs', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState({}, 'Test', '/');
|
||||
server.use(
|
||||
rest.get(FIELDS_KEYS_URL, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(createFieldKeysResponse())),
|
||||
),
|
||||
rest.get(FIELDS_VALUES_URL, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(createFieldValuesResponse())),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('should show loading state while fetching logs', async () => {
|
||||
let resolveRequest: (value: any) => void;
|
||||
const pendingPromise = new Promise<void>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async () => await pendingPromise,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(
|
||||
await screen.findByText('pending_data_placeholder'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
resolveRequest!(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty state', () => {
|
||||
it('should show no logs message when no logs are returned', async () => {
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 0,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(
|
||||
await screen.findByText(/No logs found for this host/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error state', () => {
|
||||
it('should show error state when API returns error', async () => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: 'Internal Server Error' })),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(
|
||||
await screen.findByText(/Something went wrong/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('success state', () => {
|
||||
it('should render logs when API returns data', async () => {
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 5,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText(/Log message 0/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render initial expression in QuerySearch editor', async () => {
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 5,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
const editorText =
|
||||
document.querySelector('.query-where-clause-editor')?.textContent || '';
|
||||
expect(editorText).toContain('host_name');
|
||||
expect(editorText).toContain('test-host');
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the filter section', async () => {
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 5,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('.code-mirror-where-clause'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render date time selection component', async () => {
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 5,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
// DateTimeSelectionV2 renders a time picker button
|
||||
expect(document.querySelector('.datetime-section')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
it('should send correct offset for pagination', async () => {
|
||||
const requestPayloads: any[] = [];
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const payload = await req.json();
|
||||
requestPayloads.push(payload);
|
||||
|
||||
const querySpec = payload.compositeQuery?.queries?.[0]?.spec;
|
||||
const offset = querySpec?.offset ?? 0;
|
||||
|
||||
return {
|
||||
offset,
|
||||
pageSize: 100,
|
||||
hasMore: offset === 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
const firstPayload = requestPayloads[0];
|
||||
const querySpec = firstPayload.compositeQuery?.queries?.[0]?.spec;
|
||||
expect(querySpec?.offset).toBe(0);
|
||||
});
|
||||
|
||||
it('should fetch next page when virtuoso endReached is triggered', async () => {
|
||||
const requestPayloads: any[] = [];
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const payload = await req.json();
|
||||
requestPayloads.push(payload);
|
||||
|
||||
const querySpec = payload.compositeQuery?.queries?.[0]?.spec;
|
||||
const offset = querySpec?.offset ?? 0;
|
||||
|
||||
return {
|
||||
offset,
|
||||
pageSize: 100,
|
||||
hasMore: offset === 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText(/Log message 0/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(requestPayloads[0]?.compositeQuery?.queries?.[0]?.spec?.offset).toBe(
|
||||
0,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('virtuoso-end-reached'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestPayloads.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
expect(requestPayloads[1]?.compositeQuery?.queries?.[0]?.spec?.offset).toBe(
|
||||
100,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter expression', () => {
|
||||
it('should include initial expression in the query', async () => {
|
||||
const requestPayloads: any[] = [];
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const payload = await req.json();
|
||||
requestPayloads.push(payload);
|
||||
|
||||
return {
|
||||
pageSize: 5,
|
||||
offset: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
const firstPayload = requestPayloads[0];
|
||||
const querySpec = firstPayload.compositeQuery?.queries?.[0]?.spec;
|
||||
|
||||
expect(querySpec?.filter?.expression).toContain('host_name = "test-host"');
|
||||
});
|
||||
|
||||
it('should load expression from URL and persist it in the query', async () => {
|
||||
const requestPayloads: any[] = [];
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const payload = await req.json();
|
||||
requestPayloads.push(payload);
|
||||
|
||||
return {
|
||||
pageSize: 100,
|
||||
offset: 0,
|
||||
hasMore: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const urlExpression = 'service = "from-url"';
|
||||
|
||||
renderComponent(defaultProps, { hostMetricsLogsExpression: urlExpression });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
expect(
|
||||
requestPayloads[0]?.compositeQuery?.queries?.[0]?.spec?.filter?.expression,
|
||||
).toContain(urlExpression);
|
||||
|
||||
await userEvent.click(screen.getByTestId('virtuoso-end-reached'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestPayloads.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
expect(
|
||||
requestPayloads[1]?.compositeQuery?.queries?.[0]?.spec?.filter?.expression,
|
||||
).toContain(urlExpression);
|
||||
});
|
||||
|
||||
it('should use custom expression when provided', async () => {
|
||||
const requestPayloads: any[] = [];
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const payload = await req.json();
|
||||
requestPayloads.push(payload);
|
||||
|
||||
return {
|
||||
pageSize: 5,
|
||||
offset: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const customExpression = 'service = "custom-service"';
|
||||
|
||||
renderComponent({
|
||||
...defaultProps,
|
||||
initialExpression: customExpression,
|
||||
});
|
||||
|
||||
// Wait for debounce and potential re-renders to settle
|
||||
await waitFor(
|
||||
() => {
|
||||
const hasCustomExpression = requestPayloads.some((payload) => {
|
||||
const querySpec = payload.compositeQuery?.queries?.[0]?.spec;
|
||||
return querySpec?.filter?.expression?.includes('custom-service');
|
||||
});
|
||||
expect(hasCustomExpression).toBe(true);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('time range', () => {
|
||||
it('should include correct time range in the query', async () => {
|
||||
const requestPayloads: any[] = [];
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const payload = await req.json();
|
||||
requestPayloads.push(payload);
|
||||
|
||||
return {
|
||||
pageSize: 5,
|
||||
offset: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const customTimeRange = {
|
||||
startTime: 1700000000,
|
||||
endTime: 1700003600,
|
||||
};
|
||||
|
||||
renderComponent({
|
||||
...defaultProps,
|
||||
timeRange: customTimeRange,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
const firstPayload = requestPayloads[0];
|
||||
|
||||
// V5 API expects milliseconds (seconds * 1000)
|
||||
expect(firstPayload.start).toBe(customTimeRange.startTime * 1000);
|
||||
expect(firstPayload.end).toBe(customTimeRange.endTime * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('query structure', () => {
|
||||
it('should send correct query structure to the API', async () => {
|
||||
const requestPayloads: any[] = [];
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const payload = await req.json();
|
||||
requestPayloads.push(payload);
|
||||
|
||||
return {
|
||||
pageSize: 5,
|
||||
offset: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
const firstPayload = requestPayloads[0];
|
||||
const querySpec = firstPayload.compositeQuery?.queries?.[0]?.spec;
|
||||
|
||||
expect(querySpec?.signal).toBe('logs');
|
||||
expect(querySpec?.order).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ name: 'timestamp' }),
|
||||
direction: 'desc',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should send request type as raw for logs list', async () => {
|
||||
const requestPayloads: any[] = [];
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const payload = await req.json();
|
||||
requestPayloads.push(payload);
|
||||
|
||||
return {
|
||||
pageSize: 5,
|
||||
offset: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
const firstPayload = requestPayloads[0];
|
||||
expect(firstPayload.requestType).toBe('raw');
|
||||
});
|
||||
|
||||
it('should include pageSize in the query', async () => {
|
||||
const requestPayloads: any[] = [];
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const payload = await req.json();
|
||||
requestPayloads.push(payload);
|
||||
|
||||
return {
|
||||
pageSize: 5,
|
||||
offset: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
const firstPayload = requestPayloads[0];
|
||||
const querySpec = firstPayload.compositeQuery?.queries?.[0]?.spec;
|
||||
|
||||
// Should have a limit set for pagination
|
||||
expect(querySpec?.limit).toBeDefined();
|
||||
expect(typeof querySpec?.limit).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('component props', () => {
|
||||
it('should render datetime section with isModalTimeSelection', async () => {
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 5,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
renderComponent({
|
||||
...defaultProps,
|
||||
isModalTimeSelection: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.datetime-section')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render component with handleTimeChange', async () => {
|
||||
const mockHandleTimeChange = jest.fn();
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 5,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
renderComponent({
|
||||
...defaultProps,
|
||||
handleTimeChange: mockHandleTimeChange,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.datetime-section')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('log detail interactions', () => {
|
||||
it('should open log detail drawer when clicking on a log', async () => {
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 5,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Wait for logs to render
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText(/Log message 0/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the first log
|
||||
const logElement = await screen.findByText(/Log message 0/);
|
||||
await userEvent.click(logElement);
|
||||
|
||||
// Log detail drawer should open - it contains "Log details" title
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText('Log details')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should close log detail drawer when clicking on the same log again', async () => {
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 5,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Wait for logs to render
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText(/Log message 0/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the first log to open
|
||||
const logElement = await screen.findByText(/Log message 0/);
|
||||
await userEvent.click(logElement);
|
||||
|
||||
// Wait for drawer to open
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText('Log details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the same log to close (through the close button)
|
||||
const closeButton = document.querySelector('.ant-drawer-close');
|
||||
if (closeButton) {
|
||||
await userEvent.click(closeButton);
|
||||
}
|
||||
|
||||
// Drawer should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Log details')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display log body in detail drawer', async () => {
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 5,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Wait for logs to render
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText(/Log message 0/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the first log to open drawer
|
||||
const logElement = await screen.findByText(/Log message 0/);
|
||||
await userEvent.click(logElement);
|
||||
|
||||
// Wait for drawer to open
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText('Log details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify the drawer tabs are displayed
|
||||
// The drawer should show the Overview tab
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText('Overview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify other tabs are present
|
||||
expect(await screen.findByText('JSON')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Context')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('log detail filter actions', () => {
|
||||
it('should apply filter-in from log detail and close the drawer', async () => {
|
||||
const requestPayloads: any[] = [];
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const payload = await req.json();
|
||||
requestPayloads.push(payload);
|
||||
|
||||
return {
|
||||
pageSize: 5,
|
||||
offset: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText(/Log message 0/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(await screen.findByText(/Log message 0/));
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText('Log details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const serviceRow = await waitFor(() => {
|
||||
const attributeNameCells = Array.from(
|
||||
document.querySelectorAll('.attribute-name'),
|
||||
);
|
||||
const serviceCell = attributeNameCells.find((cell) =>
|
||||
(cell.textContent || '').toLowerCase().includes('service'),
|
||||
);
|
||||
const row = serviceCell?.closest('tr');
|
||||
if (!row) {
|
||||
throw new Error('Service attribute row not found');
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
const filterButtons = serviceRow.querySelectorAll('button.filter-btn');
|
||||
expect(filterButtons?.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await userEvent.click(filterButtons[0] as HTMLButtonElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Log details')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
const matched = requestPayloads.some((payload) => {
|
||||
const expression =
|
||||
payload.compositeQuery?.queries?.[0]?.spec?.filter?.expression || '';
|
||||
return (
|
||||
(expression.includes('attributes_string.service') ||
|
||||
expression.includes('service')) &&
|
||||
expression.includes("('frontend')") &&
|
||||
expression.includes('IN')
|
||||
);
|
||||
});
|
||||
expect(matched).toBe(true);
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply filter-out from log detail and close the drawer', async () => {
|
||||
const requestPayloads: any[] = [];
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const payload = await req.json();
|
||||
requestPayloads.push(payload);
|
||||
|
||||
return {
|
||||
pageSize: 5,
|
||||
offset: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(async () => {
|
||||
const el = await screen.findByText(/Log message 0/);
|
||||
|
||||
expect(el).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(await screen.findByText(/Log message 0/));
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText('Log details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const serviceRow = await waitFor(() => {
|
||||
const attributeNameCells = Array.from(
|
||||
document.querySelectorAll('.attribute-name'),
|
||||
);
|
||||
const serviceCell = attributeNameCells.find((cell) =>
|
||||
(cell.textContent || '').toLowerCase().includes('service'),
|
||||
);
|
||||
const row = serviceCell?.closest('tr');
|
||||
if (!row) {
|
||||
throw new Error('Service attribute row not found');
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
const filterButtons = serviceRow.querySelectorAll('button.filter-btn');
|
||||
expect(filterButtons?.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// the second button that represents filter out
|
||||
await userEvent.click(filterButtons[1] as HTMLButtonElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Log details')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
const matched = requestPayloads.some((payload) => {
|
||||
const expression =
|
||||
payload.compositeQuery?.queries?.[0]?.spec?.filter?.expression || '';
|
||||
return (
|
||||
(expression.includes('attributes_string.service') ||
|
||||
expression.includes('service')) &&
|
||||
expression.includes("('frontend')") &&
|
||||
(expression.includes('NIN') || expression.includes('NOT_IN'))
|
||||
);
|
||||
});
|
||||
expect(matched).toBe(true);
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('time range change', () => {
|
||||
it('should use different time ranges for different renders', async () => {
|
||||
const requestPayloads: any[] = [];
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const payload = await req.json();
|
||||
requestPayloads.push(payload);
|
||||
|
||||
return {
|
||||
pageSize: 5,
|
||||
offset: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// First render with initial time range
|
||||
const { unmount } = renderComponent();
|
||||
|
||||
// Wait for initial fetch
|
||||
await waitFor(() => {
|
||||
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
const firstStartTime = requestPayloads[0].start;
|
||||
expect(firstStartTime).toBe(defaultProps.timeRange.startTime * 1000);
|
||||
|
||||
// Unmount and render again with different time range
|
||||
unmount();
|
||||
|
||||
const newTimeRange = {
|
||||
startTime: 1709000000,
|
||||
endTime: 1709003600,
|
||||
};
|
||||
|
||||
renderComponent({
|
||||
...defaultProps,
|
||||
timeRange: newTimeRange,
|
||||
});
|
||||
|
||||
// Wait for fetch with new time range
|
||||
await waitFor(() => {
|
||||
const hasNewTimeRange = requestPayloads.some(
|
||||
(p) => p.start === newTimeRange.startTime * 1000,
|
||||
);
|
||||
expect(hasNewTimeRange).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call handleTimeChange callback when time picker is clicked', async () => {
|
||||
const mockHandleTimeChange = jest.fn();
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 5,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
renderComponent({
|
||||
...defaultProps,
|
||||
handleTimeChange: mockHandleTimeChange,
|
||||
});
|
||||
|
||||
// Wait for component to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('time-picker-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the time picker button (from mock)
|
||||
await userEvent.click(screen.getByTestId('time-picker-btn'));
|
||||
|
||||
// Verify the callback was called
|
||||
expect(mockHandleTimeChange).toHaveBeenCalledWith('5m');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
mockQueryRangeV5WithError,
|
||||
mockQueryRangeV5WithLogsResponse,
|
||||
} from '../../../../__tests__/query_range_v5.util';
|
||||
import { useInfiniteHostMetricLogs } from '../hooks';
|
||||
|
||||
const createWrapper = (): React.FC<{ children: React.ReactNode }> => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return function Wrapper({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
describe('useInfiniteHostMetricLogs', () => {
|
||||
const defaultParams = {
|
||||
expression: 'host_name = "test-host"',
|
||||
startTime: 1708000000,
|
||||
endTime: 1708003600,
|
||||
};
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should return initial loading state', () => {
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
delay: 100,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInfiniteHostMetricLogs(defaultParams),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.logs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful data fetching', () => {
|
||||
it('should return logs after successful fetch', async () => {
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 5,
|
||||
hasMore: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInfiniteHostMetricLogs(defaultParams),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeFalsy();
|
||||
expect(result.current.logs.length).toBe(5);
|
||||
expect(result.current.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('should set hasNextPage based on response size', async () => {
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 100,
|
||||
hasMore: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInfiniteHostMetricLogs(defaultParams),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.hasNextPage).toBe(true);
|
||||
});
|
||||
|
||||
it('should not have next page when response is smaller than page size', async () => {
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 100,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInfiniteHostMetricLogs(defaultParams),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.hasNextPage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty state', () => {
|
||||
it('should return empty logs array when no data', async () => {
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInfiniteHostMetricLogs(defaultParams),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.logs).toEqual([]);
|
||||
expect(result.current.hasNextPage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should set isError on API failure', async () => {
|
||||
mockQueryRangeV5WithError('Internal Server Error');
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInfiniteHostMetricLogs(defaultParams),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.logs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('query disabled state', () => {
|
||||
it('should not fetch when expression is empty', async () => {
|
||||
const requestCount = { count: 0 };
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 0,
|
||||
hasMore: false,
|
||||
onReceiveRequest: (): void => {
|
||||
requestCount.count += 1;
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useInfiniteHostMetricLogs({
|
||||
...defaultParams,
|
||||
expression: '',
|
||||
}),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
// Wait a bit to ensure no request is made
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 300);
|
||||
});
|
||||
|
||||
expect(requestCount.count).toBe(0);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load more functionality', () => {
|
||||
it('should fetch next page when loadMoreLogs is called', async () => {
|
||||
const requestCount = { count: 0 };
|
||||
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
pageSize: 100,
|
||||
offset: 0,
|
||||
hasMore: true,
|
||||
onReceiveRequest: () => {
|
||||
requestCount.count += 1;
|
||||
|
||||
if (requestCount.count > 1) {
|
||||
return { offset: 100, pageSize: 100, hasMore: false };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInfiniteHostMetricLogs(defaultParams),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.logs.length).toBe(100);
|
||||
expect(result.current.hasNextPage).toBe(true);
|
||||
expect(requestCount.count).toBe(1);
|
||||
|
||||
act(() => {
|
||||
result.current.loadMoreLogs();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.logs.length).toBe(150);
|
||||
});
|
||||
|
||||
expect(result.current.hasNextPage).toBe(false);
|
||||
expect(requestCount.count).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const getHostLogsQueryPayload = (
|
||||
start: number,
|
||||
end: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
): GetQueryResultsProps => ({
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
query: {
|
||||
clickhouse_sql: [],
|
||||
promql: [],
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.LOGS,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
id: '------false',
|
||||
dataType: DataTypes.String,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filters,
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order: 'desc',
|
||||
},
|
||||
],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
offset: 0,
|
||||
pageSize: 100,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
id: uuidv4(),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
start,
|
||||
end,
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useInfiniteQuery } from 'react-query';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { parseAsString, useQueryState, UseQueryStateReturn } from 'nuqs';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import { getHostLogsQueryPayload } from './utils';
|
||||
|
||||
export function useInfiniteHostMetricLogs({
|
||||
expression,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
expression: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}): {
|
||||
logs: ILog[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
isError: boolean;
|
||||
error?: unknown;
|
||||
hasNextPage: boolean;
|
||||
loadMoreLogs: () => void;
|
||||
refetch: () => void;
|
||||
} {
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
error,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ['hostMetricsLogs', startTime, endTime, expression],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
const { query } = getHostLogsQueryPayload({
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
expression,
|
||||
offset: pageParam,
|
||||
pageSize: DEFAULT_PER_PAGE_VALUE,
|
||||
});
|
||||
return GetMetricQueryRange(query, ENTITY_VERSION_V5);
|
||||
},
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
const list = lastPage?.payload?.data?.newResult?.data?.result?.[0]?.list;
|
||||
if (!list || list.length < DEFAULT_PER_PAGE_VALUE) {
|
||||
return undefined;
|
||||
}
|
||||
return allPages.length * DEFAULT_PER_PAGE_VALUE;
|
||||
},
|
||||
enabled: !!expression,
|
||||
});
|
||||
|
||||
const logs = useMemo<ILog[]>(() => {
|
||||
if (!data?.pages) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.pages.flatMap((page) => {
|
||||
const list = page.payload.data.newResult.data.result?.[0]?.list;
|
||||
if (!list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return list.map(
|
||||
(item) =>
|
||||
({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
} as ILog),
|
||||
);
|
||||
});
|
||||
}, [data?.pages]);
|
||||
|
||||
const loadMoreLogs = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
return {
|
||||
logs,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
error,
|
||||
hasNextPage: !!hasNextPage,
|
||||
loadMoreLogs,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function useInfraMonitoringHostLogsExpression(): UseQueryStateReturn<
|
||||
string,
|
||||
undefined
|
||||
> {
|
||||
return useQueryState('hostMetricsLogsExpression', parseAsString);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export interface HostLogsQueryParams {
|
||||
start: number;
|
||||
end: number;
|
||||
expression: string;
|
||||
offset?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const getHostLogsQueryPayload = ({
|
||||
start,
|
||||
end,
|
||||
expression,
|
||||
offset = 0,
|
||||
pageSize = DEFAULT_PER_PAGE_VALUE,
|
||||
}: HostLogsQueryParams): {
|
||||
query: GetQueryResultsProps;
|
||||
queryData: IBuilderQuery;
|
||||
} => {
|
||||
const queryData: IBuilderQuery = {
|
||||
dataSource: DataSource.LOGS,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
id: '------false',
|
||||
dataType: DataTypes.String,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: { expression },
|
||||
expression,
|
||||
having: {
|
||||
expression: '',
|
||||
},
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
limit: null,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order: 'desc',
|
||||
},
|
||||
{
|
||||
columnName: 'id',
|
||||
order: 'desc',
|
||||
},
|
||||
],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
offset,
|
||||
pageSize,
|
||||
};
|
||||
|
||||
return {
|
||||
query: {
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
query: {
|
||||
clickhouse_sql: [],
|
||||
promql: [],
|
||||
builder: {
|
||||
queryData: [queryData],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
id: uuidv4(),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
start,
|
||||
end,
|
||||
},
|
||||
queryData,
|
||||
};
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -97,9 +97,6 @@ export default defineConfig(
|
||||
javascriptEnabled: true,
|
||||
},
|
||||
},
|
||||
modules: {
|
||||
localsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
define: {
|
||||
// TODO: Remove this in favor of import.meta.env
|
||||
|
||||
@@ -32,7 +32,7 @@ func newConfig() factory.Config {
|
||||
Domain: "signozserviceaccount.com",
|
||||
},
|
||||
Analytics: AnalyticsConfig{
|
||||
Enabled: false,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ var (
|
||||
ErrCodeAPIKeyAlreadyExists = errors.MustNewCode("api_key_already_exists")
|
||||
ErrCodeAPIKeytNotFound = errors.MustNewCode("api_key_not_found")
|
||||
ErrCodeAPIKeyExpired = errors.MustNewCode("api_key_expired")
|
||||
errInvalidAPIKeyName = errors.New(errors.TypeInvalidInput, ErrCodeAPIKeyInvalidInput, "name must be 1–80 characters long and contain only lowercase letters (a-z) and hyphens (-)")
|
||||
)
|
||||
|
||||
type FactorAPIKey struct {
|
||||
@@ -113,7 +112,7 @@ func (key *PostableFactorAPIKey) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
if match := factorAPIKeyNameRegex.MatchString(temp.Name); !match {
|
||||
return errInvalidAPIKeyName
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAPIKeyInvalidInput, "name must conform to the regex: %s", factorAPIKeyNameRegex.String())
|
||||
}
|
||||
|
||||
if temp.ExpiresAt != 0 && time.Now().After(time.Unix(int64(temp.ExpiresAt), 0)) {
|
||||
@@ -133,7 +132,7 @@ func (key *UpdatableFactorAPIKey) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
if match := factorAPIKeyNameRegex.MatchString(temp.Name); !match {
|
||||
return errInvalidAPIKeyName
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAPIKeyInvalidInput, "name must conform to the regex: %s", factorAPIKeyNameRegex.String())
|
||||
}
|
||||
|
||||
if temp.ExpiresAt != 0 && time.Now().After(time.Unix(int64(temp.ExpiresAt), 0)) {
|
||||
|
||||
@@ -23,7 +23,6 @@ var (
|
||||
ErrCodeServiceAccountNotFound = errors.MustNewCode("service_account_not_found")
|
||||
ErrCodeServiceAccountRoleAlreadyExists = errors.MustNewCode("service_account_role_already_exists")
|
||||
ErrCodeServiceAccountOperationUnsupported = errors.MustNewCode("service_account_operation_unsupported")
|
||||
errInvalidServiceAccountName = errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name must be 1–50 characters long and contain only lowercase letters (a-z) and hyphens (-)")
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -215,7 +214,7 @@ func (serviceAccount *PostableServiceAccount) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
if match := serviceAccountNameRegex.MatchString(temp.Name); !match {
|
||||
return errInvalidServiceAccountName
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name must conform to the regex: %s", serviceAccountNameRegex.String())
|
||||
}
|
||||
|
||||
*serviceAccount = PostableServiceAccount(temp)
|
||||
|
||||
Reference in New Issue
Block a user