Compare commits

..

4 Commits

Author SHA1 Message Date
Abhi Kumar
6ba466ac77 feat(charts): make stacking a property of TimeSeries and Bar charts
Charts now take a `stack` prop and hand it to their config, which derives
everything else. Callers no longer compute fill bands or transform data:
V1 and V2 bar panels, Meter Explorer and Billing each drop their
`setBands` call and declare `stack` instead.

The stacking hook moves next to the shell that runs it and is no longer
bar-specific, so TimeSeries stacks too — which is what the upcoming area
chart will build on. `stack` sits on the two chart prop types rather than
the shared config, so kinds that cannot stack never expose it.

Assisted-by: Claude Opus 5
2026-08-19 22:08:24 +05:30
Abhi Kumar
0a86eef3aa feat(uplot): let tooltips report pre-stack values
Tooltips recovered a series' own value by subtracting the one below it,
which only works while stacking is cumulative. Percent stacking discards
the column total, so the raw value cannot be derived from the plot's data
at all.

Tooltips now read an optional `unstackedData` when one is supplied, and
keep the subtraction as a fallback. Nothing supplies it yet, so behaviour
is unchanged by this commit.

Assisted-by: Claude Opus 5
2026-08-19 22:07:32 +05:30
Abhi Kumar
4057dc5fdf feat(uplot): derive stacking config from a declared stack mode
The config builder gains `setStack`/`getStackMode`. From that one
declaration `getConfig` derives the fill bands, and for `percent` swaps
the y axis to percentage ticks and pins the scale to a 0-100 soft band,
so callers no longer compute bands or units themselves.

Soft rather than hard limits, since mixed-sign shares fall outside 0-100
and must stay visible. The panel's own soft limits are dropped there
because they are expressed in the source unit, which means nothing once
values are normalised; thresholds still draw but no longer widen the band
for the same reason.

Assisted-by: Claude Opus 5
2026-08-19 22:07:15 +05:30
Abhi Kumar
e679805b43 feat(charts): add percent and none stack modes to stackSeries
`stackSeries` takes a mode: `normal` keeps today's cumulative behaviour,
`percent` rescales each x-slice to its column total so every column fills
to 100, and `none` is a no-op.

Mixed-sign columns divide by the signed total, so shares can fall outside
0-100 and still sum to it; a column summing to zero yields zero rather
than dividing by it.

Assisted-by: Claude Opus 5
2026-08-19 22:06:54 +05:30
69 changed files with 834 additions and 2520 deletions

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 583 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 447 B

View File

@@ -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:#D1D5DB;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-rule:evenodd;clip-rule:evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,24 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 226 B

View File

