Compare commits

..

22 Commits

Author SHA1 Message Date
Vinícius Lourenço
5982c0854d fix(alerts): missing including error empty state 2026-05-13 16:35:47 -03:00
Vinícius Lourenço
687b40ffbb refactor(alerts): remove barrel imports 2026-05-13 13:23:01 -03:00
Vinícius Lourenço
4e111c6b83 refactor(alerts): ensure no empty has correct colors 2026-05-13 12:51:21 -03:00
Vinícius Lourenço
3f5eb62494 fix(tanstack): preserve page from URL on refresh page 2026-05-13 12:50:28 -03:00
Vinícius Lourenço
cd7b6a1d05 refactor(alerts): missing pagination class on group 2026-05-13 12:45:52 -03:00
Vinícius Lourenço
faee2f032f refactor(alerts): standardize errors and empty states 2026-05-13 12:42:40 -03:00
Vinícius Lourenço
0402cc0273 refactor(alerts): cleanup dead components 2026-05-13 12:22:57 -03:00
Vinícius Lourenço
b70f057adc refactor(alerts): disable move columns 2026-05-13 11:48:09 -03:00
Vinícius Lourenço
3b7b7202e9 refactor(alerts): remove extra columns & add back info tooltip 2026-05-13 11:45:55 -03:00
Vinícius Lourenço
e3c9babfe5 refactor(alerts): move table to use pagination instead of infinity load 2026-05-13 11:34:13 -03:00
Vinícius Lourenço
226e40cbcd refactor(alerts): removed stats card 2026-05-13 11:12:14 -03:00
Vinícius Lourenço
0f4d007104 chore(lint): fix signozhq/ui imports 2026-05-13 09:57:01 -03:00
Vinícius Lourenço
86b88eb10b chore(pr-comments): address PR comments 2026-05-13 09:55:59 -03:00
Vinícius Lourenço
0b21197689 Merge branch 'main' into feat/alerts-revamp 2026-05-13 09:35:44 -03:00
Vinicius Lourenço
6c02fe107f feat(triggered-alerts): rewrite page to use new table component (#11260)
* feat(triggered-alerts): rewrite page

* chore(triggered-alerts): move reason tooltip content to own component
2026-05-13 09:24:50 -03:00
Vinicius Lourenço
a90e915fa3 feat(list-alerts): rewrite page to use new table component (#11276) 2026-05-13 09:14:32 -03:00
Vinícius Lourenço
1a4de4328b feat(alerts-components): add badge map colors 2026-05-11 17:08:44 -03:00
Vinícius Lourenço
c53adf365a feat(time-utils): add little helper to get elapsed time in ms 2026-05-11 16:40:41 -03:00
Vinícius Lourenço
0fc16e02fa feat(hooks): add shared hooks for alerts 2026-05-11 16:37:42 -03:00
Vinícius Lourenço
fb6a29e6fa feat(components): added shared components for alerts 2026-05-11 15:52:26 -03:00
Vinícius Lourenço
0daf7a12da chore(signozhq/ui): bump to v0.0.19 2026-05-11 14:47:09 -03:00
Vinícius Lourenço
cc7d7017ae feat(tanstack-table): add showPageSize flag and callbacks to pagination 2026-05-11 14:45:20 -03:00
94 changed files with 3918 additions and 2343 deletions

View File

@@ -5,15 +5,9 @@ cd frontend && pnpm run commitlint --edit $1
branch="$(git rev-parse --abbrev-ref HEAD)"
if [ -n "$TERM" ] && [ "$TERM" != "dumb" ]; then
color_red="$(tput setaf 1)"
bold="$(tput bold)"
reset="$(tput sgr0)"
else
color_red=""
bold=""
reset=""
fi
color_red="$(tput setaf 1)"
bold="$(tput bold)"
reset="$(tput sgr0)"
if [ "$branch" = "main" ]; then
echo "${color_red}${bold}You can't commit directly to the main branch${reset}"

View File

@@ -51,7 +51,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.4.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.18",
"@signozhq/ui": "0.0.19",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",

View File

@@ -89,8 +89,8 @@ importers:
specifier: 0.0.2
version: 0.0.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@signozhq/ui':
specifier: 0.0.18
version: 0.0.18(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.27.0(react@18.2.0))(react@18.2.0)
specifier: 0.0.19
version: 0.0.19(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.27.0(react@18.2.0))(react@18.2.0)
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -1907,89 +1907,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -2344,48 +2360,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.47.0':
resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.47.0':
resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.47.0':
resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.47.0':
resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==}
@@ -2488,48 +2512,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.62.0':
resolution: {integrity: sha512-8eCy3FCDuWUM5hWujAv6heMvfZPbcCOU3SdQUAkixZLu5bSzOkNfirJiLGoQFO943xceOKkiQRMQNzH++jM3WA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.62.0':
resolution: {integrity: sha512-NjQ7K7tpTPDe9J+yq8p/s/J0E7lRCkK2uDBDqvT4XIT6f4Z0tlnr59OBg/WcrmVHER1AbrcfyxhGTXgcG8ytWg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.62.0':
resolution: {integrity: sha512-oKZed9gmSwze29dEt3/Wnsv6l/Ygw/FUst+8Kfpv2SGeS/glEoTGZAMQw37SVyzFV76UTHJN2snGgxK2t2+8ow==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.62.0':
resolution: {integrity: sha512-gBjBxQ+9lGpAYq+ELqw0w8QXsBnkZclFc7GRX2r0LnEVn3ZTEqeIKpKcGjucmp76Q53bvJD0i4qBWBhcfhSfGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.62.0':
resolution: {integrity: sha512-Ew2Kxs9EQ9/mbAIJ2hvocMC0wsOu6YKzStI2eFBDt+Td5O8seVC/oxgRIHqCcl5sf5ratA1nozQBAuv7tphkHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.62.0':
resolution: {integrity: sha512-5z25jcAA0gfKyVwz71A0VXgaPlocPoTAxhlv/hgoK6tlCrfoNuw7haWbDHvGMfjXhdic4EqVXGRv5XsTqFnbRQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.62.0':
resolution: {integrity: sha512-IWpHmMB6ZDllPvqWDkG6AmXrN7JF5e/c4g/0PuURsmlK+vHoYZPB70rr4u1bn3I4LsKCSpqqfveyx6UCOC8wdg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.62.0':
resolution: {integrity: sha512-fjlSxxrD5pA594vkyikCS9MnPRjQawW6/BLgyTYkO+73wwPlYjkcZ7LSd974l0Q2zkHQmu4DPvJFLYA7o8xrxQ==}
@@ -2584,36 +2616,42 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@@ -3474,24 +3512,28 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.53':
resolution: {integrity: sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.53':
resolution: {integrity: sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-beta.53':
resolution: {integrity: sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-beta.53':
resolution: {integrity: sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A==}
@@ -3644,8 +3686,8 @@ packages:
peerDependencies:
react: ^18.2.0
'@signozhq/ui@0.0.18':
resolution: {integrity: sha512-1p3ALh76kafiz5yX7ReNKVcHDt2od7CcZD/Vx9i2adTwTeynkLJcEfVoXoJD3oh1kKTleooOiOjRyxlA7VzmSA==}
'@signozhq/ui@0.0.19':
resolution: {integrity: sha512-2q6aRxN/PR4PlR2xJZAREEuvLPiDFggfFKzCW2Z5vHVVbrgnvZHWD1jPUuwszfEg0ceH3UvkwqceO7wN4uRJAA==}
peerDependencies:
'@signozhq/icons': 0.3.0
react: ^18.2.0
@@ -4266,41 +4308,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -4367,7 +4417,7 @@ packages:
resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: npm:rolldown-vite@7.3.1
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -7194,24 +7244,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -10239,7 +10293,7 @@ packages:
oxlint: '>=1'
stylelint: '>=16'
typescript: '*'
vite: npm:rolldown-vite@7.3.1
vite: '>=5.4.21'
vls: '*'
vti: '*'
vue-tsc: ~2.2.10 || ^3.0.0
@@ -10268,12 +10322,12 @@ packages:
vite-plugin-compression@0.5.1:
resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
peerDependencies:
vite: npm:rolldown-vite@7.3.1
vite: '>=2.0.0'
vite-plugin-html@3.2.2:
resolution: {integrity: sha512-vb9C9kcdzcIo/Oc3CLZVS03dL5pDlOFuhGlZYDCJ840BhWl/0nGeZWf3Qy7NlOayscY4Cm/QRgULCQkEZige5Q==}
peerDependencies:
vite: npm:rolldown-vite@7.3.1
vite: '>=2.0.0'
vite-plugin-image-optimizer@2.0.3:
resolution: {integrity: sha512-1vrFOTcpSvv6DCY7h8UXab4wqMAjTJB/ndOzG/Kmj1oDOuPF6mbjkNQoGzzCEYeWGe7qU93jc8oQqvoJ57al3A==}
@@ -10281,7 +10335,7 @@ packages:
peerDependencies:
sharp: '>=0.34.0'
svgo: '>=4'
vite: npm:rolldown-vite@7.3.1
vite: '>=5'
peerDependenciesMeta:
sharp:
optional: true
@@ -10291,7 +10345,7 @@ packages:
vite-tsconfig-paths@6.1.1:
resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==}
peerDependencies:
vite: npm:rolldown-vite@7.3.1
vite: '*'
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
@@ -13901,7 +13955,7 @@ snapshots:
- react-dom
- tailwindcss
'@signozhq/ui@0.0.18(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.27.0(react@18.2.0))(react@18.2.0)':
'@signozhq/ui@0.0.19(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.27.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@chenglou/pretext': 0.0.5
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { default } from './ErrorEmptyState';

View File

@@ -0,0 +1,36 @@
.labelColumn {
display: flex;
gap: 4px;
align-items: center;
overflow-x: auto;
max-width: 100%;
}
.labelBadge {
cursor: default;
font-size: 12px;
--badge-display: inline;
max-width: 180px;
min-width: 100px;
text-overflow: ellipsis;
}
.labelPopover {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
max-height: 300px;
overflow-y: auto;
}
.labelBadgePopover {
font-size: 12px;
}
.labelValue {
text-overflow: ellipsis;
overflow: hidden;
}

View File

@@ -0,0 +1,89 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LabelColumn from './LabelColumn';
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 more than 5 labels', () => {
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
renderWithProviders(<LabelColumn labels={labels} />);
// First 3 visible
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
// +3 badge for remaining
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 popover with all labels when clicking +N badge', async () => {
const user = userEvent.setup();
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
renderWithProviders(<LabelColumn labels={labels} />);
await user.click(screen.getByTestId('label-overflow-badge'));
// All labels should appear in popover
expect(screen.getByTestId('label-popover')).toBeInTheDocument();
expect(screen.getByTestId('label-popover-item-env')).toBeInTheDocument();
expect(screen.getByTestId('label-popover-item-version')).toBeInTheDocument();
});
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();
});
});

View File

@@ -0,0 +1,80 @@
import { Badge } from '@signozhq/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import LabelTag from './LabelTag';
import styles from './LabelColumn.module.scss';
export interface LabelColumnProps {
labels: string[];
color?:
| 'primary'
| 'secondary'
| 'success'
| 'error'
| 'warning'
| 'robin'
| 'forest'
| 'amber'
| 'sienna'
| 'cherry'
| 'sakura'
| 'aqua'
| 'vanilla';
value?: { [key: string]: string };
}
const MAX_LABELS_TO_DISPLAY = 5;
function LabelColumn({
labels,
value,
color = 'primary',
}: LabelColumnProps): JSX.Element {
const visibleLabels =
labels.length > MAX_LABELS_TO_DISPLAY ? labels.slice(0, 3) : labels;
const remainingLabels =
labels.length > MAX_LABELS_TO_DISPLAY ? labels.slice(3) : [];
return (
<div className={styles.labelColumn} data-testid="label-column">
{visibleLabels.map((label) => (
<LabelTag key={label} label={label} color={color} value={value?.[label]} />
))}
{remainingLabels.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<Badge
color={color}
className={styles.labelBadge}
variant="outline"
data-testid="label-overflow-badge"
>
+{remainingLabels.length}
</Badge>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
className={styles.labelPopover}
data-testid="label-popover"
>
{labels.map((label) => (
<Badge
key={label}
color={color}
className={styles.labelBadgePopover}
variant="outline"
data-testid={`label-popover-item-${label}`}
>
{value?.[label] ? `${label}: ${value?.[label]}` : label}
</Badge>
))}
</PopoverContent>
</Popover>
)}
</div>
);
}
export default LabelColumn;

View File

@@ -0,0 +1,15 @@
.labelBadge {
cursor: default;
font-size: 12px;
--badge-display: inline;
max-width: 180px;
min-width: 100px;
text-overflow: ellipsis;
}
.labelValue {
text-overflow: ellipsis;
overflow: hidden;
}

View File

@@ -0,0 +1,42 @@
import { Badge } from '@signozhq/ui/badge';
import { TooltipSimple } from '@signozhq/ui/tooltip';
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 tooltipTitle = value ? `${label}: ${value}` : label;
return (
<TooltipSimple title={tooltipTitle}>
<Badge
color={color}
className={styles.labelBadge}
variant="outline"
data-testid={`label-tag-${label}`}
>
<span className={styles.labelValue}>{tooltipTitle}</span>
</Badge>
</TooltipSimple>
);
}
export default LabelTag;

View File

@@ -0,0 +1,2 @@
export { default } from './LabelColumn';
export type { LabelColumnProps } from './LabelColumn';

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { default } from './NoResultsEmptyState';

View 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',
};

View File

@@ -0,0 +1,7 @@
export interface FilterValue {
value: string;
}
export interface AlertWithLabels {
labels?: Record<string, string>;
}

View 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']);
});
});

