Compare commits
3 Commits
feat/heatm
...
test/semco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dea82b8b84 | ||
|
|
5b94d79e46 | ||
|
|
60a6a5b38c |
1
.github/workflows/integrationci.yaml
vendored
@@ -62,6 +62,7 @@ jobs:
|
||||
- role
|
||||
- rootuser
|
||||
- savedview
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- querier_json_body
|
||||
|
||||
3
frontend/src/assets/Logos/azure.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#0078D4" d="M22.379 23.343a1.62 1.62 0 0 0 1.536-2.14v.002L17.35 1.76A1.62 1.62 0 0 0 15.816.657H8.184A1.62 1.62 0 0 0 6.65 1.76L.086 21.204a1.62 1.62 0 0 0 1.536 2.139h4.741a1.62 1.62 0 0 0 1.535-1.103l.977-2.892 4.947 3.675c.28.208.618.32.966.32m-3.084-12.531 3.624 10.739a.54.54 0 0 1-.51.713v-.001h-.03a.54.54 0 0 1-.322-.106l-9.287-6.9h4.853m6.313 7.006c.116-.326.13-.694.007-1.058L9.79 1.76a1.722 1.722 0 0 0-.007-.02h6.034a.54.54 0 0 1 .512.366l6.562 19.445a.54.54 0 0 1-.338.684"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
1
frontend/src/assets/Logos/cloudnative-pg.svg
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
5
frontend/src/assets/Logos/dify.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Dify</title>
|
||||
<path d="M7.043 6.487c1.635 0 2.241-1.003 2.241-2.243S8.681 2 7.044 2C5.405 2 4.801 3.003 4.801 4.244c0 1.24.604 2.243 2.241 2.243z" fill="#03F" />
|
||||
<path d="M14.883 6.97v1.443h-3.679v3.203h3.68v8.012H8.801V8.41h-8v3.203h4.48v8.012H0v3.203h24v-3.203h-5.6v-8.012H24V8.41h-5.6V5.206H24V2.003h-4.161a4.97 4.97 0 00-4.961 4.967h.005z" fill="#03F" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 447 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill-rule:evenodd;clip-rule:evenodd"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill:#D1D5DB;fill-rule:evenodd;clip-rule:evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
3
frontend/src/assets/Logos/github-copilot.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#9CA3AF" d="M23.922 16.997C23.061 18.492 18.063 22.02 12 22.02 5.937 22.02.939 18.492.078 16.997A.641.641 0 0 1 0 16.741v-2.869a.883.883 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.098 10.098 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952C7.255 2.937 9.248 1.98 11.978 1.98c2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.841.841 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256Zm-11.75-5.992h-.344a4.359 4.359 0 0 1-.355.508c-.77.947-1.918 1.492-3.508 1.492-1.725 0-2.989-.359-3.782-1.259a2.137 2.137 0 0 1-.085-.104L4 11.746v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.359 4.359 0 0 1-.355-.508Zm2.328 3.25c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm-5 0c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm3.313-6.185c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/src/assets/Logos/keda.svg
Normal file
|
After Width: | Height: | Size: 25 KiB |
24
frontend/src/assets/Logos/kong.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg viewBox="0 0 44.8 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="kong-a" x1="38.204" x2="8.732" y1="18.417" y2="48.543" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
<linearGradient id="kong-b" x1="38.107" x2="8.635" y1="18.322" y2="48.448" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
<linearGradient id="kong-c" x1="29.439" x2="-0.033" y1="9.842" y2="39.968" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
<linearGradient id="kong-d" x1="30.291" x2="0.819" y1="10.676" y2="40.801" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="m14.7 32.9-1.1 1.4 2.5 3.9-.3 1.8h10.6l.7-1.8-4.2-5.3z" fill="url(#kong-a)" />
|
||||
<path d="M20.5 9.4 16.7 16l18.6 22-.5 2h8.5l1.5-7.1L24.9 9.4z" fill="url(#kong-b)" />
|
||||
<path d="m23 4.4-1.8 3.3h4.5l7.7 9.2 4.6-3.8v-2.4l-1.6-2.2 1.2-1.2L28.4 0z" fill="url(#kong-c)" />
|
||||
<path d="M9.1 22.9H6.6L0 31.3V40h7.1l1.3-1.6 5.5-7.1h7.9l2.4-3.7-8.6-10.2z" fill="url(#kong-d)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
frontend/src/assets/Logos/neon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg fill="none" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M63 0.018v63.535L38.418 42.25v21.303H0V0l63 .018ZM7.723 55.839h22.972V25.323l24.583 21.725V7.729L7.723 7.716v48.123Z" fill="#37C38F" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 226 B |
@@ -0,0 +1,280 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
useRecoverFromEmptyPage,
|
||||
UseRecoverFromEmptyPageParams,
|
||||
} from '../useRecoverFromEmptyPage';
|
||||
|
||||
const REPLACE = { history: 'replace' };
|
||||
|
||||
function renderRecovery(
|
||||
overrides: Partial<UseRecoverFromEmptyPageParams> = {},
|
||||
): { setPage: jest.Mock; rerender: (next?: unknown) => void } {
|
||||
const setPage = jest.fn();
|
||||
const props: UseRecoverFromEmptyPageParams = {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
rowCount: 10,
|
||||
total: 100,
|
||||
isFetching: false,
|
||||
setPage,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(next: UseRecoverFromEmptyPageParams) => useRecoverFromEmptyPage(next),
|
||||
{ initialProps: props },
|
||||
);
|
||||
|
||||
return {
|
||||
setPage,
|
||||
rerender: (next?: unknown): void =>
|
||||
rerender({ ...props, ...(next as Partial<UseRecoverFromEmptyPageParams>) }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('useRecoverFromEmptyPage', () => {
|
||||
it('leaves the page alone while it still holds rows', () => {
|
||||
const { setPage } = renderRecovery({ page: 3, rowCount: 10 });
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the page alone on page 1 with no rows at all', () => {
|
||||
const { setPage } = renderRecovery({ page: 1, rowCount: 0, total: 0 });
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('jumps to the last page that holds data when the page is out of range', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: 7,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 25,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
|
||||
});
|
||||
|
||||
it('replaces the history entry so the back button does not return to the empty page', () => {
|
||||
const { setPage } = renderRecovery({ page: 4, rowCount: 0, total: 10 });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('falls back to page 1 when the total is unknown', () => {
|
||||
const { setPage } = renderRecovery({ page: 5, rowCount: 0, total: 0 });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('steps back one page when the total claims the page should have data', () => {
|
||||
// total says 100 rows exist, yet page 5 came back empty — step back rather
|
||||
// than stall on a page the query cannot actually serve.
|
||||
const { setPage } = renderRecovery({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(4, REPLACE);
|
||||
});
|
||||
|
||||
it('clamps a page below the first one', () => {
|
||||
const { setPage } = renderRecovery({ page: 0, rowCount: 10 });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('falls back to page 1 when pageSize is zero', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: 5,
|
||||
pageSize: 0,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('waits for the request to settle before moving the user', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 3,
|
||||
rowCount: 0,
|
||||
total: 10,
|
||||
isFetching: true,
|
||||
});
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ page: 3, rowCount: 0, total: 10, isFetching: false });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('clamps a page below the first one even when the query failed', () => {
|
||||
// A negative offset is what made the request fail (400 "offset cannot be
|
||||
// negative"), so retrying the same page loops forever — clamp regardless.
|
||||
const { setPage } = renderRecovery({
|
||||
page: 0,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isDisabled: true,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('clamps a page below the first one while the query is still in flight', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: -2,
|
||||
rowCount: 0,
|
||||
isFetching: true,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('keeps the page when the query failed so a retry lands where the user was', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: 3,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isDisabled: true,
|
||||
});
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clamps a page below the first one exactly once while the request settles', () => {
|
||||
// The clamp runs ahead of both gates, so a request settling underneath an
|
||||
// uncorrected page must not re-issue the same history rewrite.
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 0,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isFetching: true,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ page: 0, rowCount: 0, total: 0, isFetching: false });
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops correcting once the corrected page comes back with rows', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 7,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 25,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
|
||||
|
||||
// The correction lands: the query refetches, then resolves with the rows page 3 holds.
|
||||
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 25, isFetching: true });
|
||||
rerender({
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
rowCount: 5,
|
||||
total: 25,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not correct again while the same page is still being observed', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A refetch cycle that leaves the page untouched — the correction is already in flight.
|
||||
rerender({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
isFetching: true,
|
||||
});
|
||||
rerender({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('gives up on step-backs and jumps to page 1 when the total keeps lying', () => {
|
||||
// `total` claims 400 rows exist, but every page comes back empty. Walking back one
|
||||
// page at a time would cost a request per hop, so bail out to page 1 instead.
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 40,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 400,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(1, 39, REPLACE);
|
||||
|
||||
rerender({ page: 39, pageSize: 10, rowCount: 0, total: 400 });
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(2, 38, REPLACE);
|
||||
|
||||
rerender({ page: 38, pageSize: 10, rowCount: 0, total: 400 });
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(3, 1, REPLACE);
|
||||
expect(setPage).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('corrects again when the user returns to a page that is still empty', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 10,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(1, 1, REPLACE);
|
||||
|
||||
rerender({ page: 1, pageSize: 10, rowCount: 10, total: 10 });
|
||||
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 10 });
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(2, 1, REPLACE);
|
||||
});
|
||||
|
||||
it('does not re-run the correction when setPage is a fresh function each render', () => {
|
||||
// The hook reads setPage through a ref, so an inline arrow must not turn the
|
||||
// ungated `page < 1` clamp into a per-render history rewrite.
|
||||
const setPage = jest.fn();
|
||||
const { rerender } = renderHook(
|
||||
() =>
|
||||
useRecoverFromEmptyPage({
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isFetching: false,
|
||||
setPage: (nextPage, options): void => setPage(nextPage, options),
|
||||
}),
|
||||
{ initialProps: undefined },
|
||||
);
|
||||
|
||||
rerender(undefined);
|
||||
rerender(undefined);
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export * from './useCalculatedPageSize';
|
||||
export * from './useColumnState';
|
||||
export * from './useColumnStore';
|
||||
export * from './usePreferredPageSize.store';
|
||||
export * from './useRecoverFromEmptyPage';
|
||||
export * from './useTableParams';
|
||||
|
||||
/**
|
||||
@@ -285,6 +286,51 @@ export * from './useTableParams';
|
||||
*
|
||||
* **Pagination shows "Auto" option** when `calculatedPageSize` is passed, allowing users
|
||||
* to reset to auto-calculated size.
|
||||
*
|
||||
* **`setPage` accepts history options**: `setPage(page, { history: 'replace' })` rewrites the
|
||||
* current history entry instead of pushing a new one. Use `replace` for corrections the user
|
||||
* did not ask for — otherwise the back button walks straight back into the state that was just
|
||||
* corrected. Only applies when the page is synced to the URL; local (non-URL) pages ignore it.
|
||||
*
|
||||
* @example useRecoverFromEmptyPage — send the user back to a page that has data
|
||||
*
|
||||
* When rows disappear underneath the current page (filters narrowed, time range moved, items
|
||||
* deleted), the user is stranded on an empty page they cannot leave by scrolling. This hook
|
||||
* watches the fetched result and corrects the page with `history: 'replace'`, so the back
|
||||
* button does not return to the empty page.
|
||||
*
|
||||
* Correction rules:
|
||||
* - `page < 1` → jump to page 1, even while fetching or disabled. Such a page usually maps to a
|
||||
* negative offset the API rejects (400 `offset cannot be negative`), so the response can never
|
||||
* confirm the page is empty — deferring to it would strand the user on a permanent error.
|
||||
* - Page is empty and not page 1 → go to `min(ceil(total / pageSize), page - 1)`. When `total`
|
||||
* is trustworthy that lands on the last page holding data; when `total` is unknown or zero it
|
||||
* lands on page 1; and when `total` claims this page should have had rows it steps back a
|
||||
* single page. Repeated step-backs give up and jump to page 1 after the second one, so a
|
||||
* badly inflated `total` cannot walk the user down one request at a time.
|
||||
* - Page has rows, or the user is already on page 1 → do nothing (an empty page 1 means there
|
||||
* is genuinely nothing to show).
|
||||
*
|
||||
* Pass `isFetching` so the hook waits for the request to settle, and `isDisabled` so a failed
|
||||
* request is not mistaken for an empty page. Neither gate suppresses the `page < 1` clamp.
|
||||
*
|
||||
* ```tsx
|
||||
* import { useRecoverFromEmptyPage, useTableParams } from 'components/TanStackTableView';
|
||||
*
|
||||
* const { page, limit, setPage } = useTableParams(QUERY_PARAMS, { page: 1, limit: 20 });
|
||||
* const { data, isLoading, isFetching, isError } = useListQuery({ page, limit });
|
||||
*
|
||||
* useRecoverFromEmptyPage({
|
||||
* page,
|
||||
* pageSize: limit,
|
||||
* rowCount: data?.rows.length ?? 0,
|
||||
* total: data?.total ?? 0,
|
||||
* isFetching: isLoading || isFetching,
|
||||
* // Skip correction on errors — no rows there means "request failed", not "page is empty".
|
||||
* isDisabled: isError,
|
||||
* setPage,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
const TanStackTable = Object.assign(TanStackTableBase, {
|
||||
Text: TanStackTableText,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { SetPageOptions } from './useTableParams';
|
||||
|
||||
const FIRST_PAGE = 1;
|
||||
const REPLACE_HISTORY: SetPageOptions = { history: 'replace' };
|
||||
|
||||
/**
|
||||
* How many single-page step-backs to attempt before giving up and going to page 1.
|
||||
*
|
||||
* A step-back only happens when `total` claims the current page should hold data but the
|
||||
* response came back empty. Each hop costs a request, so an inflated `total` on a high page
|
||||
* number would otherwise walk the user down one page at a time behind a spinner.
|
||||
*/
|
||||
const MAX_STEP_BACKS = 2;
|
||||
|
||||
type Correction = {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
export type UseRecoverFromEmptyPageParams = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
rowCount: number;
|
||||
total: number;
|
||||
isFetching: boolean;
|
||||
isDisabled?: boolean;
|
||||
setPage: (page: number, options?: SetPageOptions) => void;
|
||||
};
|
||||
|
||||
export function useRecoverFromEmptyPage({
|
||||
page,
|
||||
pageSize,
|
||||
rowCount,
|
||||
total,
|
||||
isFetching,
|
||||
isDisabled = false,
|
||||
setPage,
|
||||
}: UseRecoverFromEmptyPageParams): void {
|
||||
const setPageRef = useRef(setPage);
|
||||
const lastCorrectionRef = useRef<Correction | null>(null);
|
||||
const stepBacksRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setPageRef.current = setPage;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (lastCorrectionRef.current && lastCorrectionRef.current.from !== page) {
|
||||
lastCorrectionRef.current = null;
|
||||
}
|
||||
|
||||
const correctTo = (nextPage: number): boolean => {
|
||||
if (lastCorrectionRef.current?.to === nextPage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
lastCorrectionRef.current = { from: page, to: nextPage };
|
||||
setPageRef.current(nextPage, REPLACE_HISTORY);
|
||||
return true;
|
||||
};
|
||||
|
||||
// A page below the first one is invalid on its own terms — it usually maps to a
|
||||
// negative offset the API rejects outright, so waiting for a response that will
|
||||
// never arrive (or trusting a failed one) would strand the user for good.
|
||||
if (page < FIRST_PAGE) {
|
||||
stepBacksRef.current = 0;
|
||||
void correctTo(FIRST_PAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFetching || isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The page has data, or there is genuinely nothing to show anywhere.
|
||||
if (rowCount > 0 || page === FIRST_PAGE) {
|
||||
stepBacksRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPage = Math.floor(page);
|
||||
const lastPageWithData =
|
||||
pageSize > 0 && total > 0 ? Math.ceil(total / pageSize) : FIRST_PAGE;
|
||||
const nextPage = Math.max(
|
||||
FIRST_PAGE,
|
||||
Math.min(lastPageWithData, currentPage - 1),
|
||||
);
|
||||
|
||||
// `total` disagrees with the response: it says this page should have rows, so the
|
||||
// only safe move is one page back. Cap how often that repeats — every hop is a
|
||||
// request, and a badly inflated `total` would otherwise crawl down from page 40.
|
||||
const isStepBack = nextPage === currentPage - 1;
|
||||
|
||||
if (isStepBack && stepBacksRef.current >= MAX_STEP_BACKS) {
|
||||
if (correctTo(FIRST_PAGE)) {
|
||||
stepBacksRef.current = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (correctTo(nextPage) && isStepBack) {
|
||||
stepBacksRef.current += 1;
|
||||
}
|
||||
}, [isFetching, isDisabled, page, pageSize, rowCount, total]);
|
||||
}
|
||||
@@ -29,12 +29,16 @@ type Defaults = {
|
||||
cleanupOnUnmount?: boolean;
|
||||
};
|
||||
|
||||
export type SetPageOptions = {
|
||||
history?: 'push' | 'replace';
|
||||
};
|
||||
|
||||
export type TableParamsResult = {
|
||||
page: number;
|
||||
limit: number;
|
||||
orderBy: SortState | null;
|
||||
expanded: ExpandedState;
|
||||
setPage: (p: number) => void;
|
||||
setPage: (p: number, options?: SetPageOptions) => void;
|
||||
setLimit: (l: number) => void;
|
||||
setOrderBy: (s: SortState | null) => void;
|
||||
setExpanded: (updaterOrValue: Updater<ExpandedState>) => void;
|
||||
@@ -249,6 +253,17 @@ export function useTableParams(
|
||||
[],
|
||||
);
|
||||
|
||||
const setUrlPageWithOptions = useCallback(
|
||||
(page: number, options?: SetPageOptions): void => {
|
||||
void setUrlPage(page, options);
|
||||
},
|
||||
[setUrlPage],
|
||||
);
|
||||
|
||||
const setLocalPageValue = useCallback((page: number): void => {
|
||||
setLocalPage(page);
|
||||
}, []);
|
||||
|
||||
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
|
||||
const prevOrderByRef = useRef<string | null>(null);
|
||||
|
||||
@@ -303,7 +318,7 @@ export function useTableParams(
|
||||
limit: useUrlForLimit ? urlLimit : localLimit,
|
||||
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
|
||||
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
|
||||
setPage: useUrlForPage ? setUrlPage : setLocalPage,
|
||||
setPage: useUrlForPage ? setUrlPageWithOptions : setLocalPageValue,
|
||||
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist,
|
||||
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
|
||||
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,
|
||||
|
||||
@@ -7,6 +7,7 @@ import TanStackTable, {
|
||||
TableColumnDef,
|
||||
useCalculatedPageSize,
|
||||
useHiddenColumnIds,
|
||||
useRecoverFromEmptyPage,
|
||||
useTableParams,
|
||||
} from 'components/TanStackTableView';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
@@ -136,6 +137,7 @@ export function K8sBaseList<
|
||||
page: currentPage,
|
||||
limit: currentPageSize,
|
||||
setLimit,
|
||||
setPage,
|
||||
} = useTableParams(
|
||||
{
|
||||
page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
|
||||
@@ -243,6 +245,16 @@ export function K8sBaseList<
|
||||
const totalCount = data?.total || 0;
|
||||
const hasFilters = !!expression?.trim();
|
||||
|
||||
useRecoverFromEmptyPage({
|
||||
page: currentPage,
|
||||
pageSize: currentPageSize,
|
||||
rowCount: pageData.length,
|
||||
total: totalCount,
|
||||
isFetching: isLoading || isFetching,
|
||||
isDisabled: isError || Boolean(data?.error),
|
||||
setPage,
|
||||
});
|
||||
|
||||
const getGroupKeyFn = useCallback(
|
||||
(item: T) => getGroupedByMeta(item, groupBy),
|
||||
[groupBy],
|
||||
|
||||
@@ -591,12 +591,14 @@ describe('K8sBaseList', () => {
|
||||
});
|
||||
|
||||
describe('with empty data', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [],
|
||||
@@ -605,6 +607,7 @@ describe('K8sBaseList', () => {
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
@@ -625,6 +628,177 @@ describe('K8sBaseList', () => {
|
||||
expect(fetchListDataMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not rewrite the page when already on the first page', async () => {
|
||||
await waitFor(() => {
|
||||
expect(fetchListDataMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const pageUpdates = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('page'))
|
||||
.filter(Boolean);
|
||||
|
||||
expect(pageUpdates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a page beyond the end of the list', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
// 25 rows exist, so pages 1-3 serve data and page 7 of 10 comes back empty.
|
||||
const rows: TestItem[] = Array.from({ length: 25 }, (_, index) => ({
|
||||
id: `pod-${index + 1}`,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
// Offset-aware on purpose: a mock that answers empty for every offset would let
|
||||
// the assertions pass against a page the recovery has already moved on from.
|
||||
fetchListDataMock.mockImplementation(async ({ offset = 0, limit = 10 }) => ({
|
||||
data: rows.slice(offset, offset + limit),
|
||||
total: rows.length,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: { page: '7', pageSize: '10' },
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should send the user back to the last page holding data', async () => {
|
||||
// The rows of page 3 on screen are what proves the recovery settled there,
|
||||
// rather than passing through on its way somewhere else.
|
||||
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
|
||||
|
||||
const pageUpdates = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('page'))
|
||||
.filter(Boolean);
|
||||
|
||||
expect(pageUpdates).toStrictEqual(['3']);
|
||||
});
|
||||
|
||||
it('should correct the page in a single hop', async () => {
|
||||
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
|
||||
|
||||
// Only the original out-of-range page and the corrected one are requested.
|
||||
expect(
|
||||
fetchListDataMock.mock.calls.map((call) => call[0].offset),
|
||||
).toStrictEqual([60, 20]);
|
||||
});
|
||||
|
||||
it('should replace the history entry instead of pushing the correction', async () => {
|
||||
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
|
||||
|
||||
const pageCorrection = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('page') === '3',
|
||||
);
|
||||
|
||||
expect(pageCorrection?.[0].options.history).toBe('replace');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a page below the first one', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
// page=0 turns into offset=-10, which the API rejects outright — the list
|
||||
// can only recover by clamping the page, never by reading the response.
|
||||
fetchListDataMock.mockImplementation(async ({ offset = 0 }) => {
|
||||
if (offset < 0) {
|
||||
throw new APIError({
|
||||
httpStatusCode: 400,
|
||||
error: {
|
||||
code: 'invalid_input',
|
||||
message: 'offset cannot be negative',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { data: [{ id: 'pod-1' }], total: 1, error: null };
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: { page: '0', pageSize: '10' },
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject the request that carried the negative offset', async () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchListDataMock.mock.calls.some((call) => call[0].offset === -10),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchListDataMock.mock.results[0].value as Promise<unknown>,
|
||||
).rejects.toThrow('offset cannot be negative');
|
||||
});
|
||||
|
||||
it('should clamp the page to the first one even though the request failed', async () => {
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Page 1 is the default, so the correction drops the param rather than
|
||||
// writing `page=1`.
|
||||
const pageCorrection = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('page') === null,
|
||||
);
|
||||
|
||||
expect(pageCorrection).toBeDefined();
|
||||
expect(pageCorrection?.[0].queryString).toBe('?pageSize=10');
|
||||
});
|
||||
|
||||
it('should replace the history entry instead of pushing the correction', async () => {
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const pageCorrection = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('page') === null,
|
||||
);
|
||||
|
||||
expect(pageCorrection?.[0].options.history).toBe('replace');
|
||||
});
|
||||
|
||||
it('should refetch with a non-negative offset after clamping', async () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchListDataMock.mock.calls.some((call) => call[0].offset === 0),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('pod-1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with error response', () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
useInfraMonitoringCategory,
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
} from './hooks';
|
||||
|
||||
@@ -67,6 +68,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
const [, setOrderBy] = useInfraMonitoringOrderBy();
|
||||
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
@@ -218,6 +220,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
void setSelectedCategory(key as string);
|
||||
void setOrderBy(null);
|
||||
void setGroupBy(null);
|
||||
void setCurrentPage(null);
|
||||
setSelectedItemParams(null);
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter as MemoryRouterV5 } from 'react-router-dom-v5-compat';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import store from 'store';
|
||||
|
||||
import { K8sCategories } from '../constants';
|
||||
import InfraMonitoringK8s from '../InfraMonitoringK8s';
|
||||
|
||||
// Quick filters fire their own field APIs and are irrelevant to pagination.
|
||||
jest.mock('components/QuickFilters/QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="quick-filters" />,
|
||||
}));
|
||||
|
||||
// The list owns its own page recovery; stubbing it keeps the page param under the
|
||||
// sole control of the category handler being tested here.
|
||||
jest.mock('../Base/K8sDynamicList', () => ({
|
||||
__esModule: true,
|
||||
K8sDynamicList: (): JSX.Element => <div data-testid="k8s-dynamic-list" />,
|
||||
default: (): JSX.Element => <div data-testid="k8s-dynamic-list" />,
|
||||
}));
|
||||
|
||||
// Analytics only; jsdom lacks the Performance navigation entries it reads.
|
||||
jest.mock('lib/navigation', () => ({
|
||||
getNavigationReferrer: (): string => 'direct',
|
||||
}));
|
||||
|
||||
function renderPage(
|
||||
queryParams: Record<string, string>,
|
||||
onUrlUpdate: jest.Mock<void, [UrlUpdateEvent]>,
|
||||
): void {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouterV5>
|
||||
<TimezoneProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppProvider>
|
||||
<Provider store={store}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={queryParams}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<QueryBuilderProvider>
|
||||
<InfraMonitoringK8s />
|
||||
</QueryBuilderProvider>
|
||||
</TooltipProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</Provider>
|
||||
</AppProvider>
|
||||
</QueryClientProvider>
|
||||
</TimezoneProvider>
|
||||
</MemoryRouterV5>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('InfraMonitoringK8s', () => {
|
||||
describe('when the category changes from a page other than the first', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
beforeEach(async () => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
|
||||
renderPage(
|
||||
{ category: K8sCategories.PODS, page: '3', pageSize: '10' },
|
||||
onUrlUpdateMock,
|
||||
);
|
||||
|
||||
await screen.findByTestId(`category-${K8sCategories.NODES}`);
|
||||
});
|
||||
|
||||
it('should drop the page so the new category starts at the first one', async () => {
|
||||
fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`));
|
||||
|
||||
// Page 3 of pods says nothing about nodes — keeping it asks the new entity
|
||||
// for an offset it may not have. The param is cleared rather than set to 1,
|
||||
// since an absent page already means the first one.
|
||||
await waitFor(() => {
|
||||
const categorySwitch = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('category') === K8sCategories.NODES,
|
||||
);
|
||||
|
||||
expect(categorySwitch).toBeDefined();
|
||||
expect(categorySwitch?.[0].searchParams.get('page')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep the page size, which is not category specific', async () => {
|
||||
fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`));
|
||||
|
||||
await waitFor(() => {
|
||||
const categorySwitch = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('category') === K8sCategories.NODES,
|
||||
);
|
||||
|
||||
expect(categorySwitch?.[0].searchParams.get('pageSize')).toBe('10');
|
||||
});
|
||||
});
|
||||
|
||||
it('should leave the page alone when the same category is clicked again', async () => {
|
||||
fireEvent.click(screen.getByTestId(`category-${K8sCategories.PODS}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('k8s-dynamic-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const droppedPage = onUrlUpdateMock.mock.calls.some(
|
||||
(call) => !call[0].searchParams.has('page'),
|
||||
);
|
||||
|
||||
expect(droppedPage).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,9 @@ export const useInfraMonitoringPageListing = (): UseQueryStateReturn<
|
||||
> =>
|
||||
useQueryState(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
|
||||
// do not use .withDefault here, this can cause bugs when
|
||||
// two hooks of nuqs define default twice, this is also
|
||||
// defined at useTableParams
|
||||
parseAsInteger.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import azureMysqlUrl from '@/assets/Logos/azure-mysql.svg';
|
||||
import azureOpenaiUrl from '@/assets/Logos/azure-openai.svg';
|
||||
import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metrics.svg';
|
||||
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
|
||||
import azureUrl from '@/assets/Logos/azure.svg';
|
||||
import basetenUrl from '@/assets/Logos/baseten.svg';
|
||||
import cassandraUrl from '@/assets/Logos/cassandra.svg';
|
||||
import celeryUrl from '@/assets/Logos/celery.svg';
|
||||
@@ -28,6 +29,7 @@ import certManagerUrl from '@/assets/Logos/cert-manager.svg';
|
||||
import claudeCodeUrl from '@/assets/Logos/claude-code.svg';
|
||||
import clickhouseUrl from '@/assets/Logos/clickhouse.svg';
|
||||
import cloudflareUrl from '@/assets/Logos/cloudflare.svg';
|
||||
import cloudnativePgUrl from '@/assets/Logos/cloudnative-pg.svg';
|
||||
import cloudwatchLogsUrl from '@/assets/Logos/cloudwatch-logs.svg';
|
||||
import cohereUrl from '@/assets/Logos/cohere.svg';
|
||||
import confluentKafkaUrl from '@/assets/Logos/confluent-kafka.svg';
|
||||
@@ -39,6 +41,7 @@ import datadogUrl from '@/assets/Logos/datadog.svg';
|
||||
import dbosUrl from '@/assets/Logos/dbos.svg';
|
||||
import deepseekUrl from '@/assets/Logos/deepseek.svg';
|
||||
import denoUrl from '@/assets/Logos/deno.svg';
|
||||
import difyUrl from '@/assets/Logos/dify.svg';
|
||||
import dockerUrl from '@/assets/Logos/docker.svg';
|
||||
import documentLoadUrl from '@/assets/Logos/document-load.svg';
|
||||
import dotnetUrl from '@/assets/Logos/dotnet.svg';
|
||||
@@ -70,6 +73,8 @@ import gcpCloudStorageUrl from '@/assets/Logos/gcp-cloud-storage.svg';
|
||||
import gcpComputeEngineUrl from '@/assets/Logos/gcp-compute-engine.svg';
|
||||
import gcpGkeUrl from '@/assets/Logos/gcp-gke.svg';
|
||||
import gcpVpcUrl from '@/assets/Logos/gcp-vpc.svg';
|
||||
import gcpUrl from '@/assets/Logos/gcp.svg';
|
||||
import githubCopilotUrl from '@/assets/Logos/github-copilot.svg';
|
||||
import githubUrl from '@/assets/Logos/github.svg';
|
||||
import goUrl from '@/assets/Logos/go.svg';
|
||||
import googleAdkUrl from '@/assets/Logos/google-adk.svg';
|
||||
@@ -96,6 +101,8 @@ import javascriptUrl from '@/assets/Logos/javascript.svg';
|
||||
import jbossUrl from '@/assets/Logos/jboss.svg';
|
||||
import jenkinsUrl from '@/assets/Logos/jenkins.svg';
|
||||
import kafkaUrl from '@/assets/Logos/kafka.svg';
|
||||
import kedaUrl from '@/assets/Logos/keda.svg';
|
||||
import kongUrl from '@/assets/Logos/kong.svg';
|
||||
import kubernetesUrl from '@/assets/Logos/kubernetes.svg';
|
||||
import lambdaUrl from '@/assets/Logos/lambda.svg';
|
||||
import langchainUrl from '@/assets/Logos/langchain.svg';
|
||||
@@ -114,6 +121,7 @@ import microsoftSqlServerUrl from '@/assets/Logos/microsoft-sql-server.svg';
|
||||
import mistralUrl from '@/assets/Logos/mistral.svg';
|
||||
import mongoUrl from '@/assets/Logos/mongo.svg';
|
||||
import n8nUrl from '@/assets/Logos/n8n.svg';
|
||||
import neonUrl from '@/assets/Logos/neon.svg';
|
||||
import newrelicUrl from '@/assets/Logos/newrelic.svg';
|
||||
import nextjsUrl from '@/assets/Logos/nextjs.svg';
|
||||
import nginxUrl from '@/assets/Logos/nginx.svg';
|
||||
@@ -2859,6 +2867,25 @@ const onboardingConfigWithLinks = [
|
||||
label: 'Traces',
|
||||
imgUrl: lambdaUrl,
|
||||
link: '/docs/aws-monitoring/lambda/lambda-traces/',
|
||||
question: {
|
||||
desc: 'Which runtime does your Lambda function use?',
|
||||
helpText:
|
||||
'Python, Node.js, Java, and Ruby use the OpenTelemetry auto-instrumentation layer. Go has no layer, so you add the SDK to your code.',
|
||||
options: [
|
||||
{
|
||||
key: 'aws-lambda-traces-auto',
|
||||
label: 'Python, Node.js, Java, Ruby',
|
||||
imgUrl: lambdaUrl,
|
||||
link: '/docs/aws-monitoring/lambda/lambda-traces/',
|
||||
},
|
||||
{
|
||||
key: 'aws-lambda-traces-golang',
|
||||
label: 'Go',
|
||||
imgUrl: goUrl,
|
||||
link: '/docs/aws-monitoring/lambda/lambda-traces-golang/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'aws-lambda-metrics',
|
||||
@@ -5510,8 +5537,10 @@ const onboardingConfigWithLinks = [
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'integrations',
|
||||
'logs',
|
||||
'metrics',
|
||||
'supabase',
|
||||
'supabase logs',
|
||||
'supabase metrics',
|
||||
'supabase monitoring',
|
||||
'supabase observability',
|
||||
@@ -5545,43 +5574,23 @@ const onboardingConfigWithLinks = [
|
||||
label: 'Traefik',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['infrastructure monitoring'],
|
||||
module: 'infrastructure',
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'infrastructure',
|
||||
'traefik',
|
||||
'traefik access logs',
|
||||
'traefik logs',
|
||||
'traefik metrics',
|
||||
'traefik monitoring',
|
||||
'traefik observability',
|
||||
'traefik tracing',
|
||||
],
|
||||
link: '/docs/tutorial/traefik-observability/',
|
||||
question: {
|
||||
desc: 'Which Traefik signals do you want to send to SigNoz?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{
|
||||
key: 'traefik-metrics-traces',
|
||||
label: 'Metrics & Traces',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/tutorial/traefik-observability/',
|
||||
},
|
||||
{
|
||||
key: 'traefik-logs',
|
||||
label: 'Access Logs',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/integrations/opentelemetry-traefik/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataSource: 'mongodb-atlas',
|
||||
label: 'MongoDB (Atlas)',
|
||||
imgUrl: mongoUrl,
|
||||
tags: ['database'],
|
||||
module: 'database',
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'atlas',
|
||||
'atlas metrics',
|
||||
@@ -5600,36 +5609,15 @@ const onboardingConfigWithLinks = [
|
||||
label: 'MySQL',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['database'],
|
||||
module: 'database',
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'mysql',
|
||||
'mysql error log',
|
||||
'mysql logs',
|
||||
'mysql metrics',
|
||||
'mysql monitoring',
|
||||
'mysql observability',
|
||||
'mysql slow query log',
|
||||
],
|
||||
link: '/docs/metrics-management/mysql-metrics/',
|
||||
question: {
|
||||
desc: 'Which MySQL signals do you want to send to SigNoz?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{
|
||||
key: 'mysql-metrics',
|
||||
label: 'Metrics',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/metrics-management/mysql-metrics/',
|
||||
},
|
||||
{
|
||||
key: 'mysql-logs',
|
||||
label: 'Logs',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/integrations/opentelemetry-mysql/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataSource: 'jmx',
|
||||
@@ -6992,5 +6980,514 @@ const onboardingConfigWithLinks = [
|
||||
id: 'dspy',
|
||||
link: '/docs/dspy-observability/',
|
||||
},
|
||||
{
|
||||
dataSource: 'grok-build',
|
||||
label: 'Grok Build',
|
||||
imgUrl: grokUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'coding agent',
|
||||
'grok build',
|
||||
'grok build events',
|
||||
'grok build logs',
|
||||
'grok build metrics',
|
||||
'grok build monitoring',
|
||||
'grok build observability',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'otel grok build integration',
|
||||
'terminal coding agent',
|
||||
'token usage',
|
||||
'xai',
|
||||
],
|
||||
id: 'grok-build',
|
||||
link: '/docs/grok-build-observability/',
|
||||
},
|
||||
{
|
||||
dataSource: 'neon',
|
||||
label: 'Neon',
|
||||
imgUrl: neonUrl,
|
||||
tags: ['database'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'neon',
|
||||
'neon database',
|
||||
'neon db',
|
||||
'neon logs',
|
||||
'neon metrics',
|
||||
'neon monitoring',
|
||||
'neon observability',
|
||||
'neondb',
|
||||
'opentelemetry neon',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'serverless postgres',
|
||||
],
|
||||
id: 'neon',
|
||||
link: '/docs/integrations/opentelemetry-neondb/',
|
||||
},
|
||||
{
|
||||
dataSource: 'dify',
|
||||
label: 'Dify',
|
||||
imgUrl: difyUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'ai agent',
|
||||
'dify',
|
||||
'dify monitoring',
|
||||
'dify observability',
|
||||
'dify traces',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'metrics',
|
||||
'no code ai',
|
||||
'observability',
|
||||
'opentelemetry dify',
|
||||
'traces',
|
||||
],
|
||||
id: 'dify',
|
||||
link: '/docs/dify-observability/',
|
||||
},
|
||||
{
|
||||
dataSource: 'firecrawl',
|
||||
label: 'Firecrawl',
|
||||
imgUrl: llmMonitoringUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'crawl',
|
||||
'firecrawl',
|
||||
'firecrawl metrics',
|
||||
'firecrawl monitoring',
|
||||
'firecrawl observability',
|
||||
'firecrawl traces',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'opentelemetry firecrawl',
|
||||
'scrape',
|
||||
'traces',
|
||||
'web scraping',
|
||||
],
|
||||
id: 'firecrawl',
|
||||
link: '/docs/firecrawl-monitoring/',
|
||||
},
|
||||
{
|
||||
dataSource: 'keda',
|
||||
label: 'KEDA',
|
||||
imgUrl: kedaUrl,
|
||||
tags: ['infrastructure monitoring', 'metrics'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'autoscaling',
|
||||
'event driven autoscaling',
|
||||
'keda',
|
||||
'keda metrics',
|
||||
'keda monitoring',
|
||||
'kubernetes',
|
||||
'kubernetes autoscaling',
|
||||
'metrics',
|
||||
'opentelemetry keda',
|
||||
'scaled jobs',
|
||||
'scaled objects',
|
||||
],
|
||||
id: 'keda',
|
||||
link: '/docs/metrics-management/keda-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'opentelemetry-collector-metrics',
|
||||
label: 'OpenTelemetry Collector Metrics',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['infrastructure monitoring', 'metrics'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'collector health',
|
||||
'collector internal metrics',
|
||||
'collector metrics',
|
||||
'metrics',
|
||||
'opentelemetry',
|
||||
'opentelemetry collector',
|
||||
'otel collector',
|
||||
'otelcol metrics',
|
||||
'pipeline health',
|
||||
],
|
||||
id: 'opentelemetry-collector-metrics',
|
||||
link: '/docs/metrics-management/opentelemetry-collector-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'cloudnative-pg',
|
||||
label: 'CloudNativePG',
|
||||
imgUrl: cloudnativePgUrl,
|
||||
tags: ['database'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'cloud native postgres',
|
||||
'cloudnativepg',
|
||||
'cnpg',
|
||||
'cnpg metrics',
|
||||
'database',
|
||||
'kubernetes postgres',
|
||||
'metrics',
|
||||
'opentelemetry cloudnativepg',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
],
|
||||
id: 'cloudnative-pg',
|
||||
link: '/docs/metrics-management/opentelemetry-cloudnative-pg/',
|
||||
},
|
||||
{
|
||||
dataSource: 'kong-gateway',
|
||||
label: 'Kong Gateway',
|
||||
imgUrl: kongUrl,
|
||||
tags: ['infrastructure monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'api gateway',
|
||||
'kong',
|
||||
'kong gateway',
|
||||
'kong logs',
|
||||
'kong metrics',
|
||||
'kong monitoring',
|
||||
'kong observability',
|
||||
'kong traces',
|
||||
'opentelemetry kong',
|
||||
'proxy',
|
||||
'traces',
|
||||
],
|
||||
id: 'kong-gateway',
|
||||
link: '/docs/integrations/kong-gateway/',
|
||||
},
|
||||
{
|
||||
dataSource: 'github-copilot',
|
||||
label: 'GitHub Copilot',
|
||||
imgUrl: githubCopilotUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'coding agent',
|
||||
'copilot',
|
||||
'copilot chat',
|
||||
'github copilot',
|
||||
'github copilot metrics',
|
||||
'github copilot monitoring',
|
||||
'github copilot observability',
|
||||
'github copilot traces',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'otel github copilot integration',
|
||||
'traces',
|
||||
'tracing',
|
||||
'vs code',
|
||||
],
|
||||
id: 'github-copilot',
|
||||
link: '/docs/github-copilot-monitoring/',
|
||||
},
|
||||
{
|
||||
dataSource: 'serilog',
|
||||
label: 'Serilog',
|
||||
imgUrl: dotnetUrl,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'.net',
|
||||
'asp.net',
|
||||
'c#',
|
||||
'csharp',
|
||||
'dotnet',
|
||||
'dotnet logs',
|
||||
'logging',
|
||||
'logs',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'otel serilog integration',
|
||||
'serilog',
|
||||
'serilog logs',
|
||||
'serilog sink',
|
||||
'structured logging',
|
||||
],
|
||||
id: 'serilog',
|
||||
link: '/docs/logs-management/send-logs/serilog-to-signoz/',
|
||||
},
|
||||
{
|
||||
dataSource: 'gcp-integration',
|
||||
label: 'GCP Integration',
|
||||
imgUrl: gcpUrl,
|
||||
tags: ['GCP'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'cloud monitoring metrics',
|
||||
'connect gcp account',
|
||||
'gcp',
|
||||
'gcp integration',
|
||||
'gcp manual setup',
|
||||
'gcp metrics',
|
||||
'gcp monitoring',
|
||||
'gcp observability',
|
||||
'gcp service account',
|
||||
'google cloud',
|
||||
'google cloud integration',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry collector gcp',
|
||||
],
|
||||
id: 'gcp-integration',
|
||||
link: '/docs/integrations/gcp/gcp-integration/',
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-cosmos-db',
|
||||
label: 'Azure Cosmos DB',
|
||||
imgUrl: azureUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure cosmos db',
|
||||
'azure integration',
|
||||
'cosmos db',
|
||||
'cosmosdb',
|
||||
'database',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'nosql',
|
||||
'observability',
|
||||
'one click azure',
|
||||
'request units',
|
||||
],
|
||||
id: 'azure-cosmos-db',
|
||||
link: '/integrations/azure?service=cosmosdb',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-mongodb',
|
||||
label: 'Azure MongoDB vCore',
|
||||
imgUrl: mongoUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure integration',
|
||||
'azure mongodb',
|
||||
'azure mongodb vcore',
|
||||
'database',
|
||||
'metrics',
|
||||
'mongodb',
|
||||
'mongodb vcore',
|
||||
'monitoring',
|
||||
'nosql',
|
||||
'observability',
|
||||
'one click azure',
|
||||
],
|
||||
id: 'azure-mongodb',
|
||||
link: '/integrations/azure?service=mongodb',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-postgresql-flexible-server',
|
||||
label: 'Azure PostgreSQL Flexible Server',
|
||||
imgUrl: postgresqlUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure integration',
|
||||
'azure postgresql',
|
||||
'azure postgresql flexible server',
|
||||
'database',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click azure',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'postgresql flexible server',
|
||||
],
|
||||
id: 'azure-postgresql-flexible-server',
|
||||
link: '/integrations/azure?service=postgresqlflexibleserver',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-cache-redis',
|
||||
label: 'Azure Cache for Redis',
|
||||
imgUrl: redisUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure cache for redis',
|
||||
'azure integration',
|
||||
'azure redis',
|
||||
'cache',
|
||||
'database',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click azure',
|
||||
'redis',
|
||||
],
|
||||
id: 'azure-cache-redis',
|
||||
link: '/integrations/azure?service=redis',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-sql-managed-instance',
|
||||
label: 'Azure SQL Managed Instance',
|
||||
imgUrl: azureSqlDatabaseMetricsUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure integration',
|
||||
'azure sql',
|
||||
'azure sql managed instance',
|
||||
'database',
|
||||
'managed instance',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click azure',
|
||||
'sql server',
|
||||
],
|
||||
id: 'azure-sql-managed-instance',
|
||||
link: '/integrations/azure?service=sqldatabasemi',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-cassandra-db',
|
||||
label: 'Azure Managed Instance for Apache Cassandra',
|
||||
imgUrl: cassandraUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'apache cassandra',
|
||||
'azure',
|
||||
'azure cassandra',
|
||||
'azure integration',
|
||||
'azure managed instance for apache cassandra',
|
||||
'cassandra',
|
||||
'database',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click azure',
|
||||
],
|
||||
id: 'azure-cassandra-db',
|
||||
link: '/integrations/azure?service=cassandradb',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'gcp-cloud-sql-postgresql',
|
||||
label: 'GCP Cloud SQL for PostgreSQL',
|
||||
imgUrl: gcpCloudSqlUrl,
|
||||
tags: ['GCP'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'cloud sql',
|
||||
'cloud sql for postgresql',
|
||||
'database',
|
||||
'gcp',
|
||||
'gcp integration',
|
||||
'google cloud',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
],
|
||||
id: 'gcp-cloud-sql-postgresql',
|
||||
link: '/integrations/gcp?service=cloudsql_postgres',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'gcp-memorystore-redis',
|
||||
label: 'GCP Memorystore for Redis',
|
||||
imgUrl: redisUrl,
|
||||
tags: ['GCP'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'cache',
|
||||
'database',
|
||||
'gcp',
|
||||
'gcp integration',
|
||||
'google cloud',
|
||||
'memorystore',
|
||||
'memorystore for redis',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'redis',
|
||||
],
|
||||
id: 'gcp-memorystore-redis',
|
||||
link: '/integrations/gcp?service=memorystore_redis',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'supabase-logs',
|
||||
label: 'Supabase Logs',
|
||||
imgUrl: supabaseUrl,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'logging',
|
||||
'logs',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'send supabase logs',
|
||||
'supabase',
|
||||
'supabase log drains',
|
||||
'supabase logs',
|
||||
'supabase observability',
|
||||
],
|
||||
id: 'supabase-logs',
|
||||
link: '/docs/logs-management/send-logs/supabase-logs/',
|
||||
},
|
||||
{
|
||||
dataSource: 'traefik-logs',
|
||||
label: 'Traefik Access Logs',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'access logs',
|
||||
'logging',
|
||||
'logs',
|
||||
'opentelemetry traefik',
|
||||
'proxy',
|
||||
'reverse proxy',
|
||||
'traefik',
|
||||
'traefik access logs',
|
||||
'traefik logs',
|
||||
],
|
||||
id: 'traefik-logs',
|
||||
link: '/docs/integrations/opentelemetry-traefik/',
|
||||
},
|
||||
{
|
||||
dataSource: 'mysql-logs',
|
||||
label: 'MySQL Logs',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'logging',
|
||||
'logs',
|
||||
'mysql',
|
||||
'mysql error log',
|
||||
'mysql general query log',
|
||||
'mysql logs',
|
||||
'mysql slow query log',
|
||||
'opentelemetry mysql',
|
||||
],
|
||||
id: 'mysql-logs',
|
||||
link: '/docs/integrations/opentelemetry-mysql/',
|
||||
},
|
||||
];
|
||||
export default onboardingConfigWithLinks;
|
||||
|
||||
@@ -19,6 +19,7 @@ pytest_plugins = [
|
||||
"fixtures.traces",
|
||||
"fixtures.metrics",
|
||||
"fixtures.queriercommon",
|
||||
"fixtures.semconvfamilies",
|
||||
"fixtures.metadata",
|
||||
"fixtures.meter",
|
||||
"fixtures.browser",
|
||||
|
||||
64
tests/fixtures/semconvfamilies.py
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
from collections.abc import Callable, Generator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
PREFIX = "semconv-fam"
|
||||
CURRENT_KEY = "deployment.environment.name"
|
||||
OLD_KEY = "deployment.environment"
|
||||
|
||||
# Row identities. The span name, the log body, and service.name are the identity.
|
||||
# Tests compare identity sets filtered by PREFIX, so reruns on a reused stack
|
||||
# with leftover rows stay stable.
|
||||
OLD = f"{PREFIX}-old" # only the old spelling, value "production"
|
||||
NEW = f"{PREFIX}-new" # only the current spelling, value "production"
|
||||
BOTH = f"{PREFIX}-both" # current "staging" and old "production" - the conflict row
|
||||
NEITHER = f"{PREFIX}-neither" # no member at all
|
||||
|
||||
_ROWS = [
|
||||
(OLD, {OLD_KEY: "production"}, timedelta(seconds=4)),
|
||||
(NEW, {CURRENT_KEY: "production"}, timedelta(seconds=3)),
|
||||
(BOTH, {CURRENT_KEY: "staging", OLD_KEY: "production"}, timedelta(seconds=2)),
|
||||
(NEITHER, {}, timedelta(seconds=1)),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(name="family_fleet", scope="function")
|
||||
def family_fleet(
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> Generator[datetime]:
|
||||
"""Yields the base timestamp of the inserted rows."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0) - timedelta(minutes=1)
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - offset,
|
||||
duration=timedelta(milliseconds=10),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name=identity,
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": identity, **family},
|
||||
attributes=dict(family),
|
||||
)
|
||||
for identity, family, offset in _ROWS
|
||||
]
|
||||
)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - offset,
|
||||
body=identity,
|
||||
resources={"service.name": identity, **family},
|
||||
attributes=dict(family),
|
||||
)
|
||||
for identity, family, offset in _ROWS
|
||||
]
|
||||
)
|
||||
yield now
|
||||
218
tests/integration/tests/semconvfamilies/01_family_matrix.py
Normal file
@@ -0,0 +1,218 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import (
|
||||
RequestType,
|
||||
build_aggregation,
|
||||
build_group_by_field,
|
||||
build_order_by,
|
||||
build_raw_query,
|
||||
build_traces_scalar_query,
|
||||
get_column_data_from_response,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.semconvfamilies import (
|
||||
BOTH,
|
||||
CURRENT_KEY,
|
||||
NEITHER,
|
||||
NEW,
|
||||
OLD,
|
||||
OLD_KEY,
|
||||
PREFIX,
|
||||
)
|
||||
|
||||
FILTER_MATRIX = [
|
||||
pytest.param("{key} = 'production'", {OLD, NEW}, id="eq_matches_either_spelling"),
|
||||
pytest.param("{key} = 'staging'", {BOTH}, id="eq_current_wins_on_conflict"),
|
||||
pytest.param("{key} != 'production'", {BOTH, NEITHER}, id="neq_keeps_keyless_and_conflict"),
|
||||
pytest.param("{key} IN ['production', 'staging']", {OLD, NEW, BOTH}, id="in_matches_merged_value"),
|
||||
pytest.param("{key} NOT IN ['production']", {BOTH, NEITHER}, id="not_in_keeps_keyless"),
|
||||
pytest.param("{key} LIKE '%prod%'", {OLD, NEW}, id="like_matches_merged_value"),
|
||||
pytest.param("{key} EXISTS", {OLD, NEW, BOTH}, id="exists_is_any_member"),
|
||||
pytest.param("{key} NOT EXISTS", {NEITHER}, id="not_exists_is_no_member"),
|
||||
pytest.param("{key} != 'production' AND {key} EXISTS", {BOTH}, id="neq_composed_with_exists"),
|
||||
]
|
||||
|
||||
LITERAL_MATRIX = [
|
||||
pytest.param("{key} = 'production'", {NEW}, id="literal_eq_reads_one_spelling"),
|
||||
pytest.param("{key} != 'production'", {OLD, BOTH, NEITHER}, id="literal_neq_reads_one_spelling"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expression_template,expected", FILTER_MATRIX)
|
||||
@pytest.mark.parametrize("requested_key", [CURRENT_KEY, OLD_KEY], ids=["current", "old"])
|
||||
@pytest.mark.parametrize("context", ["resource", "attribute"])
|
||||
def test_family_filters(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
family_fleet: datetime,
|
||||
context: str,
|
||||
requested_key: str,
|
||||
expression_template: str,
|
||||
expected: set[str],
|
||||
) -> None:
|
||||
"""The result set is a property of the family, not of the requested spelling."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
expression = expression_template.format(key=f"{context}.{requested_key}")
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((family_fleet - timedelta(minutes=2)).timestamp() * 1000),
|
||||
end_ms=int((family_fleet + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.RAW,
|
||||
queries=[
|
||||
build_raw_query(
|
||||
"A",
|
||||
"traces",
|
||||
limit=100,
|
||||
filter_expression=expression,
|
||||
order=[build_order_by("timestamp", "asc")],
|
||||
select_fields=[{"name": "span.name"}],
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
matched = {name for name in get_column_data_from_response(response.json(), "name") if name.startswith(PREFIX)}
|
||||
assert matched == expected, expression
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expression_template,expected", LITERAL_MATRIX)
|
||||
def test_flag_off_stays_literal(
|
||||
signoz_families_off: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
family_fleet: datetime,
|
||||
expression_template: str,
|
||||
expected: set[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
expression = expression_template.format(key=f"resource.{CURRENT_KEY}")
|
||||
response = make_query_request(
|
||||
signoz_families_off,
|
||||
token,
|
||||
start_ms=int((family_fleet - timedelta(minutes=2)).timestamp() * 1000),
|
||||
end_ms=int((family_fleet + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.RAW,
|
||||
queries=[
|
||||
build_raw_query(
|
||||
"A",
|
||||
"traces",
|
||||
limit=100,
|
||||
filter_expression=expression,
|
||||
order=[build_order_by("timestamp", "asc")],
|
||||
select_fields=[{"name": "span.name"}],
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
matched = {name for name in get_column_data_from_response(response.json(), "name") if name.startswith(PREFIX)}
|
||||
assert matched == expected, expression
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expression_template,expected", LITERAL_MATRIX)
|
||||
def test_logs_stay_literal_with_flag_on(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
family_fleet: datetime,
|
||||
expression_template: str,
|
||||
expected: set[str],
|
||||
) -> None:
|
||||
"""Only traces have family support today."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
expression = expression_template.format(key=f"resource.{CURRENT_KEY}")
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((family_fleet - timedelta(minutes=2)).timestamp() * 1000),
|
||||
end_ms=int((family_fleet + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.RAW,
|
||||
queries=[
|
||||
build_raw_query(
|
||||
"A",
|
||||
"logs",
|
||||
limit=100,
|
||||
filter_expression=expression,
|
||||
order=[build_order_by("timestamp", "asc")],
|
||||
select_fields=[{"name": "body"}],
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
matched = {body for body in get_column_data_from_response(response.json(), "body") if body.startswith(PREFIX)}
|
||||
assert matched == expected, expression
|
||||
|
||||
|
||||
def test_group_by_merges_and_echoes_requested_spelling(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
family_fleet: datetime,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((family_fleet - timedelta(minutes=2)).timestamp() * 1000),
|
||||
end_ms=int((family_fleet + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.SCALAR,
|
||||
queries=[
|
||||
build_traces_scalar_query(
|
||||
[build_aggregation("count()")],
|
||||
filter_expression=f"service.name LIKE '{PREFIX}%'",
|
||||
group_by=[build_group_by_field(CURRENT_KEY, "string", "resource")],
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
result = response.json()["data"]["data"]["results"][0]
|
||||
group_column = result["columns"][0]
|
||||
assert group_column["name"] == CURRENT_KEY, group_column
|
||||
assert group_column["columnType"] == "group", group_column
|
||||
|
||||
groups = {row[0] for row in result["data"]}
|
||||
assert {"production", "staging"}.issubset(groups), groups
|
||||
assert None in groups, groups
|
||||
|
||||
|
||||
def test_bare_name_prefers_resource_and_warns(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
family_fleet: datetime,
|
||||
) -> None:
|
||||
"""Both contexts carry the family, so a bare name is ambiguous: resolution
|
||||
warns and keeps the resource side."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((family_fleet - timedelta(minutes=2)).timestamp() * 1000),
|
||||
end_ms=int((family_fleet + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.RAW,
|
||||
queries=[
|
||||
build_raw_query(
|
||||
"A",
|
||||
"traces",
|
||||
limit=100,
|
||||
filter_expression=f"{CURRENT_KEY} = 'production'",
|
||||
order=[build_order_by("timestamp", "asc")],
|
||||
select_fields=[{"name": "span.name"}],
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
matched = {name for name in get_column_data_from_response(response.json(), "name") if name.startswith(PREFIX)}
|
||||
assert matched == {OLD, NEW}
|
||||
|
||||
warning = response.json()["data"].get("warning") or {}
|
||||
messages = " ".join(entry.get("message", "") for entry in warning.get("warnings", []))
|
||||
assert "ambiguous" in messages.lower(), messages
|
||||
0
tests/integration/tests/semconvfamilies/__init__.py
Normal file
55
tests/integration/tests/semconvfamilies/conftest.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import pytest
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.signoz import create_signoz
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz", scope="package")
|
||||
def signoz_semconv_families(
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz-semconv-families",
|
||||
env_overrides={
|
||||
"SIGNOZ_FLAGGER_CONFIG_BOOLEAN_RESOLVE__SEMCONV__FAMILIES": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz_families_off", scope="package")
|
||||
def signoz_families_off(
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
"""Shares the sqlstore and clickhouse with the flag-on instance, so the
|
||||
same admin token and seeded rows work."""
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz-semconv-families-off",
|
||||
env_overrides={},
|
||||
)
|
||||