@@ -19,7 +19,6 @@ export type LogDetailProps = {
onScrollToLog?: (logId: string) => void;
handleOpenInExplorer?: MouseEventHandler;
getContainer?: DrawerProps['getContainer'];
onApplyLogFilter?: (expression: string) => void;
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
Pick<DrawerProps, 'onClose'>;

View File

@@ -16,7 +16,6 @@ import {
Link,
} from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import { normalizeTimeToMs } from 'utils/timeUtils';
import { ILog } from 'types/api/logs/log';
import { MouseEvent, MouseEventHandler } from 'react';
import { useCopyToClipboard } from 'react-use';
@@ -68,11 +67,6 @@ function LogDetailsHeader({
},
];
const rawTimestamp = log.date ?? log.timestamp;
const displayTimestamp = Number.isNaN(Number(rawTimestamp))
? rawTimestamp
: normalizeTimeToMs(rawTimestamp);
return (
<div className={styles.header} data-log-detail-ignore="true">
<div className={styles.leftSection}>
@@ -82,7 +76,7 @@ function LogDetailsHeader({
data-testid="log-details-header-timestamp"
>
{formatTimezoneAdjustedTimestamp(
displayTimestamp,
log.date ?? log.timestamp,
DATE_TIME_FORMATS.DASH_DATETIME,
)}
</Typography.Text>

View File

@@ -18,9 +18,10 @@ jest.mock('periscope/components/DataViewer', () => ({
DataViewer: (): JSX.Element => <div data-testid="overview-data-viewer" />,
}));
// Force v2 for these tests regardless of route.
jest.mock('../useIsLogDetailsV2', () => ({
useIsLogDetailsV2: (): boolean => true,
// The flag to be removed later
jest.mock('../constants', () => ({
...jest.requireActual('../constants'),
isLogDetailsV2: true,
}));
const mockLog: ILog = {
@@ -91,24 +92,6 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
);
});
it('normalizes a nanosecond-epoch timestamp in the header', () => {
localStorage.setItem(LOCALSTORAGE.PREFERRED_TIMEZONE, 'UTC');
// Same instant as mockLog but as epoch nanoseconds (e.g. dashboard list panel).
// Must scale to ms, not render a wildly wrong date.
renderDrawer({
log: {
...mockLog,
date: '1705311930000000000',
timestamp: 1705311930000000000,
},
});
expect(screen.getByTestId('log-details-header-timestamp')).toHaveTextContent(
'Jan 15, 2024 ⎯ 09:45:30',
);
});
it('copies the log link from the ⋯ menu', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });

View File

@@ -1,3 +1,6 @@
// temporary flag to be removed with old log details code.
export const isLogDetailsV2 = true;
export const VIEW_TYPES = {
OVERVIEW: 'OVERVIEW',
JSON: 'JSON',

View File

@@ -51,12 +51,11 @@ import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { isLogDetailsV2, RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
import LogHighlights from './LogHighlights/LogHighlights';
import { useIsLogDetailsV2 } from './useIsLogDetailsV2';
import './LogDetails.styles.scss';
@@ -75,7 +74,6 @@ function LogDetailInner({
onScrollToLog,
handleOpenInExplorer,
getContainer,
onApplyLogFilter,
}: LogDetailInnerProps): JSX.Element {
const initialContextQuery = useInitialQuery(log);
const [contextQuery, setContextQuery] = useState<Query | undefined>(
@@ -94,8 +92,6 @@ function LogDetailInner({
const [isEdit, setIsEdit] = useState<boolean>(false);
const { stagedQuery } = useQueryBuilder();
const isLogDetailsV2 = useIsLogDetailsV2();
// Handle clicks outside to close drawer, except on explicitly ignored regions
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
@@ -520,7 +516,6 @@ function LogDetailInner({
selectedOptions={options}
listViewPanelSelectedFields={listViewPanelSelectedFields}
handleChangeSelectedView={handleChangeSelectedView}
onApplyLogFilter={onApplyLogFilter}
/>
)}
{!isLogDetailsV2 && selectedView === VIEW_TYPES.JSON && (

View File

@@ -1,11 +0,0 @@
import ROUTES from 'constants/routes';
import { useLocation } from 'react-router-dom';
export function useIsLogDetailsV2(): boolean {
const { pathname } = useLocation();
return (
pathname === ROUTES.LOGS_EXPLORER ||
pathname.startsWith(ROUTES.INFRASTRUCTURE_MONITORING_BASE) ||
pathname.startsWith(`${ROUTES.ALL_DASHBOARD}/`)
);
}

View File

@@ -1,280 +0,0 @@
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);
});
});

View File

@@ -9,7 +9,6 @@ export * from './useCalculatedPageSize';
export * from './useColumnState';
export * from './useColumnStore';
export * from './usePreferredPageSize.store';
export * from './useRecoverFromEmptyPage';
export * from './useTableParams';
/**
@@ -286,51 +285,6 @@ 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,

View File

@@ -1,107 +0,0 @@
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]);
}

View File

@@ -29,16 +29,12 @@ 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, options?: SetPageOptions) => void;
setPage: (p: number) => void;
setLimit: (l: number) => void;
setOrderBy: (s: SortState | null) => void;
setExpanded: (updaterOrValue: Updater<ExpandedState>) => void;
@@ -253,17 +249,6 @@ 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);
@@ -318,7 +303,7 @@ export function useTableParams(
limit: useUrlForLimit ? urlLimit : localLimit,
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
setPage: useUrlForPage ? setUrlPageWithOptions : setLocalPageValue,
setPage: useUrlForPage ? setUrlPage : setLocalPage,
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist,
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,

View File

@@ -5,6 +5,7 @@ import BarChart from 'container/DashboardContainer/visualization/charts/BarChart
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { StackMode } from 'lib/uPlotV2/config/types';
import {
LegendPosition,
TooltipRenderArgs,
@@ -131,9 +132,9 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
<div ref={graphRef} className={styles.graphContainer}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
stack={StackMode.Normal}
config={config}
data={chartData}
isStackedBarChart
legendConfig={{ position: LegendPosition.BOTTOM }}
customTooltip={renderBillingTooltip}
width={containerDimensions.width}

View File

@@ -58,26 +58,17 @@ describe('prepareBillingBarConfig', () => {
expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500);
});
it('sets stacking bands, padding, and focus alpha for behavioral parity', () => {
it('sets padding and focus alpha for behavioral parity', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
});
const config = builder.getConfig();
expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
// Stacking bands come from the chart now — see useChartStacking.
expect(config.padding).toStrictEqual([32, 32, 16, 16]);
expect(config.focus).toStrictEqual({ alpha: 0.3 });
});
it('sets no bands when result is empty', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse([]),
});
const config = builder.getConfig();
expect(config.bands).toBeUndefined();
});
it('uses queryName as label when legend is undefined', () => {
const apiResponse: MetricRangePayloadProps = {
data: {

View File

@@ -1,7 +1,6 @@
import { Color } from '@signozhq/design-tokens';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
@@ -63,7 +62,6 @@ export function prepareBillingBarConfig({
});
});
builder.setBands(getInitialStackedBands(results.length));
builder.setPadding([32, 32, 16, 16]);
builder.setFocus({ alpha: 0.3 });

View File

@@ -6,25 +6,24 @@ import {
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { useBarChartStacking } from '../../hooks/useBarChartStacking';
import { StackMode } from 'lib/uPlotV2/config/types';
import { BarChartProps } from '../types';
export default function BarChart(props: BarChartProps): JSX.Element {
const {
children,
isStackedBarChart,
customTooltip,
config,
data,
stack = StackMode.None,
pinnedTooltipElement,
...rest
} = props;
const chartData = useBarChartStacking({
data,
isStackedBarChart,
config,
});
// Written during render so it lands before UPlotChart's effect reads the config,
// which derives the fill bands, percent axis unit and percent range from it.
config.setStack(stack);
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
@@ -37,7 +36,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
isStackedBarChart: isStackedBarChart,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
@@ -48,7 +46,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
rest.timezone,
rest.yAxisUnit,
rest.decimalPrecision,
isStackedBarChart,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
@@ -58,7 +55,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
<ChartWrapper
{...rest}
config={config}
data={chartData}
data={data}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>

View File

@@ -6,12 +6,15 @@ import {
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart/UPlotChart';
import { StackMode } from 'lib/uPlotV2/config/types';
import { prepareAlignedData } from 'lib/uPlotV2/components/UPlotChart/utils';
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import noop from 'lodash-es/noop';
import uPlot from 'uplot';
import { ChartProps } from '../types';
import { ChartWrapperProps } from '../types';
import { useChartStacking } from './useChartStacking';
const TOOLTIP_WIDTH_PADDING = 120;
const TOOLTIP_MIN_WIDTH = 300;
@@ -39,9 +42,20 @@ export default function ChartWrapper({
pinnedTooltipElement,
tooltipPortalRoot,
'data-testid': testId,
}: ChartProps): JSX.Element {
}: ChartWrapperProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
const stack = config.getStackMode();
const chartData = useChartStacking({ data, config });
// Tooltips need pre-stack values, gap-processed exactly as UPlotChart processes the
// plot data — otherwise the cursor's index addresses a shorter array.
const unstackedData = useMemo(
() =>
stack === StackMode.None ? undefined : prepareAlignedData({ data, config }),
[data, config, stack],
);
const legendComponent = useCallback(
(averageLegendWidth: number): React.ReactNode => {
if (!showLegend) {
@@ -61,11 +75,11 @@ export default function ChartWrapper({
const renderTooltipCallback = useCallback(
(args: TooltipRenderArgs): React.ReactNode => {
if (customTooltip) {
return customTooltip(args);
return customTooltip({ ...args, unstackedData });
}
return null;
},
[customTooltip],
[customTooltip, unstackedData],
);
const syncMetadata = useMemo(
@@ -91,7 +105,7 @@ export default function ChartWrapper({
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
<UPlotChart
config={config}
data={data}
data={chartData}
width={chartWidth}
height={chartHeight}
plotRef={(plot): void => {

View File

@@ -0,0 +1,98 @@
import { renderHook } from '@testing-library/react';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { StackMode } from 'lib/uPlotV2/config/types';
import uPlot from 'uplot';
import { useChartStacking } from '../useChartStacking';
type Hooks = Record<string, (...args: unknown[]) => void>;
function createConfig(stack: StackMode): {
config: UPlotConfigBuilder;
hooks: Hooks;
} {
const hooks: Hooks = {};
const config = {
getStackMode: (): StackMode => stack,
addHook: jest.fn((type: string, hook: (...args: unknown[]) => void) => {
hooks[type] = hook;
return jest.fn();
}),
} as unknown as UPlotConfigBuilder;
return { config, hooks };
}
const data = [[1], [30], [10]] as unknown as uPlot.AlignedData;
describe('useChartStacking', () => {
it('returns the data untouched and registers nothing when the config says `none`', () => {
const { config } = createConfig(StackMode.None);
const { result } = renderHook(() => useChartStacking({ data, config }));
expect(result.current).toBe(data);
expect(config.addHook).not.toHaveBeenCalled();
});
it('treats a missing config as unstacked', () => {
const { result } = renderHook(() => useChartStacking({ data, config: null }));
expect(result.current).toBe(data);
});
it('accumulates raw values when the config declares `normal`', () => {
const { config } = createConfig(StackMode.Normal);
const { result } = renderHook(() => useChartStacking({ data, config }));
expect(result.current).toStrictEqual([[1], [40], [10]]);
});
it('rescales each column to its total when the config declares `percent`', () => {
const { config } = createConfig(StackMode.Percent);
const { result } = renderHook(() => useChartStacking({ data, config }));
expect(result.current).toStrictEqual([[1], [100], [25]]);
});
it('registers the uPlot hooks that re-stack on data and visibility changes', () => {
const { config } = createConfig(StackMode.Normal);
renderHook(() => useChartStacking({ data, config }));
expect(
(config.addHook as jest.Mock).mock.calls.map(([type]) => type),
).toStrictEqual(['setData', 'setSeries']);
});
it('re-stacks from the raw values when the legend hides a series', () => {
const { config, hooks } = createConfig(StackMode.Normal);
renderHook(() => useChartStacking({ data, config }));
const plot = {
data: [[1]],
series: [{}, { show: true }, { show: false }],
delBand: jest.fn(),
addBand: jest.fn(),
setData: jest.fn(),
};
hooks.setSeries(plot, 2, { show: false });
// The hidden series keeps its raw value and stops contributing to the total.
expect(plot.setData).toHaveBeenCalledWith([[1], [30], [10]]);
expect(plot.delBand).toHaveBeenCalledWith(null);
});
it('ignores a focus-only setSeries so hovering does not re-stack', () => {
const { config, hooks } = createConfig(StackMode.Normal);
renderHook(() => useChartStacking({ data, config }));
const plot = {
data: [[1]],
series: [{}, { show: true }, { show: true }],
delBand: jest.fn(),
addBand: jest.fn(),
setData: jest.fn(),
};
hooks.setSeries(plot, 1, { focus: true });
expect(plot.setData).not.toHaveBeenCalled();
});
});

View File

@@ -6,10 +6,11 @@ import {
useRef,
} from 'react';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { StackMode } from 'lib/uPlotV2/config/types';
import { has } from 'lodash-es';
import uPlot from 'uplot';
import { stackSeries } from '../charts/utils/stackSeriesUtils';
import { stackSeries } from '../utils/stackSeriesUtils';
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
@@ -31,12 +32,12 @@ function canApplyStacking(
function setupStackingHooks(
config: UPlotConfigBuilder,
applyStackingToChart: (plot: uPlot) => void,
restack: (plot: uPlot) => void,
isUpdatingRef: MutableRefObject<boolean>,
): () => void {
const onDataChange = (plot: uPlot): void => {
if (!isUpdatingRef.current) {
applyStackingToChart(plot);
restack(plot);
}
};
@@ -45,8 +46,9 @@ function setupStackingHooks(
_seriesIdx: number | null,
opts: uPlot.Series,
): void => {
// uPlot fires setSeries for hover focus too; only visibility changes restack.
if (!has(opts, 'focus')) {
applyStackingToChart(plot);
restack(plot);
}
};
@@ -62,64 +64,69 @@ function setupStackingHooks(
};
}
export interface UseBarChartStackingParams {
export interface UseChartStackingParams {
data: uPlot.AlignedData;
isStackedBarChart?: boolean;
config: UPlotConfigBuilder | null;
}
/**
* Handles stacking for bar charts: computes initial stacked data and re-stacks
* when data or series visibility changes (e.g. legend toggles).
* Stacks a chart's data for the mode declared on its config, and re-stacks on data or
* visibility changes. The pre-stack values live in a ref because the uPlot hooks that
* read them run outside React's render cycle.
*/
export function useBarChartStacking({
export function useChartStacking({
data,
isStackedBarChart = false,
config,
}: UseBarChartStackingParams): uPlot.AlignedData {
// Store unstacked source data so uPlot hooks can access it (hooks run outside React's render cycle)
}: UseChartStackingParams): uPlot.AlignedData {
const stack = config?.getStackMode() ?? StackMode.None;
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
unstackedDataRef.current = isStackedBarChart ? data : null;
unstackedDataRef.current = stack === 'none' ? null : data;
// Prevents re-entrant calls when we update chart data (avoids infinite loop in setData hook)
// Guards the re-entrant setData below, which would otherwise re-trigger our own hook.
const isUpdatingChartRef = useRef(false);
const chartData = useMemo((): uPlot.AlignedData => {
if (!isStackedBarChart || !data || data.length < 2) {
if (stack === StackMode.None || !data || data.length < 2) {
return data;
}
const noSeriesHidden = (): boolean => false; // include all series in initial stack
const { data: stacked } = stackSeries(data, noSeriesHidden);
return stacked;
}, [data, isStackedBarChart]);
return stackSeries(data, noSeriesHidden, stack).data;
}, [data, stack]);
const applyStackingToChart = useCallback((plot: uPlot): void => {
const unstacked = unstackedDataRef.current;
if (
!unstacked ||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
) {
return;
}
const restack = useCallback(
(plot: uPlot): void => {
const unstacked = unstackedDataRef.current;
if (
!unstacked ||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
) {
return;
}
const shouldExcludeSeries = (idx: number): boolean =>
isSeriesHidden(plot, idx);
const { data: stacked, bands } = stackSeries(unstacked, shouldExcludeSeries);
const shouldExcludeSeries = (idx: number): boolean =>
isSeriesHidden(plot, idx);
const { data: stacked, bands } = stackSeries(
unstacked,
shouldExcludeSeries,
stack,
);
plot.delBand(null);
bands.forEach((band: uPlot.Band) => plot.addBand(band));
plot.delBand(null);
bands.forEach((band: uPlot.Band) => plot.addBand(band));
isUpdatingChartRef.current = true;
plot.setData(stacked);
isUpdatingChartRef.current = false;
}, []);
isUpdatingChartRef.current = true;
plot.setData(stacked);
isUpdatingChartRef.current = false;
},
[stack],
);
useLayoutEffect(() => {
if (!isStackedBarChart || !config) {
if (stack === StackMode.None || !config) {
return undefined;
}
return setupStackingHooks(config, applyStackingToChart, isUpdatingChartRef);
}, [isStackedBarChart, config, applyStackingToChart]);
return setupStackingHooks(config, restack, isUpdatingChartRef);
}, [stack, config, restack]);
return chartData;
}

View File

@@ -6,10 +6,16 @@ import {
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { StackMode } from 'lib/uPlotV2/config/types';
import { TimeSeriesChartProps } from '../types';
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
const { children, customTooltip, ...rest } = props;
const { children, customTooltip, stack = StackMode.None, ...rest } = props;
// Written during render so it lands before UPlotChart's effect reads the config,
// which derives the fill bands, percent axis unit and percent range from it.
rest.config.setStack(stack);
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {

View File

@@ -14,6 +14,7 @@ import {
ChartClickData,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { StackMode } from 'lib/uPlotV2/config/types';
interface BaseChartProps {
width: number;
@@ -52,27 +53,26 @@ interface UPlotChartDataProps {
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
}
export interface TimeSeriesChartProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
/** Everything the shared uPlot shell consumes; each chart's props narrow it. */
export interface ChartWrapperProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {}
export interface TimeSeriesChartProps extends ChartWrapperProps {
timezone?: Timezone;
/** How series compose. Defaults to `none`, which draws them independently. */
stack?: StackMode;
}
export interface HistogramChartProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
export interface BarChartProps extends ChartWrapperProps {
timezone?: Timezone;
/** How series compose. Defaults to `none`, which draws them independently. */
stack?: StackMode;
}
export interface HistogramChartProps extends ChartWrapperProps {
isQueriesMerged?: boolean;
}
export interface BarChartProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
isStackedBarChart?: boolean;
timezone?: Timezone;
}
export type ChartProps =
| TimeSeriesChartProps
| BarChartProps
| HistogramChartProps;
/**
* One resolved pie/donut slice: a display label, its (already parsed) positive
* numeric value, and the colour used for the arc + legend swatch.

View File

@@ -0,0 +1,158 @@
import { AlignedData } from 'uplot';
import { StackMode } from 'lib/uPlotV2/config/types';
import { stackSeries } from '../stackSeriesUtils';
const includeAll = (): boolean => false;
// Stacking is top-down: the first series carries the column total, the last its own
// raw value. Every expectation below reads in that order.
describe('stackSeries', () => {
it('is a no-op under `none`, returning the data and no bands', () => {
const data: AlignedData = [[1], [30], [10]];
const { data: result, bands } = stackSeries(data, includeAll, StackMode.None);
expect(result).toBe(data);
expect(bands).toStrictEqual([]);
});
describe('normal', () => {
it('accumulates raw values from the bottom series upward', () => {
const data: AlignedData = [
[1, 2],
[10, 20],
[1, 2],
];
expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([
[1, 2],
[11, 22],
[1, 2],
]);
});
it('treats nulls as 0 without breaking the running total', () => {
const data: AlignedData = [
[1, 2],
[10, null],
[1, 2],
];
expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([
[1, 2],
[11, 2],
[1, 2],
]);
});
it('emits one band per adjacent pair of participating series', () => {
const data: AlignedData = [[1], [10], [5], [1]];
expect(stackSeries(data, includeAll, StackMode.Normal).bands).toStrictEqual([
{ series: [1, 2] },
{ series: [2, 3] },
]);
});
it('copies omitted series through unstacked and skips their bands', () => {
const data: AlignedData = [[1], [10], [5], [1]];
const omitMiddle = (seriesIndex: number): boolean => seriesIndex === 2;
const { data: stacked, bands } = stackSeries(
data,
omitMiddle,
StackMode.Normal,
);
expect(stacked).toStrictEqual([[1], [11], [5], [1]]);
expect(bands).toStrictEqual([{ series: [1, 3] }]);
});
});
describe('percent', () => {
it('rescales each column to its total so the top series reads 100', () => {
const data: AlignedData = [
[1, 2],
[30, 10],
[10, 10],
];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1, 2],
[100, 100],
[25, 50],
]);
});
it('normalises per column, so an identical series differs across x', () => {
const data: AlignedData = [
[1, 2],
[1, 3],
[1, 1],
];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1, 2],
[100, 100],
[50, 25],
]);
});
it('excludes omitted series from the total, so the visible ones still reach 100', () => {
const data: AlignedData = [[1], [30], [10], [60]];
const omitLast = (seriesIndex: number): boolean => seriesIndex === 3;
expect(stackSeries(data, omitLast, StackMode.Percent).data).toStrictEqual([
[1],
[100],
[25],
[60],
]);
});
it('yields 0 for a column whose participating series sum to zero', () => {
const data: AlignedData = [
[1, 2],
[0, 5],
[0, 5],
];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1, 2],
[0, 100],
[0, 50],
]);
});
it('divides by the signed total when a column mixes signs', () => {
// 30 + (-10) = 20, so the shares are 150% and -50% and still sum to 100.
const data: AlignedData = [[1], [30], [-10]];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1],
[100],
[-50],
]);
});
it('yields 0 across a column whose signed total cancels to zero', () => {
const data: AlignedData = [[1], [10], [-10]];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1],
[0],
[0],
]);
});
});
it('defaults to normal when no mode is given', () => {
const data: AlignedData = [[1], [30], [10]];
expect(stackSeries(data, includeAll).data).toStrictEqual(
stackSeries(data, includeAll, StackMode.Normal).data,
);
});
});

View File

@@ -1,13 +1,20 @@
import { StackMode } from 'lib/uPlotV2/config/types';
import uPlot, { AlignedData } from 'uplot';
/**
* Stack data cumulatively (top-down: first series = top, last = bottom).
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
* When `omit(seriesIndex)` returns true, that series keeps its raw values and
* contributes nothing to the total. `None` is a no-op.
*/
export function stackSeries(
data: AlignedData,
omit: (seriesIndex: number) => boolean,
mode: StackMode = StackMode.Normal,
): { data: AlignedData; bands: uPlot.Band[] } {
if (mode === StackMode.None) {
return { data, bands: [] };
}
const timeAxis = data[0];
const pointCount = timeAxis.length;
const valueSeriesCount = data.length - 1; // exclude time axis
@@ -17,6 +24,7 @@ export function stackSeries(
valueSeriesCount,
pointCount,
omit,
mode,
});
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
@@ -31,6 +39,34 @@ interface BuildStackedSeriesParams {
valueSeriesCount: number;
pointCount: number;
omit: (seriesIndex: number) => boolean;
mode: StackMode;
}
/** Per-point total. Mixed-sign columns sum signed, as "share of total" implies. */
function columnTotals({
data,
valueSeriesCount,
pointCount,
omit,
}: Omit<BuildStackedSeriesParams, 'mode'>): number[] {
const totals = Array(pointCount).fill(0) as number[];
for (let seriesIndex = 1; seriesIndex <= valueSeriesCount; seriesIndex++) {
if (omit(seriesIndex)) {
continue;
}
const rawValues = data[seriesIndex] as (number | null)[];
rawValues.forEach((rawValue, pointIndex) => {
totals[pointIndex] += rawValue == null ? 0 : Number(rawValue);
});
}
return totals;
}
/** A column whose participating series sum to 0 has no share to divide, so every slice is 0. */
function toPercent(value: number, total: number): number {
return total === 0 ? 0 : (value / total) * 100;
}
/**
@@ -42,9 +78,15 @@ function buildStackedSeries({
valueSeriesCount,
pointCount,
omit,
mode,
}: BuildStackedSeriesParams): (number | null)[][] {
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
const cumulativeSums = Array(pointCount).fill(0) as number[];
// Known up front: totals span series the accumulation below has not reached yet.
const totals =
mode === StackMode.Percent
? columnTotals({ data, valueSeriesCount, pointCount, omit })
: undefined;
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
const rawValues = data[seriesIndex] as (number | null)[];
@@ -54,7 +96,10 @@ function buildStackedSeries({
} else {
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
const numericValue = rawValue == null ? 0 : Number(rawValue);
return (cumulativeSums[pointIndex] += numericValue);
const contribution = totals
? toPercent(numericValue, totals[pointIndex])
: numericValue;
return (cumulativeSums[pointIndex] += contribution);
});
}
}
@@ -101,16 +146,3 @@ function findNextVisibleSeriesIndex(
}
return -1;
}
/**
* Returns band indices for initial stacked state (no series omitted).
* Top-down: first series at top, band fills between consecutive series.
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
*/
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
bands.push({ series: [seriesIndex, seriesIndex + 1] });
}
return bands;
}

View File

@@ -1,313 +0,0 @@
import { renderHook } from '@testing-library/react';
import uPlot from 'uplot';
import type { UseBarChartStackingParams } from '../useBarChartStacking';
import { useBarChartStacking } from '../useBarChartStacking';
type MockConfig = { addHook: jest.Mock };
function asConfig(c: MockConfig): UseBarChartStackingParams['config'] {
return c as unknown as UseBarChartStackingParams['config'];
}
function createMockConfig(): {
config: MockConfig;
invokeSetData: (plot: uPlot) => void;
invokeSetSeries: (
plot: uPlot,
seriesIndex: number | null,
opts: Partial<uPlot.Series> & { focus?: boolean },
) => void;
removeSetData: jest.Mock;
removeSetSeries: jest.Mock;
} {
let setDataHandler: ((plot: uPlot) => void) | null = null;
let setSeriesHandler:
| ((plot: uPlot, seriesIndex: number | null, opts: uPlot.Series) => void)
| null = null;
const removeSetData = jest.fn();
const removeSetSeries = jest.fn();
const addHook = jest.fn(
(
hookName: string,
handler: (plot: uPlot, ...args: unknown[]) => void,
): (() => void) => {
if (hookName === 'setData') {
setDataHandler = handler as (plot: uPlot) => void;
return removeSetData;
}
if (hookName === 'setSeries') {
setSeriesHandler = handler as (
plot: uPlot,
seriesIndex: number | null,
opts: uPlot.Series,
) => void;
return removeSetSeries;
}
return jest.fn();
},
);
const config: MockConfig = { addHook };
const invokeSetData = (plot: uPlot): void => {
setDataHandler?.(plot);
};
const invokeSetSeries = (
plot: uPlot,
seriesIndex: number | null,
opts: Partial<uPlot.Series> & { focus?: boolean },
): void => {
setSeriesHandler?.(plot, seriesIndex, opts as uPlot.Series);
};
return {
config,
invokeSetData,
invokeSetSeries,
removeSetData,
removeSetSeries,
};
}
function createMockPlot(overrides: Partial<uPlot> = {}): uPlot {
return {
data: [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
],
series: [{ show: true }, { show: true }, { show: true }],
delBand: jest.fn(),
addBand: jest.fn(),
setData: jest.fn(),
...overrides,
} as unknown as uPlot;
}
describe('useBarChartStacking', () => {
it('returns data as-is when isStackedBarChart is false', () => {
const data: uPlot.AlignedData = [
[100, 200],
[1, 2],
[3, 4],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: false,
config: null,
}),
);
expect(result.current).toBe(data);
});
it('returns data as-is when config is null and isStackedBarChart is true', () => {
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[4, 5],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
// Still returns stacked data (computed in useMemo); no hooks registered
expect(result.current[0]).toStrictEqual([0, 1]);
expect(result.current[1]).toStrictEqual([5, 7]); // stacked
expect(result.current[2]).toStrictEqual([4, 5]);
});
it('returns stacked data when isStackedBarChart is true and multiple value series', () => {
const data: uPlot.AlignedData = [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
expect(result.current[0]).toStrictEqual([0, 1, 2]);
expect(result.current[1]).toStrictEqual([12, 15, 18]); // s1+s2+s3
expect(result.current[2]).toStrictEqual([11, 13, 15]); // s2+s3
expect(result.current[3]).toStrictEqual([7, 8, 9]);
});
it('returns data as-is when only one value series (no stacking needed)', () => {
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
expect(result.current).toStrictEqual(data);
});
it('registers setData and setSeries hooks when isStackedBarChart and config provided', () => {
const { config } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
expect(config.addHook).toHaveBeenCalledWith('setData', expect.any(Function));
expect(config.addHook).toHaveBeenCalledWith(
'setSeries',
expect.any(Function),
);
});
it('does not register hooks when isStackedBarChart is false', () => {
const { config } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: false,
config: asConfig(config),
}),
);
expect(config.addHook).not.toHaveBeenCalled();
});
it('calls cleanup when unmounted', () => {
const { config, removeSetData, removeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
const { unmount } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
unmount();
expect(removeSetData).toHaveBeenCalled();
expect(removeSetSeries).toHaveBeenCalled();
});
it('re-stacks and updates plot when setData hook is invoked', () => {
const { config, invokeSetData } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
];
const plot = createMockPlot({
data: [
[0, 1, 2],
[5, 7, 9],
[4, 5, 6],
],
});
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
invokeSetData(plot);
expect(plot.delBand).toHaveBeenCalledWith(null);
expect(plot.addBand).toHaveBeenCalled();
expect(plot.setData).toHaveBeenCalledWith(
expect.arrayContaining([
[0, 1, 2],
expect.any(Array), // stacked row 1
expect.any(Array), // stacked row 2
]),
);
});
it('re-stacks when setSeries hook is invoked (e.g. legend toggle)', () => {
const { config, invokeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[10, 20],
[5, 10],
];
// Plot data must match unstacked length so canApplyStacking passes
const plot = createMockPlot({
data: [
[0, 1],
[15, 30],
[5, 10],
],
});
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
invokeSetSeries(plot, 1, { show: false });
expect(plot.setData).toHaveBeenCalled();
});
it('does not re-stack when setSeries is called with focus option', () => {
const { config, invokeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
const plot = createMockPlot();
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
(plot.setData as jest.Mock).mockClear();
invokeSetSeries(plot, 1, { focus: true } as uPlot.Series);
expect(plot.setData).not.toHaveBeenCalled();
});
});

View File

@@ -22,6 +22,7 @@ import { prepareBarPanelConfig } from './utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { StackMode } from 'lib/uPlotV2/config/types';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -147,6 +148,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={`${syncMode}-${syncFilterMode}`}
stack={widget.stackedBarChart ? StackMode.Normal : StackMode.None}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -159,7 +161,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
height={containerDimensions.height}
layoutChildren={layoutChildren}
groupByPerQuery={groupByPerQuery}
isStackedBarChart={widget.stackedBarChart ?? false}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}

View File

@@ -35,20 +35,10 @@ jest.mock('lib/getLabelName', () => ({
),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
() => ({
getInitialStackedBands: jest.fn().mockReturnValue([]),
}),
);
const getLegendMock = jest.requireMock('lib/dashboard/getQueryResults')
.getLegend as jest.Mock;
const getLabelNameMock = jest.requireMock('lib/getLabelName')
.default as jest.Mock;
const getInitialStackedBandsMock = jest.requireMock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
).getInitialStackedBands as jest.Mock;
const createApiResponse = (
result: MetricRangePayloadProps['data']['result'] = [],
@@ -247,36 +237,5 @@ describe('BarPanel utils', () => {
}).getConfig();
expect(config.series?.[1]).toMatchObject({ stroke: '#ff0000' });
});
it('calls getInitialStackedBands when widget is stackedBarChart', () => {
const widget = createWidget({ stackedBarChart: true });
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
{
metric: {},
queryName: 'Q2',
values: [[1000, '2']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({ ...baseParams, widget, apiResponse });
// seriesCount = result.length + 1 = 3
expect(getInitialStackedBandsMock).toHaveBeenCalledWith(3);
});
it('does not call getInitialStackedBands for non-stacked chart', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({ ...baseParams, apiResponse });
expect(getInitialStackedBandsMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,7 +1,6 @@
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
@@ -69,11 +68,6 @@ export function prepareBarPanelConfig({
return builder;
}
if (widget.stackedBarChart) {
const seriesCount = (apiResponse.data.result.length ?? 0) + 1; // +1 for 1-based uPlot series indices
builder.setBands(getInitialStackedBands(seriesCount));
}
apiResponse.data.result.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,

View File

@@ -7,7 +7,6 @@ import TanStackTable, {
TableColumnDef,
useCalculatedPageSize,
useHiddenColumnIds,
useRecoverFromEmptyPage,
useTableParams,
} from 'components/TanStackTableView';
import { InfraMonitoringEvents } from 'constants/events';
@@ -137,7 +136,6 @@ export function K8sBaseList<
page: currentPage,
limit: currentPageSize,
setLimit,
setPage,
} = useTableParams(
{
page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
@@ -245,16 +243,6 @@ 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],

View File

@@ -591,14 +591,12 @@ 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: [],
@@ -607,7 +605,6 @@ describe('K8sBaseList', () => {
});
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
@@ -628,177 +625,6 @@ 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', () => {

View File

@@ -82,7 +82,6 @@ function EntityLogsContent({
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
useLogDetailHandlers();
// TODO: Move away from using onAddToQuery after old drawer cleanup
const onAddToQuery = useCallback(
(fieldKey: string, fieldValue: string, operator: string): void => {
handleCloseLogDetail();
@@ -105,21 +104,6 @@ function EntityLogsContent({
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
);
const onApplyLogFilter = useCallback(
(expression: string): void => {
handleCloseLogDetail();
const newUser = userExpression.trim()
? `${userExpression} AND ${expression}`
: expression;
querySearchOnRun(newUser);
logInfraDrawerFilterCustomizedEvent(category, 'logs', newUser, 'logs');
},
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
);
const {
logs,
loadMoreLogs,
@@ -344,7 +328,6 @@ function EntityLogsContent({
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onApplyLogFilter={onApplyLogFilter}
onScrollToLog={handleScrollToLog}
handleOpenInExplorer={(e) => handleOpenInExplorer(e, activeLog)}
getContainer={(): HTMLElement =>

View File

@@ -47,7 +47,6 @@ import {
useInfraMonitoringCategory,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringSelectedItemParams,
} from './hooks';
@@ -68,7 +67,6 @@ 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();
@@ -220,7 +218,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
void setSelectedCategory(key as string);
void setOrderBy(null);
void setGroupBy(null);
void setCurrentPage(null);
setSelectedItemParams(null);
redirectWithQueryBuilderData({
...currentQuery,

View File

@@ -1,128 +0,0 @@
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);
});
});
});

View File

@@ -34,9 +34,6 @@ 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),
);

View File

@@ -13,7 +13,7 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ChevronDown, ChevronRight, Search } from '@signozhq/icons';
import { useIsLogDetailsV2 } from 'components/LogDetail/useIsLogDetailsV2';
import { isLogDetailsV2 } from 'components/LogDetail/constants';
import { DataViewer } from 'periscope/components/DataViewer';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
@@ -41,7 +41,6 @@ interface OverviewProps {
selectedOptions: OptionsQuery;
listViewPanelSelectedFields?: IField[] | null;
handleChangeSelectedView?: ChangeViewFunctionType;
onApplyLogFilter?: (expression: string) => void;
}
type Props = OverviewProps &
@@ -56,7 +55,6 @@ function Overview({
selectedOptions,
listViewPanelSelectedFields,
handleChangeSelectedView,
onApplyLogFilter,
}: Props): JSX.Element {
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
@@ -69,11 +67,8 @@ function Overview({
const { actions, visibleActions } = useLogAttributeActions({
handleChangeSelectedView,
isListViewPanel,
onApplyLogFilter,
});
const isLogDetailsV2 = useIsLogDetailsV2();
if (isLogDetailsV2) {
const raw = aggregateAttributesResourcesToObject(logData);
const prettyData = buildPrettyViewData(raw);

View File

@@ -1,7 +1,6 @@
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
@@ -16,7 +15,6 @@ import {
VisibleActionsConfig,
} from 'periscope/components/PrettyView/PrettyView';
import { useAppContext } from 'providers/App/App';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { LogDetailsAction } from '../constants';
import {
@@ -29,7 +27,6 @@ import {
interface UseLogAttributeActionsParams {
handleChangeSelectedView?: ChangeViewFunctionType;
isListViewPanel?: boolean;
onApplyLogFilter?: (expression: string) => void;
}
interface UseLogAttributeActionsResult {
@@ -53,7 +50,6 @@ const ALL_LEAF_ACTIONS = [
export function useLogAttributeActions({
handleChangeSelectedView,
isListViewPanel = false,
onApplyLogFilter,
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
@@ -69,6 +65,9 @@ export function useLogAttributeActions({
const filterFor = useCallback(
(context: FieldContext, isFilterIn: boolean): void => {
if (!stagedQuery) {
return;
}
const target = buildLogFilterTarget(
context.fieldKeyPath,
context.fieldValue,
@@ -78,29 +77,6 @@ export function useLogAttributeActions({
? target.filterInOperator
: target.filterOutOperator;
// Non-explorer surfaces (infra monitoring, etc.) apply a ready v5
// expression fragment to their own query.
if (onApplyLogFilter) {
const base = {
filters: { items: [], op: 'AND' },
} as unknown as IBuilderQuery;
const nextFilters = getFilterQueryData(
base,
target,
context.fieldValue,
operator,
).filters ?? { items: [], op: 'AND' };
const { expression } = convertFiltersToExpression(nextFilters);
if (expression) {
onApplyLogFilter(expression);
}
return;
}
if (!stagedQuery) {
return;
}
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
@@ -123,7 +99,6 @@ export function useLogAttributeActions({
updateQueriesData,
viewName,
handleChangeSelectedView,
onApplyLogFilter,
],
);
@@ -204,25 +179,20 @@ export function useLogAttributeActions({
buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.isRestricted;
// The using surface must provide an apply path.
const canApplyFilter = !!handleChangeSelectedView || !!onApplyLogFilter;
return [
{
key: LogDetailsAction.FILTER_IN,
label: 'Filter for value',
icon: <CirclePlus size={12} />,
onClick: (context): void => filterFor(context, true),
shouldHide: (_key, fieldKeyPath): boolean =>
!canApplyFilter || isRestricted(fieldKeyPath),
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
},
{
key: LogDetailsAction.FILTER_OUT,
label: 'Filter out value',
icon: <CircleMinus size={12} />,
onClick: (context): void => filterFor(context, false),
shouldHide: (_key, fieldKeyPath): boolean =>
!canApplyFilter || isRestricted(fieldKeyPath),
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
},
{
key: LogDetailsAction.GROUP_BY,
@@ -230,10 +200,8 @@ export function useLogAttributeActions({
icon: <Layers size={12} />,
onClick: groupBy,
shouldHide: (_key, fieldKeyPath): boolean =>
!handleChangeSelectedView ||
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.groupBySupported ||
isOldExplorerOrLive,
.groupBySupported || isOldExplorerOrLive,
},
{
key: LogDetailsAction.REPLACE_FILTER,
@@ -241,9 +209,7 @@ export function useLogAttributeActions({
icon: <RefreshCw size={12} />,
onClick: replaceFilter,
shouldHide: (_key, fieldKeyPath): boolean =>
!handleChangeSelectedView ||
isRestricted(fieldKeyPath) ||
isOldExplorerOrLive,
isRestricted(fieldKeyPath) || isOldExplorerOrLive,
},
];
}, [
@@ -252,8 +218,6 @@ export function useLogAttributeActions({
replaceFilter,
isBodyJsonQueryEnabled,
isOldExplorerOrLive,
handleChangeSelectedView,
onApplyLogFilter,
]);
const visibleActions = useMemo<VisibleActionsConfig>(

View File

@@ -9,6 +9,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { StackMode } from 'lib/uPlotV2/config/types';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { useTimezone } from 'providers/Timezone';
import { AppState } from 'store/reducers';
@@ -137,6 +138,7 @@ function TimeSeries({
key={`${WIDGET_ID}-${index}`}
>
<BarChart
stack={StackMode.Normal}
config={chart.config}
legendConfig={{
position: LegendPosition.BOTTOM,
@@ -144,7 +146,6 @@ function TimeSeries({
data={chart.chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
isStackedBarChart
yAxisUnit={yAxisUnit || 'short'}
timezone={timezone}
/>

View File

@@ -1,6 +1,5 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
@@ -89,9 +88,6 @@ export function buildMeterChartConfig({
return builder;
}
const seriesCount = (apiResponse.data.result.length ?? 0) + 1;
builder.setBands(getInitialStackedBands(seriesCount));
apiResponse.data.result.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,

View File

@@ -21,7 +21,6 @@ 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';
@@ -29,7 +28,6 @@ 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';
@@ -41,7 +39,6 @@ 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';
@@ -73,8 +70,6 @@ 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';
@@ -101,8 +96,6 @@ 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';
@@ -121,7 +114,6 @@ 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';
@@ -2867,25 +2859,6 @@ 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',
@@ -5537,10 +5510,8 @@ const onboardingConfigWithLinks = [
module: 'metrics',
relatedSearchKeywords: [
'integrations',
'logs',
'metrics',
'supabase',
'supabase logs',
'supabase metrics',
'supabase monitoring',
'supabase observability',
@@ -5574,23 +5545,43 @@ const onboardingConfigWithLinks = [
label: 'Traefik',
imgUrl: opentelemetryUrl,
tags: ['infrastructure monitoring'],
module: 'apm',
module: 'infrastructure',
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: 'metrics',
module: 'database',
relatedSearchKeywords: [
'atlas',
'atlas metrics',
@@ -5609,15 +5600,36 @@ const onboardingConfigWithLinks = [
label: 'MySQL',
imgUrl: opentelemetryUrl,
tags: ['database'],
module: 'metrics',
module: 'database',
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',
@@ -6980,514 +6992,5 @@ 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;

View File

@@ -9,6 +9,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
(): TooltipContentItem[] =>
buildTooltipContent({
data: props.uPlotInstance.data,
unstackedData: props.unstackedData,
series: props.uPlotInstance.series,
dataIndexes: props.dataIndexes,
activeSeriesIndex: props.seriesIndex,
@@ -21,6 +22,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
}),
[
props.uPlotInstance,
props.unstackedData,
props.seriesIndex,
props.dataIndexes,
props.yAxisUnit,

View File

@@ -11,6 +11,7 @@ export default function TimeSeriesTooltip(
(): TooltipContentItem[] =>
buildTooltipContent({
data: props.uPlotInstance.data,
unstackedData: props.unstackedData,
series: props.uPlotInstance.series,
dataIndexes: props.dataIndexes,
activeSeriesIndex: props.seriesIndex,
@@ -22,6 +23,7 @@ export default function TimeSeriesTooltip(
}),
[
props.uPlotInstance,
props.unstackedData,
props.seriesIndex,
props.dataIndexes,
props.yAxisUnit,

View File

@@ -72,6 +72,35 @@ describe('Tooltip utils', () => {
expect(result).toBe(20);
});
it('reports the pre-stack value, identically for normal and percent', () => {
const unstackedData: AlignedData = [[0], [30], [10]];
const series = [{}, { show: true }, { show: true }] as Series[];
const read = (data: AlignedData): number | null =>
getTooltipBaseValue({
data,
unstackedData,
index: 1,
dataIndex: 0,
isStackedBarChart: true,
series,
});
expect(read([[0], [40], [10]])).toBe(30);
expect(read([[0], [100], [25]])).toBe(30);
});
it('falls back to subtraction when no pre-stack data is given', () => {
const result = getTooltipBaseValue({
data: [[0], [40], [10]],
index: 1,
dataIndex: 0,
isStackedBarChart: true,
series: [{}, { show: true }, { show: true }] as Series[],
});
expect(result).toBe(30);
});
it('returns null when value is missing', () => {
const data: AlignedData = [
[0, 1],

View File

@@ -23,17 +23,25 @@ export function resolveSeriesColor(
export function getTooltipBaseValue({
data,
unstackedData,
index,
dataIndex,
isStackedBarChart,
series,
}: {
data: AlignedData;
unstackedData?: AlignedData;
index: number;
dataIndex: number;
isStackedBarChart?: boolean;
series?: Series[];
}): number | null {
// The subtraction below only recovers the raw value under `normal` stacking.
const unstackedSeries = unstackedData?.[index];
if (unstackedSeries) {
return unstackedSeries[dataIndex] ?? null;
}
let baseValue = data[index][dataIndex] ?? null;
// Top-down stacking (first series at top): raw = stacked[i] - stacked[nextVisible].
// When series are hidden, we must use the next *visible* series, not index+1,
@@ -56,6 +64,7 @@ export function getTooltipBaseValue({
export function buildTooltipContent({
data,
unstackedData,
series,
dataIndexes,
activeSeriesIndex,
@@ -67,6 +76,7 @@ export function buildTooltipContent({
syncFilterMode,
}: {
data: AlignedData;
unstackedData?: AlignedData;
series: Series[];
dataIndexes: Array<number | null>;
activeSeriesIndex: number | null;
@@ -115,6 +125,7 @@ export function buildTooltipContent({
const baseValue = getTooltipBaseValue({
data,
unstackedData,
index: seriesIndex,
dataIndex,
isStackedBarChart,

View File

@@ -69,6 +69,11 @@ export interface TooltipRenderArgs {
syncedSeriesIndexes?: number[] | null;
/** Receiver-side filter mode for the synced tooltip. Defaults to Filtered. */
syncFilterMode?: SyncTooltipFilterMode;
/**
* Pre-stack values, injected by `ChartWrapper`. `Percent` discards the column total,
* so the raw value cannot be recovered from the plot's own cumulative data.
*/
unstackedData?: uPlot.AlignedData;
}
export interface IRenderTooltipFooterArgs {

View File

@@ -20,6 +20,7 @@ import {
ConfigBuilderProps,
LegendItem,
SelectionPreferencesSource,
StackMode,
} from './types';
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
@@ -28,6 +29,11 @@ import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
/**
* Type definitions for uPlot option objects
*/
/** Renders a 0100 number as `50%`, unlike the 01 `percentunit`. */
const PERCENT_AXIS_UNIT = 'percent';
const PERCENT_AXIS_MAX = 100;
type LegendConfig = {
show?: boolean;
live?: boolean;
@@ -57,6 +63,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
private bands: uPlot.Band[] = [];
private stack: StackMode = StackMode.None;
private cursor: Cursor | undefined;
private hooks: Hooks.Arrays = {};
@@ -143,6 +151,15 @@ export class UPlotConfigBuilder extends ConfigBuilder<
this.axes[scaleKey] = new UPlotAxisBuilder(props);
}
/** Drives the fill bands, the percent axis unit and the percent range below. */
setStack(stack: StackMode): void {
this.stack = stack;
}
getStackMode(): StackMode {
return this.stack;
}
/**
* Add or merge a scale configuration
*/
@@ -211,6 +228,41 @@ export class UPlotConfigBuilder extends ConfigBuilder<
this.bands = bands;
}
/**
* The panel's own limits are in the source unit, which means nothing once values are
* normalised. Soft rather than hard, so mixed-sign shares outside 0100 stay visible.
*/
private resolveScale(scale: UPlotScaleBuilder): UPlotScaleBuilder {
if (this.stack !== StackMode.Percent || scale.props.scaleKey !== 'y') {
return scale;
}
return new UPlotScaleBuilder({
...scale.props,
min: undefined,
max: undefined,
softMin: 0,
softMax: PERCENT_AXIS_MAX,
// Thresholds still draw, but a 500ms one must not stretch the axis to 0500.
thresholds: undefined,
});
}
/** Explicit bands win; otherwise a stack fills between consecutive series. */
private resolveBands(): uPlot.Band[] | undefined {
if (this.bands.length > 0) {
return this.bands;
}
if (this.stack === StackMode.None || this.series.length < 2) {
return undefined;
}
return (
this.series
.slice(0, -1)
// uPlot series are 1-based (index 0 is the timestamp axis).
.map((_, index) => ({ series: [index + 1, index + 2] as [number, number] }))
);
}
/**
* Set cursor configuration
*/
@@ -444,9 +496,19 @@ export class UPlotConfigBuilder extends ConfigBuilder<
};
}),
];
config.axes = Object.values(this.axes).map((a) => a.getConfig());
config.axes = Object.entries(this.axes).map(([scaleKey, axis]) => {
if (scaleKey !== 'y' || this.stack !== StackMode.Percent) {
return axis.getConfig();
}
// Ticks read as percentages; the panel unit still applies to tooltips and
// thresholds, so build from a copy rather than touching the axis props.
return new UPlotAxisBuilder({
...axis.props,
yAxisUnit: PERCENT_AXIS_UNIT,
}).getConfig();
});
config.scales = this.scales.reduce(
(acc, s) => ({ ...acc, ...s.getConfig() }),
(acc, s) => ({ ...acc, ...this.resolveScale(s).getConfig() }),
{} as Record<string, uPlot.Scale>,
);
@@ -456,7 +518,7 @@ export class UPlotConfigBuilder extends ConfigBuilder<
config.cursor = this.getCursorConfig();
config.tzDate = this.tzDate;
config.plugins = this.plugins.length > 0 ? this.plugins : undefined;
config.bands = this.bands.length > 0 ? this.bands : undefined;
config.bands = this.resolveBands();
if (Array.isArray(this.padding)) {
config.padding = this.padding;

View File

@@ -5,7 +5,7 @@ import {
STEP_INTERVAL_MULTIPLIER,
} from '../../constants';
import type { SeriesProps } from '../types';
import { DrawStyle, SelectionPreferencesSource } from '../types';
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
// Mock only the real boundary that hits localStorage
@@ -496,3 +496,161 @@ describe('UPlotConfigBuilder', () => {
expect(config.bands).toBeUndefined();
});
});
describe('UPlotConfigBuilder stacking', () => {
beforeEach(() => {
jest.clearAllMocks();
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue([]);
});
/**
* Soft limits end up captured in the scale's range closure, so the only way to read
* them back is to run it and inspect the range config it hands uPlot.
*/
function scaleSoftLimits(
builder: UPlotConfigBuilder,
scaleKey: string,
): { min: number; max: number } {
const rangeNum = jest.fn().mockReturnValue([0, 0]);
(uPlot as unknown as { rangeNum: unknown }).rangeNum = rangeNum;
const range = builder.getConfig().scales?.[scaleKey]?.range as (
u: unknown,
min: number,
max: number,
key: string,
) => void;
range({ scales: { [scaleKey]: { distr: 1 } } }, 40, 60, scaleKey);
const [, , rangeConfig] = rangeNum.mock.calls[0] as [
number,
number,
{ min: { soft: number }; max: { soft: number } },
];
return { min: rangeConfig.min.soft, max: rangeConfig.max.soft };
}
/** Renders y-axis ticks the way uPlot would, so unit formatting is observable. */
function yAxisTicks(builder: UPlotConfigBuilder, ticks: number[]): string[] {
const yAxis = builder.getConfig().axes?.find((a) => a.scale === 'y');
const values = yAxis?.values as (
u: unknown,
splits: number[],
) => (string | null)[];
return values(null, ticks).map((v) => String(v));
}
function builderFor(stack?: StackMode, seriesCount = 3): UPlotConfigBuilder {
const builder = new UPlotConfigBuilder({ id: 'stack-test' });
if (stack) {
builder.setStack(stack);
}
builder.addAxis({ scaleKey: 'y', show: true, side: 3, yAxisUnit: 'ms' });
for (let i = 0; i < seriesCount; i++) {
builder.addSeries({
scaleKey: 'y',
label: `S${i}`,
drawStyle: DrawStyle.Bar,
colorMapping: {},
isDarkMode: false,
} as SeriesProps);
}
return builder;
}
it('defaults to no stacking, so no bands and the panel unit on the axis', () => {
const builder = builderFor();
expect(builder.getStackMode()).toBe('none');
expect(builder.getConfig().bands).toBeUndefined();
expect(yAxisTicks(builder, [1000])).toStrictEqual(['1 s']);
});
it('derives one band per adjacent series pair once a stack is declared', () => {
expect(builderFor(StackMode.Normal).getConfig().bands).toStrictEqual([
{ series: [1, 2] },
{ series: [2, 3] },
]);
});
it('emits no bands for a single series', () => {
expect(builderFor(StackMode.Normal, 1).getConfig().bands).toBeUndefined();
});
it('keeps the panel unit on the axis for a normal stack', () => {
expect(yAxisTicks(builderFor(StackMode.Normal), [1000])).toStrictEqual([
'1 s',
]);
});
it('formats the axis as percentages for a percent stack', () => {
expect(yAxisTicks(builderFor(StackMode.Percent), [0, 50, 100])).toStrictEqual(
['0%', '50%', '100%'],
);
});
it('leaves other axes on their own unit under a percent stack', () => {
const builder = builderFor(StackMode.Percent);
builder.addAxis({ scaleKey: 'x', show: true, side: 2 });
expect(builder.getConfig().axes?.map((a) => a.scale)).toStrictEqual([
'y',
'x',
]);
});
it('pins the y scale to the 0100 band under a percent stack, dropping panel limits', () => {
const builder = new UPlotConfigBuilder({ id: 'stack-scale' });
builder.setStack(StackMode.Percent);
builder.addScale({ scaleKey: 'y', softMin: 5, softMax: 500 });
// Soft, not hard: mixed-sign shares fall outside 0100 and must stay visible.
expect(builder.getConfig().scales?.y).toMatchObject({ auto: true });
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 0, max: 100 });
});
it('leaves the panel limits alone when the stack is not percent', () => {
const builder = new UPlotConfigBuilder({ id: 'stack-scale' });
builder.setStack(StackMode.Normal);
builder.addScale({ scaleKey: 'y', softMin: 5, softMax: 500 });
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 5, max: 500 });
});
it.each([StackMode.Normal, StackMode.Percent])(
'draws thresholds under a %s stack',
(stack) => {
const builder = new UPlotConfigBuilder({ id: 'stack-thr' });
builder.setStack(stack);
builder.addThresholds({
scaleKey: 'y',
thresholds: [{ thresholdValue: 500, thresholdColor: 'red' }],
yAxisUnit: 'ms',
});
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
},
);
it('keeps a source-unit threshold from stretching the percent band', () => {
const builder = new UPlotConfigBuilder({ id: 'stack-thr' });
builder.setStack(StackMode.Percent);
const thresholds = {
scaleKey: 'y',
thresholds: [{ thresholdValue: 500, thresholdColor: 'red' }],
yAxisUnit: 'ms',
};
builder.addThresholds(thresholds);
builder.addScale({ scaleKey: 'y', thresholds });
// Without this the 500ms threshold would widen a percentage axis to 0500.
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 0, max: 100 });
});
it('lets explicit bands win over the derived ones', () => {
const builder = builderFor(StackMode.Normal);
builder.setBands([{ series: [1, 3] }]);
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
});
});

View File

@@ -33,6 +33,13 @@ export enum SelectionPreferencesSource {
/**
* Props for configuring the uPlot config builder
*/
/** `Percent` rescales each x-slice to its column total, so every column fills to 100. */
export enum StackMode {
None = 'none',
Normal = 'normal',
Percent = 'percent',
}
export interface ConfigBuilderProps {
id: string;
onDragSelect?: (startTime: number, endTime: number) => void;

View File

@@ -281,3 +281,20 @@ describe('dataUtils', () => {
});
});
});
describe('insertLargeGapNullsIntoAlignedData index alignment', () => {
// ChartWrapper gap-processes the pre-stack series to keep tooltip indices aligned;
// that only holds because insertions are decided from the x axis, never from y.
it('inserts at the same positions regardless of the y values', () => {
const x = [0, 100, 200];
const options = [{ spanGaps: 50 }];
const raw = [x, [1, 2, 3]] as uPlot.AlignedData;
const stacked = [x, [10, 20, 30]] as uPlot.AlignedData;
const fromRaw = insertLargeGapNullsIntoAlignedData(raw, options);
const fromStacked = insertLargeGapNullsIntoAlignedData(stacked, options);
expect(fromRaw[0]).toStrictEqual(fromStacked[0]);
expect(fromRaw[1]).toHaveLength((fromStacked[1] as unknown[]).length);
});
});

View File

@@ -7,6 +7,7 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import { StackMode } from 'lib/uPlotV2/config/types';
import {
flattenTimeSeries,
getExecStats,
@@ -219,7 +220,9 @@ function BarPanelRenderer({
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
stack={
spec.visualization?.stackedBarChart ? StackMode.Normal : StackMode.None
}
renderTooltipFooter={renderTooltipFooter}
onClick={enableDrillDown ? handleChartClick : undefined}
/>

View File

@@ -1,7 +1,6 @@
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
@@ -101,12 +100,6 @@ function addSeries({
}: AddSeriesArgs): void {
const colorMapping = spec.legend?.customColors ?? {};
if (spec.visualization?.stackedBarChart) {
// uPlot uses 1-based series indices (index 0 is the timestamp axis);
// `+1` keeps the band targets aligned with the series we're about to add.
builder.setBands(getInitialStackedBands(series.length + 1));
}
series.forEach((s) => {
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);

View File

@@ -336,7 +336,7 @@ func (r *ClickHouseReader) GetTopLevelOperations(ctx context.Context, start, end
return &operations, nil
}
func (r *ClickHouseReader) buildResourceSubQuery(ctx context.Context, orgID valuer.UUID, tags []model.TagQueryParam, svc string, start, end time.Time) (string, error) {
func (r *ClickHouseReader) buildResourceSubQuery(tags []model.TagQueryParam, svc string, start, end time.Time) (string, error) {
// assuming all will be resource attributes.
// and resource attributes are string for traces
filterSet := v3.FilterSet{}
@@ -387,8 +387,7 @@ func (r *ClickHouseReader) buildResourceSubQuery(ctx context.Context, orgID valu
&filterSet,
[]v3.AttributeKey{},
v3.AttributeKey{},
false,
r.fl.BooleanOrEmpty(ctx, flagger.FeatureResolveSemconvFamilies, featuretypes.NewFlaggerEvaluationContext(orgID)))
false)
if err != nil {
r.logger.Error("Error in processing sql query", errorsV2.Attr(err))
return "", err
@@ -396,7 +395,7 @@ func (r *ClickHouseReader) buildResourceSubQuery(ctx context.Context, orgID valu
return resourceSubQuery, nil
}
func (r *ClickHouseReader) GetServices(ctx context.Context, orgID valuer.UUID, queryParams *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError) {
func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
@@ -468,7 +467,7 @@ func (r *ClickHouseReader) GetServices(ctx context.Context, orgID valuer.UUID, q
clickhouse.Named("names", ops),
)
resourceSubQuery, err := r.buildResourceSubQuery(ctx, orgID, queryParams.Tags, svc, *queryParams.Start, *queryParams.End)
resourceSubQuery, err := r.buildResourceSubQuery(queryParams.Tags, svc, *queryParams.Start, *queryParams.End)
if err != nil {
r.logger.Error("Error in processing sql query", errorsV2.Attr(err))
return
@@ -704,9 +703,9 @@ func addExistsOperator(item model.TagQuery, tagMapType string, not bool) (string
return fmt.Sprintf(" AND %s (%s)", notStr, strings.Join(tagOperatorPair, " OR ")), args
}
func (r *ClickHouseReader) GetEntryPointOperations(ctx context.Context, orgID valuer.UUID, queryParams *model.GetTopOperationsParams) (*[]model.TopOperationsItem, error) {
func (r *ClickHouseReader) GetEntryPointOperations(ctx context.Context, queryParams *model.GetTopOperationsParams) (*[]model.TopOperationsItem, error) {
// Step 1: Get top operations for the given service
topOps, err := r.GetTopOperations(ctx, orgID, queryParams)
topOps, err := r.GetTopOperations(ctx, queryParams)
if err != nil {
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "Error in getting Top Operations")
}
@@ -758,7 +757,7 @@ func (r *ClickHouseReader) GetEntryPointOperations(ctx context.Context, orgID va
return &filtered, nil
}
func (r *ClickHouseReader) GetTopOperations(ctx context.Context, orgID valuer.UUID, queryParams *model.GetTopOperationsParams) (*[]model.TopOperationsItem, *model.ApiError) {
func (r *ClickHouseReader) GetTopOperations(ctx context.Context, queryParams *model.GetTopOperationsParams) (*[]model.TopOperationsItem, *model.ApiError) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
@@ -788,7 +787,7 @@ func (r *ClickHouseReader) GetTopOperations(ctx context.Context, orgID valuer.UU
r.TraceDB, r.traceTableName,
)
resourceSubQuery, err := r.buildResourceSubQuery(ctx, orgID, queryParams.Tags, queryParams.ServiceName, *queryParams.Start, *queryParams.End)
resourceSubQuery, err := r.buildResourceSubQuery(queryParams.Tags, queryParams.ServiceName, *queryParams.Start, *queryParams.End)
if err != nil {
r.logger.Error("Error in processing sql query", errorsV2.Attr(err))
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in processing sql query")}
@@ -859,7 +858,7 @@ func (r *ClickHouseReader) GetUsage(ctx context.Context, queryParams *model.GetU
return &usageItems, nil
}
func (r *ClickHouseReader) GetDependencyGraph(ctx context.Context, orgID valuer.UUID, queryParams *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error) {
func (r *ClickHouseReader) GetDependencyGraph(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
@@ -896,7 +895,7 @@ func (r *ClickHouseReader) GetDependencyGraph(ctx context.Context, orgID valuer.
)
tags := createTagQueryFromTagQueryParams(queryParams.Tags)
filterQuery, filterArgs := services.BuildServiceMapQuery(tags, r.fl.BooleanOrEmpty(ctx, flagger.FeatureResolveSemconvFamilies, featuretypes.NewFlaggerEvaluationContext(orgID)))
filterQuery, filterArgs := services.BuildServiceMapQuery(tags)
query += filterQuery + " GROUP BY src, dest;"
args = append(args, filterArgs...)

View File

@@ -1128,19 +1128,13 @@ func (aH *APIHandler) registerEvent(w http.ResponseWriter, r *http.Request) {
}
func (aH *APIHandler) getTopOperations(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
query, err := parseGetTopOperationsRequest(r)
if aH.HandleError(w, err, http.StatusBadRequest) {
return
}
result, apiErr := aH.reader.GetTopOperations(r.Context(), orgID, query)
result, apiErr := aH.reader.GetTopOperations(r.Context(), query)
if apiErr != nil && aH.HandleError(w, apiErr.Err, http.StatusInternalServerError) {
return
@@ -1151,20 +1145,13 @@ func (aH *APIHandler) getTopOperations(w http.ResponseWriter, r *http.Request) {
}
func (aH *APIHandler) getEntryPointOps(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
query, err := parseGetTopOperationsRequest(r)
if err != nil {
render.Error(w, err)
return
}
result, apiErr := aH.reader.GetEntryPointOperations(r.Context(), orgID, query)
result, apiErr := aH.reader.GetEntryPointOperations(r.Context(), query)
if apiErr != nil {
render.Error(w, apiErr)
return
@@ -1239,19 +1226,12 @@ func (aH *APIHandler) getServicesTopLevelOps(w http.ResponseWriter, r *http.Requ
}
func (aH *APIHandler) getServices(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
query, err := parseGetServicesRequest(r)
if aH.HandleError(w, err, http.StatusBadRequest) {
return
}
result, apiErr := aH.reader.GetServices(r.Context(), orgID, query)
result, apiErr := aH.reader.GetServices(r.Context(), query)
if apiErr != nil && aH.HandleError(w, apiErr.Err, http.StatusInternalServerError) {
return
}
@@ -1260,19 +1240,13 @@ func (aH *APIHandler) getServices(w http.ResponseWriter, r *http.Request) {
}
func (aH *APIHandler) dependencyGraph(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
query, err := parseGetServicesRequest(r)
if aH.HandleError(w, err, http.StatusBadRequest) {
return
}
result, err := aH.reader.GetDependencyGraph(r.Context(), orgID, query)
result, err := aH.reader.GetDependencyGraph(r.Context(), query)
if aH.HandleError(w, err, http.StatusBadRequest) {
return
}

View File

@@ -383,7 +383,7 @@ func buildLogsQuery(panelType v3.PanelType, start, end, step int64, mq *v3.Build
}
// build the where clause for resource table
resourceSubQuery, err := resource.BuildResourceSubQuery(DB_NAME, DISTRIBUTED_LOGS_V2_RESOURCE, bucketStart, bucketEnd, mq.Filters, mq.GroupBy, mq.AggregateAttribute, false, false)
resourceSubQuery, err := resource.BuildResourceSubQuery(DB_NAME, DISTRIBUTED_LOGS_V2_RESOURCE, bucketStart, bucketEnd, mq.Filters, mq.GroupBy, mq.AggregateAttribute, false)
if err != nil {
return "", err
}
@@ -475,7 +475,7 @@ func buildLogsLiveTailQuery(mq *v3.BuilderQuery) (string, error) {
}
// no values for bucket start and end
resourceSubQuery, err := resource.BuildResourceSubQuery(DB_NAME, DISTRIBUTED_LOGS_V2_RESOURCE, 0, 0, mq.Filters, mq.GroupBy, mq.AggregateAttribute, true, false)
resourceSubQuery, err := resource.BuildResourceSubQuery(DB_NAME, DISTRIBUTED_LOGS_V2_RESOURCE, 0, 0, mq.Filters, mq.GroupBy, mq.AggregateAttribute, true)
if err != nil {
return "", err
}

View File

@@ -6,9 +6,6 @@ import (
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/semconv"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
var resourceLogOperators = map[v3.FilterOperator]string{
@@ -33,49 +30,22 @@ var resourceLogOperators = map[v3.FilterOperator]string{
}
// buildResourceFilter builds a clickhouse filter string for resource labels
func buildResourceFilter(logsOp string, key string, op v3.FilterOperator, value interface{}, members []string) string {
func buildResourceFilter(logsOp string, key string, op v3.FilterOperator, value interface{}) string {
// for all operators except contains and like
searchKey := fmt.Sprintf("simpleJSONExtractString(labels, %s)", querybuilder.ClickHouseStringLiteral(key))
if len(members) > 1 {
values := make([]string, 0, len(members))
for _, member := range members {
values = append(values, fmt.Sprintf("NULLIF(simpleJSONExtractString(labels, %s), '')", querybuilder.ClickHouseStringLiteral(member)))
}
searchKey = "COALESCE(" + strings.Join(values, ", ") + ", '')"
}
searchKey := fmt.Sprintf("simpleJSONExtractString(labels, '%s')", key)
// for contains and like it will be case insensitive
lowerSearchKey := fmt.Sprintf("simpleJSONExtractString(lower(labels), %s)", querybuilder.ClickHouseStringLiteral(key))
if len(members) > 1 {
lowerSearchKey = "lower(" + searchKey + ")"
}
lowerSearchKey := fmt.Sprintf("simpleJSONExtractString(lower(labels), '%s')", key)
chFmtVal := utils.ClickHouseFormattedValue(value)
lowerValue := strings.ToLower(fmt.Sprintf("%s", value))
switch op {
case v3.FilterOperatorExists, v3.FilterOperatorNotExists:
exists := op == v3.FilterOperatorExists
if len(members) == 1 {
if exists {
return fmt.Sprintf("simpleJSONHas(labels, %s)", querybuilder.ClickHouseStringLiteral(key))
}
return fmt.Sprintf("not simpleJSONHas(labels, %s)", querybuilder.ClickHouseStringLiteral(key))
}
presence := make([]string, 0, len(members))
for _, member := range members {
if exists {
presence = append(presence, fmt.Sprintf("simpleJSONHas(labels, %s)", querybuilder.ClickHouseStringLiteral(member)))
} else {
presence = append(presence, fmt.Sprintf("not simpleJSONHas(labels, %s)", querybuilder.ClickHouseStringLiteral(member)))
}
}
separator := " OR "
if !exists {
separator = " AND "
}
return "(" + strings.Join(presence, separator) + ")"
case v3.FilterOperatorExists:
return fmt.Sprintf("simpleJSONHas(labels, '%s')", key)
case v3.FilterOperatorNotExists:
return fmt.Sprintf("not simpleJSONHas(labels, '%s')", key)
case v3.FilterOperatorRegex, v3.FilterOperatorNotRegex:
return fmt.Sprintf(logsOp, searchKey, chFmtVal)
case v3.FilterOperatorContains, v3.FilterOperatorNotContains:
@@ -123,10 +93,9 @@ func buildIndexFilterForInOperator(key string, op v3.FilterOperator, value inter
// if there are no values to filter on, return an empty string
if len(values) > 0 {
escapedKey := utils.QuoteEscapedStringForContains(key, true)
for _, v := range values {
value := utils.QuoteEscapedStringForContains(v, true)
conditions = append(conditions, fmt.Sprintf("labels %s '%%\"%s\":\"%s\"%%'", sqlOp, escapedKey, value))
conditions = append(conditions, fmt.Sprintf("labels %s '%%\"%s\":\"%s\"%%'", sqlOp, key, value))
}
return "(" + strings.Join(conditions, separator) + ")"
}
@@ -140,34 +109,8 @@ func buildIndexFilterForInOperator(key string, op v3.FilterOperator, value inter
// for like/contains we will use lower index
// we can use lower index for =, in etc but it's difficult to do it for !=, NIN etc
// if as x != "ABC" we cannot predict something like "not lower(labels) like '%%x%%abc%%'". It has it be "not lower(labels) like '%%x%%ABC%%'"
func buildResourceIndexFilter(key string, op v3.FilterOperator, value interface{}, members []string) string {
if len(members) > 1 {
// A negated hint would drop rows where another member holds the value.
switch op {
case v3.FilterOperatorNotEqual,
v3.FilterOperatorNotLike,
v3.FilterOperatorNotILike,
v3.FilterOperatorNotContains,
v3.FilterOperatorNotExists,
v3.FilterOperatorNotRegex,
v3.FilterOperatorNotIn:
return ""
}
conditions := make([]string, 0, len(members))
for _, member := range members {
if condition := buildResourceIndexFilter(member, op, value, []string{member}); condition != "" {
conditions = append(conditions, condition)
}
}
if len(conditions) == 0 {
return ""
}
return "(" + strings.Join(conditions, " OR ") + ")"
}
func buildResourceIndexFilter(key string, op v3.FilterOperator, value interface{}) string {
// not using clickhouseFormattedValue as we don't wan't the quotes
escapedKey := utils.QuoteEscapedStringForContains(key, true)
strVal := fmt.Sprintf("%s", value)
fmtValEscapedForContains := utils.QuoteEscapedStringForContains(strVal, true)
fmtValEscapedForContainsLower := strings.ToLower(fmtValEscapedForContains)
@@ -176,36 +119,36 @@ func buildResourceIndexFilter(key string, op v3.FilterOperator, value interface{
// add index filters
switch op {
case v3.FilterOperatorEqual:
return fmt.Sprintf("labels like '%%%s\":\"%s%%'", escapedKey, fmtValEscapedForContains)
return fmt.Sprintf("labels like '%%%s\":\"%s%%'", key, fmtValEscapedForContains)
case v3.FilterOperatorNotEqual:
return fmt.Sprintf("labels not like '%%%s\":\"%s%%'", escapedKey, fmtValEscapedForContains)
return fmt.Sprintf("labels not like '%%%s\":\"%s%%'", key, fmtValEscapedForContains)
case v3.FilterOperatorLike, v3.FilterOperatorILike:
return fmt.Sprintf("lower(labels) like '%%%s%%%s%%'", escapedKey, fmtValEscapedLower)
return fmt.Sprintf("lower(labels) like '%%%s%%%s%%'", key, fmtValEscapedLower)
case v3.FilterOperatorNotLike, v3.FilterOperatorNotILike:
// cannot apply not contains x%y as y can be somewhere else
return ""
case v3.FilterOperatorContains:
return fmt.Sprintf("lower(labels) like '%%%s%%%s%%'", escapedKey, fmtValEscapedForContainsLower)
return fmt.Sprintf("lower(labels) like '%%%s%%%s%%'", key, fmtValEscapedForContainsLower)
case v3.FilterOperatorNotContains:
// cannot apply not contains x%y as y can be somewhere else
return ""
case v3.FilterOperatorExists:
return fmt.Sprintf("lower(labels) like '%%%s%%'", escapedKey)
return fmt.Sprintf("lower(labels) like '%%%s%%'", key)
case v3.FilterOperatorNotExists:
return fmt.Sprintf("lower(labels) not like '%%%s%%'", escapedKey)
return fmt.Sprintf("lower(labels) not like '%%%s%%'", key)
case v3.FilterOperatorRegex, v3.FilterOperatorNotRegex:
// don't try to do anything for regex.
return ""
case v3.FilterOperatorIn, v3.FilterOperatorNotIn:
return buildIndexFilterForInOperator(key, op, value)
default:
return fmt.Sprintf("labels like '%%%s%%'", escapedKey)
return fmt.Sprintf("labels like '%%%s%%'", key)
}
}
// buildResourceFiltersFromFilterItems builds a list of clickhouse filter strings for resource labels from a FilterSet.
// It skips any filter items that are not resource attributes and checks that the operator is supported and the data type is correct.
func buildResourceFiltersFromFilterItems(fs *v3.FilterSet, resolveSemconvFamilies bool) ([]string, error) {
func buildResourceFiltersFromFilterItems(fs *v3.FilterSet) ([]string, error) {
var conditions []string
if fs == nil || len(fs.Items) == 0 {
return nil, nil
@@ -239,20 +182,12 @@ func buildResourceFiltersFromFilterItems(fs *v3.FilterSet, resolveSemconvFamilie
}
if logsOp, ok := resourceLogOperators[op]; ok {
members := []string{keyName}
if resolveSemconvFamilies {
members = semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
Name: keyName,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
})
}
// the filter
if resourceFilter := buildResourceFilter(logsOp, keyName, op, value, members); resourceFilter != "" {
if resourceFilter := buildResourceFilter(logsOp, keyName, op, value); resourceFilter != "" {
conditions = append(conditions, resourceFilter)
}
// the additional filter for better usage of the index
if resourceIndexFilter := buildResourceIndexFilter(keyName, op, value, members); resourceIndexFilter != "" {
if resourceIndexFilter := buildResourceIndexFilter(keyName, op, value); resourceIndexFilter != "" {
conditions = append(conditions, resourceIndexFilter)
}
} else {
@@ -284,12 +219,12 @@ func buildResourceFiltersFromAggregateAttribute(aggregateAttribute v3.AttributeK
return ""
}
func BuildResourceSubQuery(dbName, tableName string, bucketStart, bucketEnd int64, fs *v3.FilterSet, groupBy []v3.AttributeKey, aggregateAttribute v3.AttributeKey, isLiveTail bool, resolveSemconvFamilies bool) (string, error) {
func BuildResourceSubQuery(dbName, tableName string, bucketStart, bucketEnd int64, fs *v3.FilterSet, groupBy []v3.AttributeKey, aggregateAttribute v3.AttributeKey, isLiveTail bool) (string, error) {
// BUILD THE WHERE CLAUSE
var conditions []string
// only add the resource attributes to the filters here
rs, err := buildResourceFiltersFromFilterItems(fs, resolveSemconvFamilies)
rs, err := buildResourceFiltersFromFilterItems(fs)
if err != nil {
return "", err
}

View File

@@ -5,7 +5,6 @@ import (
"testing"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/stretchr/testify/require"
)
func Test_buildResourceFilter(t *testing.T) {
@@ -89,7 +88,7 @@ func Test_buildResourceFilter(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := buildResourceFilter(tt.args.logsOp, tt.args.key, tt.args.op, tt.args.value, []string{tt.args.key}); got != tt.want {
if got := buildResourceFilter(tt.args.logsOp, tt.args.key, tt.args.op, tt.args.value); got != tt.want {
t.Errorf("buildResourceFilter() = %v, want %v", got, tt.want)
}
})
@@ -283,7 +282,7 @@ func Test_buildResourceIndexFilter(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := buildResourceIndexFilter(tt.args.key, tt.args.op, tt.args.value, []string{tt.args.key}); got != tt.want {
if got := buildResourceIndexFilter(tt.args.key, tt.args.op, tt.args.value); got != tt.want {
t.Errorf("buildResourceIndexFilter() = %v, want %v", got, tt.want)
}
})
@@ -380,7 +379,7 @@ func Test_buildResourceFiltersFromFilterItems(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := buildResourceFiltersFromFilterItems(tt.args.fs, false)
got, err := buildResourceFiltersFromFilterItems(tt.args.fs)
if (err != nil) != tt.wantErr {
t.Errorf("buildResourceFiltersFromFilterItems() error = %v, wantErr %v", err, tt.wantErr)
return
@@ -542,7 +541,7 @@ func Test_buildResourceSubQuery(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := BuildResourceSubQuery("signoz_logs", "distributed_logs_v2_resource", tt.args.bucketStart, tt.args.bucketEnd, tt.args.fs, tt.args.groupBy, tt.args.aggregateAttribute, false, false)
got, err := BuildResourceSubQuery("signoz_logs", "distributed_logs_v2_resource", tt.args.bucketStart, tt.args.bucketEnd, tt.args.fs, tt.args.groupBy, tt.args.aggregateAttribute, false)
if (err != nil) != tt.wantErr {
t.Errorf("buildResourceSubQuery() error = %v, wantErr %v", err, tt.wantErr)
return
@@ -553,58 +552,3 @@ func Test_buildResourceSubQuery(t *testing.T) {
})
}
}
func Test_buildResourceFilterFamily(t *testing.T) {
members := []string{"deployment.environment.name", "deployment.environment"}
require.Equal(t,
"COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') = 'production'",
buildResourceFilter("=", "deployment.environment.name", v3.FilterOperatorEqual, "production", members))
require.Equal(t,
"COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') != 'production'",
buildResourceFilter("!=", "deployment.environment.name", v3.FilterOperatorNotEqual, "production", members))
require.Equal(t,
"(simpleJSONHas(labels, 'deployment.environment.name') OR simpleJSONHas(labels, 'deployment.environment'))",
buildResourceFilter("", "deployment.environment.name", v3.FilterOperatorExists, nil, members))
require.Equal(t,
"(not simpleJSONHas(labels, 'deployment.environment.name') AND not simpleJSONHas(labels, 'deployment.environment'))",
buildResourceFilter("", "deployment.environment.name", v3.FilterOperatorNotExists, nil, members))
}
func Test_buildResourceIndexFilterFamily(t *testing.T) {
members := []string{"deployment.environment.name", "deployment.environment"}
require.Equal(t,
`(labels like '%deployment.environment.name":"production%' OR labels like '%deployment.environment":"production%')`,
buildResourceIndexFilter("deployment.environment.name", v3.FilterOperatorEqual, "production", members))
require.Equal(t, "",
buildResourceIndexFilter("deployment.environment.name", v3.FilterOperatorNotEqual, "production", members))
require.Equal(t, "",
buildResourceIndexFilter("deployment.environment.name", v3.FilterOperatorNotIn, []interface{}{"production"}, members))
}
func TestBuildResourceSubQueryFamily(t *testing.T) {
fs := &v3.FilterSet{Items: []v3.FilterItem{{
Key: v3.AttributeKey{
Key: "deployment.environment.name",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeResource,
},
Operator: v3.FilterOperatorEqual,
Value: "production",
}}}
familyOn, err := BuildResourceSubQuery("signoz_traces", "distributed_traces_v3_resource", 1, 2, fs, nil, v3.AttributeKey{}, false, true)
require.NoError(t, err)
require.Contains(t, familyOn, "COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') = 'production'")
require.Contains(t, familyOn, `(labels like '%deployment.environment.name":"production%' OR labels like '%deployment.environment":"production%')`)
familyOff, err := BuildResourceSubQuery("signoz_traces", "distributed_traces_v3_resource", 1, 2, fs, nil, v3.AttributeKey{}, false, false)
require.NoError(t, err)
require.Contains(t, familyOff, "simpleJSONExtractString(labels, 'deployment.environment.name') = 'production'")
require.NotContains(t, familyOff, "COALESCE")
}

View File

@@ -6,25 +6,17 @@ import (
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/semconv"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
func BuildServiceMapQuery(tags []model.TagQuery, resolveSemconvFamilies bool) (string, []interface{}) {
columns := map[string]string{
"deployment_environment": "deployment_environment",
"k8s_cluster_name": "k8s_cluster_name",
"k8s_namespace_name": "k8s_namespace_name",
}
if resolveSemconvFamilies {
for _, member := range semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
}) {
columns[strings.ReplaceAll(member, ".", "_")] = "deployment_environment"
}
var (
columns = map[string]struct{}{
"deployment_environment": {},
"k8s_cluster_name": {},
"k8s_namespace_name": {},
}
)
func BuildServiceMapQuery(tags []model.TagQuery) (string, []interface{}) {
var filterQuery string
var namedArgs []interface{}
for _, tag := range tags {
@@ -32,40 +24,39 @@ func BuildServiceMapQuery(tags []model.TagQuery, resolveSemconvFamilies bool) (s
operator := tag.GetOperator()
value := tag.GetValues()
column, ok := columns[key]
if !ok {
if _, ok := columns[key]; !ok {
continue
}
switch operator {
case model.InOperator:
filterQuery += fmt.Sprintf(" AND %s IN @%s", column, key)
filterQuery += fmt.Sprintf(" AND %s IN @%s", key, key)
namedArgs = append(namedArgs, clickhouse.Named(key, value))
case model.NotInOperator:
filterQuery += fmt.Sprintf(" AND %s NOT IN @%s", column, key)
filterQuery += fmt.Sprintf(" AND %s NOT IN @%s", key, key)
namedArgs = append(namedArgs, clickhouse.Named(key, value))
case model.EqualOperator:
filterQuery += fmt.Sprintf(" AND %s = @%s", column, key)
filterQuery += fmt.Sprintf(" AND %s = @%s", key, key)
namedArgs = append(namedArgs, clickhouse.Named(key, value))
case model.NotEqualOperator:
filterQuery += fmt.Sprintf(" AND %s != @%s", column, key)
filterQuery += fmt.Sprintf(" AND %s != @%s", key, key)
namedArgs = append(namedArgs, clickhouse.Named(key, value))
case model.ContainsOperator:
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", column, key)
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", key, key)
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%%%s%%", value)))
case model.NotContainsOperator:
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", column, key)
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", key, key)
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%%%s%%", value)))
case model.StartsWithOperator:
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", column, key)
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", key, key)
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%s%%", value)))
case model.NotStartsWithOperator:
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", column, key)
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", key, key)
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%s%%", value)))
case model.ExistsOperator:
filterQuery += fmt.Sprintf(" AND %s IS NOT NULL", column)
filterQuery += fmt.Sprintf(" AND %s IS NOT NULL", key)
case model.NotExistsOperator:
filterQuery += fmt.Sprintf(" AND %s IS NULL", column)
filterQuery += fmt.Sprintf(" AND %s IS NULL", key)
}
}
return filterQuery, namedArgs

View File

@@ -1,37 +0,0 @@
package services
import (
"testing"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/stretchr/testify/require"
)
func TestBuildServiceMapQueryFamily(t *testing.T) {
newSpelling := []model.TagQuery{model.NewTagQueryString(model.TagQueryParam{
Key: "deployment.environment.name",
StringValues: []string{"production"},
Operator: model.EqualOperator,
})}
oldSpelling := []model.TagQuery{model.NewTagQueryString(model.TagQueryParam{
Key: "deployment.environment",
StringValues: []string{"production"},
Operator: model.EqualOperator,
})}
query, args := BuildServiceMapQuery(newSpelling, true)
require.Equal(t, " AND deployment_environment = @deployment_environment_name", query)
require.Len(t, args, 1)
query, args = BuildServiceMapQuery(oldSpelling, true)
require.Equal(t, " AND deployment_environment = @deployment_environment", query)
require.Len(t, args, 1)
query, args = BuildServiceMapQuery(newSpelling, false)
require.Equal(t, "", query)
require.Empty(t, args)
query, args = BuildServiceMapQuery(oldSpelling, false)
require.Equal(t, " AND deployment_environment = @deployment_environment", query)
require.Len(t, args, 1)
}

View File

@@ -282,7 +282,7 @@ func buildTracesQuery(start, end, step int64, mq *v3.BuilderQuery, panelType v3.
filterSubQuery = filterSubQuery + " AND " + emptyValuesInGroupByFilter
}
resourceSubQuery, err := resource.BuildResourceSubQuery("signoz_traces", "distributed_traces_v3_resource", bucketStart, bucketEnd, mq.Filters, mq.GroupBy, mq.AggregateAttribute, false, false)
resourceSubQuery, err := resource.BuildResourceSubQuery("signoz_traces", "distributed_traces_v3_resource", bucketStart, bucketEnd, mq.Filters, mq.GroupBy, mq.AggregateAttribute, false)
if err != nil {
return "", err
}

View File

@@ -17,12 +17,12 @@ type Reader interface {
GetInstantQueryMetricsResult(ctx context.Context, query *model.InstantQueryMetricsParams) (*promql.Result, *stats.QueryStats, *model.ApiError)
GetQueryRangeResult(ctx context.Context, query *model.QueryRangeParams) (*promql.Result, *stats.QueryStats, *model.ApiError)
GetTopLevelOperations(ctx context.Context, start, end time.Time, services []string) (*map[string][]string, *model.ApiError)
GetEntryPointOperations(ctx context.Context, orgID valuer.UUID, query *model.GetTopOperationsParams) (*[]model.TopOperationsItem, error)
GetServices(ctx context.Context, orgID valuer.UUID, query *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError)
GetTopOperations(ctx context.Context, orgID valuer.UUID, query *model.GetTopOperationsParams) (*[]model.TopOperationsItem, *model.ApiError)
GetEntryPointOperations(ctx context.Context, query *model.GetTopOperationsParams) (*[]model.TopOperationsItem, error)
GetServices(ctx context.Context, query *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError)
GetTopOperations(ctx context.Context, query *model.GetTopOperationsParams) (*[]model.TopOperationsItem, *model.ApiError)
GetUsage(ctx context.Context, query *model.GetUsageParams) (*[]model.UsageItem, error)
GetServicesList(ctx context.Context) (*[]string, error)
GetDependencyGraph(ctx context.Context, orgID valuer.UUID, query *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error)
GetDependencyGraph(ctx context.Context, query *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error)
GetTTL(ctx context.Context, orgID string, ttlParams *retentiontypes.GetTTLParams) (*retentiontypes.GetTTLResponseItem, *model.ApiError)
GetCustomRetentionTTL(ctx context.Context, orgID string) (*retentiontypes.GetCustomRetentionTTLResponse, error)

View File

@@ -19,7 +19,6 @@ pytest_plugins = [
"fixtures.traces",
"fixtures.metrics",
"fixtures.queriercommon",
"fixtures.semconvfamilies",
"fixtures.metadata",
"fixtures.meter",
"fixtures.browser",

View File

@@ -1,103 +0,0 @@
from collections.abc import Callable, Generator
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.logs import Logs
from fixtures.querier import (
RequestType,
build_order_by,
build_raw_query,
get_column_data_from_response,
make_query_request,
)
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.
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
def fleet_identities(
signoz: types.SigNoz,
token: str,
base: datetime,
expression: str,
signal: str = "traces",
) -> set[str]:
identity_field = "span.name" if signal == "traces" else "body"
identity_column = "name" if signal == "traces" else "body"
response = make_query_request(
signoz,
token,
start_ms=int((base - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((base + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
build_raw_query(
"A",
signal,
limit=100,
filter_expression=expression,
order=[build_order_by("timestamp", "asc")],
select_fields=[{"name": identity_field}],
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
# Sets keep the assertion stable when the shared stack is reused and older
# rows with the same identities remain.
return {name for name in get_column_data_from_response(response.json(), identity_column) if name.startswith(PREFIX)}

View File

@@ -1,162 +0,0 @@
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,
fleet_identities,
)
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}")
assert fleet_identities(signoz, token, family_fleet, expression) == 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}")
assert fleet_identities(signoz_families_off, token, family_fleet, expression) == 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}")
assert fleet_identities(signoz, token, family_fleet, expression, signal="logs") == 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

View File

@@ -1,55 +0,0 @@
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={},
)