View 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);
});
});
}

View File

@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Dot, Sparkles } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import {
@@ -97,7 +97,7 @@ function HeaderRightSection({
</span>
) : null}
<Tooltip title="AI Assistant">
<TooltipSimple title="AI Assistant">
<Button
variant="solid"
color="secondary"
@@ -113,7 +113,7 @@ function HeaderRightSection({
>
AI Assistant
</Button>
</Tooltip>
</TooltipSimple>
</div>
)}

View File

@@ -39,6 +39,7 @@ import {
} from './TanStackTableStateContext';
import {
FlatItem,
SortState,
TableRowContext,
TanStackTableHandle,
TanStackTableProps,
@@ -100,6 +101,7 @@ function TanStackTableInner<TData>(
onRowClick,
onRowClickNewTab,
onRowDeactivate,
onSort,
activeRowIndex,
renderExpandedRow,
getRowCanExpand,
@@ -127,10 +129,10 @@ function TanStackTableInner<TData>(
const {
page,
limit,
setPage,
setLimit,
setPage: internalSetPage,
setLimit: internalSetLimit,
orderBy,
setOrderBy,
setOrderBy: internalSetOrderBy,
expanded,
setExpanded,
} = useTableParams(enableQueryParams, {
@@ -138,6 +140,31 @@ function TanStackTableInner<TData>(
limit: pagination?.defaultLimit,
});
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 setOrderBy = useCallback(
(sort: SortState | null) => {
internalSetOrderBy(sort);
onSort?.(sort);
},
[internalSetOrderBy, onSort],
);
const isGrouped = (groupBy?.length ?? 0) > 0;
const {
@@ -607,14 +634,17 @@ function TanStackTableInner<TData>(
setPage(p);
}}
/>
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => setLimit(+value)}
items={paginationPageSizeItems}
/>
</div>
{(pagination.showPageSize ?? true) && (
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
testId="pagination-page-size"
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => setLimit(+value)}
items={paginationPageSizeItems}
/>
</div>
)}
{suffixPaginationContent}
</div>
)}

View File

@@ -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,6 +23,14 @@ jest.mock('../TanStackTable.module.scss', () => ({
},
}));
beforeAll(() => {
window.ResizeObserver = jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
});
describe('TanStackTableView Integration', () => {
describe('rendering', () => {
it('renders all data rows', async () => {
@@ -269,6 +277,54 @@ describe('TanStackTableView Integration', () => {
screen.queryByTestId('pagination-total-count'),
).not.toBeInTheDocument();
});
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();
renderTanStackTable({
props: {
pagination: { total: 100, defaultPage: 1, defaultLimit: 5 },
enableQueryParams: true,
},
});
const nav = await screen.findByRole('navigation');
const page1Button = within(nav).getByRole('button', { name: '1' });
const page2Button = within(nav).getByRole('button', { name: '2' });
expect(page1Button).toHaveAttribute('aria-current', 'page');
await user.click(page2Button);
await waitFor(() => {
expect(page2Button).toHaveAttribute('aria-current', 'page');
});
await user.click(screen.getByTestId('pagination-page-size'));
const option10 = await screen.findByRole('option', { name: '10' });
await user.click(option10);
await waitFor(() => {
expect(page1Button).toHaveAttribute('aria-current', 'page');
});
});
});
describe('sorting', () => {

View File

@@ -117,6 +117,10 @@ export type PaginationProps = {
defaultLimit?: number;
showTotalCount?: boolean;
totalCountLabel?: string;
/** @default true */
showPageSize?: boolean;
onPageChange?: (page: number) => void;
onLimitChange?: (limit: number) => void;
};
export type TanstackTableQueryParamsConfig = {
@@ -160,6 +164,8 @@ export type TanStackTableProps<TData> = {
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: string) => void;
onRowDeactivate?: () => void;
/** Called when sort state changes */
onSort?: (sort: SortState | null) => void;
activeRowIndex?: number;
renderExpandedRow?: (
row: TData,

View File

@@ -172,22 +172,23 @@ 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) {
setUrlPage(pageDefault);
} else {
setLocalPage(pageDefault);
}
}
}, [
useUrlForPage,
orderByDefaultMemoKey,
orderByUrlMemoKey,
pageDefault,
setUrlPage,
]);
prevOrderByRef.current = orderByUrlMemoKey;
}, [useUrlForPage, orderByUrlMemoKey, pageDefault, setUrlPage]);
return {
page: useUrlForPage ? urlPage : localPage,

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Drawer } from 'antd';
import ROUTES from 'constants/routes';
import { Maximize2, MessageSquare, Plus, X } from '@signozhq/icons';
@@ -52,7 +52,7 @@ export default function AIAssistantDrawer(): JSX.Element {
</div>
<div>
<Tooltip title="New conversation">
<TooltipSimple title="New conversation">
<Button
variant="ghost"
size="icon"
@@ -62,9 +62,9 @@ export default function AIAssistantDrawer(): JSX.Element {
>
<Plus size={16} />
</Button>
</Tooltip>
</TooltipSimple>
<Tooltip title="Open full screen">
<TooltipSimple title="Open full screen">
<Button
variant="ghost"
size="icon"
@@ -75,9 +75,9 @@ export default function AIAssistantDrawer(): JSX.Element {
>
<Maximize2 size={16} />
</Button>
</Tooltip>
</TooltipSimple>
<Tooltip title="Close">
<TooltipSimple title="Close">
<Button
variant="ghost"
size="icon"
@@ -87,7 +87,7 @@ export default function AIAssistantDrawer(): JSX.Element {
>
<X size={16} />
</Button>
</Tooltip>
</TooltipSimple>
</div>
</div>
}

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Minus, Plus, Sparkles, X } from '@signozhq/icons';
@@ -132,7 +132,7 @@ export default function AIAssistantModal(): JSX.Element | null {
</div>
<div className={styles.actions}>
<Tooltip title={showHistory ? 'Back to chat' : 'Conversations'}>
<TooltipSimple title={showHistory ? 'Back to chat' : 'Conversations'}>
<Button
variant="ghost"
size="icon"
@@ -142,9 +142,9 @@ export default function AIAssistantModal(): JSX.Element | null {
>
<History size={14} />
</Button>
</Tooltip>
</TooltipSimple>
<Tooltip title="New conversation">
<TooltipSimple title="New conversation">
<Button
variant="ghost"
size="icon"
@@ -153,9 +153,9 @@ export default function AIAssistantModal(): JSX.Element | null {
>
<Plus size={14} />
</Button>
</Tooltip>
</TooltipSimple>
<Tooltip title="Open full screen">
<TooltipSimple title="Open full screen">
<Button
variant="ghost"
size="icon"
@@ -165,9 +165,9 @@ export default function AIAssistantModal(): JSX.Element | null {
>
<Maximize2 size={14} />
</Button>
</Tooltip>
</TooltipSimple>
<Tooltip title="Minimize to side panel">
<TooltipSimple title="Minimize to side panel">
<Button
variant="ghost"
size="icon"
@@ -176,9 +176,9 @@ export default function AIAssistantModal(): JSX.Element | null {
>
<Minus size={14} />
</Button>
</Tooltip>
</TooltipSimple>
<Tooltip title="Close">
<TooltipSimple title="Close">
<Button
variant="ghost"
size="icon"
@@ -187,7 +187,7 @@ export default function AIAssistantModal(): JSX.Element | null {
>
<X size={14} />
</Button>
</Tooltip>
</TooltipSimple>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { matchPath, useHistory, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Plus, Sparkles, X } from '@signozhq/icons';
@@ -125,7 +125,7 @@ export default function AIAssistantPanel(): JSX.Element | null {
</div>
<div className={styles.actions}>
<Tooltip title={showHistory ? 'Back to chat' : 'Conversations'}>
<TooltipSimple title={showHistory ? 'Back to chat' : 'Conversations'}>
<Button
variant="ghost"
size="icon"
@@ -135,9 +135,9 @@ export default function AIAssistantPanel(): JSX.Element | null {
>
<History size={14} />
</Button>
</Tooltip>
</TooltipSimple>
<Tooltip title="New conversation">
<TooltipSimple title="New conversation">
<Button
variant="ghost"
size="icon"
@@ -147,9 +147,9 @@ export default function AIAssistantPanel(): JSX.Element | null {
>
<Plus size={14} />
</Button>
</Tooltip>
</TooltipSimple>
<Tooltip title="Open full screen">
<TooltipSimple title="Open full screen">
<Button
variant="ghost"
size="icon"
@@ -160,9 +160,9 @@ export default function AIAssistantPanel(): JSX.Element | null {
>
<Maximize2 size={14} />
</Button>
</Tooltip>
</TooltipSimple>
<Tooltip title="Close">
<TooltipSimple title="Close">
<Button
variant="ghost"
size="icon"
@@ -172,7 +172,7 @@ export default function AIAssistantPanel(): JSX.Element | null {
>
<X size={14} />
</Button>
</Tooltip>
</TooltipSimple>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { matchPath, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { Bot } from '@signozhq/icons';
@@ -30,7 +30,7 @@ export default function AIAssistantTrigger(): JSX.Element | null {
}
return (
<Tooltip title="AI Assistant">
<TooltipSimple title="AI Assistant">
<Button
variant="solid"
color="primary"
@@ -40,6 +40,6 @@ export default function AIAssistantTrigger(): JSX.Element | null {
>
<Bot size={20} />
</Button>
</Tooltip>
</TooltipSimple>
);
}

View File

@@ -12,7 +12,7 @@ import {
import cx from 'classnames';
import { v4 as uuidv4 } from 'uuid';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
ApplyFilterSignalDTO,
@@ -524,9 +524,9 @@ export default function ActionsSection({
);
return tooltip ? (
<Tooltip key={key} title={tooltip}>
<TooltipSimple key={key} title={tooltip}>
{chip}
</Tooltip>
</TooltipSimple>
) : (
<span key={key}>{chip}</span>
);

View File

@@ -5,7 +5,7 @@ import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { Tooltip } from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import type { UploadFile } from 'antd';
import {
getListRulesQueryKey,
@@ -899,7 +899,7 @@ export default function ChatInput({
</div>
</div>
) : (
<Tooltip title="Voice input">
<TooltipSimple title="Voice input">
<Button
variant="ghost"
size="icon"
@@ -910,11 +910,11 @@ export default function ChatInput({
>
<Mic size={14} />
</Button>
</Tooltip>
</TooltipSimple>
))}
{isStreaming && onCancel ? (
<Tooltip title="Stop generating">
<TooltipSimple title="Stop generating">
<Button
variant="solid"
size="icon"
@@ -924,7 +924,7 @@ export default function ChatInput({
>
<Square size={10} fill="currentColor" strokeWidth={0} />
</Button>
</Tooltip>
</TooltipSimple>
) : (
<Button
variant="solid"

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Tooltip } from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Plus, Search } from '@signozhq/icons';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
@@ -157,7 +157,7 @@ export default function ConversationsList({
{isLoadingThreads && <HeaderLoadingDots />}
{!isLoadingThreads && showAddNewConversation && (
<Tooltip title="New conversation">
<TooltipSimple title="New conversation">
<Button
variant="solid"
size="sm"
@@ -167,7 +167,7 @@ export default function ConversationsList({
>
<Plus size={12} />
</Button>
</Tooltip>
</TooltipSimple>
)}
</div>

View File

@@ -3,7 +3,7 @@ import cx from 'classnames';
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Tooltip } from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { Check, Copy, RefreshCw, ThumbsDown, ThumbsUp } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
@@ -126,7 +126,7 @@ export default function MessageFeedback({
<>
<div className={cx(styles.feedback, { [styles.visible]: isLastAssistant })}>
<div className={styles.actions}>
<Tooltip title={copied ? 'Copied!' : 'Copy'}>
<TooltipSimple title={copied ? 'Copied!' : 'Copy'}>
<Button
className={styles.btn}
size="icon"
@@ -136,9 +136,9 @@ export default function MessageFeedback({
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</Tooltip>
</TooltipSimple>
<Tooltip title="Good response">
<TooltipSimple title="Good response">
<Button
className={cx(styles.btn, { [styles.votedUp]: vote === 'positive' })}
size="icon"
@@ -148,9 +148,9 @@ export default function MessageFeedback({
>
<ThumbsUp size={12} />
</Button>
</Tooltip>
</TooltipSimple>
<Tooltip title="Bad response">
<TooltipSimple title="Bad response">
<Button
className={cx(styles.btn, {
[styles.votedDown]: vote === 'negative',
@@ -162,10 +162,10 @@ export default function MessageFeedback({
>
<ThumbsDown size={12} />
</Button>
</Tooltip>
</TooltipSimple>
{onRegenerate && (
<Tooltip title="Regenerate">
<TooltipSimple title="Regenerate">
<Button
className={styles.btn}
size="icon"
@@ -175,7 +175,7 @@ export default function MessageFeedback({
>
<RefreshCw size={12} />
</Button>
</Tooltip>
</TooltipSimple>
)}
</div>

View File

@@ -1,7 +1,7 @@
import { useCallback, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Check, Copy } from '@signozhq/icons';
import { Message } from '../../types';
@@ -32,7 +32,7 @@ export default function UserMessageActions({
return (
<div className={styles.actions}>
<Tooltip title={copied ? 'Copied!' : 'Copy'}>
<TooltipSimple title={copied ? 'Copied!' : 'Copy'}>
<Button
className={styles.btn}
size="icon"
@@ -42,7 +42,7 @@ export default function UserMessageActions({
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</Tooltip>
</TooltipSimple>
</div>
);
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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.ALERT_TYPE_SELECTION, {
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;

View File

@@ -0,0 +1,82 @@
.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: 5px;
--tanstack-cell-padding-right-override: 5px;
--badge-cursor: pointer;
}
.searchIcon {
color: var(--l2-foreground);
}
.actionsColumn {
display: flex;
justify-content: flex-end;
}
.paginationContainer {
padding-right: var(--spacing-12);
height: 62px;
}

View File

@@ -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;

View File

@@ -1,97 +0,0 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { patchRulePartial } from 'api/alerts/patchRulePartial';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
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 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,
}));
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;

View File

@@ -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: new Date('2024-01-01T00:00:00Z'),
createdBy: 'test-user',
updatedAt: new Date('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');
});
});

View File

@@ -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);
}
}

View File

@@ -0,0 +1,172 @@
import { 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 handleClone = (): 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',
},
);
};
const handleDelete = (): 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',
},
);
};
const handleToggle = (): 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',
},
);
};
const menuItems = useMemo(
() => [
{
key: 'toggle',
label: rule.disabled ? 'Enable' : 'Disable',
disabled: externalLoading,
onClick: handleToggle,
},
{
key: 'edit',
label: 'Edit',
disabled: externalLoading,
onClick: (): void => {
alertActionLogEvent(ALERT_ACTIONS.EDIT, rule);
onEdit(rule);
},
},
{
key: 'edit-new-tab',
label: 'Edit in New Tab',
disabled: externalLoading,
onClick: (): void => {
alertActionLogEvent(ALERT_ACTIONS.EDIT, rule);
onEdit(rule, { newTab: true });
},
},
{
key: 'clone',
label: 'Clone',
disabled: externalLoading,
onClick: handleClone,
},
{ key: 'divider', type: 'divider' as const },
{
key: 'delete',
label: 'Delete',
disabled: externalLoading,
danger: true,
onClick: handleDelete,
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[rule, externalLoading, onEdit],
);
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;

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
export { default as ActionsMenu } from './ActionsMenu';
export { default as ColumnSelector } from './ColumnSelector';

View 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),
);

View File

@@ -1,67 +1,179 @@
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 { 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 { orderBy, page, limit } = useTableParams(QUERY_PARAMS_CONFIG, {
page: DEFAULT_PAGE,
limit: DEFAULT_LIMIT,
});
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: '',
accessorKey: 'id',
width: { min: 50, default: 50 },
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 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,
defaultPage: DEFAULT_PAGE,
defaultLimit: DEFAULT_LIMIT,
showTotalCount: true,
}}
paginationClassname={styles.paginationContainer}
/>
)}
</div>
</div>
);
}

View File

@@ -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;
}
`;

View 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: { min: 80, 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: { min: 200, default: 300 },
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: { min: 120, default: 120 },
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: { min: 150, default: 250 },
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: { min: 180, default: 200 },
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: { min: 100, default: 120 },
enableSort: false,
enableMove: false,
defaultVisibility: false,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{String(value ?? '-')}</TanStackTable.Text>
),
},
{
id: 'updatedAt',
header: 'Updated At',
accessorKey: 'updatedAt',
width: { min: 180, default: 200 },
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: { min: 100, default: 120 },
enableSort: false,
enableMove: false,
defaultVisibility: false,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{String(value ?? '-')}</TanStackTable.Text>
),
},
];
}

View File

@@ -0,0 +1,3 @@
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
export type AlertRule = RuletypesRuleDTO;

View 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,
};
}

View File

@@ -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.ALERT_TYPE_SELECTION, {
newTab: isModifierKeyPressed(e),
});
},
[allRulesCount, safeNavigate],
);
const { handleRowClick, handleRowClickNewTab } = useTableRowClick<AlertRule>({
getUrl: getEditUrl,
onNavigate: safeNavigate,
});
return {
handleEdit,
handleNewAlert,
handleRowClick,
handleRowClickNewTab,
};
}

View File

@@ -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;
});
}

View File

@@ -1,5 +1,5 @@
import { Button } from '@signozhq/ui/button';
import { Tooltip, TooltipProvider } from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Copy } from '@signozhq/icons';
import './CopyIconButton.styles.scss';
@@ -19,22 +19,20 @@ function CopyIconButton({
: 'Copy to clipboard';
return (
<TooltipProvider>
<Tooltip title={tooltipTitle}>
<span>
<Button
color="secondary"
variant="ghost"
size="icon"
aria-label={ariaLabel}
disabled={disabled}
className="mcp-copy-btn"
prefix={<Copy size={14} />}
onClick={onCopy}
/>
</span>
</Tooltip>
</TooltipProvider>
<TooltipSimple title={tooltipTitle}>
<span>
<Button
color="secondary"
variant="ghost"
size="icon"
aria-label={ariaLabel}
disabled={disabled}
className="mcp-copy-btn"
prefix={<Copy size={14} />}
onClick={onCopy}
/>
</span>
</TooltipSimple>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
`;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,109 @@
.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: 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-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;
}

View File

@@ -1,84 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import NoFilterTable from '../NoFilterTable';
import { createAlert } from './mockUtils';
jest.mock('providers/Timezone', () => ({
useTimezone: jest.requireActual('./mockUtils').useMockTimezone,
}));
const allAlerts = [
createAlert({
name: 'Alert B',
labels: {
severity: 'warning',
alertname: 'Alert B',
},
}),
createAlert({
name: 'Alert C',
labels: {
severity: 'info',
alertname: 'Alert C',
},
}),
createAlert({
name: 'Alert A',
labels: {
severity: 'critical',
alertname: 'Alert A',
},
}),
];
describe('NoFilterTable', () => {
it('should render the no filter table with correct rows', () => {
render(<NoFilterTable allAlerts={allAlerts} selectedFilter={[]} />);
const rows = screen.getAllByRole('row');
expect(rows).toHaveLength(4); // 1 header row + 2 data rows
const [headerRow, dataRow1, dataRow2, dataRow3] = rows;
// Verify header row
expect(headerRow).toHaveTextContent('Status');
expect(headerRow).toHaveTextContent('Alert Name');
expect(headerRow).toHaveTextContent('Tags');
expect(headerRow).toHaveTextContent('Severity');
expect(headerRow).toHaveTextContent('Firing Since');
// Verify 1st data row
expect(dataRow1).toHaveTextContent('Alert B');
// Verify 2nd data row
expect(dataRow2).toHaveTextContent('Alert C');
// Verify 3rd data row
expect(dataRow3).toHaveTextContent('Alert A');
});
it('should sort the table by severity when header is clicked', () => {
render(<NoFilterTable allAlerts={allAlerts} selectedFilter={[]} />);
const headers = screen.getAllByRole('columnheader');
const severityHeader = headers.find((header) =>
header.textContent?.includes('Severity'),
);
expect(severityHeader).toBeInTheDocument();
if (severityHeader) {
const initialRows = screen.getAllByRole('row');
expect(initialRows).toHaveLength(4);
expect(initialRows[1]).toHaveTextContent('Alert B');
expect(initialRows[2]).toHaveTextContent('Alert C');
expect(initialRows[3]).toHaveTextContent('Alert A');
fireEvent.click(severityHeader);
const sortedRows = screen.getAllByRole('row');
expect(sortedRows).toHaveLength(4);
expect(sortedRows[1]).toHaveTextContent('Alert A');
expect(sortedRows[2]).toHaveTextContent('Alert B');
expect(sortedRows[3]).toHaveTextContent('Alert C');
}
});
});

View File

@@ -1,53 +0,0 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { Alerts } from 'types/api/alerts/getTriggered';
export function createAlert(overrides: Partial<Alerts> = {}): Alerts {
return {
labels: undefined,
annotations: {
description: 'Test Description',
summary: 'Test Summary',
},
state: 'firing',
name: 'Test Alert',
id: 1,
endsAt: '2021-01-02T00:00:00Z',
fingerprint: '1234567890',
generatorURL: 'https://test.com',
receivers: [{ name: 'Test Receiver' }],
startsAt: '2021-01-03T00:00:00Z',
status: {
inhibitedBy: [],
silencedBy: [],
state: 'firing',
},
updatedAt: '2021-01-01T00:00:00Z',
...overrides,
};
}
export function useMockTimezone(): {
timezone: Timezone;
browserTimezone: Timezone;
updateTimezone: (timezone: Timezone) => void;
formatTimezoneAdjustedTimestamp: (input: string, format?: string) => string;
isAdaptationEnabled: boolean;
setIsAdaptationEnabled: (enabled: boolean) => void;
} {
const mockTimezone: Timezone = {
name: 'timezone',
value: 'mock-timezone',
offset: '+1.30',
searchIndex: '1',
};
return {
timezone: mockTimezone,
browserTimezone: mockTimezone,
updateTimezone: jest.fn(),
formatTimezoneAdjustedTimestamp: jest
.fn()
.mockImplementation((date: string) => new Date(date).toISOString()),
isAdaptationEnabled: true,
setIsAdaptationEnabled: jest.fn(),
};
}

View File

@@ -1,85 +0,0 @@
import type { Value } from '../Filter';
import { FilterAlerts } from '../utils';
import { createAlert } from './mockUtils';
describe('FilterAlerts', () => {
it('returns all alerts when no filters are selected', () => {
const alerts = [
createAlert({ fingerprint: 'fp-1' }),
createAlert({ fingerprint: 'fp-2' }),
];
const filters: Value[] = [];
const result = FilterAlerts(alerts, filters);
expect(result).toBe(alerts);
});
it('filters alerts that have matching label key and value', () => {
const warningAlert = createAlert({
fingerprint: 'warning',
labels: { severity: 'warning' },
});
const criticalAlert = createAlert({
fingerprint: 'critical',
labels: { severity: 'critical' },
});
const alerts = [warningAlert, criticalAlert];
const filters: Value[] = [{ value: 'severity:critical' }];
const result = FilterAlerts(alerts, filters);
expect(result).toStrictEqual([criticalAlert]);
});
it('includes alerts when any filter matches', () => {
const severityAlert = createAlert({
fingerprint: 'severity',
labels: { severity: 'warning' },
});
const teamAlert = createAlert({
fingerprint: 'team',
labels: { team: 'core-observability' },
});
const otherAlert = createAlert({
fingerprint: 'other',
labels: { service: 'ingestor' },
});
const alerts = [severityAlert, teamAlert, otherAlert];
const filters: Value[] = [
{ value: 'severity:warning' },
{ value: 'team:core-observability' },
];
const result = FilterAlerts(alerts, filters);
expect(result).toHaveLength(2);
expect(result).toStrictEqual([severityAlert, teamAlert]);
});
it('matches labels even when filters contain surrounding whitespace', () => {
const alert = createAlert({
fingerprint: 'trim-test',
labels: { severity: 'critical' },
});
const alerts = [alert];
const filters: Value[] = [{ value: ' severity : critical ' }];
const result = FilterAlerts(alerts, filters);
expect(result).toStrictEqual([alert]);
});
it('ignores filters that do not contain a key/value delimiter', () => {
const alert = createAlert({
fingerprint: 'invalid-filter',
labels: { severity: 'warning' },
});
const alerts = [alert];
const filters: Value[] = [{ value: 'severitywarning' }];
const result = FilterAlerts(alerts, filters);
expect(result).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,36 @@
import { Badge } from '@signozhq/ui/badge';
interface AlertStatusTagProps {
state: string;
}
function AlertStatusTag({ state }: AlertStatusTagProps): JSX.Element {
switch (state) {
case 'unprocessed':
return (
<Badge color="success" variant="outline">
Unprocessed
</Badge>
);
case 'active':
return (
<Badge color="error" variant="outline">
Firing
</Badge>
);
case 'suppressed':
return (
<Badge color="error" variant="outline">
Suppressed
</Badge>
);
default:
return (
<Badge color="secondary" variant="outline">
Unknown
</Badge>
);
}
}
export default AlertStatusTag;

View File

@@ -0,0 +1,36 @@
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
gap: 16px;
}
.emptyStateIcon {
font-size: 48px;
color: var(--bg-forest-500);
}
.emptyStateIconMuted {
font-size: 48px;
color: var(--l2-foreground);
}
.emptyStateTitle {
font-size: 18px;
font-weight: 600;
color: var(--l1-foreground);
}
.emptyStateSubtitle {
font-size: 14px;
color: var(--l2-foreground);
max-width: 400px;
}
.emptyStateActions {
display: flex;
gap: 8px;
}

View File

@@ -0,0 +1,49 @@
import { useCallback } from 'react';
import { CircleCheck, Plus, RefreshCw } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import styles from './EmptyStates.module.scss';
interface EmptyStateProps {
onRefresh?: () => void;
}
export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const handleCreateAlert = useCallback((): void => {
safeNavigate(ROUTES.ALERTS_NEW);
}, [safeNavigate]);
return (
<div className={styles.emptyState}>
<CircleCheck className={styles.emptyStateIcon} size={16} />
<div className={styles.emptyStateTitle}>No alerts firing</div>
<div className={styles.emptyStateSubtitle}>
All systems are healthy. No alerts are currently triggered.
</div>
<div className={styles.emptyStateActions}>
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={handleCreateAlert}
>
Create Alert Rule
</Button>
{onRefresh && (
<Button
variant="outlined"
color="secondary"
prefix={<RefreshCw size={14} />}
onClick={onRefresh}
>
Refresh
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
.expandedRowContainer {
overflow-x: auto;
--tanstack-table-header-cell-bg: var(--l1-background);
--tanstack-table-header-cell-color: var(--l1-foreground);
--tanstack-table-cell-bg: var(--l1-background);
--tanstack-table-cell-color: var(--l1-foreground);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
--tanstack-table-row-active-bg: var(--l1-background-active);
--tanstack-table-resize-handle-bg: var(--l1-background);
--tanstack-table-resize-handle-hover-bg: var(--l1-border);
--tanstack-table-row-height: 36px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-padding-right-override: 15px;
th {
position: unset;
}
background-color: var(--l1-background);
}
.expandedTable {
min-height: 290px;
}
.expandedPagination {
padding-right: var(--spacing-8);
min-height: 62px;
}

View File

@@ -0,0 +1,78 @@
import { useCallback, useMemo, useState } from 'react';
import TanStackTable, {
SortState,
TableColumnDef,
} from 'components/TanStackTableView';
import type { Alert } from '../types';
import { sortAlerts } from '../utils';
import styles from './ExpandedAlertsTable.module.scss';
const EXPANDED_PAGE_SIZE = 5;
interface ExpandedAlertsTableProps {
alerts: Alert[];
columns: TableColumnDef<Alert>[];
onRowClick: (alert: Alert) => void;
onRowClickNewTab: (alert: Alert) => void;
isLoading?: boolean;
}
function ExpandedAlertsTable({
alerts,
columns,
onRowClick,
onRowClickNewTab,
isLoading,
}: ExpandedAlertsTableProps): JSX.Element {
const [page, setPage] = useState(1);
const [orderBy, setOrderBy] = useState<SortState | null>(null);
const handlePageChange = useCallback((newPage: number) => {
setPage(newPage);
}, []);
const handleSort = useCallback((sort: SortState | null) => {
setOrderBy(sort);
setPage(1);
}, []);
const sortedAlerts = useMemo(
() => sortAlerts(alerts, orderBy),
[alerts, orderBy],
);
const paginatedAlerts = useMemo(() => {
const start = (page - 1) * EXPANDED_PAGE_SIZE;
return sortedAlerts.slice(start, start + EXPANDED_PAGE_SIZE);
}, [sortedAlerts, page]);
return (
<div className={styles.expandedRowContainer}>
<TanStackTable<Alert>
className={styles.expandedTable}
data={paginatedAlerts}
columns={columns}
isLoading={isLoading}
getRowKey={(row): string => row.fingerprint ?? ''}
getItemKey={(row): string => row.fingerprint ?? ''}
onRowClick={onRowClick}
onRowClickNewTab={onRowClickNewTab}
onSort={handleSort}
disableVirtualScroll
pagination={{
total: alerts.length,
defaultPage: page,
defaultLimit: EXPANDED_PAGE_SIZE,
showTotalCount: true,
totalCountLabel: 'Alerts',
showPageSize: false,
onPageChange: handlePageChange,
}}
paginationClassname={styles.expandedPagination}
/>
</div>
);
}
export default ExpandedAlertsTable;

View File

@@ -0,0 +1,34 @@
import { Options, useQueryState, UseQueryStateReturn } from 'nuqs';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
const defaultNuqsOptions: Options = {
history: 'push',
};
export const TRIGGERED_ALERTS_PARAMS = {
FILTERS: 'alertFilters',
GROUP_BY: 'alertGroupBy',
SEARCH: 'alertSearch',
} as const;
export const useTriggeredAlertsFilters = (): UseQueryStateReturn<
string[],
string[]
> =>
useQueryState(
TRIGGERED_ALERTS_PARAMS.FILTERS,
parseAsJsonNoValidate<string[]>()
.withDefault([])
.withOptions(defaultNuqsOptions),
);
export const useTriggeredAlertsGroupBy = (): UseQueryStateReturn<
string[],
string[]
> =>
useQueryState(
TRIGGERED_ALERTS_PARAMS.GROUP_BY,
parseAsJsonNoValidate<string[]>()
.withDefault([])
.withOptions(defaultNuqsOptions),
);

View File

@@ -1,82 +1,235 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import getTriggeredApi from 'api/alerts/getTriggered';
import logEvent from 'api/common/logEvent';
import Spinner from 'components/Spinner';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useAxiosError from 'hooks/useAxiosError';
import { isUndefined } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react';
import { Search } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { ComboboxSimple, ComboboxSimpleItem } from '@signozhq/ui/combobox';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import type { FilterValue } from 'components/Alerts/types';
import TanStackTable from 'components/TanStackTableView';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import { useUrlSearchState } from 'hooks/useUrlSearchState';
import { useTimezone } from 'providers/Timezone';
import { Value } from './Filter';
import TriggerComponent from './TriggeredAlert';
import { EmptyState } from './components/EmptyStates';
import ExpandedAlertsTable from './components/ExpandedAlertsTable';
import {
TRIGGERED_ALERTS_PARAMS,
useTriggeredAlertsFilters,
useTriggeredAlertsGroupBy,
} from './hooks';
import { getAlertColumns, groupedColumns } from './table.config';
import styles from './TriggeredAlerts.module.scss';
import type { Alert, GroupedAlert } from './types';
import { useTriggeredAlertsData } from './useTriggeredAlertsData';
import { useTriggeredAlertsHandlers } from './useTriggeredAlertsHandlers';
const QUERY_PARAMS_CONFIG = {
orderBy: 'orderBy',
page: 'page',
limit: 'limit',
} as const;
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10;
const severyFilters: ComboboxSimpleItem[] = [
{
value: 'severity:critical',
label: 'Critical (severity:critical)',
displayValue: 'Critical',
},
{
value: 'severity:error',
label: 'Error (severity:error)',
displayValue: 'Error',
},
{
value: 'severity:warning',
label: 'Warning (severity:warning)',
displayValue: 'Warning',
},
{
value: 'severity:info',
label: 'Info (severity:info)',
displayValue: 'Info',
},
];
function TriggeredAlerts(): JSX.Element {
const [selectedGroup, setSelectedGroup] = useState<Value[]>([]);
const [selectedFilter, setSelectedFilter] = useState<Value[]>([]);
const [filterValues, setFilterValues] = useTriggeredAlertsFilters();
const [selectedGroupBy, setSelectedGroupBy] = useTriggeredAlertsGroupBy();
const { searchText, debouncedSearch, handleSearchChange, clearSearch } =
useUrlSearchState(TRIGGERED_ALERTS_PARAMS.SEARCH);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { orderBy, page, limit } = useTableParams(QUERY_PARAMS_CONFIG, {
page: DEFAULT_PAGE,
limit: DEFAULT_LIMIT,
});
const { user } = useAppContext();
const hasLoggedEvent = useRef(false); // Track if logEvent has been called
const handleError = useAxiosError();
const alertsResponse = useQuery(
[REACT_QUERY_KEY.GET_TRIGGERED_ALERTS, user.id],
{
queryFn: () =>
getTriggeredApi({
active: true,
inhibited: true,
silenced: false,
}),
refetchInterval: 30000,
onError: handleError,
},
const selectedFilter = useMemo(
(): FilterValue[] => (filterValues ?? []).map((v: string) => ({ value: v })),
[filterValues],
);
const handleSelectedFilterChange = useCallback((newFilter: Value[]) => {
setSelectedFilter(newFilter);
}, []);
const {
filteredAlerts,
groupedData,
uniqueLabels,
isFetching,
isError,
isGrouped,
allAlerts,
refetch,
} = useTriggeredAlertsData(
selectedFilter,
selectedGroupBy,
orderBy,
debouncedSearch,
);
const handleSelectedGroupChange = useCallback((newGroup: Value[]) => {
setSelectedGroup(newGroup);
}, []);
const handleFilterChange = useCallback(
(values: unknown): void => {
if (Array.isArray(values)) {
void setFilterValues(values.length ? values : null);
}
},
[setFilterValues],
);
useEffect(() => {
if (!hasLoggedEvent.current && !isUndefined(alertsResponse.data?.payload)) {
logEvent('Alert: Triggered alert list page visited', {
number: alertsResponse.data?.payload?.length,
});
hasLoggedEvent.current = true;
}
}, [alertsResponse.data?.payload]);
const { handleGroupByChange, handleRowClick, handleRowClickNewTab } =
useTriggeredAlertsHandlers(setSelectedGroupBy);
if (alertsResponse.error) {
return (
<TriggerComponent
allAlerts={[]}
selectedFilter={selectedFilter}
selectedGroup={selectedGroup}
onSelectedFilterChange={handleSelectedFilterChange}
onSelectedGroupChange={handleSelectedGroupChange}
const columns = useMemo(
() => getAlertColumns(formatTimezoneAdjustedTimestamp),
[formatTimezoneAdjustedTimestamp],
);
const labelOptions: ComboboxSimpleItem[] = uniqueLabels.map((label) => ({
value: label,
label,
}));
const paginatedAlerts = useMemo(() => {
const start = (page - 1) * limit;
return filteredAlerts.slice(start, start + limit);
}, [filteredAlerts, page, limit]);
const paginatedGroupedData = useMemo(() => {
const start = (page - 1) * limit;
return groupedData.slice(start, start + limit);
}, [groupedData, page, limit]);
const renderExpandedRow = useCallback(
(group: GroupedAlert): ReactNode => (
<ExpandedAlertsTable
alerts={group.alerts}
columns={columns}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
isLoading={isFetching}
/>
);
}
),
[columns, handleRowClick, handleRowClickNewTab, isFetching],
);
if (alertsResponse.isFetching || alertsResponse?.data?.payload === undefined) {
return <Spinner height="75vh" tip="Loading Alerts..." />;
}
const hasActiveFilters = selectedFilter.length > 0 || searchText.length > 0;
const isEmptyDueToFilters =
!isFetching &&
filteredAlerts.length === 0 &&
hasActiveFilters &&
allAlerts.length > 0;
const isEmptyNoAlerts = !isFetching && !isError && allAlerts.length === 0;
const handleClearFilters = useCallback((): void => {
void setFilterValues(null);
clearSearch();
}, [setFilterValues, clearSearch]);
return (
<div className="triggered-alerts-container">
<TriggerComponent
allAlerts={alertsResponse?.data?.payload || []}
selectedFilter={selectedFilter}
selectedGroup={selectedGroup}
onSelectedFilterChange={handleSelectedFilterChange}
onSelectedGroupChange={handleSelectedGroupChange}
/>
<div className={styles.container}>
<div className={styles.filtersRow}>
<Input
className={styles.searchInput}
placeholder="Search alerts by name"
value={searchText}
onChange={handleSearchChange}
suffix={<Search size={14} className={styles.searchIcon} />}
/>
<ComboboxSimple
className={styles.filterSelect}
multiple
value={selectedFilter.map((f) => f.value)}
onChange={handleFilterChange}
placeholder="Filter by tags"
inputPlaceholder="Create new filters with 'label:value'"
allowCreate
items={severyFilters}
maxDisplayedPills={2}
/>
<ComboboxSimple
className={styles.filterSelect}
value={selectedGroupBy}
onChange={handleGroupByChange}
placeholder="Group by tag"
inputPlaceholder="Select one or more"
items={labelOptions}
multiple
maxDisplayedPills={2}
/>
</div>
<div className={styles.tableContainer}>
{isError ? (
<ErrorEmptyState title="Failed to load alerts" onRefresh={refetch} />
) : isEmptyDueToFilters ? (
<NoResultsEmptyState
title="No matching alerts"
subtitle="No alerts match your current filters. Try adjusting your search criteria."
onClear={handleClearFilters}
/>
) : isEmptyNoAlerts ? (
<EmptyState onRefresh={refetch} />
) : isGrouped ? (
<TanStackTable<GroupedAlert>
data={paginatedGroupedData}
columns={groupedColumns}
isLoading={isFetching}
getRowKey={(row): string => row.groupKey}
getItemKey={(row): string => row.groupKey}
renderExpandedRow={renderExpandedRow}
getRowCanExpand={(): boolean => true}
columnStorageKey="triggered-alerts-grouped-columns"
enableQueryParams={QUERY_PARAMS_CONFIG}
pagination={{
total: groupedData.length,
defaultPage: DEFAULT_PAGE,
defaultLimit: DEFAULT_LIMIT,
}}
paginationClassname={styles.paginationContainer}
/>
) : (
<TanStackTable<Alert>
data={paginatedAlerts}
columns={columns}
isLoading={isFetching}
getRowKey={(row): string => row.fingerprint ?? ''}
getItemKey={(row): string => row.fingerprint ?? ''}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
columnStorageKey="triggered-alerts-columns"
enableQueryParams={QUERY_PARAMS_CONFIG}
pagination={{
total: filteredAlerts.length,
defaultPage: DEFAULT_PAGE,
defaultLimit: DEFAULT_LIMIT,
showTotalCount: true,
}}
paginationClassname={styles.paginationContainer}
/>
)}
</div>
</div>
);
}

View File

@@ -1,29 +0,0 @@
import { Select as SelectComponent } from 'antd';
import styled from 'styled-components';
export const Select = styled(SelectComponent)`
&&& {
min-width: 350px;
}
`;
export const Container = styled.div`
&&& {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
}
`;
export const TableContainer = styled.div`
&&& {
margin-top: 2rem;
}
`;
export const NoTableContainer = styled.div`
&&& {
margin-top: 2rem;
}
`;

View File

@@ -0,0 +1,153 @@
import { BellDot, ChevronDown, ChevronRight } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { SEVERITY_BADGE_COLORS } from 'components/Alerts/constants';
import TanStackTable from 'components/TanStackTableView';
import type { TableColumnDef } from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import AlertStatusTag from './components/AlertStatusTag';
import LabelColumn from 'components/Alerts/LabelColumn';
import styles from './TriggeredAlerts.module.scss';
import type { Alert, GroupedAlert } from './types';
export function getAlertColumns(
formatTimezoneAdjustedTimestamp: (date: string, format: string) => string,
): TableColumnDef<Alert>[] {
return [
{
id: 'status',
header: 'Status',
accessorFn: (row) => row.status?.state,
width: { min: 120, default: 120 },
enableSort: false,
enableMove: false,
cell: ({ value }): JSX.Element => (
<AlertStatusTag state={String(value ?? '')} />
),
},
{
id: 'alertName',
header: 'Alert Name',
accessorFn: (row) => row.labels?.alertname ?? '',
width: { min: 200, default: 330 },
enableSort: true,
enableMove: false,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{String(value ?? '-')}</TanStackTable.Text>
),
},
{
id: 'severity',
header: 'Severity',
accessorFn: (row) => row.labels?.severity ?? '',
width: { min: 150, default: 150 },
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: 'firingSince',
header: 'Firing Since',
accessorKey: 'startsAt',
width: { min: 280, default: 280 },
enableSort: true,
enableMove: false,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>
{value
? formatTimezoneAdjustedTimestamp(String(value), DATE_TIME_FORMATS.UTC_US)
: '-'}
</TanStackTable.Text>
),
},
{
id: 'labels',
header: 'Labels',
accessorKey: 'labels',
width: { min: 200, default: 300 },
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" />;
},
},
];
}
export const groupedColumns: TableColumnDef<GroupedAlert>[] = [
{
id: 'groupTags',
header: (): JSX.Element => (
<div className={styles.groupHeader}>
<BellDot size={14} />
<span>Group</span>
</div>
),
accessorFn: (row) => row.groupKey,
width: { default: '100%' },
enableRemove: false,
enableMove: false,
pin: 'left',
cell: ({ row: groupRow, isExpanded, toggleExpanded }): JSX.Element => {
const tags = Object.entries(groupRow.groupLabels)
.filter(([, v]) => v)
.map(([k, v]) => `${k}:${v}`);
return (
<div className={styles.groupCell}>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(e): void => {
e.stopPropagation();
toggleExpanded();
}}
prefix={
isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />
}
/>
<div className={styles.tagsContainer}>
{tags.map((tag) => (
<Badge color="error" key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
);
},
},
{
id: 'alertCount',
header: 'Alerts',
accessorFn: (row) => row.alerts.length,
width: { min: 80, default: 100 },
enableMove: false,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{String(value)}</TanStackTable.Text>
),
},
];

View File

@@ -0,0 +1,10 @@
import type { AlertmanagertypesDeprecatedGettableAlertDTO } from 'api/generated/services/sigNoz.schemas';
export type Alert = AlertmanagertypesDeprecatedGettableAlertDTO;
export interface GroupedAlert {
groupKey: string;
groupLabels: Record<string, string>;
alerts: Alert[];
firstAlert: Alert;
}

View File

@@ -0,0 +1,107 @@
import { useEffect, useMemo, useRef } from 'react';
import logEvent from 'api/common/logEvent';
import { useGetAlerts } from 'api/generated/services/alerts';
import type { FilterValue } from 'components/Alerts/types';
import { filterByLabels, searchByLabels } from 'components/Alerts/utils';
import type { SortState } from 'components/TanStackTableView/types';
import { groupBy as lodashGroupBy, isUndefined } from 'lodash-es';
import type { Alert, GroupedAlert } from './types';
import { normalizeAlerts, sortAlerts } from './utils';
interface UseTriggeredAlertsDataReturn {
allAlerts: Alert[];
filteredAlerts: Alert[];
groupedData: GroupedAlert[];
uniqueLabels: string[];
isFetching: boolean;
isError: boolean;
isGrouped: boolean;
refetch: () => void;
}
const TRIGGERED_ALERTS_REFRESH_INTERVAL = 30_000;
export function useTriggeredAlertsData(
selectedFilter: FilterValue[],
selectedGroupBy: string[],
orderBy: SortState | null,
searchText = '',
): UseTriggeredAlertsDataReturn {
const hasLoggedEvent = useRef(false);
const alertsResponse = useGetAlerts({
query: {
refetchInterval: TRIGGERED_ALERTS_REFRESH_INTERVAL,
},
});
useEffect(() => {
const alerts = alertsResponse.data?.data;
if (!hasLoggedEvent.current && !isUndefined(alerts)) {
logEvent('Alert: Triggered alert list page visited', {
number: alerts?.length,
});
hasLoggedEvent.current = true;
}
}, [alertsResponse.data]);
const allAlerts = useMemo(
() => normalizeAlerts(alertsResponse.data?.data),
[alertsResponse.data],
);
const filteredAlerts = useMemo(() => {
let result = filterByLabels(allAlerts, selectedFilter);
result = searchByLabels(result, searchText, (a) => a.labels?.alertname ?? '');
return sortAlerts(result, orderBy);
}, [allAlerts, selectedFilter, searchText, orderBy]);
const uniqueLabels = useMemo(() => {
const labelsSet = new Set<string>();
allAlerts.forEach((alert) => {
if (alert.labels) {
Object.keys(alert.labels).forEach((key) => labelsSet.add(key));
}
});
return Array.from(labelsSet);
}, [allAlerts]);
const groupedData = useMemo((): GroupedAlert[] => {
if (!selectedGroupBy.length) {
return [];
}
const grouped = lodashGroupBy(filteredAlerts, (alert) =>
selectedGroupBy.map((key) => alert.labels?.[key] ?? '').join('+'),
);
return Object.entries(grouped)
.filter(([, alerts]) => alerts.length > 0)
.map(([groupKey, alerts]) => {
const firstAlert = alerts[0];
const groupLabels: Record<string, string> = {};
selectedGroupBy.forEach((key) => {
groupLabels[key] = firstAlert.labels?.[key] ?? '';
});
return {
groupKey,
groupLabels,
alerts,
firstAlert,
};
});
}, [filteredAlerts, selectedGroupBy]);
return {
allAlerts,
filteredAlerts,
groupedData,
uniqueLabels,
isFetching: alertsResponse.isFetching,
isError: alertsResponse.isError,
isGrouped: selectedGroupBy.length > 0,
refetch: alertsResponse.refetch,
};
}

View File

@@ -0,0 +1,58 @@
import { useCallback } from 'react';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useTableRowClick } from 'hooks/useTableRowClick';
import type { Alert } from './types';
import { getRuleId } from './utils';
interface UseTriggeredAlertsHandlersReturn {
handleGroupByChange: (values: unknown) => void;
handleRowClick: (alert: Alert) => void;
handleRowClickNewTab: (alert: Alert) => void;
}
export function useTriggeredAlertsHandlers(
setSelectedGroupBy: (groupBy: string[]) => void,
): UseTriggeredAlertsHandlersReturn {
const { safeNavigate } = useSafeNavigate();
const handleGroupByChange = useCallback(
(values: unknown) => {
if (Array.isArray(values)) {
setSelectedGroupBy(values);
}
},
[setSelectedGroupBy],
);
const getAlertUrl = useCallback((alert: Alert): string | null => {
const ruleId = getRuleId(alert);
if (!ruleId) {
return null;
}
return `${ROUTES.ALERT_OVERVIEW}?${QueryParams.ruleId}=${ruleId}`;
}, []);
const onBeforeNavigate = useCallback((alert: Alert): void => {
const ruleId = getRuleId(alert);
logEvent('Alert: Triggered alert clicked', {
ruleId,
alertName: alert.labels?.alertname,
});
}, []);
const { handleRowClick, handleRowClickNewTab } = useTableRowClick<Alert>({
getUrl: getAlertUrl,
onNavigate: safeNavigate,
onBeforeNavigate,
});
return {
handleGroupByChange,
handleRowClick,
handleRowClickNewTab,
};
}

View File

@@ -1,54 +1,78 @@
import { Alerts } from 'types/api/alerts/getTriggered';
import { v4 as uuidv4 } from 'uuid';
import { Value } from './Filter';
import type { FilterValue } from 'components/Alerts/types';
import {
filterByLabels,
searchByLabels,
sortByColumn,
} from 'components/Alerts/utils';
import type { SortState } from 'components/TanStackTableView/types';
export const FilterAlerts = (
allAlerts: Alerts[],
selectedFilter: Value[],
): Alerts[] => {
// also we need to update the alerts
// [[key,value]]
import { getElapsedMs } from 'utils/timeUtils';
if (selectedFilter?.length === 0 || selectedFilter === undefined) {
return allAlerts;
import type { Alert } from './types';
export function normalizeAlerts(rawAlerts: Alert[] | undefined): Alert[] {
if (!rawAlerts) {
return [];
}
return rawAlerts.map((alert) => ({
...alert,
fingerprint: alert.fingerprint ?? uuidv4(),
}));
}
export function getAlertSortValue(
alert: Alert,
columnName: string,
): string | number {
switch (columnName) {
case 'status':
return alert.status?.state ?? '';
case 'alertName':
return alert.labels?.alertname ?? '';
case 'severity':
return alert.labels?.severity ?? '';
case 'firingSince':
return alert.startsAt ? getElapsedMs(alert.startsAt) : '';
case 'duration':
return getElapsedMs(alert.startsAt);
default:
return '';
}
}
export function sortAlerts(
alerts: Alert[],
orderBy: SortState | null,
): Alert[] {
return sortByColumn(alerts, orderBy, getAlertSortValue, {
columnName: 'duration',
order: 'asc',
});
}
export { filterByLabels as filterAlerts, searchByLabels as searchAlerts };
export type { FilterValue };
export function getRuleId(alert: Alert): string | null {
// Primary: labels.ruleId
if (alert.labels?.ruleId) {
return alert.labels.ruleId;
}
const filter: string[] = [];
// filtering the value
selectedFilter.forEach((e) => {
const valueKey = e.value.split(':');
if (valueKey.length === 2) {
filter.push(e.value);
}
});
const tags = filter.map((e) => e.split(':'));
const objectMap = new Map();
const filteredKey = tags.reduce((acc, curr) => [...acc, curr[0]], []);
const filteredValue = tags.reduce((acc, curr) => [...acc, curr[1]], []);
filteredKey.forEach((key, index) =>
objectMap.set(key.trim(), filteredValue[index].trim()),
);
const filteredAlerts: Set<string> = new Set();
allAlerts.forEach((alert) => {
const { labels } = alert;
if (!labels) {
return;
}
Object.keys(labels).forEach((e) => {
const selectedKey = objectMap.get(e);
// alerts which does not have the key with value
if (selectedKey && labels[e] === selectedKey) {
filteredAlerts.add(alert.fingerprint);
// Fallback: parse from generatorURL
if (alert.generatorURL) {
try {
const url = new URL(alert.generatorURL);
const ruleId = url.searchParams.get('ruleId');
if (ruleId) {
return ruleId;
}
});
});
} catch {
// Invalid URL, ignore
}
}
return allAlerts.filter((e) => filteredAlerts.has(e.fingerprint));
};
return null;
}

View File

@@ -0,0 +1,65 @@
import { useCallback } from 'react';
import { useIsTextSelected } from './useIsTextSelected';
interface UseTableRowClickOptions<T> {
getUrl: (item: T) => string | null;
onNavigate: (url: string, options?: { newTab?: boolean }) => void;
onBeforeNavigate?: (item: T) => void;
}
interface UseTableRowClickReturn<T> {
handleRowClick: (item: T) => void;
handleRowClickNewTab: (item: T) => void;
}
/**
* Hook for handling table row clicks with text selection check
* Prevents navigation when user is selecting text
*/
export function useTableRowClick<T>({
getUrl,
onNavigate,
onBeforeNavigate,
}: UseTableRowClickOptions<T>): UseTableRowClickReturn<T> {
const isTextSelected = useIsTextSelected();
const handleRowClick = useCallback(
(item: T): void => {
if (isTextSelected()) {
return;
}
const url = getUrl(item);
if (!url) {
return;
}
onBeforeNavigate?.(item);
onNavigate(url);
},
[isTextSelected, getUrl, onNavigate, onBeforeNavigate],
);
const handleRowClickNewTab = useCallback(
(item: T): void => {
if (isTextSelected()) {
return;
}
const url = getUrl(item);
if (!url) {
return;
}
onBeforeNavigate?.(item);
onNavigate(url, { newTab: true });
},
[isTextSelected, getUrl, onNavigate, onBeforeNavigate],
);
return {
handleRowClick,
handleRowClickNewTab,
};
}

View File

@@ -0,0 +1,266 @@
import { act, renderHook } from '@testing-library/react';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { useUrlSearchState } from './useUrlSearchState';
jest.useFakeTimers();
const DEFAULT_DEBOUNCE_MS = 300;
function createWrapper(searchParams?: string) {
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<NuqsTestingAdapter searchParams={searchParams}>
{children}
</NuqsTestingAdapter>
);
};
}
describe('useUrlSearchState', () => {
afterEach(() => {
jest.clearAllTimers();
});
describe('initialization', () => {
it('initializes with empty string when no URL param', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper(),
});
expect(result.current.searchText).toBe('');
expect(result.current.debouncedSearch).toBe('');
});
it('initializes from URL param', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper('?search=hello'),
});
expect(result.current.searchText).toBe('hello');
expect(result.current.debouncedSearch).toBe('hello');
});
});
describe('user typing', () => {
it('updates searchText immediately on handleSearchChange', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper(),
});
act(() => {
result.current.handleSearchChange({
target: { value: 'test' },
} as React.ChangeEvent<HTMLInputElement>);
});
expect(result.current.searchText).toBe('test');
expect(result.current.debouncedSearch).toBe('');
});
it('updates searchText immediately on setSearchText', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper(),
});
act(() => {
result.current.setSearchText('direct');
});
expect(result.current.searchText).toBe('direct');
});
it('updates debouncedSearch after debounce delay', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper(),
});
act(() => {
result.current.setSearchText('delayed');
});
expect(result.current.debouncedSearch).toBe('');
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
});
expect(result.current.debouncedSearch).toBe('delayed');
});
it('respects custom debounce delay', () => {
const customDelay = 500;
const { result } = renderHook(
() => useUrlSearchState('search', { debounceMs: customDelay }),
{ wrapper: createWrapper() },
);
act(() => {
result.current.setSearchText('custom');
});
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
});
expect(result.current.debouncedSearch).toBe('');
act(() => {
jest.advanceTimersByTime(customDelay - DEFAULT_DEBOUNCE_MS);
});
expect(result.current.debouncedSearch).toBe('custom');
});
});
describe('debounce behavior', () => {
it('does not update debouncedSearch before delay', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper(),
});
act(() => {
result.current.setSearchText('urltest');
});
// Advance less than debounce time
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS - 50);
});
expect(result.current.debouncedSearch).toBe('');
});
it('updates debouncedSearch exactly at delay', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper(),
});
act(() => {
result.current.setSearchText('urltest');
});
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
});
expect(result.current.debouncedSearch).toBe('urltest');
});
it('resets debounce timer on rapid typing', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper(),
});
act(() => {
result.current.setSearchText('a');
});
act(() => {
jest.advanceTimersByTime(200);
});
act(() => {
result.current.setSearchText('ab');
});
act(() => {
jest.advanceTimersByTime(200);
});
// Still hasn't debounced because timer reset
expect(result.current.debouncedSearch).toBe('');
act(() => {
jest.advanceTimersByTime(100);
});
// Now it should have debounced
expect(result.current.debouncedSearch).toBe('ab');
});
});
describe('clearSearch', () => {
it('clears searchText immediately', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper('?search=toclear'),
});
expect(result.current.searchText).toBe('toclear');
act(() => {
result.current.clearSearch();
});
expect(result.current.searchText).toBe('');
});
it('clears debouncedSearch after delay', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper('?search=toclear'),
});
act(() => {
result.current.clearSearch();
});
expect(result.current.debouncedSearch).toBe('toclear');
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
});
expect(result.current.debouncedSearch).toBe('');
});
});
describe('browser navigation (back/forward)', () => {
it('syncs local state when URL changes externally', () => {
const { result, rerender } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper('?search=first'),
});
expect(result.current.searchText).toBe('first');
// Simulate user typing "second"
act(() => {
result.current.setSearchText('second');
});
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
});
expect(result.current.searchText).toBe('second');
expect(result.current.debouncedSearch).toBe('second');
// Simulate browser back - URL changes externally to "first"
rerender();
// Note: In real scenario, NuqsTestingAdapter would update searchParam
// This test verifies the hook's internal logic is correct
});
});
describe('different query keys', () => {
it('reads from correct URL param key', () => {
const { result } = renderHook(() => useUrlSearchState('mySearch'), {
wrapper: createWrapper('?mySearch=fromurl&other=ignored'),
});
expect(result.current.searchText).toBe('fromurl');
});
it('ignores other URL params', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper('?other=value&search=correct&another=test'),
});
expect(result.current.searchText).toBe('correct');
});
});
});

