Compare commits
32 Commits
ankitnayan
...
feat/alert
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a8880854e | ||
|
|
3a40702c61 | ||
|
|
e373140701 | ||
|
|
cdd06ee6b8 | ||
|
|
eef2b6a961 | ||
|
|
330038a35f | ||
|
|
d4dea81bb6 | ||
|
|
dfd7d8a871 | ||
|
|
40d2906835 | ||
|
|
3fcb6b3b00 | ||
|
|
5982c0854d | ||
|
|
687b40ffbb | ||
|
|
4e111c6b83 | ||
|
|
3f5eb62494 | ||
|
|
cd7b6a1d05 | ||
|
|
faee2f032f | ||
|
|
0402cc0273 | ||
|
|
b70f057adc | ||
|
|
3b7b7202e9 | ||
|
|
e3c9babfe5 | ||
|
|
226e40cbcd | ||
|
|
0f4d007104 | ||
|
|
86b88eb10b | ||
|
|
0b21197689 | ||
|
|
6c02fe107f | ||
|
|
a90e915fa3 | ||
|
|
1a4de4328b | ||
|
|
c53adf365a | ||
|
|
0fc16e02fa | ||
|
|
fb6a29e6fa | ||
|
|
0daf7a12da | ||
|
|
cc7d7017ae |
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.125.1
|
||||
image: signoz/signoz:v0.125.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.125.1
|
||||
image: signoz/signoz:v0.125.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.125.1}
|
||||
image: signoz/signoz:${VERSION:-v0.125.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.125.1}
|
||||
image: signoz/signoz:${VERSION:-v0.125.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
|
||||
const BANNED_COMPONENTS = {
|
||||
Typography: 'Use @signozhq/ui Typography instead of antd Typography.',
|
||||
Badge: 'Use @signozhq/ui/badge instead of antd Badge.',
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -166,7 +166,6 @@ function createMockAppContext(
|
||||
userPreferences: [],
|
||||
hostsData: null,
|
||||
isLoggedIn: true,
|
||||
isPreflightLoading: false,
|
||||
org: [{ createdAt: 0, id: 'org-id', displayName: 'Test Org' }],
|
||||
isFetchingUser: false,
|
||||
isFetchingActiveLicense: false,
|
||||
|
||||
@@ -59,7 +59,6 @@ function App(): JSX.Element {
|
||||
isLoggedIn: isLoggedInState,
|
||||
featureFlags,
|
||||
org,
|
||||
isPreflightLoading,
|
||||
} = useAppContext();
|
||||
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
@@ -387,10 +386,6 @@ function App(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isCloudUser, isEnterpriseSelfHostedUser]);
|
||||
|
||||
if (isPreflightLoading) {
|
||||
return <Spinner tip="Loading..." />;
|
||||
}
|
||||
|
||||
// if the user is in logged in state
|
||||
if (isLoggedInState) {
|
||||
// if the setup calls are loading then return a spinner
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { getIsNoAuthMode } from 'utils/noAuthMode';
|
||||
|
||||
import { interceptorRejected } from '../index';
|
||||
|
||||
jest.mock('utils/noAuthMode', () => ({
|
||||
getIsNoAuthMode: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/v2/sessions/rotate/post', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('AppRoutes/utils', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils', () => ({
|
||||
Logout: jest.fn(),
|
||||
}));
|
||||
|
||||
// oxlint-disable-next-line typescript/no-require-imports typescript/no-var-requires
|
||||
const post = require('api/v2/sessions/rotate/post').default;
|
||||
// oxlint-disable-next-line typescript/no-require-imports typescript/no-var-requires
|
||||
const { Logout } = require('../utils');
|
||||
|
||||
describe('interceptorRejected — no-auth mode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(axios, 'isAxiosError').mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('does NOT call rotate or Logout when no-auth mode is enabled on 401', async () => {
|
||||
(getIsNoAuthMode as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const error = {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 401,
|
||||
config: { url: '/dashboards', method: 'get' },
|
||||
},
|
||||
config: { url: '/dashboards', headers: {} },
|
||||
};
|
||||
|
||||
await interceptorRejected(error as any).catch(() => {});
|
||||
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(Logout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DOES attempt rotate when no-auth mode is disabled on 401', async () => {
|
||||
(getIsNoAuthMode as jest.Mock).mockReturnValue(false);
|
||||
(post as jest.Mock).mockResolvedValue({
|
||||
data: { accessToken: 'a', refreshToken: 'b' },
|
||||
});
|
||||
|
||||
const error = {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 401,
|
||||
config: { url: '/dashboards', method: 'get' },
|
||||
},
|
||||
config: { url: '/dashboards', headers: {} },
|
||||
};
|
||||
|
||||
await interceptorRejected(error as any).catch(() => {});
|
||||
|
||||
expect(post).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import { Events } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { getBasePath } from 'utils/basePath';
|
||||
import { eventEmitter } from 'utils/getEventEmitter';
|
||||
import { getIsNoAuthMode } from 'utils/noAuthMode';
|
||||
|
||||
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
|
||||
import { Logout } from './utils';
|
||||
@@ -109,10 +108,7 @@ export const interceptorRejected = async (
|
||||
if (axios.isAxiosError(value) && value.response) {
|
||||
const { response } = value;
|
||||
|
||||
const isNoAuthMode = getIsNoAuthMode();
|
||||
|
||||
if (
|
||||
!isNoAuthMode &&
|
||||
response.status === 401 &&
|
||||
// if the session rotate call or the create session errors out with 401 or the delete sessions call returns 401 then we do not retry!
|
||||
response.config.url !== '/sessions/rotate' &&
|
||||
@@ -144,20 +140,16 @@ export const interceptorRejected = async (
|
||||
return await Promise.resolve(reResponse);
|
||||
} catch (error) {
|
||||
if ((error as AxiosError)?.response?.status === 401) {
|
||||
void Logout();
|
||||
Logout();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
void Logout();
|
||||
Logout();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!isNoAuthMode &&
|
||||
response.status === 401 &&
|
||||
response.config.url === '/sessions/rotate'
|
||||
) {
|
||||
void Logout();
|
||||
if (response.status === 401 && response.config.url === '/sessions/rotate') {
|
||||
Logout();
|
||||
}
|
||||
}
|
||||
return await Promise.reject(value);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,7 +0,0 @@
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="456" height="456" rx="50" fill="#512BD4"/>
|
||||
<path d="M81.2738 291.333C78.0496 291.333 75.309 290.259 73.052 288.11C70.795 285.906 69.6665 283.289 69.6665 280.259C69.6665 277.173 70.795 274.529 73.052 272.325C75.309 270.121 78.0496 269.019 81.2738 269.019C84.5518 269.019 87.3193 270.121 89.5763 272.325C91.887 274.529 93.0424 277.173 93.0424 280.259C93.0424 283.289 91.887 285.906 89.5763 288.11C87.3193 290.259 84.5518 291.333 81.2738 291.333Z" fill="white"/>
|
||||
<path d="M210.167 289.515H189.209L133.994 202.406C132.597 200.202 131.441 197.915 130.528 195.546H130.044C130.474 198.081 130.689 203.508 130.689 211.827V289.515H112.149V171H134.477L187.839 256.043C190.096 259.57 191.547 261.994 192.192 263.316H192.514C191.977 260.176 191.708 254.859 191.708 247.365V171H210.167V289.515Z" fill="white"/>
|
||||
<path d="M300.449 289.515H235.561V171H297.87V187.695H254.746V221.249H294.485V237.861H254.746V272.903H300.449V289.515Z" fill="white"/>
|
||||
<path d="M392.667 187.695H359.457V289.515H340.272V187.695H307.143V171H392.667V187.695Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,15 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18">
|
||||
<defs>
|
||||
<linearGradient id="a" x1="9" y1="17" x2="9" y2="1" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0078d4"/>
|
||||
<stop offset="1" stop-color="#5ea0ef"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="9" cy="9" r="8" fill="url(#a)"/>
|
||||
<ellipse cx="9" cy="9" rx="3.2" ry="8" fill="none" stroke="#fff" stroke-width=".7"/>
|
||||
<line x1="1" y1="9" x2="17" y2="9" stroke="#fff" stroke-width=".7"/>
|
||||
<line x1="2" y1="5.5" x2="16" y2="5.5" stroke="#fff" stroke-width=".5"/>
|
||||
<line x1="2" y1="12.5" x2="16" y2="12.5" stroke="#fff" stroke-width=".5"/>
|
||||
<circle cx="9" cy="9" r="8" fill="none" stroke="#fff" stroke-width=".7"/>
|
||||
<path d="M13.5 10.5l1.5-1.5-1.5-1.5M4.5 10.5L3 9l1.5-1.5" stroke="#50e6ff" stroke-width="1" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 877 B |
@@ -1,15 +0,0 @@
|
||||
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_0_812)" transform="matrix(1.002103,0,0,1.0377318,6.9399999e-7,-2.5317276e-4)">
|
||||
<path d="m 141.702,68.418 c 0,7.4632 -4.567,14.1123 -6.748,20.8385 -2.263,6.9789 -2.552,15.0285 -6.776,20.8385 -4.267,5.868 -11.856,8.611 -17.719,12.881 -5.805,4.228 -10.7345,10.628 -17.7061,12.895 -6.7286,2.186 -14.4463,-0.021 -21.9018,-0.021 -7.4555,0 -15.1731,2.207 -21.8998,0.021 C 41.9778,133.604 37.048,127.204 31.2428,122.976 25.3799,118.706 17.7913,115.963 13.5247,110.095 9.30055,104.287 9.01135,96.2374 6.74791,89.2565 4.56351,82.5225 0,75.8735 0,68.418 0,60.9624 4.56737,54.3057 6.74791,47.5795 9.01135,40.6005 9.30055,32.5507 13.5247,26.741 17.7913,20.8753 25.3799,18.1297 31.2428,13.8617 37.048,9.63414 41.9778,3.23209 48.9513,0.966872 55.678,-1.21924 63.3956,0.986167 70.8511,0.986167 c 7.4555,0 15.1732,-2.205407 21.8999,-0.019295 6.9735,2.265218 11.903,8.667268 17.708,12.894828 5.863,4.268 13.452,7.0136 17.719,12.8793 4.224,5.8097 4.513,13.8595 6.776,20.8385 2.181,6.7262 6.748,13.3771 6.748,20.8385 z" fill="#326ce5"/>
|
||||
<path d="m 70.8473,8.18683 c -33.2383,0 -60.1837,26.96657 -60.1837,60.23097 0,33.2642 26.9454,60.2312 60.1837,60.2312 33.2387,0 60.1837,-26.959 60.1837,-60.2312 0,-33.2721 -26.945,-60.23097 -60.1837,-60.23097 z M 70.8319,123.408 C 40.4778,123.408 15.9058,98.8053 15.9,68.4274 15.9,38.0167 40.5357,13.3791 70.9109,13.437 c 30.3751,0.0579 54.9111,24.6589 54.8841,55.0329 -0.027,30.374 -24.609,54.9481 -54.9631,54.9381 z" fill="#ffffff"/>
|
||||
<path d="m 13.5883,60.53 c 8.1804,0 8.1804,3.859 16.3589,3.859 8.1784,0 8.1804,-3.859 16.3607,-3.859 8.1804,0 8.1785,3.859 16.3589,3.859 8.1804,0 8.1785,-3.859 16.3589,-3.859 8.1804,0 8.1803,3.859 16.3588,3.859 8.1785,0 8.1805,-3.859 16.3605,-3.859 8.181,0 8.181,3.859 16.361,3.859" stroke="#ffffff" strokeMiterlimit="10"/>
|
||||
<path d="m 13.5883,68.248 c 8.1804,0 8.1804,3.859 16.3589,3.859 8.1784,0 8.1804,-3.859 16.3607,-3.859 8.1804,0 8.1785,3.859 16.3589,3.859 8.1804,0 8.1785,-3.859 16.3589,-3.859 8.1804,0 8.1803,3.859 16.3588,3.859 8.1785,0 8.1805,-3.859 16.3605,-3.859 8.181,0 8.181,3.859 16.361,3.859" stroke="#ffffff" strokeMiterlimit="10"/>
|
||||
<path d="m 13.5883,77.5095 c 8.1804,0 8.1804,3.859 16.3589,3.859 8.1784,0 8.1804,-3.859 16.3607,-3.859 8.1804,0 8.1785,3.859 16.3589,3.859 8.1804,0 8.1785,-3.859 16.3589,-3.859 8.1804,0 8.1803,3.859 16.3588,3.859 8.1785,0 8.1805,-3.859 16.3605,-3.859 8.181,0 8.181,3.859 16.361,3.859" stroke="#ffffff" strokeMiterlimit="10"/>
|
||||
<path d="m 70.8473,8.18683 c -33.2383,0 -60.1837,26.96657 -60.1837,60.23097 0,33.2642 26.9454,60.2312 60.1837,60.2312 33.2387,0 60.1837,-26.959 60.1837,-60.2312 0,-33.2721 -26.945,-60.23097 -60.1837,-60.23097 z M 70.8319,123.408 C 40.4778,123.408 15.9058,98.8053 15.9,68.4274 15.9,38.0167 40.5357,13.3791 70.9109,13.437 c 30.3751,0.0579 54.9111,24.6589 54.8841,55.0329 -0.027,30.374 -24.609,54.9481 -54.9631,54.9381 z" fill="#ffffff"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_812">
|
||||
<rect width="141.702" height="136.837" fill="#ffffff"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -1,55 +0,0 @@
|
||||
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="122" y="-0.4" transform="matrix(-0.866 -0.5 0.5 -0.866 163.3196 363.3136)" fill="#E535AB" width="16.6" height="320.3"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="39.8" y="272.2" fill="#E535AB" width="320.3" height="16.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="37.9" y="312.2" transform="matrix(-0.866 -0.5 0.5 -0.866 83.0693 663.3409)" fill="#E535AB" width="185" height="16.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="177.1" y="71.1" transform="matrix(-0.866 -0.5 0.5 -0.866 463.3409 283.0693)" fill="#E535AB" width="185" height="16.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="122.1" y="-13" transform="matrix(-0.5 -0.866 0.866 -0.5 126.7903 232.1221)" fill="#E535AB" width="16.6" height="185"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="109.6" y="151.6" transform="matrix(-0.5 -0.866 0.866 -0.5 266.0828 473.3766)" fill="#E535AB" width="320.3" height="16.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="52.5" y="107.5" fill="#E535AB" width="16.6" height="185"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="330.9" y="107.5" fill="#E535AB" width="16.6" height="185"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="262.4" y="240.1" transform="matrix(-0.5 -0.866 0.866 -0.5 126.7953 714.2875)" fill="#E535AB" width="14.5" height="160.9"/>
|
||||
</g>
|
||||
</g>
|
||||
<path fill="#E535AB" d="M369.5,297.9c-9.6,16.7-31,22.4-47.7,12.8c-16.7-9.6-22.4-31-12.8-47.7c9.6-16.7,31-22.4,47.7-12.8 C373.5,259.9,379.2,281.2,369.5,297.9"/>
|
||||
<path fill="#E535AB" d="M90.9,137c-9.6,16.7-31,22.4-47.7,12.8c-16.7-9.6-22.4-31-12.8-47.7c9.6-16.7,31-22.4,47.7-12.8 C94.8,99,100.5,120.3,90.9,137"/>
|
||||
<path fill="#E535AB" d="M30.5,297.9c-9.6-16.7-3.9-38,12.8-47.7c16.7-9.6,38-3.9,47.7,12.8c9.6,16.7,3.9,38-12.8,47.7 C61.4,320.3,40.1,314.6,30.5,297.9"/>
|
||||
<path fill="#E535AB" d="M309.1,137c-9.6-16.7-3.9-38,12.8-47.7c16.7-9.6,38-3.9,47.7,12.8c9.6,16.7,3.9,38-12.8,47.7 C340.1,159.4,318.7,153.7,309.1,137"/>
|
||||
<path fill="#E535AB" d="M200,395.8c-19.3,0-34.9-15.6-34.9-34.9c0-19.3,15.6-34.9,34.9-34.9c19.3,0,34.9,15.6,34.9,34.9 C234.9,380.1,219.3,395.8,200,395.8"/>
|
||||
<path fill="#E535AB" d="M200,74c-19.3,0-34.9-15.6-34.9-34.9c0-19.3,15.6-34.9,34.9-34.9c19.3,0,34.9,15.6,34.9,34.9 C234.9,58.4,219.3,74,200,74"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 77.62745 102.5">
|
||||
<path fill="#516baa" d="m31.05548,54.44523v24.1773c.00065.04512-.03164.084-.07611.09164l-23.27949,3.99047c-.05091.0076-.09834-.02751-.10594-.07841-.00256-.01712-.0003-.03461.00653-.05051L30.87996,30.58635c.02242-.04633.07815-.06572.12449-.04331.0316.01529.05193.04704.05259.08214l-.00156,23.82005Zm3.92367-13.93321v38.21148c.00046.04691.03573.08617.08232.09164l34.87031,3.89415c.0512.00527.09698-.03196.10226-.08316.00167-.01616-.00092-.03247-.00751-.04732L35.15623,4.70041c-.02237-.04636-.07809-.0658-.12444-.04343-.03117.01504-.05144.04612-.05264.08071v35.77433Zm34.68546,45.76213l-38.57341,11.57218c-.02155.00797-.04524.00797-.06679,0l-23.309-11.57217c-.04636-.0203-.06749-.07435-.04719-.12071.01513-.03455.04988-.0563.08757-.05481h61.88241c.0508.00825.08531.05613.07706.10693-.00482.0297-.02369.05525-.05066.06859Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 901 B |
@@ -1,3 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M.113 10.27A13.026 13.026 0 000 11.48h18.23c-.064-.125-.15-.237-.235-.347-3.117-4.027-4.793-3.677-7.19-3.78-.8-.034-1.34-.048-4.524-.048-1.704 0-3.555.005-5.358.01-.234.63-.459 1.24-.567 1.737h9.342v1.216H.113v.002zm18.26 2.426H.009c.02.326.05.645.094.961h16.955c.754 0 1.179-.429 1.315-.96zm-17.318 4.28s2.81 6.902 10.93 7.024c4.855 0 9.027-2.883 10.92-7.024H1.056zM11.988 0C7.5 0 3.593 2.466 1.531 6.108l4.75-.005v-.002c3.71 0 3.849.016 4.573.047l.448.016c1.563.052 3.485.22 4.996 1.364.82.621 2.007 1.99 2.712 2.965.654.902.842 1.94.396 2.934-.408.914-1.289 1.458-2.353 1.458H.391s.099.42.249.886h22.748A12.026 12.026 0 0024 12.005C24 5.377 18.621 0 11.988 0z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 776 B |
@@ -1,13 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 590 270">
|
||||
<path d="M30.36,109.14v.48h0A3.73,3.73,0,0,1,30.36,109.14Z" fill="currentColor" fill-rule="evenodd"/>
|
||||
<path d="M138.66,28.78C107.2,37.87,57.29,43,30.4,43h0V94.35a.8.8,0,0,0,.19.48c18.35,0,75-6,109.18-15.4a129,129,0,0,0,17.49-5.81c4.18-1.88,6.88-3.86,6.88-5.92V15.91C164.1,20.79,151.39,25.11,138.66,28.78Z" fill="#de3423" fill-rule="evenodd"/>
|
||||
<path d="M138.66,95.37c-18.83,5.43-44.24,9.47-67.39,11.83-15.54,1.59-30.06,2.42-40.87,2.42h0v51.31a.8.8,0,0,0,.19.48c18.35,0,75-6,109.18-15.39a130.38,130.38,0,0,0,17.49-5.81c4.18-1.89,6.88-3.86,6.88-5.92V82.5C164.1,87.37,151.39,91.69,138.66,95.37Z" fill="#de3423" fill-rule="evenodd"/>
|
||||
<path d="M138.66,162c-18.83,5.43-44.24,9.46-67.39,11.83-15.56,1.59-30.1,2.42-40.91,2.42V228c18.16,0,75.1-5.95,109.37-15.39,12.63-3.48,24.37-7.44,24.37-11.74V149.08C164.1,154,151.39,158.28,138.66,162Z" fill="#de3423" fill-rule="evenodd"/>
|
||||
<path d="M30.55,94.83C32.4,97.38,48,102.19,71.27,107.2c23.27,4.46,47.47,22.07,66.29,16.64,12.73-3.68,26.54-36.47,26.54-41.34V82c0-3.4-2.55-6.13-6.88-8.4-17.75-9.07-21.11-12.41-27.69-10.6C95.37,72.43,35.06,67.61,30.55,94.83Z" fill="currentColor" fill-rule="evenodd"/>
|
||||
<path d="M30.55,161.41C32.4,164,48,168.77,71.27,173.79c26,4.74,48.61,20.19,67.44,14.75,12.73-3.68,25.39-34.58,25.39-39.46v-.48c0-3.39-2.55-6.13-6.88-8.39-13.54-7.2-31.43-15.13-38-13.32C85,136.3,39.26,138.37,30.55,161.41Z" fill="currentColor" fill-rule="evenodd"/>
|
||||
<path d="M200.7,142.39c6,11.79,15.6,17.6,29.05,17.6,14.44,0,19.59-7.64,19.59-15.11,0-5.15-1.83-8.63-6.64-11.79-4.82-3.32-8.3-4.81-16.93-8-10.63-4-16.77-7-23.41-12.29-6.64-5.48-9.79-13-9.79-22.74a28.28,28.28,0,0,1,10.29-22.58c7-5.81,15.44-8.63,25.56-8.63,15.77,0,27.72,6.31,35.69,18.76L249.34,87.78c-4.48-6.81-11.29-10.3-20.59-10.3-9.13,0-15.77,5.15-15.77,12.29,0,4.81,2,7.14,4.82,10,1.82,1.33,6.47,3.32,8.63,4.48l6,2.32,6.8,2.66c11,4.48,18.76,9.3,23.57,14.44s7.31,12.12,7.31,20.75c0,20.42-14.11,34.2-40.51,34.2-21.41,0-37.18-10-44.48-26.4Z" fill="currentColor"/>
|
||||
<path d="M354.25,104.71,342,117.49a28.14,28.14,0,0,0-21.24-9.13,25,25,0,0,0-18.43,7.47,27.76,27.76,0,0,0,0,37.52,25,25,0,0,0,18.43,7.47A28.14,28.14,0,0,0,342,151.69l12.29,12.78c-9,9.63-20.09,14.44-33.53,14.44-12.79,0-23.58-4.15-32.37-12.62s-13.12-19.09-13.12-31.7,4.32-23.08,13.12-31.54,19.58-12.78,32.37-12.78C334.16,90.27,345.28,95.08,354.25,104.71Z" fill="currentColor"/>
|
||||
<path d="M393.88,125.62C408,124.3,413,122.47,413,116c0-5.15-4.64-9.13-13.94-9.13q-13.44,0-22.41,10.95l-12.28-10.46c8.13-11.45,19.58-17.09,34.36-17.09,20.75,0,33.7,10,33.7,27.05v37c0,5.81,2.15,6.48,7,6.48h.5v15.43c-2,1.17-5.15,1.83-9.3,1.83-4.48,0-8-1.33-10.62-4a14.06,14.06,0,0,1-3-5.48c-5.81,6.8-15.27,10.29-28.39,10.29-18.42,0-30.87-10.13-30.87-25.4C357.7,136.41,369.15,127.78,393.88,125.62ZM391.56,162c13.28,0,21.41-6,21.41-16.6v-9.3a9.75,9.75,0,0,1-4.14,2.49c-3.82,1.33-6.31,1.66-14.28,2.49-11.62,1.33-17.43,5-17.43,10.79C377.12,158.33,382.43,162,391.56,162Z" fill="currentColor"/>
|
||||
<path d="M444.84,60.88h19.92V149.2c0,8.13,2.66,11.62,10,11.62a21.15,21.15,0,0,0,6-.67v17.76a35.56,35.56,0,0,1-9.47,1c-17.59,0-26.39-9-26.39-27.06Z" fill="currentColor"/>
|
||||
<path d="M521.71,125.62c14.11-1.32,19.09-3.15,19.09-9.62,0-5.15-4.64-9.13-13.94-9.13q-13.44,0-22.41,10.95l-12.28-10.46c8.13-11.45,19.58-17.09,34.36-17.09,20.75,0,33.7,10,33.7,27.05v37c0,5.81,2.15,6.48,7,6.48h.5v15.43c-2,1.17-5.15,1.83-9.3,1.83-4.48,0-8-1.33-10.62-4a13.94,13.94,0,0,1-3-5.48c-5.81,6.8-15.27,10.29-28.39,10.29-18.42,0-30.87-10.13-30.87-25.4C485.53,136.41,497,127.78,521.71,125.62ZM519.39,162c13.28,0,21.41-6,21.41-16.6v-9.3a9.73,9.73,0,0,1-4.15,2.49c-3.81,1.33-6.3,1.66-14.27,2.49-11.62,1.33-17.43,5-17.43,10.79C505,158.33,510.26,162,519.39,162Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,37 +0,0 @@
|
||||
<svg viewBox="0 0 254.5 225" xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#00ACD7" d="M40.2,101.1c-0.4,0-0.5-0.2-0.3-0.5l2.1-2.7c0.2-0.3,0.7-0.5,1.1-0.5l35.7,0c0.4,0,0.5,0.3,0.3,0.6 l-1.7,2.6c-0.2,0.3-0.7,0.6-1,0.6L40.2,101.1z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#00ACD7" d="M25.1,110.3c-0.4,0-0.5-0.2-0.3-0.5l2.1-2.7c0.2-0.3,0.7-0.5,1.1-0.5l45.6,0c0.4,0,0.6,0.3,0.5,0.6 l-0.8,2.4c-0.1,0.4-0.5,0.6-0.9,0.6L25.1,110.3z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#00ACD7" d="M49.3,119.5c-0.4,0-0.5-0.3-0.3-0.6l1.4-2.5c0.2-0.3,0.6-0.6,1-0.6l20,0c0.4,0,0.6,0.3,0.6,0.7l-0.2,2.4 c0,0.4-0.4,0.7-0.7,0.7L49.3,119.5z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g id="CXHf1q_3_">
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#00ACD7" d="M153.1,99.3c-6.3,1.6-10.6,2.8-16.8,4.4c-1.5,0.4-1.6,0.5-2.9-1c-1.5-1.7-2.6-2.8-4.7-3.8 c-6.3-3.1-12.4-2.2-18.1,1.5c-6.8,4.4-10.3,10.9-10.2,19c0.1,8,5.6,14.6,13.5,15.7c6.8,0.9,12.5-1.5,17-6.6 c0.9-1.1,1.7-2.3,2.7-3.7c-3.6,0-8.1,0-19.3,0c-2.1,0-2.6-1.3-1.9-3c1.3-3.1,3.7-8.3,5.1-10.9c0.3-0.6,1-1.6,2.5-1.6 c5.1,0,23.9,0,36.4,0c-0.2,2.7-0.2,5.4-0.6,8.1c-1.1,7.2-3.8,13.8-8.2,19.6c-7.2,9.5-16.6,15.4-28.5,17 c-9.8,1.3-18.9-0.6-26.9-6.6c-7.4-5.6-11.6-13-12.7-22.2c-1.3-10.9,1.9-20.7,8.5-29.3c7.1-9.3,16.5-15.2,28-17.3 c9.4-1.7,18.4-0.6,26.5,4.9c5.3,3.5,9.1,8.3,11.6,14.1C154.7,98.5,154.3,99,153.1,99.3z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#00ACD7" d="M186.2,154.6c-9.1-0.2-17.4-2.8-24.4-8.8c-5.9-5.1-9.6-11.6-10.8-19.3c-1.8-11.3,1.3-21.3,8.1-30.2 c7.3-9.6,16.1-14.6,28-16.7c10.2-1.8,19.8-0.8,28.5,5.1c7.9,5.4,12.8,12.7,14.1,22.3c1.7,13.5-2.2,24.5-11.5,33.9 c-6.6,6.7-14.7,10.9-24,12.8C191.5,154.2,188.8,154.3,186.2,154.6z M210,114.2c-0.1-1.3-0.1-2.3-0.3-3.3 c-1.8-9.9-10.9-15.5-20.4-13.3c-9.3,2.1-15.3,8-17.5,17.4c-1.8,7.8,2,15.7,9.2,18.9c5.5,2.4,11,2.1,16.3-0.6 C205.2,129.2,209.5,122.8,210,114.2z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,31 @@
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-vanilla-400);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback } from 'react';
|
||||
import { LifeBuoy, RefreshCw, TriangleAlert } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import styles from './ErrorEmptyState.module.scss';
|
||||
|
||||
interface ErrorEmptyStateProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
function ErrorEmptyState({
|
||||
title = 'Something went wrong',
|
||||
subtitle = 'Our team is getting on top to resolve this. Please reach out to support if the issue persists.',
|
||||
onRefresh,
|
||||
}: ErrorEmptyStateProps): JSX.Element {
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
|
||||
const onContactSupport = useCallback((): void => {
|
||||
handleContactSupport(isCloudUser);
|
||||
}, [isCloudUser]);
|
||||
|
||||
return (
|
||||
<div className={styles.emptyState} data-testid="error-empty-state">
|
||||
<TriangleAlert className={styles.icon} size={32} />
|
||||
<div className={styles.title} data-testid="error-title">
|
||||
{title}
|
||||
</div>
|
||||
<div className={styles.subtitle} data-testid="error-subtitle">
|
||||
{subtitle}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<LifeBuoy size={14} />}
|
||||
onClick={onContactSupport}
|
||||
data-testid="error-contact-support-button"
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={onRefresh}
|
||||
data-testid="error-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorEmptyState;
|
||||
1
frontend/src/components/Alerts/ErrorEmptyState/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './ErrorEmptyState';
|
||||
@@ -0,0 +1,68 @@
|
||||
.labelColumn {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.labelBadge {
|
||||
cursor: default;
|
||||
font-size: 12px;
|
||||
|
||||
--badge-display: inline;
|
||||
|
||||
max-width: 180px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.overflowTrigger {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overflowBadge {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.labelPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.labelTooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.labelValue {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
142
frontend/src/components/Alerts/LabelColumn/LabelColumn.test.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
|
||||
import LabelColumn from './LabelColumn';
|
||||
|
||||
let resizeCallback: ResizeObserverCallback | null = null;
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
}
|
||||
|
||||
function triggerResize(width: number): void {
|
||||
if (resizeCallback) {
|
||||
act(() => {
|
||||
resizeCallback?.(
|
||||
[{ contentRect: { width } } as ResizeObserverEntry],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
global.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resizeCallback = null;
|
||||
});
|
||||
|
||||
function renderWithProviders(
|
||||
ui: React.ReactElement,
|
||||
): ReturnType<typeof render> {
|
||||
return render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
}
|
||||
|
||||
describe('LabelColumn', () => {
|
||||
it('should render all labels when 5 or fewer', () => {
|
||||
const labels = ['env', 'service', 'region'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should truncate labels and show +N badge when container is narrow', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// Simulate narrow container that fits ~3 badges
|
||||
// Badge widths: env=37, service=65, region=58, team=44, owner=51, version=65
|
||||
// 220px available = 3 badges (160px) + gaps (8px) + overflow (44px)
|
||||
triggerResize(220);
|
||||
|
||||
// First 3 visible
|
||||
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
|
||||
|
||||
// Remaining in overflow badge
|
||||
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+3');
|
||||
});
|
||||
|
||||
it('should render label with value when value prop provided', () => {
|
||||
const labels = ['env'];
|
||||
const value = { env: 'production' };
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} value={value} />);
|
||||
|
||||
expect(screen.getByTestId('label-tag-env')).toHaveTextContent(
|
||||
'env: production',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render labels without value when value is not provided for that label', () => {
|
||||
const labels = ['env', 'service'];
|
||||
const value = { env: 'production' };
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} value={value} />);
|
||||
|
||||
expect(screen.getByTestId('label-tag-env')).toHaveTextContent(
|
||||
'env: production',
|
||||
);
|
||||
expect(screen.getByTestId('label-tag-service')).toHaveTextContent('service');
|
||||
});
|
||||
|
||||
it('should show overflow badge with remaining count when container is narrow', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// Simulate narrow container to trigger overflow (shows 3 labels)
|
||||
// 220px fits first 3 badges before overflow
|
||||
triggerResize(220);
|
||||
|
||||
// Overflow badge shows +3 (remaining labels)
|
||||
const overflowBadge = screen.getByTestId('label-overflow-badge');
|
||||
expect(overflowBadge).toBeInTheDocument();
|
||||
expect(overflowBadge).toHaveTextContent('+3');
|
||||
});
|
||||
|
||||
it('should render empty when no labels provided', () => {
|
||||
renderWithProviders(<LabelColumn labels={[]} />);
|
||||
|
||||
const column = screen.getByTestId('label-column');
|
||||
expect(column.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should use primary color by default', () => {
|
||||
const labels = ['env'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show all labels when container is wide enough', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// Simulate wide container
|
||||
triggerResize(1000);
|
||||
|
||||
// All labels visible
|
||||
labels.forEach((label) => {
|
||||
expect(screen.getByTestId(`label-tag-${label}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// No overflow badge
|
||||
expect(screen.queryByTestId('label-overflow-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
150
frontend/src/components/Alerts/LabelColumn/LabelColumn.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Copy } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import LabelTag from './LabelTag';
|
||||
|
||||
import styles from './LabelColumn.module.scss';
|
||||
import { BADGE_GAP, estimateBadgeWidth, OVERFLOW_BADGE_WIDTH } from './utils';
|
||||
|
||||
export interface LabelColumnProps {
|
||||
labels: string[];
|
||||
color?:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'robin'
|
||||
| 'forest'
|
||||
| 'amber'
|
||||
| 'sienna'
|
||||
| 'cherry'
|
||||
| 'sakura'
|
||||
| 'aqua'
|
||||
| 'vanilla';
|
||||
value?: { [key: string]: string };
|
||||
}
|
||||
|
||||
function LabelColumn({
|
||||
labels,
|
||||
value,
|
||||
color = 'primary',
|
||||
}: LabelColumnProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [maxVisibleCount, setMaxVisibleCount] = useState(labels.length);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const calculateMaxVisible = useCallback(
|
||||
(width: number): number => {
|
||||
if (width <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const availableWidth = width - OVERFLOW_BADGE_WIDTH - BADGE_GAP;
|
||||
let usedWidth = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const label of labels) {
|
||||
const badgeWidth = estimateBadgeWidth(label, value?.[label]) + BADGE_GAP;
|
||||
if (usedWidth + badgeWidth > availableWidth && count > 0) {
|
||||
break;
|
||||
}
|
||||
usedWidth += badgeWidth;
|
||||
count++;
|
||||
}
|
||||
|
||||
return Math.max(1, count);
|
||||
},
|
||||
[labels, value],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry && entry.contentRect.width > 0) {
|
||||
setMaxVisibleCount(calculateMaxVisible(entry.contentRect.width));
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
|
||||
if (container.clientWidth > 0) {
|
||||
setMaxVisibleCount(calculateMaxVisible(container.clientWidth));
|
||||
}
|
||||
|
||||
return (): void => observer.disconnect();
|
||||
}, [calculateMaxVisible]);
|
||||
|
||||
const needsOverflow = labels.length > maxVisibleCount;
|
||||
const visibleLabels = needsOverflow
|
||||
? labels.slice(0, maxVisibleCount)
|
||||
: labels;
|
||||
const remainingLabels = needsOverflow ? labels.slice(maxVisibleCount) : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.labelColumn}
|
||||
data-testid="label-column"
|
||||
>
|
||||
{visibleLabels.map((label) => (
|
||||
<LabelTag key={label} label={label} color={color} value={value?.[label]} />
|
||||
))}
|
||||
{remainingLabels.length > 0 && (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Badge
|
||||
color={color}
|
||||
className={styles.overflowBadge}
|
||||
variant="outline"
|
||||
data-testid="label-overflow-badge"
|
||||
>
|
||||
+{remainingLabels.length}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end">
|
||||
<div className={styles.tooltipContent}>
|
||||
<span>
|
||||
{remainingLabels
|
||||
.map((label) => (value?.[label] ? `${label}: ${value[label]}` : label))
|
||||
.join(', ')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.copyButton}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
const searchFormat = remainingLabels
|
||||
.map((label) => (value?.[label] ? `${label} ${value[label]}` : label))
|
||||
.join(' ');
|
||||
copyToClipboard(searchFormat);
|
||||
toast.success('Copied! Use in search to filter alerts.');
|
||||
}}
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LabelColumn;
|
||||
@@ -0,0 +1,30 @@
|
||||
.labelBadge {
|
||||
cursor: default;
|
||||
font-size: 12px;
|
||||
|
||||
max-width: 180px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.labelValue {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
74
frontend/src/components/Alerts/LabelColumn/LabelTag.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Copy } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import styles from './LabelTag.module.scss';
|
||||
|
||||
export interface LabelTagProps {
|
||||
label: string;
|
||||
color?:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'robin'
|
||||
| 'forest'
|
||||
| 'amber'
|
||||
| 'sienna'
|
||||
| 'cherry'
|
||||
| 'sakura'
|
||||
| 'aqua'
|
||||
| 'vanilla';
|
||||
value?: string;
|
||||
}
|
||||
|
||||
function LabelTag({ label, value, color }: LabelTagProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const displayText = value ? `${label}: ${value}` : label;
|
||||
const searchFormat = value ? `${label} ${value}` : label;
|
||||
|
||||
const handleCopy = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(searchFormat);
|
||||
toast.success('Copied! Use in search to filter alerts.');
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Badge
|
||||
color={color}
|
||||
className={styles.labelBadge}
|
||||
variant="outline"
|
||||
data-testid={`label-tag-${label}`}
|
||||
>
|
||||
<span className={styles.labelValue}>{displayText}</span>
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className={styles.tooltipContent}>
|
||||
<span>{displayText}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.copyButton}
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export default LabelTag;
|
||||
2
frontend/src/components/Alerts/LabelColumn/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './LabelColumn';
|
||||
export type { LabelColumnProps } from './LabelColumn';
|
||||
14
frontend/src/components/Alerts/LabelColumn/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const BADGE_GAP = 4;
|
||||
export const OVERFLOW_BADGE_WIDTH = 40;
|
||||
|
||||
export const BADGE_MAX_WIDTH = 180;
|
||||
export const BADGE_PADDING = 16;
|
||||
export const CHAR_WIDTH = 7;
|
||||
|
||||
export function estimateBadgeWidth(label: string, value?: string): number {
|
||||
const displayText = value ? `${label}: ${value}` : label;
|
||||
return Math.min(
|
||||
displayText.length * CHAR_WIDTH + BADGE_PADDING,
|
||||
BADGE_MAX_WIDTH,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-vanilla-400);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import NoResultsEmptyState from './NoResultsEmptyState';
|
||||
|
||||
describe('NoResultsEmptyState', () => {
|
||||
it('should render with default props', () => {
|
||||
render(<NoResultsEmptyState />);
|
||||
|
||||
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'No matching results',
|
||||
);
|
||||
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
|
||||
'No items match your current filters. Try adjusting your search criteria.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render with custom title and subtitle', () => {
|
||||
render(
|
||||
<NoResultsEmptyState title="Custom Title" subtitle="Custom Subtitle" />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'Custom Title',
|
||||
);
|
||||
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
|
||||
'Custom Subtitle',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not render clear button when onClear is not provided', () => {
|
||||
render(<NoResultsEmptyState />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('no-results-clear-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render clear button when onClear is provided', () => {
|
||||
const onClear = jest.fn();
|
||||
|
||||
render(<NoResultsEmptyState onClear={onClear} />);
|
||||
|
||||
expect(screen.getByTestId('no-results-clear-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-results-clear-button')).toHaveTextContent(
|
||||
'Clear Filters',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render custom clear button text', () => {
|
||||
render(
|
||||
<NoResultsEmptyState onClear={jest.fn()} clearButtonText="Reset All" />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('no-results-clear-button')).toHaveTextContent(
|
||||
'Reset All',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call onClear when clear button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = jest.fn();
|
||||
|
||||
render(<NoResultsEmptyState onClear={onClear} />);
|
||||
|
||||
await user.click(screen.getByTestId('no-results-clear-button'));
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { RefreshCw, Search } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from './NoResultsEmptyState.module.scss';
|
||||
|
||||
interface NoResultsEmptyStateProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
onClear?: () => void;
|
||||
clearButtonText?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
function NoResultsEmptyState({
|
||||
title = 'No matching results',
|
||||
subtitle = 'No items match your current filters. Try adjusting your search criteria.',
|
||||
onClear,
|
||||
clearButtonText = 'Clear Filters',
|
||||
onRefresh,
|
||||
}: NoResultsEmptyStateProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.emptyState} data-testid="no-results-empty-state">
|
||||
<Search className={styles.icon} size={16} />
|
||||
<div className={styles.title} data-testid="no-results-title">
|
||||
{title}
|
||||
</div>
|
||||
<div className={styles.subtitle} data-testid="no-results-subtitle">
|
||||
{subtitle}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
{onClear && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onClear}
|
||||
data-testid="no-results-clear-button"
|
||||
>
|
||||
{clearButtonText}
|
||||
</Button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={onRefresh}
|
||||
data-testid="no-results-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoResultsEmptyState;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './NoResultsEmptyState';
|
||||
32
frontend/src/components/Alerts/constants.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { BadgeColor } from '@signozhq/ui/badge';
|
||||
|
||||
export const STATE_ORDER = ['firing', 'pending', 'inactive', 'disabled'];
|
||||
export const SEVERITY_ORDER = ['critical', 'error', 'warning', 'info'];
|
||||
|
||||
export const STATE_LABELS: Record<string, string> = {
|
||||
firing: 'Firing',
|
||||
pending: 'Pending',
|
||||
inactive: 'OK',
|
||||
disabled: 'Disabled',
|
||||
};
|
||||
|
||||
export const STATE_COLORS: Record<string, string> = {
|
||||
firing: 'var(--bg-cherry-500)',
|
||||
pending: 'var(--bg-amber-500)',
|
||||
inactive: 'var(--bg-forest-500)',
|
||||
disabled: 'var(--l2-foreground)',
|
||||
};
|
||||
|
||||
export const SEVERITY_COLORS: Record<string, string> = {
|
||||
critical: 'var(--bg-cherry-500)',
|
||||
error: 'var(--bg-cherry-400)',
|
||||
warning: 'var(--bg-amber-500)',
|
||||
info: 'var(--bg-robin-500)',
|
||||
};
|
||||
|
||||
export const SEVERITY_BADGE_COLORS: Record<string, BadgeColor> = {
|
||||
critical: 'error',
|
||||
error: 'error',
|
||||
warning: 'warning',
|
||||
info: 'primary',
|
||||
};
|
||||
7
frontend/src/components/Alerts/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface FilterValue {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AlertWithLabels {
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
287
frontend/src/components/Alerts/utils.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
|
||||
import type { AlertWithLabels, FilterValue } from './types';
|
||||
import { filterByLabels, searchByLabels, sortByColumn } from './utils';
|
||||
|
||||
interface TestAlert extends AlertWithLabels {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const createAlert = (
|
||||
name: string,
|
||||
value: number,
|
||||
labels?: Record<string, string>,
|
||||
): TestAlert => ({
|
||||
name,
|
||||
value,
|
||||
labels,
|
||||
});
|
||||
|
||||
describe('sortByColumn', () => {
|
||||
const alerts: TestAlert[] = [
|
||||
createAlert('Alert C', 3),
|
||||
createAlert('Alert A', 1),
|
||||
createAlert('Alert B', 2),
|
||||
];
|
||||
|
||||
const getSortValue = (
|
||||
item: TestAlert,
|
||||
columnName: string,
|
||||
): string | number => {
|
||||
if (columnName === 'name') {
|
||||
return item.name;
|
||||
}
|
||||
if (columnName === 'value') {
|
||||
return item.value;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
it('should return items unchanged when no orderBy provided', () => {
|
||||
const result = sortByColumn(alerts, null, getSortValue);
|
||||
expect(result).toStrictEqual(alerts);
|
||||
});
|
||||
|
||||
it('should sort by string column ascending', () => {
|
||||
const orderBy: SortState = { columnName: 'name', order: 'asc' };
|
||||
const result = sortByColumn(alerts, orderBy, getSortValue);
|
||||
expect(result.map((a) => a.name)).toStrictEqual([
|
||||
'Alert A',
|
||||
'Alert B',
|
||||
'Alert C',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort by string column descending', () => {
|
||||
const orderBy: SortState = { columnName: 'name', order: 'desc' };
|
||||
const result = sortByColumn(alerts, orderBy, getSortValue);
|
||||
expect(result.map((a) => a.name)).toStrictEqual([
|
||||
'Alert C',
|
||||
'Alert B',
|
||||
'Alert A',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort by number column ascending', () => {
|
||||
const orderBy: SortState = { columnName: 'value', order: 'asc' };
|
||||
const result = sortByColumn(alerts, orderBy, getSortValue);
|
||||
expect(result.map((a) => a.value)).toStrictEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should sort by number column descending', () => {
|
||||
const orderBy: SortState = { columnName: 'value', order: 'desc' };
|
||||
const result = sortByColumn(alerts, orderBy, getSortValue);
|
||||
expect(result.map((a) => a.value)).toStrictEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
it('should use defaultSort when orderBy is null', () => {
|
||||
const defaultSort: SortState = { columnName: 'value', order: 'asc' };
|
||||
const result = sortByColumn(alerts, null, getSortValue, defaultSort);
|
||||
expect(result.map((a) => a.value)).toStrictEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should not mutate original array', () => {
|
||||
const original = [...alerts];
|
||||
const orderBy: SortState = { columnName: 'name', order: 'asc' };
|
||||
sortByColumn(alerts, orderBy, getSortValue);
|
||||
expect(alerts).toStrictEqual(original);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = sortByColumn(
|
||||
[],
|
||||
{ columnName: 'name', order: 'asc' },
|
||||
getSortValue,
|
||||
);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should handle equal values', () => {
|
||||
const duplicates = [
|
||||
createAlert('Same', 1),
|
||||
createAlert('Same', 1),
|
||||
createAlert('Same', 1),
|
||||
];
|
||||
const orderBy: SortState = { columnName: 'name', order: 'asc' };
|
||||
const result = sortByColumn(duplicates, orderBy, getSortValue);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchByLabels', () => {
|
||||
const alerts: TestAlert[] = [
|
||||
createAlert('CPU High', 1, { severity: 'critical', team: 'infra' }),
|
||||
createAlert('Memory Warning', 2, { severity: 'warning', team: 'backend' }),
|
||||
createAlert('Disk Full', 3, { severity: 'error', region: 'us-east' }),
|
||||
createAlert('Network Slow', 4, {}),
|
||||
createAlert('No Labels', 5),
|
||||
];
|
||||
|
||||
const getAlertName = (alert: TestAlert): string => alert.name;
|
||||
|
||||
it('should return all items when search is empty', () => {
|
||||
const result = searchByLabels(alerts, '', getAlertName);
|
||||
expect(result).toStrictEqual(alerts);
|
||||
});
|
||||
|
||||
it('should return all items when search is whitespace', () => {
|
||||
const result = searchByLabels(alerts, ' ', getAlertName);
|
||||
expect(result).toStrictEqual(alerts);
|
||||
});
|
||||
|
||||
it('should search by alert name', () => {
|
||||
const result = searchByLabels(alerts, 'CPU', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('CPU High');
|
||||
});
|
||||
|
||||
it('should search by alert name case-insensitive', () => {
|
||||
const result = searchByLabels(alerts, 'cpu', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('CPU High');
|
||||
});
|
||||
|
||||
it('should search by severity label', () => {
|
||||
const result = searchByLabels(alerts, 'critical', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('CPU High');
|
||||
});
|
||||
|
||||
it('should search by any label key', () => {
|
||||
const result = searchByLabels(alerts, 'team', getAlertName);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should search by any label value', () => {
|
||||
const result = searchByLabels(alerts, 'infra', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('CPU High');
|
||||
});
|
||||
|
||||
it('should handle alerts with no labels', () => {
|
||||
const result = searchByLabels(alerts, 'No Labels', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('No Labels');
|
||||
});
|
||||
|
||||
it('should handle partial matches', () => {
|
||||
const result = searchByLabels(alerts, 'warn', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('Memory Warning');
|
||||
});
|
||||
|
||||
it('should return empty for no matches', () => {
|
||||
const result = searchByLabels(alerts, 'nonexistent', getAlertName);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should trim search text', () => {
|
||||
const result = searchByLabels(alerts, ' CPU ', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('CPU High');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterByLabels', () => {
|
||||
const alerts: TestAlert[] = [
|
||||
createAlert('A1', 1, { severity: 'critical', team: 'infra', env: 'prod' }),
|
||||
createAlert('A2', 2, { severity: 'critical', team: 'backend', env: 'prod' }),
|
||||
createAlert('A3', 3, { severity: 'warning', team: 'infra', env: 'staging' }),
|
||||
createAlert('A4', 4, { severity: 'info', team: 'frontend', env: 'dev' }),
|
||||
createAlert('A5', 5, {}),
|
||||
createAlert('A6', 6),
|
||||
];
|
||||
|
||||
const createFilter = (value: string): FilterValue => ({ value });
|
||||
|
||||
it('should return all items when filters are empty', () => {
|
||||
const result = filterByLabels(alerts, []);
|
||||
expect(result).toStrictEqual(alerts);
|
||||
});
|
||||
|
||||
it('should return all items when filters is null-ish', () => {
|
||||
const result = filterByLabels(alerts, null as unknown as FilterValue[]);
|
||||
expect(result).toStrictEqual(alerts);
|
||||
});
|
||||
|
||||
it('should filter by single label', () => {
|
||||
const filters = [createFilter('severity:critical')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((a) => a.name)).toStrictEqual(['A1', 'A2']);
|
||||
});
|
||||
|
||||
it('should use OR logic for same key', () => {
|
||||
const filters = [
|
||||
createFilter('severity:critical'),
|
||||
createFilter('severity:warning'),
|
||||
];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((a) => a.name)).toStrictEqual(['A1', 'A2', 'A3']);
|
||||
});
|
||||
|
||||
it('should use AND logic for different keys', () => {
|
||||
const filters = [
|
||||
createFilter('severity:critical'),
|
||||
createFilter('team:infra'),
|
||||
];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('A1');
|
||||
});
|
||||
|
||||
it('should handle case-insensitive keys', () => {
|
||||
const filters = [createFilter('SEVERITY:critical')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive values', () => {
|
||||
const filters = [createFilter('severity:CRITICAL')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
const filters = [createFilter(' severity : critical ')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return empty for invalid filter format', () => {
|
||||
const filters = [createFilter('invalid')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore invalid filters mixed with valid', () => {
|
||||
const filters = [createFilter('invalid'), createFilter('severity:critical')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should exclude alerts without matching label key', () => {
|
||||
const filters = [createFilter('nonexistent:value')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should exclude alerts with no labels', () => {
|
||||
const filters = [createFilter('severity:critical')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result.every((a) => a.labels !== undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle complex AND/OR combinations', () => {
|
||||
const filters = [
|
||||
createFilter('env:prod'),
|
||||
createFilter('env:staging'),
|
||||
createFilter('team:infra'),
|
||||
];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((a) => a.name)).toStrictEqual(['A1', 'A3']);
|
||||
});
|
||||
});
|
||||
116
frontend/src/components/Alerts/utils.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
|
||||
import type { AlertWithLabels, FilterValue } from './types';
|
||||
|
||||
/**
|
||||
* Generic sort function for alert-like data
|
||||
*/
|
||||
export function sortByColumn<T>(
|
||||
items: T[],
|
||||
orderBy: SortState | null,
|
||||
getSortValue: (item: T, columnName: string) => string | number,
|
||||
defaultSort?: SortState,
|
||||
): T[] {
|
||||
const sortState = orderBy ?? defaultSort;
|
||||
if (!sortState) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const { columnName, order } = sortState;
|
||||
const multiplier = order === 'asc' ? 1 : -1;
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const aVal = getSortValue(a, columnName);
|
||||
const bVal = getSortValue(b, columnName);
|
||||
|
||||
if (aVal < bVal) {
|
||||
return -1 * multiplier;
|
||||
}
|
||||
if (aVal > bVal) {
|
||||
return 1 * multiplier;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search alerts/rules by name, severity, and all labels
|
||||
*/
|
||||
export function searchByLabels<T extends AlertWithLabels>(
|
||||
items: T[],
|
||||
searchText: string,
|
||||
getAlertName: (item: T) => string,
|
||||
): T[] {
|
||||
if (!searchText.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const value = searchText.toLowerCase().trim();
|
||||
|
||||
return items.filter((item) => {
|
||||
const alertName = getAlertName(item).toLowerCase();
|
||||
const severity = item.labels?.severity?.toLowerCase() ?? '';
|
||||
|
||||
const labelSearchString = Object.entries(item.labels ?? {})
|
||||
.map(([key, val]) => `${key} ${val}`)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return (
|
||||
alertName.includes(value) ||
|
||||
severity.includes(value) ||
|
||||
labelSearchString.includes(value)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter alerts by label key:value pairs
|
||||
* Same key uses OR logic, different keys use AND logic
|
||||
*/
|
||||
export function filterByLabels<T extends AlertWithLabels>(
|
||||
items: T[],
|
||||
selectedFilters: FilterValue[],
|
||||
): T[] {
|
||||
if (!selectedFilters?.length) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const validFilters = selectedFilters
|
||||
.map((e) => e.value)
|
||||
.filter((v) => v.split(':').length === 2);
|
||||
|
||||
if (!validFilters.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group values by key - same key uses OR, different keys use AND
|
||||
const filtersByKey = new Map<string, string[]>();
|
||||
validFilters.forEach((f) => {
|
||||
const [key, value] = f.split(':');
|
||||
const trimmedKey = key.trim().toLowerCase();
|
||||
const trimmedValue = value.trim().toLowerCase();
|
||||
const existing = filtersByKey.get(trimmedKey) ?? [];
|
||||
existing.push(trimmedValue);
|
||||
filtersByKey.set(trimmedKey, existing);
|
||||
});
|
||||
|
||||
return items.filter((item) => {
|
||||
if (!item.labels) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All keys must match (AND), any value per key can match (OR)
|
||||
return Array.from(filtersByKey.entries()).every(([filterKey, values]) => {
|
||||
// Case-insensitive key lookup
|
||||
const matchingKey = Object.keys(item.labels ?? {}).find(
|
||||
(k) => k.toLowerCase() === filterKey,
|
||||
);
|
||||
if (!matchingKey) {
|
||||
return false;
|
||||
}
|
||||
const labelValue = item.labels?.[matchingKey]?.toLowerCase();
|
||||
return values.some((v) => labelValue === v);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
.banner {
|
||||
height: var(--spacing-20);
|
||||
|
||||
a {
|
||||
color: var(--callout-warning-title);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: var(--callout-warning-title);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { PersistedAnnouncementBanner } from '@signozhq/ui/announcement-banner';
|
||||
|
||||
import styles from './NoAuthBanner.module.scss';
|
||||
|
||||
export function NoAuthBanner(): JSX.Element {
|
||||
return (
|
||||
<PersistedAnnouncementBanner
|
||||
type="warning"
|
||||
storageKey="no-auth-banner-v1"
|
||||
testId="no-auth-banner"
|
||||
className={styles.banner}
|
||||
>
|
||||
Impersonation mode: authentication is disabled. Anyone with access to this
|
||||
instance has admin privileges.{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/manage/administrator-guide/configuration/no-auth-mode/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</PersistedAnnouncementBanner>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoAuthBanner;
|
||||
@@ -1,24 +0,0 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { NoAuthBanner } from '../NoAuthBanner';
|
||||
|
||||
describe('NoAuthBanner', () => {
|
||||
it('renders the no-auth message', () => {
|
||||
render(<NoAuthBanner />);
|
||||
expect(
|
||||
screen.getByText(/Impersonation mode: authentication is disabled/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with the warning test id', () => {
|
||||
render(<NoAuthBanner />);
|
||||
expect(screen.getByTestId('no-auth-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a docs link that opens in a new tab', () => {
|
||||
render(<NoAuthBanner />);
|
||||
const link = screen.getByRole('link', { name: /learn more/i });
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noreferrer');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { KeyRound, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton, Table, Tooltip } from 'antd';
|
||||
import { Skeleton, Table } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
@@ -110,34 +110,28 @@ function buildColumns({
|
||||
onClick: (e): void => e.stopPropagation(),
|
||||
style: { cursor: 'default' },
|
||||
}),
|
||||
render: (_, record): JSX.Element => {
|
||||
const tooltipTitle = isDisabled ? 'Service account disabled' : 'Revoke Key';
|
||||
return (
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
buildAPIKeyDeletePermission(record.id),
|
||||
buildSADetachPermission(accountId),
|
||||
]}
|
||||
enabled={!isDisabled && !!accountId}
|
||||
render: (_, record): JSX.Element => (
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
buildAPIKeyDeletePermission(record.id),
|
||||
buildSADetachPermission(accountId),
|
||||
]}
|
||||
enabled={!isDisabled && !!accountId}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={(): void => {
|
||||
onRevokeClick(record.id);
|
||||
}}
|
||||
className="keys-tab__revoke-btn"
|
||||
>
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onRevokeClick(record.id);
|
||||
}}
|
||||
className="keys-tab__revoke-btn"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</AuthZTooltip>
|
||||
);
|
||||
},
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { ComboboxSimple, ComboboxSimpleItem } from '@signozhq/ui/combobox';
|
||||
import { ComboboxSimple } from '@signozhq/ui/combobox';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { Pagination } from '@signozhq/ui/pagination';
|
||||
import type { Row } from '@tanstack/react-table';
|
||||
@@ -51,7 +51,7 @@ import { useEffectiveData } from './useEffectiveData';
|
||||
import { useFlatItems } from './useFlatItems';
|
||||
import { useRowKeyData } from './useRowKeyData';
|
||||
import { useTableParams } from './useTableParams';
|
||||
import { buildTanstackColumnDef } from './utils';
|
||||
import { buildPageSizeItems, buildTanstackColumnDef } from './utils';
|
||||
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
@@ -66,14 +66,6 @@ const INCREASE_VIEWPORT_BY = { top: 500, bottom: 500 };
|
||||
|
||||
const noopColumnVisibility = (): void => {};
|
||||
|
||||
const paginationPageSizeItems: ComboboxSimpleItem[] = [10, 20, 30, 50, 100].map(
|
||||
(value) => ({
|
||||
value: value.toString(),
|
||||
label: value.toString(),
|
||||
displayValue: value.toString(),
|
||||
}),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function TanStackTableInner<TData>(
|
||||
{
|
||||
@@ -89,7 +81,6 @@ function TanStackTableInner<TData>(
|
||||
enableQueryParams,
|
||||
pagination,
|
||||
paginationClassname,
|
||||
onSort,
|
||||
onEndReached,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
@@ -102,6 +93,7 @@ function TanStackTableInner<TData>(
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
onSort,
|
||||
activeRowIndex,
|
||||
renderExpandedRow,
|
||||
getRowCanExpand,
|
||||
@@ -129,17 +121,22 @@ function TanStackTableInner<TData>(
|
||||
const {
|
||||
page,
|
||||
limit,
|
||||
setPage,
|
||||
setLimit,
|
||||
setPage: internalSetPage,
|
||||
setLimit: internalSetLimit,
|
||||
orderBy,
|
||||
setOrderBy: internalSetOrderBy,
|
||||
expanded,
|
||||
setExpanded,
|
||||
} = useTableParams(enableQueryParams, {
|
||||
page: pagination?.defaultPage,
|
||||
limit: pagination?.defaultLimit,
|
||||
limit: pagination?.defaultLimit ?? pagination?.calculatedPageSize ?? 10,
|
||||
});
|
||||
|
||||
const pageSizeItems = useMemo(
|
||||
() => buildPageSizeItems(pagination?.calculatedPageSize),
|
||||
[pagination?.calculatedPageSize],
|
||||
);
|
||||
|
||||
const setOrderBy = useCallback(
|
||||
(sort: SortState | null) => {
|
||||
internalSetOrderBy(sort);
|
||||
@@ -148,6 +145,23 @@ function TanStackTableInner<TData>(
|
||||
[internalSetOrderBy, onSort],
|
||||
);
|
||||
|
||||
const setPage = useCallback(
|
||||
(p: number) => {
|
||||
internalSetPage(p);
|
||||
pagination?.onPageChange?.(p);
|
||||
},
|
||||
[internalSetPage, pagination],
|
||||
);
|
||||
|
||||
const setLimit = useCallback(
|
||||
(l: number) => {
|
||||
internalSetLimit(l);
|
||||
internalSetPage(1);
|
||||
pagination?.onLimitChange?.(l);
|
||||
},
|
||||
[internalSetLimit, internalSetPage, pagination],
|
||||
);
|
||||
|
||||
const isGrouped = (groupBy?.length ?? 0) > 0;
|
||||
|
||||
const {
|
||||
@@ -621,6 +635,7 @@ function TanStackTableInner<TData>(
|
||||
{pagination.showPageSize !== false && (
|
||||
<div className={viewStyles.paginationPageSize}>
|
||||
<ComboboxSimple
|
||||
testId="pagination-page-size"
|
||||
value={limit?.toString()}
|
||||
defaultValue="10"
|
||||
onChange={(value): void => {
|
||||
@@ -631,7 +646,7 @@ function TanStackTableInner<TData>(
|
||||
pagination.onPageChange?.(1);
|
||||
}
|
||||
}}
|
||||
items={paginationPageSizeItems}
|
||||
items={pageSizeItems}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
|
||||
|
||||
@@ -23,12 +23,13 @@ jest.mock('../TanStackTable.module.scss', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ResizeObserver for combobox tests
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
};
|
||||
beforeAll(() => {
|
||||
window.ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
describe('TanStackTableView Integration', () => {
|
||||
describe('rendering', () => {
|
||||
@@ -402,6 +403,22 @@ describe('TanStackTableView Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves page from URL on initial mount', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
|
||||
enableQueryParams: true,
|
||||
},
|
||||
queryParams: { page: '3' },
|
||||
});
|
||||
|
||||
const nav = await screen.findByRole('navigation');
|
||||
const page3Button = within(nav).getByRole('button', { name: '3' });
|
||||
|
||||
// Page 3 should be active (from URL), not reset to defaultPage 1
|
||||
expect(page3Button).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('resets page to 1 when limit changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useCalculatedPageSize } from '../useCalculatedPageSize';
|
||||
|
||||
describe('useCalculatedPageSize', () => {
|
||||
it('returns containerRef and null calculatedPageSize initially', () => {
|
||||
const { result } = renderHook(() => useCalculatedPageSize());
|
||||
expect(result.current.containerRef).toBeDefined();
|
||||
expect(result.current.containerRef.current).toBeNull();
|
||||
expect(result.current.calculatedPageSize).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts custom config', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCalculatedPageSize({
|
||||
rowHeight: 50,
|
||||
headerHeight: 40,
|
||||
paginationHeight: 50,
|
||||
minPageSize: 3,
|
||||
maxPageSize: 20,
|
||||
}),
|
||||
);
|
||||
expect(result.current.containerRef).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
getPreferredPageSize,
|
||||
usePreferredPageSize,
|
||||
usePreferredPageSizeStore,
|
||||
} from '../usePreferredPageSize.store';
|
||||
|
||||
const STORAGE_KEY = 'test-table';
|
||||
const FULL_STORAGE_KEY = '@signoz/table-columns/test-table-preferred-page-size';
|
||||
|
||||
describe('usePreferredPageSize', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
it('returns null when no stored value exists', () => {
|
||||
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
|
||||
expect(result.current[0]).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when storageKey is undefined', () => {
|
||||
const { result } = renderHook(() => usePreferredPageSize(undefined));
|
||||
expect(result.current[0]).toBeNull();
|
||||
});
|
||||
|
||||
it('loads stored page size from localStorage', () => {
|
||||
localStorage.setItem(FULL_STORAGE_KEY, '25');
|
||||
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
|
||||
expect(result.current[0]).toBe(25);
|
||||
});
|
||||
|
||||
it('ignores invalid stored values', () => {
|
||||
localStorage.setItem(FULL_STORAGE_KEY, 'invalid');
|
||||
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
|
||||
expect(result.current[0]).toBeNull();
|
||||
});
|
||||
|
||||
it('persists page size to localStorage when set', () => {
|
||||
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
|
||||
|
||||
act(() => {
|
||||
result.current[1](30);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(30);
|
||||
expect(localStorage.getItem(FULL_STORAGE_KEY)).toBe('30');
|
||||
});
|
||||
|
||||
it('removes from localStorage when set to null', () => {
|
||||
localStorage.setItem(FULL_STORAGE_KEY, '25');
|
||||
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
|
||||
|
||||
act(() => {
|
||||
result.current[1](null);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBeNull();
|
||||
expect(localStorage.getItem(FULL_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing when storageKey is undefined and set is called', () => {
|
||||
const { result } = renderHook(() => usePreferredPageSize(undefined));
|
||||
|
||||
act(() => {
|
||||
result.current[1](30);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreferredPageSize', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
it('returns null when no stored value exists', () => {
|
||||
expect(getPreferredPageSize(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns stored value from localStorage', () => {
|
||||
localStorage.setItem(FULL_STORAGE_KEY, '42');
|
||||
expect(getPreferredPageSize(STORAGE_KEY)).toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from 'nuqs/adapters/testing';
|
||||
|
||||
import { useTableParams } from '../useTableParams';
|
||||
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
|
||||
|
||||
function createNuqsWrapper(
|
||||
queryParams?: Record<string, string>,
|
||||
@@ -543,3 +544,406 @@ describe('useTableParams (selective URL mode — partial config object)', () =>
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTableParams (cleanupOnUnmount option)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('clears URL params on unmount when cleanupOnUnmount is true', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
cleanupOnUnmount: true,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Set some values
|
||||
await act(async () => {
|
||||
result.current.setLimit(50);
|
||||
result.current.setPage(3);
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Verify values set
|
||||
expect(result.current.limit).toBe(50);
|
||||
expect(result.current.page).toBe(3);
|
||||
|
||||
// Unmount triggers cleanup
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Last URL update should have cleared params
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
expect(lastUpdate[0].searchParams.get('limit')).toBeNull();
|
||||
expect(lastUpdate[0].searchParams.get('page')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not clear URL params on unmount when cleanupOnUnmount is false', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
cleanupOnUnmount: false,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.setLimit(50);
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(50);
|
||||
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// No new URL updates after unmount (or same count)
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
expect(lastUpdate[0].searchParams.get('limit')).toBe('50');
|
||||
});
|
||||
|
||||
it('defaults cleanupOnUnmount to false', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams({ page: 'page', limit: 'limit' }, { page: 1, limit: 10 }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.setLimit(50);
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// URL should still have limit=50 (cleanup not triggered)
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
expect(lastUpdate[0].searchParams.get('limit')).toBe('50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTableParams (auto page size with storageKey)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit default when no URL, no calculated, no preferred', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: null,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Should use explicit default (10), NOT the internal DEFAULT_LIMIT (50)
|
||||
expect(result.current.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('uses calculatedPageSize when available and no preferred', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.limit).toBe(42);
|
||||
});
|
||||
|
||||
it('prefers stored value over calculatedPageSize', () => {
|
||||
// Pre-populate the store
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/test-table-preferred-page-size',
|
||||
'25',
|
||||
);
|
||||
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Should use preferred (25), not calculated (42)
|
||||
expect(result.current.limit).toBe(25);
|
||||
});
|
||||
|
||||
it('preserves URL limit over calculated and preferred', () => {
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/test-table-preferred-page-size',
|
||||
'25',
|
||||
);
|
||||
|
||||
const wrapper = createNuqsWrapper({ limit: '30' });
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Should use URL (30), not preferred (25) or calculated (42)
|
||||
expect(result.current.limit).toBe(30);
|
||||
});
|
||||
|
||||
it('persists user selection when different from calculated', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// User selects 30 (different from calculated 42)
|
||||
act(() => {
|
||||
result.current.setLimit(30);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(30);
|
||||
expect(
|
||||
localStorage.getItem('@signoz/table-columns/test-table-preferred-page-size'),
|
||||
).toBe('30');
|
||||
});
|
||||
|
||||
it('clears preference when user selects calculated value', () => {
|
||||
// Pre-set a preference
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/test-table-preferred-page-size',
|
||||
'30',
|
||||
);
|
||||
usePreferredPageSizeStore.setState({ tables: { 'test-table': 30 } });
|
||||
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// User selects 42 (same as calculated)
|
||||
act(() => {
|
||||
result.current.setLimit(42);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(42);
|
||||
// Preference should be cleared (null removes from storage)
|
||||
expect(
|
||||
localStorage.getItem('@signoz/table-columns/test-table-preferred-page-size'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns calculated value even before URL is synced', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Limit should be 42 (calculated) even if URL sync is async
|
||||
expect(result.current.limit).toBe(42);
|
||||
});
|
||||
|
||||
it('does not override URL when it already has a value', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({ limit: '30' }, onUrlUpdate);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Limit should stay at 30 (from URL), not change to 42
|
||||
expect(result.current.limit).toBe(30);
|
||||
});
|
||||
|
||||
it('handles calculatedPageSize changing from null to number', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ calculated }) =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table-2',
|
||||
calculatedPageSize: calculated,
|
||||
},
|
||||
),
|
||||
{ wrapper, initialProps: { calculated: null as number | null } },
|
||||
);
|
||||
|
||||
// Initially should use explicit default (10)
|
||||
expect(result.current.limit).toBe(10);
|
||||
|
||||
// When calculated becomes available, should update
|
||||
rerender({ calculated: 42 });
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Limit should now be 42
|
||||
expect(result.current.limit).toBe(42);
|
||||
});
|
||||
|
||||
it('keeps user selection when calculatedPageSize changes', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ calculated }) =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table-3',
|
||||
calculatedPageSize: calculated,
|
||||
},
|
||||
),
|
||||
{ wrapper, initialProps: { calculated: 42 as number | null } },
|
||||
);
|
||||
|
||||
expect(result.current.limit).toBe(42);
|
||||
|
||||
// User selects 30
|
||||
act(() => {
|
||||
result.current.setLimit(30);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(30);
|
||||
|
||||
// calculatedPageSize changes (e.g., window resize)
|
||||
rerender({ calculated: 50 });
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Should keep user's selection (30), not change to new calculated (50)
|
||||
expect(result.current.limit).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useQueryStates, parseAsInteger } from 'nuqs';
|
||||
import {
|
||||
NuqsTestingAdapter,
|
||||
OnUrlUpdateFunction,
|
||||
UrlUpdateEvent,
|
||||
} from 'nuqs/adapters/testing';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import { useTableParams } from '../useTableParams';
|
||||
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
|
||||
|
||||
function createNuqsWrapper(
|
||||
queryParams?: Record<string, string>,
|
||||
onUrlUpdate?: OnUrlUpdateFunction,
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
return function NuqsWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<NuqsTestingAdapter
|
||||
searchParams={queryParams}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
hasMemory
|
||||
>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const QUERY_PARAMS_CONFIG = {
|
||||
orderBy: 'orderBy',
|
||||
page: 'page',
|
||||
limit: 'limit',
|
||||
} as const;
|
||||
|
||||
type TableParamsWithCleanup = ReturnType<typeof useTableParams> & {
|
||||
clearParams: ReturnType<typeof useQueryStates>[1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Simulates the cleanup pattern used in ListAlertRules:
|
||||
* - Uses useQueryStates to clear URL params on unmount
|
||||
*/
|
||||
function useTableParamsWithCleanup(
|
||||
storageKey: string,
|
||||
calculatedPageSize: number | null,
|
||||
): TableParamsWithCleanup {
|
||||
const result = useTableParams(QUERY_PARAMS_CONFIG, {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey,
|
||||
calculatedPageSize,
|
||||
});
|
||||
|
||||
// This mirrors the cleanup effect in ListAlertRules
|
||||
const [, setTableQueryParams] = useQueryStates({
|
||||
[QUERY_PARAMS_CONFIG.orderBy]: parseAsJsonNoValidate(),
|
||||
[QUERY_PARAMS_CONFIG.page]: parseAsInteger,
|
||||
[QUERY_PARAMS_CONFIG.limit]: parseAsInteger,
|
||||
});
|
||||
|
||||
// Note: We can't use useEffect cleanup in tests easily, but we can verify
|
||||
// that calling setTableQueryParams with nulls does clear the URL
|
||||
|
||||
return { ...result, clearParams: setTableQueryParams };
|
||||
}
|
||||
|
||||
describe('URL cleanup pattern (simulating ListAlertRules behavior)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('setTableQueryParams with null values should clear URL params', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useTableParamsWithCleanup('alert-rules', 42),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Set limit to 100
|
||||
await act(async () => {
|
||||
result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(100);
|
||||
|
||||
// Verify limit=100 is in URL
|
||||
const limitAfterSet = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('limit'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(limitAfterSet).toBe('100');
|
||||
|
||||
// Simulate cleanup: clear all params
|
||||
await act(async () => {
|
||||
void result.current.clearParams({
|
||||
orderBy: null,
|
||||
page: null,
|
||||
limit: null,
|
||||
});
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Verify limit was cleared (last update should have limit=null or removed)
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
const finalLimit = lastUpdate[0].searchParams.get('limit');
|
||||
expect(finalLimit).toBeNull();
|
||||
});
|
||||
|
||||
it('cleanup should work even when limit was set from localStorage preference', async () => {
|
||||
// Pre-set preference
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/alert-rules-preferred-page-size',
|
||||
'100',
|
||||
);
|
||||
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useTableParamsWithCleanup('alert-rules', 42),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Should use preferred value
|
||||
expect(result.current.limit).toBe(100);
|
||||
|
||||
// Simulate cleanup
|
||||
await act(async () => {
|
||||
void result.current.clearParams({
|
||||
orderBy: null,
|
||||
page: null,
|
||||
limit: null,
|
||||
});
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// URL should be cleared
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
const finalLimit = lastUpdate[0].searchParams.get('limit');
|
||||
expect(finalLimit).toBeNull();
|
||||
});
|
||||
|
||||
it('demonstrates the bug: component without cleanup leaves limit in URL', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
// Mount TriggeredAlerts-like component (no cleanup)
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams(QUERY_PARAMS_CONFIG, {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'triggered-alerts',
|
||||
calculatedPageSize: 42,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Set limit to 100
|
||||
await act(async () => {
|
||||
result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(100);
|
||||
|
||||
// Unmount WITHOUT cleanup
|
||||
unmount();
|
||||
|
||||
// Verify limit=100 is STILL in URL (this is the bug!)
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
const finalLimit = lastUpdate[0].searchParams.get('limit');
|
||||
expect(finalLimit).toBe('100'); // BUG: limit persists after unmount
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import {
|
||||
NuqsTestingAdapter,
|
||||
OnUrlUpdateFunction,
|
||||
UrlUpdateEvent,
|
||||
} from 'nuqs/adapters/testing';
|
||||
|
||||
import { useTableParams } from '../useTableParams';
|
||||
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
|
||||
|
||||
function createNuqsWrapper(
|
||||
queryParams?: Record<string, string>,
|
||||
onUrlUpdate?: OnUrlUpdateFunction,
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
return function NuqsWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<NuqsTestingAdapter
|
||||
searchParams={queryParams}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
hasMemory
|
||||
>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('useTableParams navigation scenarios', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Tab navigation: Alert Rules -> Configuration -> Routing Policies', () => {
|
||||
it('preferred value from one table should NOT leak to URL when navigating away', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
// Simulate Alert Rules: user sets limit=100
|
||||
const alertRules = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'alert-rules',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// User selects limit=100
|
||||
act(() => {
|
||||
alertRules.result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(alertRules.result.current.limit).toBe(100);
|
||||
|
||||
// Verify it's persisted in localStorage
|
||||
expect(
|
||||
localStorage.getItem(
|
||||
'@signoz/table-columns/alert-rules-preferred-page-size',
|
||||
),
|
||||
).toBe('100');
|
||||
|
||||
// Simulate unmount (user navigates away)
|
||||
alertRules.unmount();
|
||||
|
||||
// At this point, the URL should NOT have limit=100 from alert-rules
|
||||
// when another component mounts with a different storageKey
|
||||
});
|
||||
|
||||
it('different tables with different storageKeys maintain separate preferences', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
// Alert Rules sets limit=100
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/alert-rules-preferred-page-size',
|
||||
'100',
|
||||
);
|
||||
// Triggered Alerts sets limit=25
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/triggered-alerts-preferred-page-size',
|
||||
'25',
|
||||
);
|
||||
|
||||
// Mount Triggered Alerts (simulating tab switch from Alert Rules)
|
||||
const triggeredAlerts = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'triggered-alerts',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Should use triggered-alerts preference (25), NOT alert-rules (100)
|
||||
expect(triggeredAlerts.result.current.limit).toBe(25);
|
||||
});
|
||||
|
||||
it('table without storageKey should NOT write preference to URL from another table', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
// Pre-set alert-rules preference
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/alert-rules-preferred-page-size',
|
||||
'100',
|
||||
);
|
||||
|
||||
// Start fresh with NO URL params
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
// Mount a table WITHOUT storageKey (simulating a simple table)
|
||||
const simpleTable = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
// NO storageKey
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Should use calculated (42), not alert-rules preference (100)
|
||||
expect(simpleTable.result.current.limit).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL cleanup on unmount', () => {
|
||||
it('URL params should be cleanable by consumer on unmount', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-cleanup',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Set some values
|
||||
act(() => {
|
||||
result.current.setLimit(50);
|
||||
result.current.setPage(3);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Verify URL was updated
|
||||
const limitUpdates = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('limit'))
|
||||
.filter(Boolean);
|
||||
expect(limitUpdates).toContain('50');
|
||||
|
||||
// Unmount (note: useTableParams itself doesn't cleanup URL - consumer should)
|
||||
unmount();
|
||||
|
||||
// Verify the component unmounted (no errors)
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parallel tables sharing URL params', () => {
|
||||
it('two tables using same URL params should see same values when URL pre-set', () => {
|
||||
const wrapper = createNuqsWrapper({ limit: '30' });
|
||||
|
||||
const table1 = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'table-1',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const table2 = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 20,
|
||||
storageKey: 'table-2',
|
||||
calculatedPageSize: 50,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Both should see URL value (30), not their defaults
|
||||
expect(table1.result.current.limit).toBe(30);
|
||||
expect(table2.result.current.limit).toBe(30);
|
||||
});
|
||||
|
||||
it('table mounted after setLimit should see updated URL value', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
|
||||
// Table1 mounts first
|
||||
const table1 = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'table-1',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(table1.result.current.limit).toBe(42);
|
||||
|
||||
// Table1 sets limit to 100
|
||||
act(() => {
|
||||
table1.result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(table1.result.current.limit).toBe(100);
|
||||
|
||||
// Table2 mounts AFTER table1 set limit=100 in URL
|
||||
// In test environment, URL state doesn't persist between renderHook calls
|
||||
// This test documents current behavior - each hook instance is independent
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL state initialization race conditions', () => {
|
||||
it('should not write preferred value to URL if URL already has value', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
// Pre-set preference
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/test-table-preferred-page-size',
|
||||
'100',
|
||||
);
|
||||
|
||||
// URL already has limit=30
|
||||
const wrapper = createNuqsWrapper({ limit: '30' }, onUrlUpdate);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Should use URL (30), not preferred (100)
|
||||
expect(result.current.limit).toBe(30);
|
||||
|
||||
// URL should NOT have been overwritten with 100
|
||||
const limitUpdates = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('limit'))
|
||||
.filter((v) => v === '100');
|
||||
expect(limitUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('URL init effect should write calculated value when URL empty', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
// Mount with no URL params
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'table-1',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Effects run after render, need to flush
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Should use calculated value
|
||||
expect(result.current.limit).toBe(42);
|
||||
|
||||
// The URL init effect writes to URL asynchronously
|
||||
// Check that limit is 42 (which it is from the limitDefault calculation)
|
||||
});
|
||||
|
||||
it('consumer cleanup effect is responsible for clearing URL params', () => {
|
||||
// This test documents that useTableParams does NOT auto-cleanup URL
|
||||
// Consumer components (like ListAlertRules) must use useEffect cleanup
|
||||
// to clear URL params when unmounting
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'table-1',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(100);
|
||||
|
||||
// Unmount - useTableParams does NOT clear URL
|
||||
unmount();
|
||||
|
||||
// Verify unmount happened without clearing URL
|
||||
// The last URL update should still have limit=100, not null
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
expect(lastUpdate[0].searchParams.get('limit')).toBe('100');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,10 @@ import TanStackTableText from './TanStackTableText';
|
||||
|
||||
export * from './TanStackTableStateContext';
|
||||
export * from './types';
|
||||
export * from './useCalculatedPageSize';
|
||||
export * from './useColumnState';
|
||||
export * from './useColumnStore';
|
||||
export * from './usePreferredPageSize.store';
|
||||
export * from './useTableParams';
|
||||
|
||||
/**
|
||||
@@ -192,6 +194,67 @@ export * from './useTableParams';
|
||||
* )}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example useTableParams — manages pagination state with URL sync and persistence
|
||||
*
|
||||
* The `useTableParams` hook handles page, limit, orderBy, and expanded state. It can sync
|
||||
* to URL params, persist user's page size preference, and auto-calculate page size from
|
||||
* container height.
|
||||
*
|
||||
* **Priority chain for limit**: URL > preferred (localStorage) > calculated > explicit default > 50
|
||||
*
|
||||
* ```tsx
|
||||
* import { useCalculatedPageSize, useTableParams } from 'components/TanStackTableView';
|
||||
*
|
||||
* const QUERY_PARAMS = { page: 'page', limit: 'limit', orderBy: 'orderBy' } as const;
|
||||
*
|
||||
* function MyTable({ data, columns }) {
|
||||
* // Auto-calculate page size based on container height
|
||||
* const { containerRef, calculatedPageSize } = useCalculatedPageSize({ rowHeight: 42 });
|
||||
*
|
||||
* // useTableParams options:
|
||||
* // - storageKey: persists user's page size selection to localStorage
|
||||
* // - calculatedPageSize: uses this when no URL/preferred value exists
|
||||
* // - cleanupOnUnmount: clears URL params when component unmounts
|
||||
* const { page, limit, setLimit, orderBy } = useTableParams(QUERY_PARAMS, {
|
||||
* page: 1,
|
||||
* limit: 10,
|
||||
* storageKey: 'my-table',
|
||||
* calculatedPageSize,
|
||||
* cleanupOnUnmount: true,
|
||||
* });
|
||||
*
|
||||
* const paginatedData = useMemo(() => {
|
||||
* const start = (page - 1) * limit;
|
||||
* return data.slice(start, start + limit);
|
||||
* }, [data, page, limit]);
|
||||
*
|
||||
* return (
|
||||
* <div ref={containerRef} style={{ height: '100%' }}>
|
||||
* <TanStackTable
|
||||
* data={paginatedData}
|
||||
* columns={columns}
|
||||
* enableQueryParams={QUERY_PARAMS}
|
||||
* pagination={{
|
||||
* total: data.length,
|
||||
* calculatedPageSize,
|
||||
* onLimitChange: setLimit,
|
||||
* }}
|
||||
* />
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* **useTableParams options:**
|
||||
* - `storageKey`: Persists user's page size to localStorage. When user selects a size
|
||||
* different from calculated, it's saved. Selecting calculated size clears preference.
|
||||
* - `calculatedPageSize`: From `useCalculatedPageSize`. Used as default when no URL/preferred.
|
||||
* - `cleanupOnUnmount`: Clears URL params (page, limit, orderBy, expanded) on unmount.
|
||||
* Use when navigating away should reset table state.
|
||||
*
|
||||
* **Pagination shows "Auto" option** when `calculatedPageSize` is passed, allowing users
|
||||
* to reset to auto-calculated size.
|
||||
*/
|
||||
const TanStackTable = Object.assign(TanStackTableBase, {
|
||||
Text: TanStackTableText,
|
||||
|
||||
@@ -74,6 +74,7 @@ export type TableColumnDef<
|
||||
min?: number | string;
|
||||
default?: number | string;
|
||||
max?: number | string;
|
||||
ignoreLastColumnFill?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -111,6 +112,14 @@ export type TableRowContext<TData> = {
|
||||
enableAlternatingRowColors?: boolean;
|
||||
};
|
||||
|
||||
export type AutoPageSizeConfig = {
|
||||
rowHeight?: number;
|
||||
headerHeight?: number;
|
||||
paginationHeight?: number;
|
||||
minPageSize?: number;
|
||||
maxPageSize?: number;
|
||||
};
|
||||
|
||||
export type PaginationProps = {
|
||||
total: number;
|
||||
defaultPage?: number;
|
||||
@@ -123,6 +132,12 @@ export type PaginationProps = {
|
||||
onLimitChange?: (limit: number) => void;
|
||||
showTotalCount?: boolean;
|
||||
totalCountLabel?: string;
|
||||
/**
|
||||
* Auto-calculated page size for the current container.
|
||||
* When set, shows as "Auto (N)" option in the page size dropdown.
|
||||
* Consumer is responsible for calculating this via useCalculatedPageSize.
|
||||
*/
|
||||
calculatedPageSize?: number | null;
|
||||
};
|
||||
|
||||
export type TanstackTableQueryParamsConfig = {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AutoPageSizeConfig } from './types';
|
||||
|
||||
const DEFAULT_ROW_HEIGHT = 36;
|
||||
const DEFAULT_HEADER_HEIGHT = 36;
|
||||
const DEFAULT_PAGINATION_HEIGHT = 62;
|
||||
const MIN_PAGE_SIZE = 5;
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
export type UseCalculatedPageSizeResult = {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
calculatedPageSize: number | null;
|
||||
};
|
||||
|
||||
export function useCalculatedPageSize(
|
||||
config?: AutoPageSizeConfig,
|
||||
): UseCalculatedPageSizeResult {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [calculatedPageSize, setCalculatedPageSize] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const rowHeight = config?.rowHeight ?? DEFAULT_ROW_HEIGHT;
|
||||
const headerHeight = config?.headerHeight ?? DEFAULT_HEADER_HEIGHT;
|
||||
const paginationHeight = config?.paginationHeight ?? DEFAULT_PAGINATION_HEIGHT;
|
||||
const minPageSize = config?.minPageSize ?? MIN_PAGE_SIZE;
|
||||
const maxPageSize = config?.maxPageSize ?? MAX_PAGE_SIZE;
|
||||
|
||||
const calculatePageSize = useCallback(
|
||||
(containerHeight: number): number => {
|
||||
const availableHeight = containerHeight - headerHeight - paginationHeight;
|
||||
const rawPageSize = Math.floor(availableHeight / rowHeight);
|
||||
return Math.min(maxPageSize, Math.max(minPageSize, rawPageSize));
|
||||
},
|
||||
[rowHeight, headerHeight, paginationHeight, minPageSize, maxPageSize],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height } = entry.contentRect;
|
||||
if (height > 0) {
|
||||
const newPageSize = calculatePageSize(height);
|
||||
setCalculatedPageSize((prev) =>
|
||||
prev !== newPageSize ? newPageSize : prev,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
|
||||
const { height } = container.getBoundingClientRect();
|
||||
if (height > 0) {
|
||||
setCalculatedPageSize(calculatePageSize(height));
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [calculatePageSize]);
|
||||
|
||||
return { containerRef, calculatedPageSize };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import get from 'api/browser/localstorage/get';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import { create } from 'zustand';
|
||||
|
||||
const STORAGE_PREFIX = '@signoz/table-columns/';
|
||||
const STORAGE_SUFFIX = '-preferred-page-size';
|
||||
|
||||
type PreferredPageSizeState = {
|
||||
tables: Record<string, number | null>;
|
||||
setPreferredPageSize: (storageKey: string, pageSize: number | null) => void;
|
||||
};
|
||||
|
||||
const getStorageKey = (tableKey: string): string =>
|
||||
`${STORAGE_PREFIX}${tableKey}${STORAGE_SUFFIX}`;
|
||||
|
||||
const loadFromStorage = (tableKey: string): number | null => {
|
||||
try {
|
||||
const raw = get(getStorageKey(tableKey));
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseInt(raw, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const saveToStorage = (tableKey: string, pageSize: number | null): void => {
|
||||
try {
|
||||
const key = getStorageKey(tableKey);
|
||||
if (pageSize === null) {
|
||||
remove(key);
|
||||
} else {
|
||||
set(key, String(pageSize));
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
export const usePreferredPageSizeStore = create<PreferredPageSizeState>()(
|
||||
(set, get) => ({
|
||||
tables: {},
|
||||
setPreferredPageSize: (storageKey, pageSize): void => {
|
||||
set({ tables: { ...get().tables, [storageKey]: pageSize } });
|
||||
saveToStorage(storageKey, pageSize);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export function usePreferredPageSize(
|
||||
storageKey: string | undefined,
|
||||
): [number | null, (pageSize: number | null) => void] {
|
||||
const pageSize = usePreferredPageSizeStore((s) => {
|
||||
if (!storageKey) {
|
||||
return null;
|
||||
}
|
||||
const cached = s.tables[storageKey];
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
return loadFromStorage(storageKey);
|
||||
});
|
||||
|
||||
const setPageSize = usePreferredPageSizeStore((s) => s.setPreferredPageSize);
|
||||
|
||||
const setPreferred = (size: number | null): void => {
|
||||
if (storageKey) {
|
||||
setPageSize(storageKey, size);
|
||||
}
|
||||
};
|
||||
|
||||
return [pageSize, setPreferred];
|
||||
}
|
||||
|
||||
export function getPreferredPageSize(storageKey: string): number | null {
|
||||
// oxlint-disable-next-line signoz/no-zustand-getstate-in-hooks
|
||||
const state = usePreferredPageSizeStore.getState();
|
||||
const cached = state.tables[storageKey];
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const stored = loadFromStorage(storageKey);
|
||||
if (stored !== null) {
|
||||
state.setPreferredPageSize(storageKey, stored);
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import { SortState, TanstackTableQueryParamsConfig } from './types';
|
||||
import { usePreferredPageSize } from './usePreferredPageSize.store';
|
||||
|
||||
const NUQS_OPTIONS = { history: 'push' as const };
|
||||
const DEFAULT_PAGE = 1;
|
||||
@@ -20,9 +21,15 @@ type Defaults = {
|
||||
limit?: number;
|
||||
orderBy?: SortState | null;
|
||||
expanded?: ExpandedState;
|
||||
/** Storage key for persisting user's page size preference */
|
||||
storageKey?: string;
|
||||
/** Auto-calculated page size from container. URL initializes with this when available. */
|
||||
calculatedPageSize?: number | null;
|
||||
/** Clear URL params on unmount. Useful when navigating away from table views. */
|
||||
cleanupOnUnmount?: boolean;
|
||||
};
|
||||
|
||||
type TableParamsResult = {
|
||||
export type TableParamsResult = {
|
||||
page: number;
|
||||
limit: number;
|
||||
orderBy: SortState | null;
|
||||
@@ -99,15 +106,23 @@ export function useTableParams(
|
||||
? (enableQueryParams.expanded ?? URL_KEYS_DEFAULT.expanded)
|
||||
: URL_KEYS_DEFAULT.expanded;
|
||||
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
|
||||
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
|
||||
const orderByDefault = defaults?.orderBy ?? null;
|
||||
const expandedDefault = defaults?.expanded ?? {};
|
||||
const storageKey = defaults?.storageKey;
|
||||
const calculatedPageSize = defaults?.calculatedPageSize;
|
||||
const cleanupOnUnmount = defaults?.cleanupOnUnmount ?? false;
|
||||
const expandedDefaultArray = useMemo(
|
||||
() => expandedStateToArray(expandedDefault),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const [preferredPageSize, setPreferredPageSize] =
|
||||
usePreferredPageSize(storageKey);
|
||||
|
||||
const limitDefault =
|
||||
preferredPageSize ?? calculatedPageSize ?? defaults?.limit ?? DEFAULT_LIMIT;
|
||||
|
||||
const [localPage, setLocalPage] = useState(pageDefault);
|
||||
const [localLimit, setLocalLimit] = useState(limitDefault);
|
||||
const [localOrderBy, setLocalOrderBy] = useState<SortState | null>(
|
||||
@@ -120,9 +135,71 @@ export function useTableParams(
|
||||
pageQueryParam,
|
||||
parseAsInteger.withDefault(pageDefault).withOptions(NUQS_OPTIONS),
|
||||
);
|
||||
const [urlLimit, setUrlLimit] = useQueryState(
|
||||
const [urlLimitRaw, setUrlLimitRaw] = useQueryState(
|
||||
limitQueryParam,
|
||||
parseAsInteger.withDefault(limitDefault).withOptions(NUQS_OPTIONS),
|
||||
parseAsInteger.withOptions(NUQS_OPTIONS),
|
||||
);
|
||||
|
||||
// Track if URL had limit on initial mount
|
||||
const hadUrlLimitOnMountRef = useRef<boolean | null>(null);
|
||||
if (hadUrlLimitOnMountRef.current === null) {
|
||||
hadUrlLimitOnMountRef.current = urlLimitRaw !== null;
|
||||
}
|
||||
const hadUrlLimit = hadUrlLimitOnMountRef.current ?? false;
|
||||
|
||||
const urlLimit = urlLimitRaw ?? limitDefault;
|
||||
|
||||
// Initialize URL with preferred/calculated when available (only if URL was empty)
|
||||
const hasInitializedUrlRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!useUrlForLimit || hasInitializedUrlRef.current || hadUrlLimit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preferredPageSize !== null) {
|
||||
hasInitializedUrlRef.current = true;
|
||||
void setUrlLimitRaw(preferredPageSize);
|
||||
return;
|
||||
}
|
||||
if (calculatedPageSize != null) {
|
||||
hasInitializedUrlRef.current = true;
|
||||
void setUrlLimitRaw(calculatedPageSize);
|
||||
}
|
||||
}, [
|
||||
useUrlForLimit,
|
||||
calculatedPageSize,
|
||||
preferredPageSize,
|
||||
hadUrlLimit,
|
||||
setUrlLimitRaw,
|
||||
]);
|
||||
|
||||
// Wrapped setLimit that persists preference when different from calculated
|
||||
const setUrlLimit = useCallback(
|
||||
(newLimit: number): void => {
|
||||
if (storageKey) {
|
||||
if (newLimit !== calculatedPageSize) {
|
||||
setPreferredPageSize(newLimit);
|
||||
} else {
|
||||
setPreferredPageSize(null);
|
||||
}
|
||||
}
|
||||
void setUrlLimitRaw(newLimit);
|
||||
},
|
||||
[storageKey, calculatedPageSize, setPreferredPageSize, setUrlLimitRaw],
|
||||
);
|
||||
|
||||
const setLocalLimitWithPersist = useCallback(
|
||||
(newLimit: number): void => {
|
||||
if (storageKey) {
|
||||
if (newLimit !== calculatedPageSize) {
|
||||
setPreferredPageSize(newLimit);
|
||||
} else {
|
||||
setPreferredPageSize(null);
|
||||
}
|
||||
}
|
||||
setLocalLimit(newLimit);
|
||||
},
|
||||
[storageKey, calculatedPageSize, setPreferredPageSize],
|
||||
);
|
||||
const [urlOrderBy, setUrlOrderBy] = useQueryState(
|
||||
orderByQueryParam,
|
||||
@@ -155,7 +232,7 @@ export function useTableParams(
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(urlExpandedRef.current)
|
||||
: updaterOrValue;
|
||||
setUrlExpandedArray(expandedStateToArray(newState));
|
||||
void setUrlExpandedArray(expandedStateToArray(newState));
|
||||
},
|
||||
[setUrlExpandedArray],
|
||||
);
|
||||
@@ -172,21 +249,53 @@ export function useTableParams(
|
||||
[],
|
||||
);
|
||||
|
||||
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
|
||||
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
|
||||
const prevOrderByRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (useUrlForPage) {
|
||||
setUrlPage(pageDefault);
|
||||
} else {
|
||||
setLocalPage(pageDefault);
|
||||
// Only reset page when orderBy actually changes, not on initial mount
|
||||
if (
|
||||
prevOrderByRef.current !== null &&
|
||||
prevOrderByRef.current !== orderByUrlMemoKey
|
||||
) {
|
||||
if (useUrlForPage) {
|
||||
void setUrlPage(pageDefault);
|
||||
} else {
|
||||
setLocalPage(pageDefault);
|
||||
}
|
||||
}
|
||||
prevOrderByRef.current = orderByUrlMemoKey;
|
||||
}, [useUrlForPage, orderByUrlMemoKey, pageDefault, setUrlPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cleanupOnUnmount) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
if (useUrlForPage) {
|
||||
void setUrlPage(null);
|
||||
}
|
||||
if (useUrlForLimit) {
|
||||
void setUrlLimitRaw(null);
|
||||
}
|
||||
if (useUrlForOrderBy) {
|
||||
void setUrlOrderBy(null);
|
||||
}
|
||||
if (useUrlForExpanded) {
|
||||
void setUrlExpandedArray(null);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
cleanupOnUnmount,
|
||||
useUrlForPage,
|
||||
orderByDefaultMemoKey,
|
||||
orderByUrlMemoKey,
|
||||
pageDefault,
|
||||
useUrlForLimit,
|
||||
useUrlForOrderBy,
|
||||
useUrlForExpanded,
|
||||
setUrlPage,
|
||||
setUrlLimitRaw,
|
||||
setUrlOrderBy,
|
||||
setUrlExpandedArray,
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -195,7 +304,7 @@ export function useTableParams(
|
||||
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
|
||||
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
|
||||
setPage: useUrlForPage ? setUrlPage : setLocalPage,
|
||||
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimit,
|
||||
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist,
|
||||
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
|
||||
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { RowKeyData, TableColumnDef } from './types';
|
||||
import { ComboboxSimpleItem } from '@signozhq/ui/combobox';
|
||||
|
||||
export const getColumnId = <TData>(column: TableColumnDef<TData>): string =>
|
||||
column.id;
|
||||
@@ -34,7 +35,7 @@ export const getColumnWidthStyle = <TData>(
|
||||
isLastColumn?: boolean,
|
||||
): CSSProperties => {
|
||||
// Last column always fills remaining space
|
||||
if (isLastColumn) {
|
||||
if (isLastColumn && column?.width?.ignoreLastColumnFill !== true) {
|
||||
return {
|
||||
width: '100%',
|
||||
minWidth: persistedWidth ?? column?.width?.min,
|
||||
@@ -145,3 +146,31 @@ export function buildTanstackColumnDef<TData>(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZES = [10, 20, 30, 50, 100];
|
||||
|
||||
export function buildPageSizeItems(
|
||||
calculatedSize?: number | null,
|
||||
): ComboboxSimpleItem[] {
|
||||
const items: ComboboxSimpleItem[] = [];
|
||||
|
||||
if (calculatedSize) {
|
||||
items.push({
|
||||
value: calculatedSize.toString(),
|
||||
label: `Auto (${calculatedSize})`,
|
||||
displayValue: calculatedSize.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
for (const size of DEFAULT_PAGE_SIZES) {
|
||||
if (size !== calculatedSize) {
|
||||
items.push({
|
||||
value: size.toString(),
|
||||
label: size.toString(),
|
||||
displayValue: size.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ import listUserPreferences from 'api/v1/user/preferences/list';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import Header from 'components/Header/Header';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import NoAuthBanner from 'components/NoAuthBanner/NoAuthBanner';
|
||||
import { getIsNoAuthMode } from 'utils/noAuthMode';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -198,7 +196,7 @@ export default function Home(): JSX.Element {
|
||||
const { mutate: updateUserPreference } = useMutation(updateUserPreferenceAPI, {
|
||||
onSuccess: () => {
|
||||
setUpdatingUserPreferences(false);
|
||||
void refetchUserPreferences();
|
||||
refetchUserPreferences();
|
||||
},
|
||||
onError: () => {
|
||||
setUpdatingUserPreferences(false);
|
||||
@@ -206,7 +204,7 @@ export default function Home(): JSX.Element {
|
||||
});
|
||||
|
||||
const handleWillDoThisLater = (): void => {
|
||||
void logEvent('Welcome Checklist: Will do this later clicked', {});
|
||||
logEvent('Welcome Checklist: Will do this later clicked', {});
|
||||
setUpdatingUserPreferences(true);
|
||||
|
||||
updateUserPreference({
|
||||
@@ -273,12 +271,11 @@ export default function Home(): JSX.Element {
|
||||
}, [metricsOnboardingData, handleUpdateChecklistDoneItem]);
|
||||
|
||||
useEffect(() => {
|
||||
void logEvent('Homepage: Visited', {});
|
||||
logEvent('Homepage: Visited', {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
{getIsNoAuthMode() && <NoAuthBanner />}
|
||||
<div className="sticky-header">
|
||||
<Header
|
||||
leftComponent={
|
||||
@@ -301,9 +298,9 @@ export default function Home(): JSX.Element {
|
||||
autoAdjustOverflow
|
||||
onOpenChange={(visible): void => {
|
||||
if (visible) {
|
||||
void logEvent('Welcome Checklist: Expanded', {});
|
||||
logEvent('Welcome Checklist: Expanded', {});
|
||||
} else {
|
||||
void logEvent('Welcome Checklist: Minimized', {});
|
||||
logEvent('Welcome Checklist: Minimized', {});
|
||||
}
|
||||
}}
|
||||
content={renderWelcomeChecklistModal()}
|
||||
@@ -356,7 +353,7 @@ export default function Home(): JSX.Element {
|
||||
className="active-ingestion-card-actions"
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
void logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Logs',
|
||||
});
|
||||
safeNavigate(ROUTES.LOGS_EXPLORER, {
|
||||
@@ -365,7 +362,7 @@ export default function Home(): JSX.Element {
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Logs',
|
||||
});
|
||||
history.push(ROUTES.LOGS_EXPLORER);
|
||||
@@ -399,7 +396,7 @@ export default function Home(): JSX.Element {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
void logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Traces',
|
||||
});
|
||||
safeNavigate(ROUTES.TRACES_EXPLORER, {
|
||||
@@ -408,7 +405,7 @@ export default function Home(): JSX.Element {
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Traces',
|
||||
});
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
@@ -442,7 +439,7 @@ export default function Home(): JSX.Element {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
void logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Metrics',
|
||||
});
|
||||
safeNavigate(ROUTES.METRICS_EXPLORER, {
|
||||
@@ -451,7 +448,7 @@ export default function Home(): JSX.Element {
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Metrics',
|
||||
});
|
||||
history.push(ROUTES.METRICS_EXPLORER);
|
||||
@@ -499,7 +496,7 @@ export default function Home(): JSX.Element {
|
||||
className="periscope-btn secondary"
|
||||
prefix={<Wrench size={14} />}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
void logEvent('Homepage: Explore clicked', {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
source: 'Logs',
|
||||
});
|
||||
safeNavigate(ROUTES.LOGS_EXPLORER, {
|
||||
@@ -516,7 +513,7 @@ export default function Home(): JSX.Element {
|
||||
className="periscope-btn secondary"
|
||||
prefix={<Wrench size={14} />}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
void logEvent('Homepage: Explore clicked', {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
source: 'Traces',
|
||||
});
|
||||
safeNavigate(ROUTES.TRACES_EXPLORER, {
|
||||
@@ -533,7 +530,7 @@ export default function Home(): JSX.Element {
|
||||
className="periscope-btn secondary"
|
||||
prefix={<Wrench size={14} />}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
void logEvent('Homepage: Explore clicked', {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
source: 'Metrics',
|
||||
});
|
||||
safeNavigate(ROUTES.METRICS_EXPLORER_EXPLORER, {
|
||||
@@ -572,7 +569,7 @@ export default function Home(): JSX.Element {
|
||||
className="periscope-btn secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
void logEvent('Homepage: Explore clicked', {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
source: 'Dashboards',
|
||||
});
|
||||
safeNavigate(ROUTES.ALL_DASHBOARD, {
|
||||
@@ -617,7 +614,7 @@ export default function Home(): JSX.Element {
|
||||
className="periscope-btn secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
void logEvent('Homepage: Explore clicked', {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
source: 'Alerts',
|
||||
});
|
||||
safeNavigate(ROUTES.ALERTS_NEW, {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ArrowRight } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './AlertsEmptyState.module.scss';
|
||||
|
||||
interface AlertInfoCardProps {
|
||||
header: string;
|
||||
subheader: string;
|
||||
@@ -17,17 +19,17 @@ function AlertInfoCard({
|
||||
}: AlertInfoCardProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className="alert-info-card"
|
||||
className={styles.alertInfoCard}
|
||||
onClick={(): void => {
|
||||
onClick();
|
||||
openInNewTab(link);
|
||||
}}
|
||||
>
|
||||
<div className="alert-card-text">
|
||||
<Typography.Text className="alert-card-text-header">
|
||||
<div className={styles.alertCardText}>
|
||||
<Typography.Text className={styles.alertCardTextHeader}>
|
||||
{header}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="alert-card-text-subheader">
|
||||
<Typography.Text className={styles.alertCardTextSubheader}>
|
||||
{subheader}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
.alertListContainer {
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.alertListViewContent {
|
||||
width: calc(100% - 30px);
|
||||
max-width: 836px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.emptyAlertInfoContainer {
|
||||
display: flex;
|
||||
padding: 71px 193.5px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--l1-border);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.alertContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.icons {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.emptyAlertAction {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.emptyInfo {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.actionContainer {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.buttonContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.getStartedText {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
|
||||
:global(.ant-divider)::before,
|
||||
:global(.ant-divider)::after {
|
||||
border-bottom: 2px dotted var(--l1-border);
|
||||
border-top: 2px dotted var(--l1-border);
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
:global(.ant-typography) {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.alertInfoCard {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.alertCardText {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.alertCardTextHeader {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.alertCardTextSubheader {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.infoText {
|
||||
color: var(--primary);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.06px;
|
||||
margin: 0 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.infoLinkContainer {
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
.alert-list-container {
|
||||
margin-top: 104px;
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
.alert-list-view-content {
|
||||
width: calc(100% - 30px);
|
||||
max-width: 836px;
|
||||
|
||||
.alert-list-title-container {
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 28px; /* 155.556% */
|
||||
letter-spacing: -0.09px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-alert-info-container {
|
||||
display: flex;
|
||||
padding: 71px 193.5px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--l1-border);
|
||||
margin-top: 16px;
|
||||
|
||||
.alert-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.icons {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.empty-alert-action {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px; /* 171.429% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.empty-info {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-container {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.get-started-text {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
|
||||
.ant-divider::before,
|
||||
.ant-divider::after {
|
||||
border-bottom: 2px dotted var(--l1-border);
|
||||
border-top: 2px dotted var(--l1-border);
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 166.667% */
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-info-card {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.alert-card-text {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-direction: column;
|
||||
|
||||
.alert-card-text-header {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.alert-card-text-subheader {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: var(--bg-robin-400) !important;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 16px; /* 133.333% */
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
.info-link-container {
|
||||
.anticon {
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
|
||||
:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button, Divider, Flex } from 'antd';
|
||||
import { Plus, RefreshCw } from '@signozhq/icons';
|
||||
import { Divider } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -16,7 +17,7 @@ import AlertInfoCard from './AlertInfoCard';
|
||||
import { ALERT_CARDS, ALERT_INFO_LINKS } from './alertLinks';
|
||||
import InfoLinkText from './InfoLinkText';
|
||||
|
||||
import './AlertsEmptyState.styles.scss';
|
||||
import styles from './AlertsEmptyState.module.scss';
|
||||
|
||||
const alertLogEvents = (
|
||||
title: string,
|
||||
@@ -28,10 +29,16 @@ const alertLogEvents = (
|
||||
page: 'Alert empty state page',
|
||||
};
|
||||
|
||||
logEvent(title, dataSource ? { ...attributes, dataSource } : attributes);
|
||||
void logEvent(title, dataSource ? { ...attributes, dataSource } : attributes);
|
||||
};
|
||||
|
||||
export function AlertsEmptyState(): JSX.Element {
|
||||
interface AlertsEmptyStateProps {
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function AlertsEmptyState({
|
||||
onRefresh,
|
||||
}: AlertsEmptyStateProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const [addNewAlert] = useComponentPermission(
|
||||
@@ -50,45 +57,51 @@ export function AlertsEmptyState(): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="alert-list-container">
|
||||
<div className="alert-list-view-content">
|
||||
<div className="alert-list-title-container">
|
||||
<Typography.Title className="title">Alert Rules</Typography.Title>
|
||||
<Typography.Text className="subtitle">
|
||||
<div className={styles.alertListContainer}>
|
||||
<div className={styles.alertListViewContent}>
|
||||
<div>
|
||||
<Typography.Title className={styles.title}>Alert Rules</Typography.Title>
|
||||
<Typography.Text className={styles.subtitle}>
|
||||
Create and manage alert rules for your resources.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<section className="empty-alert-info-container">
|
||||
<div className="alert-content">
|
||||
<section className="heading">
|
||||
<section className={styles.emptyAlertInfoContainer}>
|
||||
<div className={styles.alertContent}>
|
||||
<section className={styles.heading}>
|
||||
<img
|
||||
src={alertEmojiUrl}
|
||||
alt="alert-header"
|
||||
style={{ height: '32px', width: '32px' }}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text className="empty-info">
|
||||
<Typography.Text className={styles.emptyInfo}>
|
||||
No Alert rules yet.{' '}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="empty-alert-action">
|
||||
<br />
|
||||
<Typography.Text className={styles.emptyAlertAction}>
|
||||
Create an Alert Rule to get started
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</section>
|
||||
<div className="action-container">
|
||||
<Button
|
||||
className="add-alert-btn"
|
||||
onClick={onClickNewAlertHandler}
|
||||
disabled={!addNewAlert}
|
||||
loading={loading}
|
||||
type="primary"
|
||||
data-testid="add-alert"
|
||||
>
|
||||
<Flex align="center" justify="center" gap={4}>
|
||||
<Plus size="md" />
|
||||
New Alert Rule
|
||||
</Flex>
|
||||
</Button>
|
||||
<div className={styles.actionContainer}>
|
||||
<div className={styles.buttonGroup}>
|
||||
<Button
|
||||
onClick={onClickNewAlertHandler}
|
||||
disabled={!addNewAlert}
|
||||
loading={loading}
|
||||
data-testid="add-alert"
|
||||
>
|
||||
<span className={styles.buttonContent}>
|
||||
<Plus size="md" />
|
||||
New Alert Rule
|
||||
</span>
|
||||
</Button>
|
||||
{onRefresh && (
|
||||
<Button onClick={onRefresh} prefix={<RefreshCw />} color="secondary">
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<InfoLinkText
|
||||
infoText="Watch a tutorial on creating a sample alert"
|
||||
link="https://youtu.be/xjxNIqiv4_M"
|
||||
@@ -123,11 +136,9 @@ export function AlertsEmptyState(): JSX.Element {
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<div className="get-started-text">
|
||||
<div className={styles.getStartedText}>
|
||||
<Divider>
|
||||
<Typography.Text className="get-started-text">
|
||||
Or get started with these sample alerts
|
||||
</Typography.Text>
|
||||
<Typography.Text>Or get started with these sample alerts</Typography.Text>
|
||||
</Divider>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Flex } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './AlertsEmptyState.module.scss';
|
||||
|
||||
interface InfoLinkTextProps {
|
||||
infoText: string;
|
||||
link: string;
|
||||
@@ -24,12 +26,12 @@ function InfoLinkText({
|
||||
onClick();
|
||||
openInNewTab(link);
|
||||
}}
|
||||
className="info-link-container"
|
||||
className={styles.infoLinkContainer}
|
||||
>
|
||||
{leftIconVisible && <CirclePlay size="md" />}
|
||||
<Typography.Text className="info-text">{infoText}</Typography.Text>
|
||||
{leftIconVisible && <CirclePlay size={16} />}
|
||||
<Typography.Text className={styles.infoText}>{infoText}</Typography.Text>
|
||||
{rightIconVisible && (
|
||||
<ArrowRight size="md" style={{ transform: 'rotate(315deg)' }} />
|
||||
<ArrowRight size={16} style={{ transform: 'rotate(315deg)' }} />
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import type { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { deleteRuleByID } from 'api/generated/services/rules';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { State } from 'hooks/useFetch';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { PayloadProps as DeleteAlertPayloadProps } from 'types/api/alerts/delete';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { ColumnButton } from './styles';
|
||||
|
||||
function DeleteAlert({
|
||||
id,
|
||||
setData,
|
||||
notifications,
|
||||
}: DeleteAlertProps): JSX.Element {
|
||||
const [deleteAlertState, setDeleteAlertState] = useState<
|
||||
State<DeleteAlertPayloadProps>
|
||||
>({
|
||||
error: false,
|
||||
errorMessage: '',
|
||||
loading: false,
|
||||
success: false,
|
||||
payload: undefined,
|
||||
});
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const onDeleteHandler = async (id: string): Promise<void> => {
|
||||
try {
|
||||
await deleteRuleByID({ id });
|
||||
|
||||
setData((state) => state.filter((alert) => alert.id !== id));
|
||||
|
||||
setDeleteAlertState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
}));
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
});
|
||||
} catch (error) {
|
||||
setDeleteAlertState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickHandler = (): void => {
|
||||
setDeleteAlertState((state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
}));
|
||||
onDeleteHandler(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<ColumnButton
|
||||
disabled={deleteAlertState.loading || false}
|
||||
loading={deleteAlertState.loading || false}
|
||||
onClick={onClickHandler}
|
||||
type="link"
|
||||
>
|
||||
Delete
|
||||
</ColumnButton>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteAlertProps {
|
||||
id: string;
|
||||
setData: Dispatch<SetStateAction<RuletypesRuleDTO[]>>;
|
||||
notifications: NotificationInstance;
|
||||
}
|
||||
|
||||
export default DeleteAlert;
|
||||
@@ -1,429 +0,0 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Button, Flex, Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { createRule } from 'api/generated/services/rules';
|
||||
import type {
|
||||
ListRules200,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { AxiosError } from 'axios';
|
||||
import DropDown from 'components/DropDown/DropDown';
|
||||
import {
|
||||
DynamicColumnsKey,
|
||||
TableDataSource,
|
||||
} from 'components/ResizeTable/contants';
|
||||
import DynamicColumnTable from 'components/ResizeTable/DynamicColumnTable';
|
||||
import DateComponent from 'components/ResizeTable/TableComponent/DateComponent';
|
||||
import LabelColumn from 'components/TableRenderer/LabelColumn';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
|
||||
import useSortableTable from 'hooks/ResizeTable/useSortableTable';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import useInterval from 'hooks/useInterval';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
|
||||
import APIError from 'types/api/error';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import DeleteAlert from './DeleteAlert';
|
||||
import { ColumnButton, SearchContainer } from './styles';
|
||||
import Status from './TableComponents/Status';
|
||||
import ToggleAlertState from './ToggleAlertState';
|
||||
import { alertActionLogEvent, filterAlerts } from './utils';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
|
||||
const { t } = useTranslation('common');
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { user } = useAppContext();
|
||||
const [addNewAlert, action] = useComponentPermission(
|
||||
['add_new_alert', 'action'],
|
||||
user.role,
|
||||
);
|
||||
|
||||
const [editLoader, setEditLoader] = useState<boolean>(false);
|
||||
const [cloneLoader, setCloneLoader] = useState<boolean>(false);
|
||||
|
||||
const params = useUrlQuery();
|
||||
const orderColumnParam = params.get('columnKey');
|
||||
const orderQueryParam = params.get('order');
|
||||
const paginationParam = params.get('page');
|
||||
const searchParams = params.get('search');
|
||||
const [searchString, setSearchString] = useState<string>(searchParams || '');
|
||||
const [data, setData] = useState<RuletypesRuleDTO[]>(() => {
|
||||
const value = searchString.toLowerCase();
|
||||
const filteredData = filterAlerts(allAlertRules, value);
|
||||
return filteredData || [];
|
||||
});
|
||||
|
||||
// Type asuring
|
||||
const sortingOrder: 'ascend' | 'descend' | null =
|
||||
orderQueryParam === 'ascend' || orderQueryParam === 'descend'
|
||||
? orderQueryParam
|
||||
: null;
|
||||
|
||||
const { sortedInfo, handleChange } = useSortableTable<RuletypesRuleDTO>(
|
||||
sortingOrder,
|
||||
orderColumnParam || '',
|
||||
searchString,
|
||||
);
|
||||
|
||||
const { notifications: notificationsApi } = useNotifications();
|
||||
|
||||
useInterval(() => {
|
||||
(async (): Promise<void> => {
|
||||
const { data: refetchData, status } = await refetch();
|
||||
if (status === 'success') {
|
||||
const value = searchString.toLowerCase();
|
||||
const filteredData = filterAlerts(refetchData?.data ?? [], value);
|
||||
setData(filteredData || []);
|
||||
}
|
||||
if (status === 'error') {
|
||||
notificationsApi.error({
|
||||
message: t('something_went_wrong'),
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, 30000);
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const onClickNewAlertHandler = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
logEvent('Alert: New alert button clicked', {
|
||||
number: allAlertRules?.length,
|
||||
layout: 'new',
|
||||
});
|
||||
safeNavigate(ROUTES.ALERTS_NEW, {
|
||||
newTab: isModifierKeyPressed(e),
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const onEditHandler = (
|
||||
record: RuletypesRuleDTO,
|
||||
options?: { newTab?: boolean },
|
||||
): void => {
|
||||
const compositeQuery = sanitizeDefaultAlertQuery(
|
||||
mapQueryDataFromApi(toCompositeMetricQuery(record.condition.compositeQuery)),
|
||||
record.alertType,
|
||||
);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const panelType = record.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
params.set(QueryParams.panelTypes, panelType);
|
||||
}
|
||||
|
||||
params.set(QueryParams.ruleId, record.id);
|
||||
|
||||
setEditLoader(false);
|
||||
|
||||
safeNavigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`, {
|
||||
newTab: options?.newTab,
|
||||
});
|
||||
};
|
||||
|
||||
const onCloneHandler =
|
||||
(originalAlert: RuletypesRuleDTO) => async (): Promise<void> => {
|
||||
const copyAlert: RuletypesRuleDTO = {
|
||||
...originalAlert,
|
||||
alert: `${originalAlert.alert} - Copy`,
|
||||
};
|
||||
|
||||
try {
|
||||
setCloneLoader(true);
|
||||
await createRule(copyAlert);
|
||||
|
||||
notificationsApi.success({
|
||||
message: 'Success',
|
||||
description: 'Alert cloned successfully',
|
||||
});
|
||||
|
||||
const { data: refetchData, status } = await refetch();
|
||||
const rules = refetchData?.data;
|
||||
if (status === 'success' && rules) {
|
||||
setData(rules);
|
||||
setTimeout(() => {
|
||||
const clonedAlert = rules[rules.length - 1];
|
||||
params.set(QueryParams.ruleId, String(clonedAlert.id));
|
||||
safeNavigate(`${ROUTES.EDIT_ALERTS}?${params.toString()}`);
|
||||
}, 2000);
|
||||
}
|
||||
if (status === 'error') {
|
||||
notificationsApi.error({
|
||||
message: t('something_went_wrong'),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
);
|
||||
} finally {
|
||||
setCloneLoader(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = useDebouncedFn((e: unknown) => {
|
||||
const value = (e as React.BaseSyntheticEvent).target.value.toLowerCase();
|
||||
setSearchString(value);
|
||||
const filteredData = filterAlerts(allAlertRules, value);
|
||||
setData(filteredData);
|
||||
});
|
||||
|
||||
const dynamicColumns: ColumnsType<RuletypesRuleDTO> = [
|
||||
{
|
||||
title: 'Created At',
|
||||
dataIndex: 'createdAt',
|
||||
width: 80,
|
||||
key: DynamicColumnsKey.CreatedAt,
|
||||
align: 'center',
|
||||
sorter: (a: RuletypesRuleDTO, b: RuletypesRuleDTO): number => {
|
||||
const prev = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const next = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
|
||||
return prev - next;
|
||||
},
|
||||
render: DateComponent,
|
||||
sortOrder:
|
||||
sortedInfo.columnKey === DynamicColumnsKey.CreatedAt
|
||||
? sortedInfo.order
|
||||
: null,
|
||||
},
|
||||
{
|
||||
title: 'Created By',
|
||||
dataIndex: 'createdBy',
|
||||
width: 80,
|
||||
key: DynamicColumnsKey.CreatedBy,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: 'Updated At',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 80,
|
||||
key: DynamicColumnsKey.UpdatedAt,
|
||||
align: 'center',
|
||||
sorter: (a: RuletypesRuleDTO, b: RuletypesRuleDTO): number => {
|
||||
const prev = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||
const next = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||
|
||||
return prev - next;
|
||||
},
|
||||
render: DateComponent,
|
||||
sortOrder:
|
||||
sortedInfo.columnKey === DynamicColumnsKey.UpdatedAt
|
||||
? sortedInfo.order
|
||||
: null,
|
||||
},
|
||||
{
|
||||
title: 'Updated By',
|
||||
dataIndex: 'updatedBy',
|
||||
width: 80,
|
||||
key: DynamicColumnsKey.UpdatedBy,
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const columns: ColumnsType<RuletypesRuleDTO> = [
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'state',
|
||||
width: 80,
|
||||
key: 'state',
|
||||
sorter: (a, b): number =>
|
||||
(b.state ? b.state.charCodeAt(0) : 1000) -
|
||||
(a.state ? a.state.charCodeAt(0) : 1000),
|
||||
render: (value): JSX.Element => <Status status={value} />,
|
||||
sortOrder: sortedInfo.columnKey === 'state' ? sortedInfo.order : null,
|
||||
},
|
||||
{
|
||||
title: 'Alert Name',
|
||||
dataIndex: 'alert',
|
||||
width: 100,
|
||||
key: 'name',
|
||||
sorter: (alertA, alertB): number => {
|
||||
if (alertA.alert && alertB.alert) {
|
||||
return alertA.alert.localeCompare(alertB.alert);
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
render: (value, record): JSX.Element => {
|
||||
const onClickHandler = (e: React.MouseEvent<HTMLElement>): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onEditHandler(record, { newTab: isModifierKeyPressed(e) });
|
||||
};
|
||||
|
||||
return <Typography.Link onClick={onClickHandler}>{value}</Typography.Link>;
|
||||
},
|
||||
sortOrder: sortedInfo.columnKey === 'name' ? sortedInfo.order : null,
|
||||
},
|
||||
{
|
||||
title: 'Severity',
|
||||
dataIndex: 'labels',
|
||||
width: 80,
|
||||
key: 'severity',
|
||||
sorter: (a, b): number =>
|
||||
(a?.labels?.severity?.length || 0) - (b?.labels?.severity?.length || 0),
|
||||
render: (value): JSX.Element => {
|
||||
const objectKeys = value ? Object.keys(value) : [];
|
||||
const withSeverityKey = objectKeys.find((e) => e === 'severity') || '';
|
||||
const severityValue = withSeverityKey ? value[withSeverityKey] : '-';
|
||||
|
||||
return <Typography>{severityValue}</Typography>;
|
||||
},
|
||||
sortOrder: sortedInfo.columnKey === 'severity' ? sortedInfo.order : null,
|
||||
},
|
||||
{
|
||||
title: 'Labels',
|
||||
dataIndex: 'labels',
|
||||
key: 'tags',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
render: (value): JSX.Element => {
|
||||
const objectKeys = value ? Object.keys(value) : [];
|
||||
const withOutSeverityKeys = objectKeys.filter((e) => e !== 'severity');
|
||||
|
||||
if (withOutSeverityKeys.length === 0) {
|
||||
return <Typography>-</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<LabelColumn labels={withOutSeverityKeys} value={value} color="magenta" />
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (action) {
|
||||
columns.push({
|
||||
title: 'Action',
|
||||
dataIndex: 'id',
|
||||
key: 'action',
|
||||
width: 10,
|
||||
render: (id: RuletypesRuleDTO['id'], record): JSX.Element => (
|
||||
<div data-testid="alert-actions">
|
||||
<DropDown
|
||||
onDropDownItemClick={(item): void =>
|
||||
alertActionLogEvent(item.key, record)
|
||||
}
|
||||
element={[
|
||||
<ToggleAlertState
|
||||
key="1"
|
||||
disabled={record.disabled ?? false}
|
||||
setData={setData}
|
||||
id={id ?? ''}
|
||||
/>,
|
||||
<ColumnButton
|
||||
key="2"
|
||||
onClick={(e: React.MouseEvent): void =>
|
||||
onEditHandler(record, { newTab: isModifierKeyPressed(e) })
|
||||
}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3"
|
||||
onClick={(): void => onEditHandler(record, { newTab: true })}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit in New Tab
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3"
|
||||
onClick={onCloneHandler(record)}
|
||||
type="link"
|
||||
loading={cloneLoader}
|
||||
>
|
||||
Clone
|
||||
</ColumnButton>,
|
||||
<DeleteAlert
|
||||
key="4"
|
||||
notifications={notificationsApi}
|
||||
setData={setData}
|
||||
id={id ?? ''}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const paginationConfig = {
|
||||
defaultCurrent: Number(paginationParam) || 1,
|
||||
};
|
||||
return (
|
||||
<div className="alert-rules-list-container">
|
||||
<SearchContainer>
|
||||
<Search
|
||||
placeholder="Search by Alert Name, Severity and Labels"
|
||||
onChange={handleSearch}
|
||||
defaultValue={searchString}
|
||||
/>
|
||||
<Flex gap={12} align="center">
|
||||
{addNewAlert && (
|
||||
<Button type="primary" onClick={onClickNewAlertHandler}>
|
||||
<Flex align="center" gap={4}>
|
||||
<Plus size="md" />
|
||||
New Alert
|
||||
</Flex>
|
||||
</Button>
|
||||
)}
|
||||
<TextToolTip
|
||||
{...{
|
||||
text: `More details on how to create alerts`,
|
||||
url: 'https://signoz.io/docs/alerts/?utm_source=product&utm_medium=list-alerts',
|
||||
urlText: 'Learn More',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</SearchContainer>
|
||||
<DynamicColumnTable
|
||||
tablesource={TableDataSource.Alert}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
dataSource={data}
|
||||
shouldSendAlertsLogEvent
|
||||
dynamicColumns={dynamicColumns}
|
||||
onChange={handleChange}
|
||||
pagination={paginationConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListAlertProps {
|
||||
allAlertRules: RuletypesRuleDTO[];
|
||||
refetch: UseQueryResult<
|
||||
ListRules200,
|
||||
ErrorType<RenderErrorResponseDTO>
|
||||
>['refetch'];
|
||||
}
|
||||
|
||||
export default ListAlert;
|
||||
@@ -0,0 +1,92 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 62px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
.refreshRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filtersRow {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--spacing-8);
|
||||
|
||||
--combobox-trigger-height: 2rem;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
min-width: 300px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
--tanstack-table-header-cell-bg: var(--l2-background);
|
||||
--tanstack-table-header-cell-color: var(--l2-foreground);
|
||||
--tanstack-table-cell-bg: var(--l2-background);
|
||||
--tanstack-table-cell-color: var(--l2-foreground);
|
||||
--tanstack-table-row-hover-bg: var(--l2-background-hover);
|
||||
--tanstack-table-row-active-bg: var(--l2-background-active);
|
||||
--tanstack-table-resize-handle-bg: var(--l2-background);
|
||||
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
|
||||
--tanstack-table-row-height: 42px;
|
||||
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-left-override: 16px;
|
||||
--tanstack-cell-padding-right-override: 16px;
|
||||
|
||||
--tanstack-table-row-odd-bg: color-mix(
|
||||
in srgb,
|
||||
var(--l1-foreground) 2%,
|
||||
transparent
|
||||
);
|
||||
--tanstack-table-row-even-bg: color-mix(
|
||||
in srgb,
|
||||
var(--l1-foreground) 1%,
|
||||
transparent
|
||||
);
|
||||
--badge-cursor: pointer;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.actionsColumn {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.paginationContainer {
|
||||
padding-right: var(--spacing-12);
|
||||
height: 62px;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Tag } from 'antd';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
function Status({ status }: StatusProps): JSX.Element {
|
||||
switch (status) {
|
||||
case 'inactive': {
|
||||
return <Tag color="green">OK</Tag>;
|
||||
}
|
||||
|
||||
case 'pending': {
|
||||
return <Tag color="orange">Pending</Tag>;
|
||||
}
|
||||
|
||||
case 'firing': {
|
||||
return <Tag color="red">Firing</Tag>;
|
||||
}
|
||||
|
||||
case 'disabled': {
|
||||
return <Tag>Disabled</Tag>;
|
||||
}
|
||||
|
||||
default: {
|
||||
return <Tag color="default">Unknown</Tag>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface StatusProps {
|
||||
status: RuletypesRuleDTO['state'];
|
||||
}
|
||||
|
||||
export default Status;
|
||||
@@ -1,103 +0,0 @@
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { patchRulePartial } from 'api/alerts/patchRulePartial';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { invalidateGetRuleByID } from 'api/generated/services/rules';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { State } from 'hooks/useFetch';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { ColumnButton } from './styles';
|
||||
|
||||
function ToggleAlertState({
|
||||
id,
|
||||
disabled,
|
||||
setData,
|
||||
}: ToggleAlertStateProps): JSX.Element {
|
||||
const [apiStatus, setAPIStatus] = useState<State<RuletypesRuleDTO>>({
|
||||
error: false,
|
||||
errorMessage: '',
|
||||
loading: false,
|
||||
success: false,
|
||||
payload: undefined,
|
||||
});
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const onToggleHandler = async (
|
||||
id: string,
|
||||
disabled: boolean,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setAPIStatus((state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
}));
|
||||
|
||||
const response = await patchRulePartial(id, { disabled });
|
||||
const { data: updatedRule } = response;
|
||||
|
||||
setData((state) =>
|
||||
state.map((alert) => {
|
||||
if (alert.id === id) {
|
||||
return {
|
||||
...alert,
|
||||
disabled: updatedRule.disabled,
|
||||
state: updatedRule.state,
|
||||
};
|
||||
}
|
||||
return alert;
|
||||
}),
|
||||
);
|
||||
|
||||
setAPIStatus((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
payload: updatedRule,
|
||||
}));
|
||||
|
||||
invalidateGetRuleByID(queryClient, { id });
|
||||
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
});
|
||||
} catch (error) {
|
||||
setAPIStatus((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ColumnButton
|
||||
disabled={apiStatus.loading || false}
|
||||
loading={apiStatus.loading || false}
|
||||
onClick={(): Promise<void> => onToggleHandler(id, !disabled)}
|
||||
type="link"
|
||||
>
|
||||
{disabled ? 'Enable' : 'Disable'}
|
||||
</ColumnButton>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleAlertStateProps {
|
||||
id: string;
|
||||
disabled: boolean;
|
||||
setData: Dispatch<SetStateAction<RuletypesRuleDTO[]>>;
|
||||
}
|
||||
|
||||
export default ToggleAlertState;
|
||||
@@ -1,147 +0,0 @@
|
||||
import type {
|
||||
RuletypesAlertStateDTO,
|
||||
RuletypesCompareOperatorDTO,
|
||||
RuletypesMatchTypeDTO,
|
||||
RuletypesPanelTypeDTO,
|
||||
RuletypesQueryTypeDTO,
|
||||
RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { filterAlerts } from '../utils';
|
||||
|
||||
describe('filterAlerts', () => {
|
||||
const mockAlertBase: Partial<RuletypesRuleDTO> = {
|
||||
state: 'active' as RuletypesAlertStateDTO,
|
||||
disabled: false,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
createdBy: 'test-user',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
updatedBy: 'test-user',
|
||||
version: '1',
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
queries: [],
|
||||
panelType: 'graph' as RuletypesPanelTypeDTO,
|
||||
queryType: 'builder' as RuletypesQueryTypeDTO,
|
||||
},
|
||||
matchType: 'at_least_once' as RuletypesMatchTypeDTO,
|
||||
op: 'above' as RuletypesCompareOperatorDTO,
|
||||
},
|
||||
ruleType: 'threshold_rule' as RuletypesRuleDTO['ruleType'],
|
||||
};
|
||||
|
||||
const mockAlerts: RuletypesRuleDTO[] = [
|
||||
{
|
||||
...mockAlertBase,
|
||||
id: '1',
|
||||
alert: 'High CPU Usage',
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
labels: {
|
||||
severity: 'warning',
|
||||
status: 'ok',
|
||||
environment: 'production',
|
||||
},
|
||||
} as RuletypesRuleDTO,
|
||||
{
|
||||
...mockAlertBase,
|
||||
id: '2',
|
||||
alert: 'Memory Leak Detected',
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
labels: {
|
||||
severity: 'critical',
|
||||
status: 'firing',
|
||||
environment: 'staging',
|
||||
},
|
||||
} as RuletypesRuleDTO,
|
||||
{
|
||||
...mockAlertBase,
|
||||
id: '3',
|
||||
alert: 'Database Connection Error',
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
labels: {
|
||||
severity: 'error',
|
||||
status: 'pending',
|
||||
environment: 'production',
|
||||
},
|
||||
} as RuletypesRuleDTO,
|
||||
];
|
||||
|
||||
it('should return all alerts when filter is empty', () => {
|
||||
const result = filterAlerts(mockAlerts, '');
|
||||
expect(result).toStrictEqual(mockAlerts);
|
||||
});
|
||||
|
||||
it('should return all alerts when filter is only whitespace', () => {
|
||||
const result = filterAlerts(mockAlerts, ' ');
|
||||
expect(result).toStrictEqual(mockAlerts);
|
||||
});
|
||||
|
||||
it('should filter alerts by alert name', () => {
|
||||
const result = filterAlerts(mockAlerts, 'CPU');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].alert).toBe('High CPU Usage');
|
||||
});
|
||||
|
||||
it('should filter alerts by severity', () => {
|
||||
const result = filterAlerts(mockAlerts, 'warning');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].labels?.severity).toBe('warning');
|
||||
});
|
||||
|
||||
it('should filter alerts by label key', () => {
|
||||
const result = filterAlerts(mockAlerts, 'environment');
|
||||
expect(result).toHaveLength(3); // All alerts have environment label
|
||||
});
|
||||
|
||||
it('should filter alerts by label value', () => {
|
||||
const result = filterAlerts(mockAlerts, 'production');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(
|
||||
result.every((alert) => alert.labels?.environment === 'production'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
const result = filterAlerts(mockAlerts, 'cpu');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].alert).toBe('High CPU Usage');
|
||||
});
|
||||
|
||||
it('should handle partial matches', () => {
|
||||
const result = filterAlerts(mockAlerts, 'mem');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].alert).toBe('Memory Leak Detected');
|
||||
});
|
||||
|
||||
it('should handle alerts with missing labels', () => {
|
||||
const alertsWithMissingLabels: RuletypesRuleDTO[] = [
|
||||
{
|
||||
...mockAlertBase,
|
||||
id: '4',
|
||||
alert: 'Test Alert',
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
labels: undefined,
|
||||
} as RuletypesRuleDTO,
|
||||
];
|
||||
const result = filterAlerts(alertsWithMissingLabels, 'test');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].alert).toBe('Test Alert');
|
||||
});
|
||||
|
||||
it('should handle alerts with missing alert name', () => {
|
||||
const alertsWithMissingName: RuletypesRuleDTO[] = [
|
||||
{
|
||||
...mockAlertBase,
|
||||
id: '5',
|
||||
alert: '',
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
labels: {
|
||||
severity: 'warning',
|
||||
},
|
||||
} as RuletypesRuleDTO,
|
||||
];
|
||||
const result = filterAlerts(alertsWithMissingName, 'warning');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].labels?.severity).toBe('warning');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
.actionButton {
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.deleteItem {
|
||||
color: var(--bg-cherry-500);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-cherry-500) 10%, transparent);
|
||||
}
|
||||
}
|
||||
183
frontend/src/container/ListAlertRules/components/ActionsMenu.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
createRule,
|
||||
deleteRuleByID,
|
||||
invalidateListRules,
|
||||
patchRuleByID,
|
||||
} from 'api/generated/services/rules';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesPostableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AxiosError } from 'axios';
|
||||
|
||||
import type { AlertRule } from '../types';
|
||||
import { ALERT_ACTIONS, alertActionLogEvent } from '../utils';
|
||||
import styles from './ActionsMenu.module.scss';
|
||||
|
||||
interface ActionsMenuProps {
|
||||
rule: AlertRule;
|
||||
onEdit: (rule: AlertRule, options?: { newTab?: boolean }) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function ActionsMenu({
|
||||
rule,
|
||||
onEdit,
|
||||
isLoading: externalLoading = false,
|
||||
}: ActionsMenuProps): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleToggle = useCallback((): void => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.TOGGLE, rule);
|
||||
const newDisabled = !rule.disabled;
|
||||
toast.promise(
|
||||
patchRuleByID({ id: rule.id ?? '' }, {
|
||||
disabled: newDisabled,
|
||||
} as RuletypesPostableRuleDTO).then(() => invalidateListRules(queryClient)),
|
||||
{
|
||||
loading: newDisabled ? 'Disabling alert...' : 'Enabling alert...',
|
||||
success: newDisabled ? 'Alert disabled' : 'Alert enabled',
|
||||
error: (error): string => {
|
||||
const apiError = convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO>,
|
||||
);
|
||||
return apiError?.getErrorMessage() || 'Failed to toggle alert state';
|
||||
},
|
||||
position: 'top-right',
|
||||
},
|
||||
);
|
||||
}, [rule, queryClient]);
|
||||
|
||||
const handleEdit = useCallback((): void => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.EDIT, rule);
|
||||
onEdit(rule);
|
||||
}, [rule, onEdit]);
|
||||
|
||||
const handleEditNewTab = useCallback((): void => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.EDIT, rule);
|
||||
onEdit(rule, { newTab: true });
|
||||
}, [rule, onEdit]);
|
||||
|
||||
const handleClone = useCallback((): void => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.CLONE, rule);
|
||||
toast.promise(
|
||||
createRule({
|
||||
...rule,
|
||||
alert: `${rule.alert} - Copy`,
|
||||
} as RuletypesPostableRuleDTO).then(async (response) => {
|
||||
await invalidateListRules(queryClient);
|
||||
const newRule = response.data;
|
||||
if (newRule) {
|
||||
onEdit(newRule as AlertRule);
|
||||
}
|
||||
}),
|
||||
{
|
||||
loading: 'Cloning alert...',
|
||||
success: 'Alert cloned successfully',
|
||||
error: (error): string => {
|
||||
const apiError = convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO>,
|
||||
);
|
||||
return apiError?.getErrorMessage() || 'Failed to clone alert';
|
||||
},
|
||||
position: 'top-right',
|
||||
},
|
||||
);
|
||||
}, [rule, queryClient, onEdit]);
|
||||
|
||||
const handleDelete = useCallback((): void => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.DELETE, rule);
|
||||
toast.promise(
|
||||
deleteRuleByID({ id: rule.id ?? '' }).then(() =>
|
||||
invalidateListRules(queryClient),
|
||||
),
|
||||
{
|
||||
loading: 'Deleting alert...',
|
||||
success: 'Alert deleted successfully',
|
||||
error: (error): string => {
|
||||
const apiError = convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO>,
|
||||
);
|
||||
return apiError?.getErrorMessage() || 'Failed to delete alert';
|
||||
},
|
||||
position: 'top-right',
|
||||
},
|
||||
);
|
||||
}, [rule, queryClient]);
|
||||
|
||||
const menuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'toggle',
|
||||
label: rule.disabled ? 'Enable' : 'Disable',
|
||||
disabled: externalLoading,
|
||||
onClick: handleToggle,
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
disabled: externalLoading,
|
||||
onClick: handleEdit,
|
||||
},
|
||||
{
|
||||
key: 'edit-new-tab',
|
||||
label: 'Edit in New Tab',
|
||||
disabled: externalLoading,
|
||||
onClick: handleEditNewTab,
|
||||
},
|
||||
{
|
||||
key: 'clone',
|
||||
label: 'Clone',
|
||||
disabled: externalLoading,
|
||||
onClick: handleClone,
|
||||
},
|
||||
{ key: 'divider', type: 'divider' as const },
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
disabled: externalLoading,
|
||||
danger: true,
|
||||
onClick: handleDelete,
|
||||
},
|
||||
],
|
||||
[
|
||||
rule.disabled,
|
||||
externalLoading,
|
||||
handleToggle,
|
||||
handleEdit,
|
||||
handleEditNewTab,
|
||||
handleClone,
|
||||
handleDelete,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClick = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div onClick={handleClick}>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.actionButton}
|
||||
data-testid="alert-actions"
|
||||
>
|
||||
<Ellipsis size={16} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActionsMenu;
|
||||
@@ -0,0 +1,34 @@
|
||||
.popoverContent {
|
||||
min-width: 180px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--l2-foreground);
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.columnList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.columnItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Columns3 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import {
|
||||
hideColumn,
|
||||
showColumn,
|
||||
useHiddenColumnIds,
|
||||
} from 'components/TanStackTableView';
|
||||
|
||||
import styles from './ColumnSelector.module.scss';
|
||||
|
||||
interface ColumnSelectorProps<TData> {
|
||||
columns: TableColumnDef<TData>[];
|
||||
storageKey: string;
|
||||
}
|
||||
|
||||
function ColumnSelector<TData>({
|
||||
columns,
|
||||
storageKey,
|
||||
}: ColumnSelectorProps<TData>): JSX.Element {
|
||||
const hiddenColumnIds = useHiddenColumnIds(storageKey);
|
||||
|
||||
const selectableColumns = useMemo(
|
||||
() =>
|
||||
columns.filter(
|
||||
(col) => col.canBeHidden !== false && col.enableRemove !== false,
|
||||
),
|
||||
[columns],
|
||||
);
|
||||
|
||||
const handleToggle = (columnId: string, checked: boolean): void => {
|
||||
if (checked) {
|
||||
showColumn(storageKey, columnId);
|
||||
} else {
|
||||
hideColumn(storageKey, columnId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
prefix={<Columns3 size={14} />}
|
||||
>
|
||||
Columns
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className={styles.popoverContent}>
|
||||
<div className={styles.title}>Toggle Columns</div>
|
||||
<div className={styles.columnList}>
|
||||
{selectableColumns.map((col) => {
|
||||
const isVisible = !hiddenColumnIds.includes(col.id);
|
||||
const label = typeof col.header === 'string' ? col.header : col.id;
|
||||
|
||||
return (
|
||||
<label key={col.id} className={styles.columnItem}>
|
||||
<Checkbox
|
||||
id={`col-${col.id}`}
|
||||
value={isVisible}
|
||||
onChange={(): void => handleToggle(col.id, !isVisible)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColumnSelector;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as ActionsMenu } from './ActionsMenu';
|
||||
export { default as ColumnSelector } from './ColumnSelector';
|
||||
46
frontend/src/container/ListAlertRules/hooks.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Options,
|
||||
parseAsInteger,
|
||||
useQueryState,
|
||||
UseQueryStateReturn,
|
||||
} from 'nuqs';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
const defaultNuqsOptions: Options = {
|
||||
history: 'push',
|
||||
};
|
||||
|
||||
export const ALERT_RULES_PARAMS = {
|
||||
SEARCH: 'search',
|
||||
PAGE: 'page',
|
||||
RULE_TYPE: 'ruleType',
|
||||
FILTERS: 'alertRulesFilters',
|
||||
} as const;
|
||||
|
||||
export const useAlertRulesPage = (): UseQueryStateReturn<number, number> =>
|
||||
useQueryState(
|
||||
ALERT_RULES_PARAMS.PAGE,
|
||||
parseAsInteger.withDefault(1).withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export const useAlertRulesRuleType = (): UseQueryStateReturn<
|
||||
string[],
|
||||
string[]
|
||||
> =>
|
||||
useQueryState(
|
||||
ALERT_RULES_PARAMS.RULE_TYPE,
|
||||
parseAsJsonNoValidate<string[]>()
|
||||
.withDefault([])
|
||||
.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export const useAlertRulesFilters = (): UseQueryStateReturn<
|
||||
string[],
|
||||
string[]
|
||||
> =>
|
||||
useQueryState(
|
||||
ALERT_RULES_PARAMS.FILTERS,
|
||||
parseAsJsonNoValidate<string[]>()
|
||||
.withDefault([])
|
||||
.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
@@ -1,67 +1,194 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Space } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Plus, Search } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
|
||||
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
|
||||
import { useTableParams } from 'components/TanStackTableView/useTableParams';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useUrlSearchState } from 'hooks/useUrlSearchState';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
|
||||
import { AlertsEmptyState } from './AlertsEmptyState/AlertsEmptyState';
|
||||
import ListAlert from './ListAlert';
|
||||
import { ActionsMenu, ColumnSelector } from './components';
|
||||
import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
|
||||
import styles from './ListAlertRules.module.scss';
|
||||
import { getAlertRuleColumns } from './table.config';
|
||||
import type { AlertRule } from './types';
|
||||
import { useAlertRulesData } from './useAlertRulesData';
|
||||
import { useAlertRulesHandlers } from './useAlertRulesHandlers';
|
||||
|
||||
const QUERY_PARAMS_CONFIG = {
|
||||
orderBy: 'orderBy',
|
||||
page: 'page',
|
||||
limit: 'limit',
|
||||
} as const;
|
||||
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 10;
|
||||
|
||||
function ListAlertRules(): JSX.Element {
|
||||
const { t } = useTranslation('common');
|
||||
const { data, isError, isLoading, refetch, error } = useListRules({
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
|
||||
const rules = data?.data ?? [];
|
||||
const hasLoaded = !isLoading && data !== undefined;
|
||||
const logEventCalledRef = useRef(false);
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const apiError = useMemo(
|
||||
() => convertToApiError(error as AxiosError<RenderErrorResponseDTO> | null),
|
||||
[error],
|
||||
const { user } = useAppContext();
|
||||
const [addNewAlert, action] = useComponentPermission(
|
||||
['add_new_alert', 'action'],
|
||||
user.role,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current && hasLoaded) {
|
||||
logEvent('Alert: List page visited', {
|
||||
number: rules.length,
|
||||
});
|
||||
logEventCalledRef.current = true;
|
||||
const [filterValues, setFilterValues] = useAlertRulesFilters();
|
||||
const { searchText, debouncedSearch, handleSearchChange, clearSearch } =
|
||||
useUrlSearchState(ALERT_RULES_PARAMS.SEARCH);
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
|
||||
rowHeight: 42,
|
||||
});
|
||||
|
||||
const { orderBy, page, limit, setLimit } = useTableParams(
|
||||
QUERY_PARAMS_CONFIG,
|
||||
{
|
||||
page: DEFAULT_PAGE,
|
||||
limit: DEFAULT_LIMIT,
|
||||
storageKey: 'alert-rules',
|
||||
calculatedPageSize,
|
||||
cleanupOnUnmount: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { filteredRules, isFetching, isError, allRules, refetch } =
|
||||
useAlertRulesData(orderBy, debouncedSearch, filterValues ?? []);
|
||||
|
||||
const { handleEdit, handleNewAlert, handleRowClick, handleRowClickNewTab } =
|
||||
useAlertRulesHandlers(allRules.length);
|
||||
|
||||
const handleClearFilters = useCallback((): void => {
|
||||
void setFilterValues(null);
|
||||
clearSearch();
|
||||
}, [setFilterValues, clearSearch]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => getAlertRuleColumns(formatTimezoneAdjustedTimestamp),
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const paginatedRules = useMemo(() => {
|
||||
const start = (page - 1) * limit;
|
||||
return filteredRules.slice(start, start + limit);
|
||||
}, [filteredRules, page, limit]);
|
||||
|
||||
const columnsWithActions = useMemo(() => {
|
||||
if (!action) {
|
||||
return columns;
|
||||
}
|
||||
}, [hasLoaded, rules.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
notifications.error({
|
||||
message: apiError?.getErrorMessage() || t('something_went_wrong'),
|
||||
});
|
||||
}
|
||||
}, [isError, apiError, t, notifications]);
|
||||
return [
|
||||
...columns,
|
||||
{
|
||||
id: 'actions',
|
||||
header: (): JSX.Element => (
|
||||
<span style={{ textAlign: 'right', display: 'block' }}>Actions</span>
|
||||
),
|
||||
accessorKey: 'id',
|
||||
width: { fixed: '80px', ignoreLastColumnFill: true },
|
||||
enableSort: false,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'right' as const,
|
||||
cell: ({ row }: { row: AlertRule }): JSX.Element => (
|
||||
<div className={styles.actionsColumn}>
|
||||
<ActionsMenu rule={row} onEdit={handleEdit} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [action, columns, handleEdit]);
|
||||
|
||||
if (isError) {
|
||||
return <div>{apiError?.getErrorMessage() || t('something_went_wrong')}</div>;
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <Spinner height="75vh" tip="Loading Rules..." />;
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
return <AlertsEmptyState />;
|
||||
}
|
||||
const hasActiveFilters =
|
||||
searchText.length > 0 || (filterValues ?? []).length > 0;
|
||||
const isEmptyDueToFilters =
|
||||
!isFetching &&
|
||||
filteredRules.length === 0 &&
|
||||
hasActiveFilters &&
|
||||
allRules.length > 0;
|
||||
const isEmptyNoRules = !isFetching && !isError && allRules.length === 0;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<ListAlert allAlertRules={rules} refetch={refetch} />
|
||||
</Space>
|
||||
<div className={styles.container}>
|
||||
{!isEmptyNoRules && (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.refreshRow}>
|
||||
<ColumnSelector columns={columns} storageKey="alert-rules-columns" />
|
||||
{addNewAlert && (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={handleNewAlert}
|
||||
color="primary"
|
||||
>
|
||||
New Alert
|
||||
</Button>
|
||||
)}
|
||||
<TextToolTip
|
||||
text="More details on how to create alerts"
|
||||
url="https://signoz.io/docs/alerts/?utm_source=product&utm_medium=list-alerts"
|
||||
urlText="Learn More"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEmptyNoRules && (
|
||||
<div className={styles.filtersRow}>
|
||||
<Input
|
||||
className={styles.searchInput}
|
||||
placeholder="Search by Alert Name, Severity and Labels"
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
suffix={<Search size={14} className={styles.searchIcon} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={containerRef} className={styles.tableContainer}>
|
||||
{isError ? (
|
||||
<ErrorEmptyState title="Failed to load alert rules" onRefresh={refetch} />
|
||||
) : isEmptyDueToFilters ? (
|
||||
<NoResultsEmptyState
|
||||
title="No matching alert rules"
|
||||
subtitle="No alert rules match your search. Try adjusting your search criteria."
|
||||
onClear={handleClearFilters}
|
||||
clearButtonText="Clear Search"
|
||||
/>
|
||||
) : isEmptyNoRules ? (
|
||||
<AlertsEmptyState onRefresh={refetch} />
|
||||
) : (
|
||||
<TanStackTable<AlertRule>
|
||||
data={paginatedRules}
|
||||
columns={columnsWithActions}
|
||||
isLoading={isFetching}
|
||||
getRowKey={(row): string => row.id ?? ''}
|
||||
getItemKey={(row): string => row.id ?? ''}
|
||||
columnStorageKey="alert-rules-columns"
|
||||
enableQueryParams={QUERY_PARAMS_CONFIG}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
pagination={{
|
||||
total: filteredRules.length,
|
||||
calculatedPageSize,
|
||||
onLimitChange: setLimit,
|
||||
showTotalCount: true,
|
||||
}}
|
||||
paginationClassname={styles.paginationContainer}
|
||||
enableAlternatingRowColors
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Button as ButtonComponent } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const SearchContainer = styled.div`
|
||||
&&& {
|
||||
display: flex;
|
||||
margin-bottom: 2rem;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Button = styled(ButtonComponent)`
|
||||
&&& {
|
||||
margin-left: 1em;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ColumnButton = styled(ButtonComponent)`
|
||||
&&& {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
margin-right: 1.5em;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
154
frontend/src/container/ListAlertRules/table.config.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Badge, BadgeColor } from '@signozhq/ui/badge';
|
||||
import { SEVERITY_BADGE_COLORS } from 'components/Alerts/constants';
|
||||
import LabelColumn from 'components/Alerts/LabelColumn';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
|
||||
import type { AlertRule } from './types';
|
||||
|
||||
const STATE_CONFIG: Record<string, { color: BadgeColor; label: string }> = {
|
||||
firing: { color: 'error', label: 'Firing' },
|
||||
inactive: { color: 'success', label: 'OK' },
|
||||
pending: { color: 'warning', label: 'Pending' },
|
||||
disabled: { color: 'secondary', label: 'Disabled' },
|
||||
};
|
||||
|
||||
export function getAlertRuleColumns(
|
||||
formatTimezoneAdjustedTimestamp: (date: string, format: string) => string,
|
||||
): TableColumnDef<AlertRule>[] {
|
||||
return [
|
||||
{
|
||||
id: 'state',
|
||||
header: 'Status',
|
||||
accessorKey: 'state',
|
||||
width: { default: '100%' },
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => {
|
||||
const state = String(value ?? '').toLowerCase();
|
||||
const config = STATE_CONFIG[state] ?? {
|
||||
color: 'secondary' as BadgeColor,
|
||||
label: 'Unknown',
|
||||
};
|
||||
return (
|
||||
<Badge color={config.color} variant="outline">
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Alert Name',
|
||||
accessorKey: 'alert',
|
||||
width: { default: '100%' },
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value ?? '-')}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'severity',
|
||||
header: 'Severity',
|
||||
accessorFn: (row) => row.labels?.severity ?? '',
|
||||
width: { default: '100%' },
|
||||
enableSort: true,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => {
|
||||
const severity = String(value ?? '').toLowerCase();
|
||||
if (!severity) {
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
color={SEVERITY_BADGE_COLORS[severity] ?? 'secondary'}
|
||||
variant="outline"
|
||||
>
|
||||
{severity}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'labels',
|
||||
header: 'Labels',
|
||||
accessorKey: 'labels',
|
||||
width: { default: '100%' },
|
||||
enableSort: false,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => {
|
||||
const labels = value as Record<string, string> | undefined;
|
||||
if (!labels) {
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
const tagKeys = Object.keys(labels).filter((k) => k !== 'severity');
|
||||
if (!tagKeys.length) {
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
return <LabelColumn labels={tagKeys} value={labels} color="sakura" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
header: 'Created At',
|
||||
accessorKey: 'createdAt',
|
||||
width: { default: '100%' },
|
||||
enableSort: true,
|
||||
enableMove: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>
|
||||
{value
|
||||
? formatTimezoneAdjustedTimestamp(String(value), DATE_TIME_FORMATS.UTC_US)
|
||||
: '-'}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'createdBy',
|
||||
header: 'Created By',
|
||||
accessorKey: 'createdBy',
|
||||
width: { default: '100%' },
|
||||
enableSort: false,
|
||||
enableMove: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value ?? '-')}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'updatedAt',
|
||||
header: 'Updated At',
|
||||
accessorKey: 'updatedAt',
|
||||
width: { default: '100%' },
|
||||
enableSort: true,
|
||||
enableMove: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>
|
||||
{value
|
||||
? formatTimezoneAdjustedTimestamp(String(value), DATE_TIME_FORMATS.UTC_US)
|
||||
: '-'}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'updatedBy',
|
||||
header: 'Updated By',
|
||||
accessorKey: 'updatedBy',
|
||||
width: { default: '100%' },
|
||||
enableSort: false,
|
||||
enableMove: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value ?? '-')}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
3
frontend/src/container/ListAlertRules/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type AlertRule = RuletypesRuleDTO;
|
||||
55
frontend/src/container/ListAlertRules/useAlertRulesData.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import { searchByLabels } from 'components/Alerts/utils';
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
import { isUndefined } from 'lodash-es';
|
||||
|
||||
import type { AlertRule } from './types';
|
||||
import { filterRulesByFilters, sortRules } from './utils';
|
||||
|
||||
interface UseAlertRulesDataReturn {
|
||||
allRules: AlertRule[];
|
||||
filteredRules: AlertRule[];
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useAlertRulesData(
|
||||
orderBy: SortState | null,
|
||||
searchText = '',
|
||||
filters: string[] = [],
|
||||
): UseAlertRulesDataReturn {
|
||||
const hasLoggedEvent = useRef(false);
|
||||
|
||||
const rulesResponse = useListRules();
|
||||
|
||||
const allRules = useMemo(
|
||||
() => rulesResponse.data?.data ?? [],
|
||||
[rulesResponse.data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasLoggedEvent.current && !isUndefined(rulesResponse.data?.data)) {
|
||||
void logEvent('Alert: List page visited', {
|
||||
number: allRules.length,
|
||||
});
|
||||
hasLoggedEvent.current = true;
|
||||
}
|
||||
}, [rulesResponse.data, allRules.length]);
|
||||
|
||||
const filteredRules = useMemo(() => {
|
||||
const filtered = filterRulesByFilters(allRules, filters);
|
||||
const searched = searchByLabels(filtered, searchText, (r) => r.alert ?? '');
|
||||
return sortRules(searched, orderBy);
|
||||
}, [allRules, filters, searchText, orderBy]);
|
||||
|
||||
return {
|
||||
allRules,
|
||||
filteredRules,
|
||||
isFetching: rulesResponse.isFetching,
|
||||
isError: rulesResponse.isError,
|
||||
refetch: rulesResponse.refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useCallback } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useTableRowClick } from 'hooks/useTableRowClick';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import type { AlertRule } from './types';
|
||||
|
||||
interface UseAlertRulesHandlersReturn {
|
||||
handleEdit: (rule: AlertRule, options?: { newTab?: boolean }) => void;
|
||||
handleNewAlert: (e: React.MouseEvent) => void;
|
||||
handleRowClick: (rule: AlertRule) => void;
|
||||
handleRowClickNewTab: (rule: AlertRule) => void;
|
||||
}
|
||||
|
||||
export function useAlertRulesHandlers(
|
||||
allRulesCount: number,
|
||||
): UseAlertRulesHandlersReturn {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const params = useUrlQuery();
|
||||
|
||||
const getEditUrl = useCallback(
|
||||
(rule: AlertRule): string => {
|
||||
const compositeQuery = sanitizeDefaultAlertQuery(
|
||||
mapQueryDataFromApi(toCompositeMetricQuery(rule.condition.compositeQuery)),
|
||||
rule.alertType,
|
||||
);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const panelType = rule.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
params.set(QueryParams.panelTypes, panelType);
|
||||
}
|
||||
|
||||
params.set(QueryParams.ruleId, rule.id);
|
||||
|
||||
return `${ROUTES.ALERT_OVERVIEW}?${params.toString()}`;
|
||||
},
|
||||
[params],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(rule: AlertRule, options?: { newTab?: boolean }): void => {
|
||||
safeNavigate(getEditUrl(rule), options);
|
||||
},
|
||||
[getEditUrl, safeNavigate],
|
||||
);
|
||||
|
||||
const handleNewAlert = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
void logEvent('Alert: New alert button clicked', {
|
||||
number: allRulesCount,
|
||||
layout: 'new',
|
||||
});
|
||||
safeNavigate(ROUTES.ALERTS_NEW, {
|
||||
newTab: isModifierKeyPressed(e),
|
||||
});
|
||||
},
|
||||
[allRulesCount, safeNavigate],
|
||||
);
|
||||
|
||||
const { handleRowClick, handleRowClickNewTab } = useTableRowClick<AlertRule>({
|
||||
getUrl: getEditUrl,
|
||||
onNavigate: safeNavigate,
|
||||
});
|
||||
|
||||
return {
|
||||
handleEdit,
|
||||
handleNewAlert,
|
||||
handleRowClick,
|
||||
handleRowClickNewTab,
|
||||
};
|
||||
}
|
||||
@@ -1,59 +1,92 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { sortByColumn } from 'components/Alerts/utils';
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
import { dataSourceForAlertType } from 'constants/alerts';
|
||||
|
||||
export const filterAlerts = (
|
||||
allAlertRules: RuletypesRuleDTO[],
|
||||
filter: string,
|
||||
): RuletypesRuleDTO[] => {
|
||||
if (!filter.trim()) {
|
||||
return allAlertRules;
|
||||
}
|
||||
import type { AlertRule } from './types';
|
||||
|
||||
const value = filter.trim().toLowerCase();
|
||||
return allAlertRules.filter((alert) => {
|
||||
const alertName = alert.alert.toLowerCase();
|
||||
const severity = alert.labels?.severity?.toLowerCase();
|
||||
export const ALERT_RULES_REFRESH_INTERVAL = 30_000;
|
||||
|
||||
// Create a string of all label keys and values for searching
|
||||
const labelSearchString = Object.entries(alert.labels || {})
|
||||
.map(([key, val]) => `${key} ${val}`)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
export const ALERT_ACTIONS = {
|
||||
TOGGLE: 'toggle',
|
||||
EDIT: 'edit',
|
||||
CLONE: 'clone',
|
||||
DELETE: 'delete',
|
||||
} as const;
|
||||
|
||||
return (
|
||||
alertName.includes(value) ||
|
||||
severity?.includes(value) ||
|
||||
labelSearchString.includes(value)
|
||||
);
|
||||
});
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
[ALERT_ACTIONS.TOGGLE]: 'Enable/Disable',
|
||||
[ALERT_ACTIONS.EDIT]: 'Edit',
|
||||
[ALERT_ACTIONS.CLONE]: 'Clone',
|
||||
[ALERT_ACTIONS.DELETE]: 'Delete',
|
||||
};
|
||||
|
||||
export const alertActionLogEvent = (
|
||||
action: string,
|
||||
record: RuletypesRuleDTO,
|
||||
): void => {
|
||||
let actionValue = '';
|
||||
switch (action) {
|
||||
case '0':
|
||||
actionValue = 'Enable/Disable';
|
||||
break;
|
||||
case '1':
|
||||
actionValue = 'Edit';
|
||||
break;
|
||||
case '2':
|
||||
actionValue = 'Clone';
|
||||
break;
|
||||
case '3':
|
||||
actionValue = 'Delete';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
logEvent('Alert: Action', {
|
||||
const actionValue = ACTION_LABELS[action] ?? action;
|
||||
void logEvent('Alert: Action', {
|
||||
ruleId: record.id,
|
||||
dataSource: dataSourceForAlertType(record.alertType),
|
||||
name: record.alert,
|
||||
action: actionValue,
|
||||
});
|
||||
};
|
||||
|
||||
export function getAlertSortValue(
|
||||
rule: AlertRule,
|
||||
columnName: string,
|
||||
): string | number {
|
||||
switch (columnName) {
|
||||
case 'state':
|
||||
return rule.state ?? '';
|
||||
case 'name':
|
||||
return rule.alert ?? '';
|
||||
case 'severity':
|
||||
return rule.labels?.severity ?? '';
|
||||
case 'createdAt':
|
||||
return rule.createdAt ? new Date(rule.createdAt).getTime() : 0;
|
||||
case 'updatedAt':
|
||||
return rule.updatedAt ? new Date(rule.updatedAt).getTime() : 0;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function sortRules(
|
||||
rules: AlertRule[],
|
||||
orderBy: SortState | null,
|
||||
): AlertRule[] {
|
||||
return sortByColumn(rules, orderBy, getAlertSortValue);
|
||||
}
|
||||
|
||||
export function filterRulesByFilters(
|
||||
rules: AlertRule[],
|
||||
filters: string[],
|
||||
): AlertRule[] {
|
||||
if (filters.length === 0) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
const stateFilters = filters
|
||||
.filter((f) => f.startsWith('state:'))
|
||||
.map((f) => f.replace('state:', '').toLowerCase());
|
||||
|
||||
const severityFilters = filters
|
||||
.filter((f) => f.startsWith('severity:'))
|
||||
.map((f) => f.replace('severity:', '').toLowerCase());
|
||||
|
||||
return rules.filter((rule) => {
|
||||
const state = rule.state?.toLowerCase() ?? '';
|
||||
const severity = rule.labels?.severity?.toLowerCase() ?? '';
|
||||
|
||||
const matchesState =
|
||||
stateFilters.length === 0 || stateFilters.includes(state);
|
||||
const matchesSeverity =
|
||||
severityFilters.length === 0 || severityFilters.includes(severity);
|
||||
|
||||
return matchesState && matchesSeverity;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const PAGE_SIZE = 20;
|
||||
function MembersSettings(): JSX.Element {
|
||||
const history = useHistory();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
|
||||
const currentPage = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
|
||||
|
||||
@@ -145,7 +146,7 @@ function MembersSettings(): JSX.Element {
|
||||
: `Deleted ⎯ ${deletedCount}`;
|
||||
|
||||
const handleInviteComplete = useCallback((): void => {
|
||||
void refetchUsers();
|
||||
refetchUsers();
|
||||
}, [refetchUsers]);
|
||||
|
||||
const handleRowClick = useCallback((member: MemberRow): void => {
|
||||
@@ -157,7 +158,7 @@ function MembersSettings(): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const handleMemberEditComplete = useCallback((): void => {
|
||||
void refetchUsers();
|
||||
refetchUsers();
|
||||
}, [refetchUsers]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -80,10 +80,10 @@ function UserInfo(): JSX.Element {
|
||||
currentPassword === updatePassword;
|
||||
|
||||
const onSaveHandler = async (): Promise<void> => {
|
||||
void logEvent('Account Settings: Name Updated', {
|
||||
logEvent('Account Settings: Name Updated', {
|
||||
name: changedName,
|
||||
});
|
||||
void logEvent(
|
||||
logEvent(
|
||||
'Account Settings: Name Updated',
|
||||
{
|
||||
name: changedName,
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
.new-explorer-cta-with-badge {
|
||||
display: inline-flex;
|
||||
.new-explorer-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted-foreground);
|
||||
|
||||
/* Bifrost (Ancient)/Content/sm */
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
export const RIBBON_STYLES = {
|
||||
top: '-0.75rem',
|
||||
};
|
||||
|
||||
export const buttonText: Record<string, string> = {
|
||||
[ROUTES.LOGS_EXPLORER]: 'Old Explorer',
|
||||
[ROUTES.TRACE]: 'New Explorer',
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Badge, Button } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Undo } from '@signozhq/icons';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import { buttonText } from './config';
|
||||
import { buttonText, RIBBON_STYLES } from './config';
|
||||
|
||||
import './NewExplorerCTA.styles.scss';
|
||||
|
||||
@@ -71,12 +70,9 @@ function NewExplorerCTA(): JSX.Element | null {
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="new-explorer-cta-with-badge">
|
||||
<Badge.Ribbon style={RIBBON_STYLES} text="New">
|
||||
{button}
|
||||
<Badge color="robin" variant="default">
|
||||
New
|
||||
</Badge>
|
||||
</span>
|
||||
</Badge.Ribbon>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void logEvent(
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.STARTED}`,
|
||||
{},
|
||||
);
|
||||
@@ -253,7 +253,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
setSelectedFramework(null);
|
||||
setSelectedEnvironment(null);
|
||||
|
||||
void logEvent(
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.DATA_SOURCE_SELECTED}`,
|
||||
{
|
||||
dataSource: dataSource.label,
|
||||
@@ -276,7 +276,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
};
|
||||
|
||||
const handleSelectFramework = (option: any): void => {
|
||||
void logEvent(
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.FRAMEWORK_SELECTED}`,
|
||||
{
|
||||
dataSource: selectedDataSource?.label,
|
||||
@@ -309,7 +309,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
selectedEnvironment: any,
|
||||
baseURL?: string,
|
||||
): void => {
|
||||
void logEvent(
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.ENVIRONMENT_SELECTED}`,
|
||||
{
|
||||
dataSource: selectedDataSource?.label,
|
||||
@@ -351,7 +351,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
groupDataSourcesByTags(filteredDataSources as Entity[]),
|
||||
);
|
||||
|
||||
void logEvent(
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.DATA_SOURCE_SEARCHED}`,
|
||||
{
|
||||
searchedDataSource: query,
|
||||
@@ -485,7 +485,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
};
|
||||
|
||||
const handleShowInviteTeamMembersModal = (): void => {
|
||||
void logEvent(
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_BUTTON_CLICKED}`,
|
||||
{
|
||||
dataSource: selectedDataSource?.label,
|
||||
@@ -498,7 +498,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
};
|
||||
|
||||
const handleSubmitDataSourceRequest = (): void => {
|
||||
void logEvent(
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.DATA_SOURCE_REQUESTED}`,
|
||||
{
|
||||
requestedDataSource: dataSourceRequest,
|
||||
@@ -513,7 +513,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
};
|
||||
|
||||
const handleRaiseRequest = (): void => {
|
||||
void logEvent(
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.DATA_SOURCE_REQUESTED}`,
|
||||
{
|
||||
requestedDataSource: searchQuery,
|
||||
@@ -635,7 +635,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
size={14}
|
||||
className="onboarding-header-container-close-icon"
|
||||
onClick={(e): void => {
|
||||
void logEvent(
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.CLOSE_ONBOARDING_CLICKED}`,
|
||||
{
|
||||
currentPage: setupStepItems[currentStep]?.title || '',
|
||||
@@ -970,7 +970,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
disabled={!selectedDataSource}
|
||||
shape="round"
|
||||
onClick={(e): void => {
|
||||
void logEvent(
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.CONFIGURED_PRODUCT}`,
|
||||
{
|
||||
dataSource: selectedDataSource?.label,
|
||||
@@ -1038,7 +1038,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
type="default"
|
||||
shape="round"
|
||||
onClick={(): void => {
|
||||
void logEvent(
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BACK_BUTTON_CLICKED}`,
|
||||
{
|
||||
dataSource: selectedDataSource?.label,
|
||||
@@ -1057,7 +1057,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
type="primary"
|
||||
shape="round"
|
||||
onClick={(e): void => {
|
||||
void logEvent(
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.CONTINUE_BUTTON_CLICKED}`,
|
||||
{
|
||||
dataSource: selectedDataSource?.label,
|
||||
|
||||
@@ -4,15 +4,12 @@ import amazonMskUrl from '@/assets/Logos/amazon-msk.svg';
|
||||
import androidJavaMonitoringUrl from '@/assets/Logos/android-java-monitoring.svg';
|
||||
import androidKotlinMonitoringUrl from '@/assets/Logos/android-kotlin-monitoring.svg';
|
||||
import anthropicApiMonitoringUrl from '@/assets/Logos/anthropic-api-monitoring.svg';
|
||||
import apacheDruidUrl from '@/assets/Logos/apache-druid.svg';
|
||||
import apiGatewayUrl from '@/assets/Logos/api-gateway.svg';
|
||||
import argocdUrl from '@/assets/Logos/argocd.svg';
|
||||
import aspnetUrl from '@/assets/Logos/aspnet.svg';
|
||||
import autogenUrl from '@/assets/Logos/autogen.svg';
|
||||
import awsAlbUrl from '@/assets/Logos/aws-alb.svg';
|
||||
import azureAppServiceUrl from '@/assets/Logos/azure-app-service.svg';
|
||||
import azureBlobStorageUrl from '@/assets/Logos/azure-blob-storage.svg';
|
||||
import azureCdnFrontdoorUrl from '@/assets/Logos/azure-cdn-frontdoor.svg';
|
||||
import azureContainerAppsUrl from '@/assets/Logos/azure-container-apps.svg';
|
||||
import azureFunctionsUrl from '@/assets/Logos/azure-functions.svg';
|
||||
import azureMysqlUrl from '@/assets/Logos/azure-mysql.svg';
|
||||
@@ -21,7 +18,6 @@ import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metric
|
||||
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
|
||||
import basetenUrl from '@/assets/Logos/baseten.svg';
|
||||
import celeryUrl from '@/assets/Logos/celery.svg';
|
||||
import certManagerUrl from '@/assets/Logos/cert-manager.svg';
|
||||
import claudeCodeUrl from '@/assets/Logos/claude-code.svg';
|
||||
import clickhouseUrl from '@/assets/Logos/clickhouse.svg';
|
||||
import cloudflareUrl from '@/assets/Logos/cloudflare.svg';
|
||||
@@ -68,7 +64,6 @@ import goUrl from '@/assets/Logos/go.svg';
|
||||
import googleAdkUrl from '@/assets/Logos/google-adk.svg';
|
||||
import googleGeminiUrl from '@/assets/Logos/google-gemini.svg';
|
||||
import grafanaUrl from '@/assets/Logos/grafana.svg';
|
||||
import graphqlUrl from '@/assets/Logos/graphql.svg';
|
||||
import grokUrl from '@/assets/Logos/grok.svg';
|
||||
import groqUrl from '@/assets/Logos/groq.svg';
|
||||
import hasuraUrl from '@/assets/Logos/hasura.svg';
|
||||
@@ -80,7 +75,6 @@ import httpUrl from '@/assets/Logos/http.svg';
|
||||
import httpMonitoringUrl from '@/assets/Logos/http-monitoring.svg';
|
||||
import huggingfaceUrl from '@/assets/Logos/huggingface.svg';
|
||||
import inkeepUrl from '@/assets/Logos/inkeep.svg';
|
||||
import istioUrl from '@/assets/Logos/istio.svg';
|
||||
import javaUrl from '@/assets/Logos/java.svg';
|
||||
import javaOthersUrl from '@/assets/Logos/java-others.svg';
|
||||
import javascriptUrl from '@/assets/Logos/javascript.svg';
|
||||
@@ -127,7 +121,6 @@ import pythonUrl from '@/assets/Logos/python.svg';
|
||||
import quarkusUrl from '@/assets/Logos/quarkus.svg';
|
||||
import quickstartUrl from '@/assets/Logos/quickstart.svg';
|
||||
import qwenUrl from '@/assets/Logos/qwen.svg';
|
||||
import railwayUrl from '@/assets/Logos/railway.svg';
|
||||
import rdsUrl from '@/assets/Logos/rds.svg';
|
||||
import reactjsUrl from '@/assets/Logos/reactjs.svg';
|
||||
import redisUrl from '@/assets/Logos/redis.svg';
|
||||
@@ -135,9 +128,7 @@ import renderUrl from '@/assets/Logos/render.svg';
|
||||
import rubyOnRailsUrl from '@/assets/Logos/ruby-on-rails.svg';
|
||||
import rustUrl from '@/assets/Logos/rust.svg';
|
||||
import s3Url from '@/assets/Logos/s3.svg';
|
||||
import scalaUrl from '@/assets/Logos/scala.svg';
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
import slogUrl from '@/assets/Logos/slog.svg';
|
||||
import slurmUrl from '@/assets/Logos/slurm.svg';
|
||||
import snowflakeUrl from '@/assets/Logos/snowflake.svg';
|
||||
import snsUrl from '@/assets/Logos/sns.svg';
|
||||
@@ -3011,18 +3002,9 @@ const onboardingConfigWithLinks = [
|
||||
'tracing',
|
||||
],
|
||||
question: {
|
||||
desc: 'How would you like to set up Azure Blob Storage monitoring?',
|
||||
desc: 'What telemetry data do you want to visualise ?',
|
||||
type: 'select',
|
||||
helpText:
|
||||
'One Click uses Azure integration for automated setup. Manual setup uses OpenTelemetry for more control.',
|
||||
options: [
|
||||
{
|
||||
key: 'azure-blob-storage-one-click',
|
||||
label: 'One Click Azure',
|
||||
imgUrl: azureBlobStorageUrl,
|
||||
link: '/integrations/azure?service=storageaccountsblob',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
key: 'logging',
|
||||
label: 'Logs',
|
||||
@@ -3038,32 +3020,6 @@ const onboardingConfigWithLinks = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-cdn-frontdoor',
|
||||
label: 'Azure CDN / Front Door',
|
||||
imgUrl: azureCdnFrontdoorUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure cdn',
|
||||
'azure cdn frontdoor',
|
||||
'azure cdn metrics',
|
||||
'azure cdn monitoring',
|
||||
'azure front door',
|
||||
'azure frontdoor',
|
||||
'cdn',
|
||||
'cdn monitoring',
|
||||
'cdn observability',
|
||||
'content delivery network',
|
||||
'front door',
|
||||
'frontdoor',
|
||||
'one click',
|
||||
],
|
||||
id: 'azure-cdn-frontdoor',
|
||||
link: '/integrations/azure?service=cdnprofile',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-mysql-flexible-server',
|
||||
label: 'Azure MySQL Flexible Server',
|
||||
@@ -5658,22 +5614,17 @@ const onboardingConfigWithLinks = [
|
||||
dataSource: 'fly-io',
|
||||
label: 'Fly.io',
|
||||
imgUrl: flyIoUrl,
|
||||
tags: ['infrastructure monitoring', 'metrics', 'logs'],
|
||||
tags: ['infrastructure monitoring', 'metrics'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'cloud',
|
||||
'fly',
|
||||
'fly.io',
|
||||
'fly.io logs',
|
||||
'fly.io metrics',
|
||||
'fly.io monitoring',
|
||||
'fly.io observability',
|
||||
'infrastructure',
|
||||
'logs',
|
||||
'fly',
|
||||
'metrics',
|
||||
'infrastructure',
|
||||
'cloud',
|
||||
'monitoring',
|
||||
],
|
||||
link: '/docs/integrations/flyio/',
|
||||
link: '/docs/metrics-management/fly-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'envoy',
|
||||
@@ -6295,194 +6246,5 @@ const onboardingConfigWithLinks = [
|
||||
id: 'render-metrics',
|
||||
link: '/docs/metrics-management/render-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'cert-manager',
|
||||
label: 'Cert Manager',
|
||||
imgUrl: certManagerUrl,
|
||||
tags: ['infrastructure monitoring', 'metrics'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'cert manager',
|
||||
'cert-manager',
|
||||
'certificate',
|
||||
'certificate management',
|
||||
'certificate monitoring',
|
||||
'infrastructure',
|
||||
'kubernetes',
|
||||
'kubernetes certificates',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'ssl',
|
||||
'tls',
|
||||
],
|
||||
id: 'cert-manager',
|
||||
link: '/docs/infrastructure-monitoring/cert-manager/',
|
||||
},
|
||||
{
|
||||
dataSource: 'graphql',
|
||||
label: 'GraphQL',
|
||||
imgUrl: graphqlUrl,
|
||||
tags: ['apm/traces'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'api',
|
||||
'graphql',
|
||||
'graphql instrumentation',
|
||||
'graphql monitoring',
|
||||
'graphql observability',
|
||||
'graphql tracing',
|
||||
'javascript',
|
||||
'monitoring',
|
||||
'nodejs',
|
||||
'observability',
|
||||
'opentelemetry graphql',
|
||||
'traces',
|
||||
'tracing',
|
||||
],
|
||||
id: 'graphql',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-graphql/',
|
||||
},
|
||||
{
|
||||
dataSource: 'railway',
|
||||
label: 'Railway',
|
||||
imgUrl: railwayUrl,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'cloud',
|
||||
'log forwarding',
|
||||
'logging',
|
||||
'logs',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'paas',
|
||||
'railway',
|
||||
'railway logs',
|
||||
'railway monitoring',
|
||||
'railway observability',
|
||||
],
|
||||
id: 'railway',
|
||||
link: '/docs/integrations/outposts/railway/',
|
||||
},
|
||||
{
|
||||
dataSource: 'aspnet-core-metrics',
|
||||
label: 'ASP.NET Core Metrics',
|
||||
imgUrl: aspnetUrl,
|
||||
tags: ['metrics'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'.net metrics',
|
||||
'asp.net',
|
||||
'asp.net core',
|
||||
'asp.net core metrics',
|
||||
'asp.net metrics',
|
||||
'asp.net monitoring',
|
||||
'asp.net observability',
|
||||
'aspnet',
|
||||
'aspnet core',
|
||||
'dotnet metrics',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry aspnet',
|
||||
],
|
||||
id: 'aspnet-core-metrics',
|
||||
link:
|
||||
'/docs/metrics-management/send-metrics/applications/opentelemetry-aspnetcore/',
|
||||
},
|
||||
{
|
||||
dataSource: 'istio-metrics',
|
||||
label: 'Istio',
|
||||
imgUrl: istioUrl,
|
||||
tags: ['infrastructure monitoring', 'metrics'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'infrastructure',
|
||||
'istio',
|
||||
'istio metrics',
|
||||
'istio monitoring',
|
||||
'istio observability',
|
||||
'kubernetes',
|
||||
'mesh',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'service mesh',
|
||||
],
|
||||
id: 'istio-metrics',
|
||||
link: '/docs/metrics-management/istio-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'slog',
|
||||
label: 'log/slog',
|
||||
imgUrl: slogUrl,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'go',
|
||||
'go logging',
|
||||
'go logs',
|
||||
'golang',
|
||||
'golang logging',
|
||||
'log/slog',
|
||||
'logging',
|
||||
'logs',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'slog',
|
||||
'slog instrumentation',
|
||||
'slog logging',
|
||||
'structured logging',
|
||||
],
|
||||
id: 'slog',
|
||||
link: '/docs/logs-management/send-logs/slog-to-signoz/',
|
||||
},
|
||||
{
|
||||
dataSource: 'scala',
|
||||
label: 'Scala',
|
||||
imgUrl: scalaUrl,
|
||||
tags: ['apm/traces'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'apm',
|
||||
'instrumentation',
|
||||
'jvm',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry scala',
|
||||
'scala',
|
||||
'scala instrumentation',
|
||||
'scala monitoring',
|
||||
'scala observability',
|
||||
'scala tracing',
|
||||
'traces',
|
||||
'tracing',
|
||||
],
|
||||
id: 'scala',
|
||||
link: '/docs/instrumentation/java/opentelemetry-scala/',
|
||||
},
|
||||
{
|
||||
dataSource: 'apache-druid',
|
||||
label: 'Apache Druid',
|
||||
imgUrl: apacheDruidUrl,
|
||||
tags: ['database'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'analytics',
|
||||
'apache druid',
|
||||
'database',
|
||||
'druid',
|
||||
'druid instrumentation',
|
||||
'druid monitoring',
|
||||
'druid observability',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'olap',
|
||||
'opentelemetry druid',
|
||||
],
|
||||
id: 'apache-druid',
|
||||
link: '/docs/integrations/opentelemetry-apache-druid/',
|
||||
},
|
||||
];
|
||||
export default onboardingConfigWithLinks;
|
||||
|
||||
@@ -75,7 +75,7 @@ function AuthDomain(): JSX.Element {
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Domain deleted successfully');
|
||||
void refetchAuthDomainListResponse();
|
||||
refetchAuthDomainListResponse();
|
||||
hideDeleteModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
@@ -311,7 +311,7 @@ export function PlannedDowntimeForm(
|
||||
default:
|
||||
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
|
||||
}
|
||||
}, [formData, recurrenceType, timezone]);
|
||||
}, [formData, recurrenceType]);
|
||||
|
||||
const endTimeText = useMemo((): string => {
|
||||
const endTime = formData.endTime;
|
||||
@@ -322,7 +322,7 @@ export function PlannedDowntimeForm(
|
||||
const formattedEndTime = endTime.format(TIME_FORMAT);
|
||||
const formattedEndDate = endTime.format(DATE_FORMAT);
|
||||
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
|
||||
}, [formData, recurrenceType, timezone]);
|
||||
}, [formData, recurrenceType]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
||||
@@ -101,7 +101,6 @@ export function getAppContextMockState(
|
||||
userPreferences: null,
|
||||
hostsData: null,
|
||||
isLoggedIn: false,
|
||||
isPreflightLoading: false,
|
||||
org: null,
|
||||
isFetchingUser: false,
|
||||
isFetchingActiveLicense: false,
|
||||
|
||||
@@ -1120,7 +1120,6 @@
|
||||
|
||||
.user-settings-dropdown-logout-section {
|
||||
color: var(--danger-background);
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
);
|
||||
|
||||
const handleReorderShortcutNavItems = (): void => {
|
||||
void logEvent('Sidebar V2: Save shortcuts clicked', {
|
||||
logEvent('Sidebar V2: Save shortcuts clicked', {
|
||||
shortcuts: tempPinnedMenuItems.map((item) => item.key),
|
||||
});
|
||||
setPinnedMenuItems(tempPinnedMenuItems);
|
||||
@@ -436,7 +436,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
const isWorkspaceBlocked = trialInfo?.workSpaceBlock || false;
|
||||
|
||||
const onClickGetStarted = (event: MouseEvent): void => {
|
||||
void logEvent('Sidebar: Menu clicked', {
|
||||
logEvent('Sidebar: Menu clicked', {
|
||||
menuRoute: '/get-started',
|
||||
menuLabel: 'Get Started',
|
||||
});
|
||||
@@ -651,7 +651,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
} else if (item) {
|
||||
onClickHandler(item?.key as string, event);
|
||||
}
|
||||
void logEvent('Sidebar V2: Menu clicked', {
|
||||
logEvent('Sidebar V2: Menu clicked', {
|
||||
menuRoute: item?.key,
|
||||
menuLabel: item?.label,
|
||||
});
|
||||
@@ -794,7 +794,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
onTogglePin={
|
||||
allowPin
|
||||
? (item): void => {
|
||||
void logEvent(
|
||||
logEvent(
|
||||
`Sidebar V2: Menu item ${item.isPinned ? 'unpinned' : 'pinned'}`,
|
||||
{
|
||||
menuRoute: item.key,
|
||||
@@ -841,7 +841,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
const event = (info as SidebarItem & { domEvent?: MouseEvent }).domEvent;
|
||||
|
||||
if (item && !('type' in item)) {
|
||||
void logEvent('Help Popover: Item clicked', {
|
||||
logEvent('Help Popover: Item clicked', {
|
||||
menuRoute: item.key,
|
||||
menuLabel: String(item.label),
|
||||
});
|
||||
@@ -890,7 +890,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
menuLabel = item.label;
|
||||
}
|
||||
|
||||
void logEvent('Settings Popover: Item clicked', {
|
||||
logEvent('Settings Popover: Item clicked', {
|
||||
menuRoute: item?.key,
|
||||
menuLabel,
|
||||
});
|
||||
@@ -927,7 +927,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
}
|
||||
break;
|
||||
case 'logout':
|
||||
void Logout();
|
||||
Logout();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
@@ -1081,7 +1081,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
<div
|
||||
className="nav-section-title-icon reorder"
|
||||
onClick={(): void => {
|
||||
void logEvent('Sidebar V2: Manage shortcuts clicked', {});
|
||||
logEvent('Sidebar V2: Manage shortcuts clicked', {});
|
||||
setIsReorderShortcutNavItemsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -1128,7 +1128,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
return;
|
||||
}
|
||||
const newCollapsedState = !isMoreMenuCollapsed;
|
||||
void logEvent('Sidebar V2: More menu clicked', {
|
||||
logEvent('Sidebar V2: More menu clicked', {
|
||||
action: isMoreMenuCollapsed ? 'expand' : 'collapse',
|
||||
});
|
||||
setIsMoreMenuCollapsed(newCollapsedState);
|
||||
@@ -1234,14 +1234,14 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
open={isReorderShortcutNavItemsModalOpen}
|
||||
closable
|
||||
onCancel={(): void => {
|
||||
void logEvent('Sidebar V2: Manage shortcuts dismissed', {});
|
||||
logEvent('Sidebar V2: Manage shortcuts dismissed', {});
|
||||
hideReorderShortcutNavItemsModal();
|
||||
}}
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={(): void => {
|
||||
void logEvent('Sidebar V2: Manage shortcuts dismissed', {});
|
||||
logEvent('Sidebar V2: Manage shortcuts dismissed', {});
|
||||
hideReorderShortcutNavItemsModal();
|
||||
}}
|
||||
className="periscope-btn cancel-btn secondary-btn"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { MenuProps } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BarChart,
|
||||
@@ -37,13 +35,15 @@ import {
|
||||
Users,
|
||||
Binoculars,
|
||||
} from '@signozhq/icons';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { MenuProps } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import {
|
||||
SecondaryMenuItemKey,
|
||||
SettingsNavSection,
|
||||
SidebarItem,
|
||||
} from './sideNav.types';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
|
||||
export const getStartedMenuItem = {
|
||||
key: ROUTES.GET_STARTED,
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { SelectProps } from 'antd';
|
||||
import { Tag, Tooltip } from 'antd';
|
||||
import type { BaseOptionType } from 'antd/es/select';
|
||||
import { Alerts } from 'types/api/alerts/getTriggered';
|
||||
|
||||
import { Container, Select } from './styles';
|
||||
|
||||
function TextOverflowTooltip({
|
||||
option,
|
||||
}: {
|
||||
option: BaseOptionType;
|
||||
}): JSX.Element {
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const isOverflow = contentRef.current
|
||||
? contentRef.current?.offsetWidth < contentRef.current?.scrollWidth
|
||||
: false;
|
||||
return (
|
||||
<Tooltip
|
||||
placement="left"
|
||||
title={option.value}
|
||||
{...(!isOverflow ? { open: false } : {})}
|
||||
>
|
||||
<div className="ant-select-item-option-content" ref={contentRef}>
|
||||
{option.value}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function Filter({
|
||||
onSelectedFilterChange,
|
||||
onSelectedGroupChange,
|
||||
allAlerts,
|
||||
selectedGroup,
|
||||
selectedFilter,
|
||||
}: FilterProps): JSX.Element {
|
||||
const onChangeSelectGroupHandler = useCallback(
|
||||
(value: unknown) => {
|
||||
if (typeof value === 'object' && Array.isArray(value)) {
|
||||
onSelectedGroupChange(
|
||||
value.map((e) => ({
|
||||
value: e,
|
||||
})),
|
||||
);
|
||||
}
|
||||
},
|
||||
[onSelectedGroupChange],
|
||||
);
|
||||
|
||||
const onChangeSelectedFilterHandler = useCallback(
|
||||
(value: unknown) => {
|
||||
if (typeof value === 'object' && Array.isArray(value)) {
|
||||
onSelectedFilterChange(
|
||||
value.map((e) => ({
|
||||
value: e,
|
||||
})),
|
||||
);
|
||||
}
|
||||
},
|
||||
[onSelectedFilterChange],
|
||||
);
|
||||
|
||||
const uniqueLabels: Array<string> = useMemo(() => {
|
||||
const allLabelsSet = new Set<string>();
|
||||
allAlerts.forEach((e) => {
|
||||
if (!e.labels) {
|
||||
return;
|
||||
}
|
||||
Object.keys(e.labels).forEach((e) => {
|
||||
allLabelsSet.add(e);
|
||||
});
|
||||
});
|
||||
return [...allLabelsSet];
|
||||
}, [allAlerts]);
|
||||
|
||||
const options = uniqueLabels.map((e) => ({
|
||||
value: e,
|
||||
title: '',
|
||||
}));
|
||||
|
||||
const getTags: SelectProps['tagRender'] = (props): JSX.Element => {
|
||||
const { closable, onClose, label } = props;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
color="magenta"
|
||||
closable={closable}
|
||||
onClose={onClose}
|
||||
style={{ marginRight: 3 }}
|
||||
>
|
||||
{label}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Select
|
||||
allowClear
|
||||
onChange={onChangeSelectedFilterHandler}
|
||||
mode="tags"
|
||||
value={selectedFilter.map((e) => e.value)}
|
||||
placeholder="Filter by Tags - e.g. severity:warning, alertname:Sample Alert"
|
||||
tagRender={(props): JSX.Element => getTags(props)}
|
||||
options={[]}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
onChange={onChangeSelectGroupHandler}
|
||||
mode="tags"
|
||||
defaultValue={selectedGroup.map((e) => e.value)}
|
||||
showArrow
|
||||
placeholder="Group by any tag"
|
||||
tagRender={(props): JSX.Element => getTags(props)}
|
||||
options={options}
|
||||
optionRender={(option): JSX.Element => (
|
||||
<TextOverflowTooltip option={option} />
|
||||
)}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterProps {
|
||||
onSelectedFilterChange: (value: Array<Value>) => void;
|
||||
onSelectedGroupChange: (value: Array<Value>) => void;
|
||||
allAlerts: Alerts[];
|
||||
selectedGroup: Array<Value>;
|
||||
selectedFilter: Array<Value>;
|
||||
}
|
||||
|
||||
export interface Value {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default Filter;
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Tag } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { Alerts } from 'types/api/alerts/getTriggered';
|
||||
|
||||
import Status from '../TableComponents/AlertStatus';
|
||||
import { TableCell, TableRow } from './styles';
|
||||
|
||||
function ExapandableRow({ allAlerts }: ExapandableRowProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
return (
|
||||
<>
|
||||
{allAlerts.map((alert) => {
|
||||
const { labels = {} } = alert;
|
||||
const labelsObject = Object.keys(labels);
|
||||
|
||||
const tags = labelsObject.filter((e) => e !== 'severity');
|
||||
|
||||
const formatedDate = new Date(alert.startsAt);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
bodyStyle={{
|
||||
minHeight: '5rem',
|
||||
marginLeft: '2rem',
|
||||
}}
|
||||
translate="yes"
|
||||
hoverable
|
||||
key={alert.fingerprint}
|
||||
>
|
||||
<TableCell minWidth="90px">
|
||||
<Status severity={alert.status.state} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell minWidth="90px" overflowX="scroll">
|
||||
<Typography>{labels.alertname || '-'}</Typography>
|
||||
</TableCell>
|
||||
|
||||
<TableCell minWidth="90px">
|
||||
<Typography>{labels.severity || '-'}</Typography>
|
||||
</TableCell>
|
||||
|
||||
<TableCell minWidth="90px">
|
||||
<Typography>{`${formatTimezoneAdjustedTimestamp(
|
||||
formatedDate,
|
||||
DATE_TIME_FORMATS.UTC_US,
|
||||
)}`}</Typography>
|
||||
</TableCell>
|
||||
|
||||
<TableCell minWidth="90px" overflowX="scroll">
|
||||
<div>
|
||||
{tags.map((e) => (
|
||||
<Tag key={e}>{`${e}:${labels[e]}`}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* <TableCell>
|
||||
<TableHeaderContainer>
|
||||
<Button type="link">Edit</Button>
|
||||
<Button type="link">Delete</Button>
|
||||
<Button type="link">Pause</Button>
|
||||
</TableHeaderContainer>
|
||||
</TableCell> */}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExapandableRowProps {
|
||||
allAlerts: Alerts[];
|
||||
}
|
||||
|
||||
export default ExapandableRow;
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { SquareMinus, SquarePlus } from '@signozhq/icons';
|
||||
import { Tag } from 'antd';
|
||||
import { Alerts } from 'types/api/alerts/getTriggered';
|
||||
|
||||
import ExapandableRow from './ExapandableRow';
|
||||
import { IconContainer, StatusContainer, TableCell, TableRow } from './styles';
|
||||
|
||||
function TableRowComponent({
|
||||
tags,
|
||||
tagsAlert,
|
||||
}: TableRowComponentProps): JSX.Element {
|
||||
const [isClicked, setIsClicked] = useState<boolean>(false);
|
||||
|
||||
const onClickHandler = (): void => {
|
||||
setIsClicked((state) => !state);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TableRow>
|
||||
<TableCell minWidth="90px">
|
||||
<StatusContainer>
|
||||
<IconContainer onClick={onClickHandler}>
|
||||
{!isClicked ? <SquarePlus size="md" /> : <SquareMinus size="md" />}
|
||||
</IconContainer>
|
||||
<>
|
||||
{tags.map((tag) => (
|
||||
<Tag color="magenta" key={tag}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
</StatusContainer>
|
||||
</TableCell>
|
||||
<TableCell minWidth="90px" />
|
||||
<TableCell minWidth="90px" />
|
||||
<TableCell minWidth="90px" />
|
||||
<TableCell minWidth="90px" />
|
||||
{/* <TableCell minWidth="200px">
|
||||
<Button type="primary">Resume Group</Button>
|
||||
</TableCell> */}
|
||||
</TableRow>
|
||||
{isClicked && <ExapandableRow allAlerts={tagsAlert} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableRowComponentProps {
|
||||
tags: string[];
|
||||
tagsAlert: Alerts[];
|
||||
}
|
||||
|
||||
export default TableRowComponent;
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import groupBy from 'lodash-es/groupBy';
|
||||
import { Alerts } from 'types/api/alerts/getTriggered';
|
||||
|
||||
import { Value } from '../Filter';
|
||||
import { FilterAlerts } from '../utils';
|
||||
import { Container, TableHeader, TableHeaderContainer } from './styles';
|
||||
import TableRowComponent from './TableRow';
|
||||
|
||||
function FilteredTable({
|
||||
selectedGroup,
|
||||
allAlerts,
|
||||
selectedFilter,
|
||||
}: FilteredTableProps): JSX.Element {
|
||||
const allGroupsAlerts = useMemo(
|
||||
() =>
|
||||
groupBy(FilterAlerts(allAlerts, selectedFilter), (obj) =>
|
||||
selectedGroup.map((e) => obj.labels?.[`${e.value}`]).join('+'),
|
||||
),
|
||||
[selectedGroup, allAlerts, selectedFilter],
|
||||
);
|
||||
|
||||
const tags = Object.keys(allGroupsAlerts);
|
||||
const tagsAlerts = Object.values(allGroupsAlerts);
|
||||
|
||||
const headers = [
|
||||
'Status',
|
||||
'Alert Name',
|
||||
'Severity',
|
||||
'Firing Since',
|
||||
'Tags',
|
||||
// 'Actions',
|
||||
];
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TableHeaderContainer>
|
||||
{headers.map((header) => (
|
||||
<TableHeader key={header} minWidth="90px">
|
||||
{header}
|
||||
</TableHeader>
|
||||
))}
|
||||
</TableHeaderContainer>
|
||||
|
||||
{tags.map((e, index) => {
|
||||
const tagsValue = e.split('+').filter((e) => e);
|
||||
const tagsAlert: Alerts[] = tagsAlerts[index];
|
||||
|
||||
if (tagsAlert.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { labels = {} } = tagsAlert[0];
|
||||
const keysArray = Object.keys(labels);
|
||||
const valueArray: string[] = [];
|
||||
|
||||
keysArray.forEach((e) => {
|
||||
valueArray.push(labels[e]);
|
||||
});
|
||||
|
||||
const tags = tagsValue
|
||||
.map((e) => keysArray[valueArray.findIndex((value) => value === e) || 0])
|
||||
.map((e, index) => `${e}:${tagsValue[index]}`);
|
||||
|
||||
return <TableRowComponent key={e} tagsAlert={tagsAlert} tags={tags} />;
|
||||
})}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilteredTableProps {
|
||||
selectedGroup: Value[];
|
||||
allAlerts: Alerts[];
|
||||
selectedFilter: Value[];
|
||||
}
|
||||
|
||||
export default FilteredTable;
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Card } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const TableHeader = styled(Card)<Props>`
|
||||
&&& {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
.ant-card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
min-width: ${(props): string => props.minWidth || ''};
|
||||
}
|
||||
`;
|
||||
|
||||
export const TableHeaderContainer = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const Container = styled.div`
|
||||
&&& {
|
||||
display: flex;
|
||||
margin-top: 1rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TableRow = styled(Card)`
|
||||
&&& {
|
||||
flex: 1;
|
||||
.ant-card-body {
|
||||
padding: 0rem;
|
||||
display: flex;
|
||||
|
||||
min-height: 3rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
minWidth?: string;
|
||||
overflowX?: string;
|
||||
}
|
||||
export const TableCell = styled.div<Props>`
|
||||
&&& {
|
||||
flex: 1;
|
||||
min-width: ${(props): string => props.minWidth || ''};
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
overflow-x: ${(props): string => props.overflowX || 'none'};
|
||||
::-webkit-scrollbar {
|
||||
height: ${(props): string => (props.overflowX ? '2px' : '8px')};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const StatusContainer = styled.div`
|
||||
&&& {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export const IconContainer = styled.div`
|
||||
&&& {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
`;
|
||||
@@ -1,110 +0,0 @@
|
||||
import { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import LabelColumn from 'components/TableRenderer/LabelColumn';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import AlertStatus from 'container/TriggeredAlerts/TableComponents/AlertStatus';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { Alerts } from 'types/api/alerts/getTriggered';
|
||||
|
||||
import { Value } from './Filter';
|
||||
import { FilterAlerts } from './utils';
|
||||
|
||||
const severitySorter = (a: Alerts, b: Alerts): number => {
|
||||
const severityLengthOfA = a.labels?.severity?.length || 0;
|
||||
const severityLengthOfB = b.labels?.severity?.length || 0;
|
||||
return severityLengthOfB - severityLengthOfA;
|
||||
};
|
||||
|
||||
function NoFilterTable({
|
||||
allAlerts,
|
||||
selectedFilter,
|
||||
}: NoFilterTableProps): JSX.Element {
|
||||
const filteredAlerts = FilterAlerts(allAlerts, selectedFilter);
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
// need to add the filter
|
||||
const columns: ColumnsType<Alerts> = [
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
key: 'status',
|
||||
sorter: (a, b): number => severitySorter(a, b),
|
||||
render: (value): JSX.Element => <AlertStatus severity={value.state} />,
|
||||
},
|
||||
{
|
||||
title: 'Alert Name',
|
||||
dataIndex: 'labels',
|
||||
key: 'alertName',
|
||||
width: 100,
|
||||
sorter: (a, b): number =>
|
||||
(a.labels?.alertname?.charCodeAt(0) || 0) -
|
||||
(b.labels?.alertname?.charCodeAt(0) || 0),
|
||||
render: (data): JSX.Element => {
|
||||
const name = data?.alertname || '';
|
||||
return <Typography>{name}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Tags',
|
||||
dataIndex: 'labels',
|
||||
key: 'tags',
|
||||
width: 100,
|
||||
render: (labels): JSX.Element => {
|
||||
const objectKeys = Object.keys(labels);
|
||||
const withOutSeverityKeys = objectKeys.filter((e) => e !== 'severity');
|
||||
|
||||
if (withOutSeverityKeys.length === 0) {
|
||||
return <Typography>-</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<LabelColumn labels={withOutSeverityKeys} value={labels} color="magenta" />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Severity',
|
||||
dataIndex: 'labels',
|
||||
key: 'severity',
|
||||
width: 100,
|
||||
sorter: (a, b): number => severitySorter(a, b),
|
||||
render: (value): JSX.Element => {
|
||||
const objectKeys = Object.keys(value);
|
||||
const withSeverityKey = objectKeys.find((e) => e === 'severity') || '';
|
||||
const severityValue = value[withSeverityKey];
|
||||
|
||||
return <Typography>{severityValue}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Firing Since',
|
||||
dataIndex: 'startsAt',
|
||||
width: 100,
|
||||
sorter: (a, b): number =>
|
||||
new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(),
|
||||
render: (date): JSX.Element => (
|
||||
<Typography>{`${formatTimezoneAdjustedTimestamp(
|
||||
date,
|
||||
DATE_TIME_FORMATS.UTC_US,
|
||||
)}`}</Typography>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
rowKey={(record): string => `${record.startsAt}-${record.fingerprint}`}
|
||||
dataSource={filteredAlerts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface NoFilterTableProps {
|
||||
allAlerts: Alerts[];
|
||||
selectedFilter: Value[];
|
||||
}
|
||||
|
||||
export default NoFilterTable;
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Tag } from 'antd';
|
||||
|
||||
function Severity({ severity }: SeverityProps): JSX.Element {
|
||||
switch (severity) {
|
||||
case 'unprocessed': {
|
||||
return <Tag color="green">UnProcessed</Tag>;
|
||||
}
|
||||
|
||||
case 'active': {
|
||||
return <Tag color="red">Firing</Tag>;
|
||||
}
|
||||
|
||||
case 'suppressed': {
|
||||
return <Tag color="red">Suppressed</Tag>;
|
||||
}
|
||||
|
||||
default: {
|
||||
return <Tag color="default">Unknown Status</Tag>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SeverityProps {
|
||||
severity: string;
|
||||
}
|
||||
|
||||
export default Severity;
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Alerts } from 'types/api/alerts/getTriggered';
|
||||
|
||||
import Filter, { Value } from './Filter';
|
||||
import FilteredTable from './FilteredTable';
|
||||
import NoFilterTable from './NoFilterTable';
|
||||
import { NoTableContainer } from './styles';
|
||||
|
||||
function TriggeredAlerts({
|
||||
allAlerts,
|
||||
selectedFilter,
|
||||
selectedGroup,
|
||||
onSelectedFilterChange,
|
||||
onSelectedGroupChange,
|
||||
}: TriggeredAlertsProps): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<Filter
|
||||
allAlerts={allAlerts}
|
||||
selectedFilter={selectedFilter}
|
||||
selectedGroup={selectedGroup}
|
||||
onSelectedFilterChange={onSelectedFilterChange}
|
||||
onSelectedGroupChange={onSelectedGroupChange}
|
||||
/>
|
||||
|
||||
{selectedFilter.length === 0 && selectedGroup.length === 0 ? (
|
||||
<NoTableContainer>
|
||||
<NoFilterTable selectedFilter={selectedFilter} allAlerts={allAlerts} />
|
||||
</NoTableContainer>
|
||||
) : (
|
||||
<div>
|
||||
{selectedFilter.length !== 0 && selectedGroup.length === 0 ? (
|
||||
<NoTableContainer>
|
||||
<NoFilterTable selectedFilter={selectedFilter} allAlerts={allAlerts} />
|
||||
</NoTableContainer>
|
||||
) : (
|
||||
<FilteredTable
|
||||
allAlerts={allAlerts}
|
||||
selectedFilter={selectedFilter}
|
||||
selectedGroup={selectedGroup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TriggeredAlertsProps {
|
||||
allAlerts: Alerts[];
|
||||
selectedFilter: Array<Value>;
|
||||
selectedGroup: Array<Value>;
|
||||
onSelectedFilterChange: (value: Array<Value>) => void;
|
||||
onSelectedGroupChange: (value: Array<Value>) => void;
|
||||
}
|
||||
|
||||
export default TriggeredAlerts;
|
||||
@@ -0,0 +1,124 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 62px);
|
||||
min-height: 400px;
|
||||
padding-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
.filtersRow {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--spacing-8);
|
||||
|
||||
--combobox-trigger-height: 2rem;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
min-width: 300px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
--tanstack-table-header-cell-bg: var(--l2-background);
|
||||
--tanstack-table-header-cell-color: var(--l2-foreground);
|
||||
--tanstack-table-cell-bg: var(--l2-background);
|
||||
--tanstack-table-cell-color: var(--l2-foreground);
|
||||
--tanstack-table-row-hover-bg: var(--l2-background-hover);
|
||||
--tanstack-table-row-active-bg: var(--l2-background-active);
|
||||
--tanstack-table-resize-handle-bg: var(--l2-background);
|
||||
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
|
||||
--tanstack-table-row-height: 42px;
|
||||
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-left-override: 5px;
|
||||
--tanstack-cell-padding-right-override: 5px;
|
||||
|
||||
--tanstack-cell-padding-left-override: 16px;
|
||||
--tanstack-cell-padding-right-override: 16px;
|
||||
|
||||
--tanstack-table-row-odd-bg: color-mix(
|
||||
in srgb,
|
||||
var(--l1-foreground) 2%,
|
||||
transparent
|
||||
);
|
||||
--tanstack-table-row-even-bg: color-mix(
|
||||
in srgb,
|
||||
var(--l1-foreground) 1%,
|
||||
transparent
|
||||
);
|
||||
|
||||
--tanstack-expansion-first-col-padding-left: 20px;
|
||||
|
||||
--badge-cursor: pointer;
|
||||
}
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.groupCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tagsContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.filterBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.filterBadgeClose {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.paginationContainer {
|
||||
padding-right: var(--spacing-12);
|
||||
height: 62px;
|
||||
}
|
||||