View File

@@ -0,0 +1,75 @@
import type { ChangeEvent } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { parseAsString, useQueryState } from 'nuqs';
import useDebounce from './useDebounce';
interface UseUrlSearchStateOptions {
debounceMs?: number;
}
interface UseUrlSearchStateReturn {
searchText: string;
debouncedSearch: string;
setSearchText: (value: string) => void;
handleSearchChange: (e: ChangeEvent<HTMLInputElement>) => void;
clearSearch: () => void;
}
/**
* Hook for managing search state synced with URL query params.
* Uses ref to track last synced value, preventing race conditions
* when browser back/forward changes URL externally.
*/
export function useUrlSearchState(
key: string,
options: UseUrlSearchStateOptions = {},
): UseUrlSearchStateReturn {
const { debounceMs = 300 } = options;
const [searchParam, setSearchParam] = useQueryState(
key,
parseAsString.withDefault('').withOptions({ history: 'push' }),
);
const [searchText, setSearchText] = useState(searchParam);
const debouncedSearch = useDebounce(searchText, debounceMs);
// Track what we last synced to URL to detect external changes
const lastSyncedToUrl = useRef(searchParam);
// Sync debounced value to URL (user typing -> URL)
useEffect(() => {
if (debouncedSearch !== lastSyncedToUrl.current) {
lastSyncedToUrl.current = debouncedSearch;
void setSearchParam(debouncedSearch || null);
}
}, [debouncedSearch, setSearchParam]);
// Sync URL to local state (browser back/forward -> input)
useEffect(() => {
if (searchParam !== lastSyncedToUrl.current) {
lastSyncedToUrl.current = searchParam;
setSearchText(searchParam);
}
}, [searchParam]);
const handleSearchChange = useCallback(
(e: ChangeEvent<HTMLInputElement>): void => {
setSearchText(e.target.value);
},
[],
);
const clearSearch = useCallback((): void => {
setSearchText('');
}, []);
return {
searchText,
debouncedSearch,
setSearchText,
handleSearchChange,
clearSearch,
};
}

View File

@@ -6,12 +6,7 @@ import {
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import {
Bookmark,
CalendarClock,
@@ -496,27 +491,22 @@ function SpanDetailsPanel({
actions.push({
key: 'dock-toggle',
component: (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void =>
onVariantChange(
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
)
}
>
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent className="dock-toggle-tooltip">
{isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipSimple
title={isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void =>
onVariantChange(
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
)
}
>
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
</Button>
</TooltipSimple>
),
});
}

View File

@@ -1,7 +1,7 @@
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
@@ -149,7 +149,7 @@ export function SpanHoverCard({
return (
<TooltipProvider>
<Tooltip open={hoverCardData !== null} onOpenChange={onOpenChange}>
<TooltipRoot open={hoverCardData !== null} onOpenChange={onOpenChange}>
<TooltipTrigger asChild>
<div
className="span-hover-card-anchor"
@@ -168,7 +168,7 @@ export function SpanHoverCard({
>
{hoverCardData && <SpanTooltipContent {...hoverCardData.tooltip} />}
</TooltipContent>
</Tooltip>
</TooltipRoot>
</TooltipProvider>
);
}

View File

@@ -2,9 +2,9 @@ import { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Skeleton } from 'antd';
@@ -145,7 +145,7 @@ function TraceDetailsHeader({
{!isFilterExpanded && (
<>
<TooltipProvider>
<Tooltip>
<TooltipRoot>
<TooltipTrigger asChild>
<Button
variant="ghost"
@@ -158,7 +158,7 @@ function TraceDetailsHeader({
</Button>
</TooltipTrigger>
<TooltipContent>Analytics</TooltipContent>
</Tooltip>
</TooltipRoot>
</TooltipProvider>
<TraceOptionsMenu
showTraceDetails={showTraceDetails}

View File

@@ -1,10 +1,5 @@
import { Button } from '@signozhq/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { Link } from '@signozhq/icons';
import { Span } from 'types/api/trace/getTraceV2';
@@ -21,24 +16,17 @@ export default function SpanLineActionButtons({
return (
<div className="span-line-action-buttons">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onSpanCopy}
className="copy-span-btn"
>
<Link size={14} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-line-action-tooltip">
Copy Span Link
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipSimple title="Copy Span Link">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onSpanCopy}
className="copy-span-btn"
>
<Link size={14} />
</Button>
</TooltipSimple>
</div>
);
}

View File

@@ -15,9 +15,9 @@ import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { toast } from '@signozhq/ui/sonner';
import { Button } from '@signozhq/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
@@ -269,7 +269,7 @@ function Filters({
<>
{isFetching && <Loader className="animate-spin" />}
{error && (
<Tooltip>
<TooltipRoot>
<TooltipTrigger asChild>
<span className="filter-status filter-status--error">
<Info />
@@ -279,7 +279,7 @@ function Filters({
<TooltipContent>
{(error as AxiosError)?.message || 'Something went wrong'}
</TooltipContent>
</Tooltip>
</TooltipRoot>
)}
{!error && noData && (
<Typography.Text className="filter-status">
@@ -304,7 +304,7 @@ function Filters({
<TooltipProvider>
<div className="trace-v3-filter-row collapsed">
{expression ? (
<Tooltip>
<TooltipRoot>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<div className="filter-pill-popover">
@@ -328,7 +328,7 @@ function Filters({
<div className="filter-pill-popover__expression">{expression}</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipRoot>
) : (
pill
)}

View File

@@ -11,12 +11,7 @@ import {
} from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import {
createColumnHelper,
flexRender,
@@ -111,26 +106,24 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
const eventTimeMs = event.timeUnixNano / 1e6;
return (
<TooltipProvider>
<Tooltip
open
onOpenChange={(open): void => {
if (!open) {
setShowPopover(false);
}
}}
>
<TooltipTrigger asChild>{dot}</TooltipTrigger>
<TooltipContent className="span-hover-card-popover">
<EventTooltipContent
eventName={event.name}
timeOffsetMs={eventTimeMs - spanTimestamp}
isError={isError}
attributeMap={event.attributeMap || {}}
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipSimple
open
onOpenChange={(open: boolean): void => {
if (!open) {
setShowPopover(false);
}
}}
title={
<EventTooltipContent
eventName={event.name}
timeOffsetMs={eventTimeMs - spanTimestamp}
isError={isError}
attributeMap={event.attributeMap || {}}
/>
}
>
{dot}
</TooltipSimple>
);
});
@@ -328,40 +321,28 @@ const SpanOverview = memo(function SpanOverview({
{/* Action buttons — shown on hover via CSS, right-aligned */}
<span className="span-row-actions">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={onSpanCopy}
>
<Link size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-action-tooltip">
Copy Span Link
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={handleFunnelClick}
>
<ListPlus size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-action-tooltip">
Add to Trace Funnel
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipSimple title="Copy Span Link">
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={onSpanCopy}
>
<Link size={12} />
</Button>
</TooltipSimple>
<TooltipSimple title="Add to Trace Funnel">
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={handleFunnelClick}
>
<ListPlus size={12} />
</Button>
</TooltipSimple>
</span>
</div>
);

View File

@@ -171,6 +171,24 @@ export const normalizeTimeToMs = (timestamp: number | string): number => {
return isNanoSeconds ? Math.floor(ts / 1_000_000) : ts;
};
/**
* Calculates milliseconds elapsed since the given timestamp.
* Returns 0 for undefined/invalid timestamps.
*/
export function getElapsedMs(startsAt: Date | string | undefined): number {
if (!startsAt) {
return 0;
}
const timestamp =
typeof startsAt === 'string'
? new Date(startsAt).getTime()
: startsAt.getTime();
if (Number.isNaN(timestamp)) {
return 0;
}
return Date.now() - timestamp;
}
export const hasDatePassed = (expiresAt: string): boolean => {
const date = dayjs(expiresAt);