Compare commits

..

2 Commits

Author SHA1 Message Date
Srikanth Chekuri
e6356b3b8c Merge branch 'main' into anomaly-v2 2026-07-14 08:07:28 +05:30
srikanthccv
90512748fb chore: add new alert experience support for anomaly alerts 2026-07-11 23:08:23 +05:30
164 changed files with 2272 additions and 7085 deletions

View File

@@ -4259,34 +4259,6 @@ components:
type: number
clusterName:
type: string
counts:
properties:
daemonSets:
format: int64
type: integer
deployments:
format: int64
type: integer
jobs:
format: int64
type: integer
namespaces:
format: int64
type: integer
nodes:
format: int64
type: integer
statefulSets:
format: int64
type: integer
required:
- nodes
- namespaces
- deployments
- daemonSets
- jobs
- statefulSets
type: object
meta:
additionalProperties:
type: string
@@ -4307,7 +4279,6 @@ components:
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- counts
- meta
type: object
InframonitoringtypesClusters:
@@ -4829,26 +4800,6 @@ components:
type: object
InframonitoringtypesNamespaceRecord:
properties:
counts:
properties:
daemonSets:
format: int64
type: integer
deployments:
format: int64
type: integer
jobs:
format: int64
type: integer
statefulSets:
format: int64
type: integer
required:
- deployments
- daemonSets
- jobs
- statefulSets
type: object
meta:
additionalProperties:
type: string
@@ -4872,7 +4823,6 @@ components:
- namespaceMemory
- podCountsByPhase
- podCountsByStatus
- counts
- meta
type: object
InframonitoringtypesNamespaces:

View File

@@ -5669,39 +5669,6 @@ export interface InframonitoringtypesChecksDTO {
type: InframonitoringtypesCheckTypeDTO;
}
export type InframonitoringtypesClusterRecordDTOCounts = {
/**
* @type integer
* @format int64
*/
daemonSets: number;
/**
* @type integer
* @format int64
*/
deployments: number;
/**
* @type integer
* @format int64
*/
jobs: number;
/**
* @type integer
* @format int64
*/
namespaces: number;
/**
* @type integer
* @format int64
*/
nodes: number;
/**
* @type integer
* @format int64
*/
statefulSets: number;
};
export type InframonitoringtypesClusterRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -5846,10 +5813,6 @@ export interface InframonitoringtypesClusterRecordDTO {
* @type string
*/
clusterName: string;
/**
* @type object
*/
counts: InframonitoringtypesClusterRecordDTOCounts;
/**
* @type object,null
*/
@@ -6405,29 +6368,6 @@ export interface InframonitoringtypesJobsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export type InframonitoringtypesNamespaceRecordDTOCounts = {
/**
* @type integer
* @format int64
*/
daemonSets: number;
/**
* @type integer
* @format int64
*/
deployments: number;
/**
* @type integer
* @format int64
*/
jobs: number;
/**
* @type integer
* @format int64
*/
statefulSets: number;
};
export type InframonitoringtypesNamespaceRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6439,10 +6379,6 @@ export type InframonitoringtypesNamespaceRecordDTOMeta =
InframonitoringtypesNamespaceRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesNamespaceRecordDTO {
/**
* @type object
*/
counts: InframonitoringtypesNamespaceRecordDTOCounts;
/**
* @type object,null
*/

View File

@@ -7,11 +7,13 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_URL_MAP } from './constants';
// The setup-guide button only exists in the classic form, which is reachable
// via the unadvertised showClassicCreateAlertsPage param.
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALERTS_NEW}`,
search: 'ruleType=anomaly_rule',
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
}),
}));
@@ -20,7 +22,7 @@ jest.mock('react-router-dom-v5-compat', () => ({
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: 'ruleType=anomaly_rule',
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
hash: '',
state: null,
})),

View File

@@ -152,7 +152,7 @@ describe('CreateAlertRule', () => {
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
});
it('should render classic flow when ruleType is anomaly_rule even if showClassicCreateAlertsPage is not true', () => {
it('should render new flow when ruleType is anomaly_rule', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'false';
@@ -163,8 +163,8 @@ describe('CreateAlertRule', () => {
return null;
});
render(<CreateAlertRule />);
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
});
it('should use alertType from URL when provided', () => {

View File

@@ -100,10 +100,9 @@ function CreateRules(): JSX.Element {
return <SelectAlertType onSelect={handleSelectType} />;
}
if (
showClassicCreateAlertsPageFlag ||
alertType === AlertTypes.ANOMALY_BASED_ALERT
) {
// The classic experience is no longer offered in the UI; the query param
// is kept as an unadvertised escape hatch until the flow is removed.
if (showClassicCreateAlertsPageFlag) {
return (
<FormAlertRules
alertType={alertType}

View File

@@ -2,7 +2,9 @@ import { useQuery } from 'react-query';
import { Button, Tooltip } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { ChartLine } from '@signozhq/icons';
import { FeatureKeys } from 'constants/features';
import { Activity, ChartLine } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
@@ -19,6 +21,7 @@ import './styles.scss';
function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const { featureFlags } = useAppContext();
const {
data,
@@ -30,9 +33,15 @@ function AlertCondition(): JSX.Element {
});
const channels = data?.data || [];
const isAnomalyDetectionEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
?.active || false;
// Anomaly alerts always show both tabs so existing rules stay editable;
// metric alerts only offer the anomaly tab when the feature is enabled.
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
alertType === AlertTypes.METRICS_BASED_ALERT;
(isAnomalyDetectionEnabled && alertType === AlertTypes.METRICS_BASED_ALERT);
const tabs = [
{
@@ -40,16 +49,15 @@ function AlertCondition(): JSX.Element {
icon: <ChartLine size={14} data-testid="threshold-view" />,
value: AlertTypes.METRICS_BASED_ALERT,
},
// Hide anomaly tab for now
// ...(showMultipleTabs
// ? [
// {
// label: 'Anomaly',
// icon: <Activity size={14} data-testid="anomaly-view" />,
// value: AlertTypes.ANOMALY_BASED_ALERT,
// },
// ]
// : []),
...(showMultipleTabs
? [
{
label: 'Anomaly',
icon: <Activity size={14} data-testid="anomaly-view" />,
value: AlertTypes.ANOMALY_BASED_ALERT,
},
]
: []),
];
const handleAlertTypeChange = (value: AlertTypes): void => {

View File

@@ -188,7 +188,7 @@ function AnomalyThreshold({
}}
options={ANOMALY_SEASONALITY_OPTIONS}
/>
{notificationSettings.routingPolicies ? (
{!notificationSettings.routingPolicies ? (
<>
<Typography.Text
data-testid="seasonality-text"

View File

@@ -1,7 +1,11 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
import { CreateAlertProvider } from '../../context';
import AlertCondition from '../AlertCondition';
@@ -96,6 +100,23 @@ const createTestQueryClient = (): QueryClient =>
},
});
const ANOMALY_DETECTION_FLAG: FeatureFlagProps = {
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
};
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
const mockAppContext = (isAnomalyDetectionEnabled: boolean): void => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState(),
featureFlags: isAnomalyDetectionEnabled ? [ANOMALY_DETECTION_FLAG] : [],
});
};
const renderAlertCondition = (
alertType?: string,
): ReturnType<typeof render> => {
@@ -113,6 +134,10 @@ const renderAlertCondition = (
};
describe('AlertCondition', () => {
beforeEach(() => {
mockAppContext(true);
});
it('renders the stepper with correct step number and label', () => {
renderAlertCondition();
expect(screen.getByTestId(STEPPER_TEST_ID)).toHaveTextContent(
@@ -125,10 +150,9 @@ describe('AlertCondition', () => {
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
// ).not.toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
// Verify threshold tab is active by default
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -136,8 +160,7 @@ describe('AlertCondition', () => {
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
});
it('renders threshold tab by default', () => {
@@ -152,13 +175,27 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('renders anomaly tab when alert type supports multiple tabs', () => {
it('renders anomaly tab when alert type supports multiple tabs', () => {
renderAlertCondition();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('does not offer the anomaly tab when anomaly detection is disabled', () => {
mockAppContext(false);
renderAlertCondition();
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
});
it('shows both tabs for anomaly alerts even when anomaly detection is disabled', () => {
mockAppContext(false);
renderAlertCondition('ANOMALY_BASED_ALERT');
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
});
it('shows AlertThreshold component when alert type is not anomaly based', () => {
renderAlertCondition();
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
@@ -167,8 +204,7 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => {
it('shows AnomalyThreshold component when alert type is anomaly based', () => {
renderAlertCondition();
// Click on anomaly tab to switch to anomaly-based alert
@@ -179,8 +215,7 @@ describe('AlertCondition', () => {
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('switches between threshold and anomaly tabs', () => {
it('switches between threshold and anomaly tabs', () => {
renderAlertCondition();
// Initially shows threshold component
@@ -205,8 +240,7 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('applies active tab styling correctly', () => {
it('applies active tab styling correctly', () => {
renderAlertCondition();
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -227,11 +261,10 @@ describe('AlertCondition', () => {
it('shows multiple tabs for METRICS_BASED_ALERT', () => {
renderAlertCondition('METRIC_BASED_ALERT');
// TODO: uncomment this when anomaly tab is implemented
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
@@ -239,9 +272,8 @@ describe('AlertCondition', () => {
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows only threshold tab for LOGS_BASED_ALERT', () => {

View File

@@ -1,14 +1,7 @@
import { useCallback, useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import classNames from 'classnames';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { RotateCcw } from '@signozhq/icons';
import { useAlertRuleOptional } from 'providers/Alert';
import { Labels } from 'types/api/alerts/def';
@@ -22,8 +15,6 @@ function CreateAlertHeader(): JSX.Element {
const alertRuleContext = useAlertRuleOptional();
const { currentQuery } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const groupByLabels = useMemo(() => {
const labels = new Array<string>();
@@ -46,14 +37,6 @@ function CreateAlertHeader(): JSX.Element {
[groupByLabels],
);
const handleSwitchToClassicExperience = useCallback(() => {
logEvent('Alert: Switch to classic experience button clicked', {});
urlQuery.set(QueryParams.showClassicCreateAlertsPage, 'true');
const url = `${ROUTES.ALERTS_NEW}?${urlQuery.toString()}`;
safeNavigate(url, { replace: true });
}, [safeNavigate, urlQuery]);
return (
<div
className={classNames('alert-header', { 'edit-alert-header': isEditMode })}
@@ -61,15 +44,6 @@ function CreateAlertHeader(): JSX.Element {
{!isEditMode && (
<div className="alert-header__tab-bar">
<div className="alert-header__tab">New Alert Rule</div>
<Button
prefix={<RotateCcw size={12} />}
onClick={handleSwitchToClassicExperience}
variant="solid"
color="secondary"
size="sm"
>
Switch to Classic Experience
</Button>
</div>
)}
<div className="alert-header__content">

View File

@@ -1,20 +1,12 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { defaultPostableAlertRuleV2 } from 'container/CreateAlertV2/constants';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import * as useSafeNavigateHook from 'hooks/useSafeNavigate';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import * as rulesHook from '../../../../api/generated/services/rules';
import { CreateAlertProvider } from '../../context';
import CreateAlertHeader from '../CreateAlertHeader';
const mockSafeNavigate = jest.fn();
jest.spyOn(useSafeNavigateHook, 'useSafeNavigate').mockReturnValue({
safeNavigate: mockSafeNavigate,
});
jest.spyOn(rulesHook, 'useCreateRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
@@ -106,34 +98,8 @@ describe('CreateAlertHeader', () => {
).toHaveValue('TEST_ALERT');
});
it('should navigate to classic experience when button is clicked', () => {
it('should not render "switch to classic experience" button', () => {
renderCreateAlertHeader();
const switchToClassicExperienceButton = screen.getByText(
'Switch to Classic Experience',
);
expect(switchToClassicExperienceButton).toBeInTheDocument();
fireEvent.click(switchToClassicExperienceButton);
const params = new URLSearchParams();
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
expect(mockSafeNavigate).toHaveBeenCalledWith(
`${ROUTES.ALERTS_NEW}?${params.toString()}`,
{ replace: true },
);
});
it('should not render "switch to classic experience" button when isEditMode is true', () => {
render(
<CreateAlertProvider
isEditMode
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
initialAlertState={getCreateAlertLocalStateFromAlertDef(
defaultPostableAlertRuleV2,
)}
>
<CreateAlertHeader />
</CreateAlertProvider>,
);
expect(
screen.queryByText('Switch to Classic Experience'),
).not.toBeInTheDocument();

View File

@@ -14,10 +14,7 @@ import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import { useCreateAlertState } from '../context';
import {
buildCreateThresholdAlertRulePayload,
validateCreateAlertState,
} from './utils';
import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
import './styles.scss';
import {
@@ -85,7 +82,7 @@ function Footer(): JSX.Element {
);
const handleTestNotification = useCallback((): void => {
const payload = buildCreateThresholdAlertRulePayload({
const payload = buildCreateAlertRulePayload({
alertType,
basicAlertState,
thresholdState,
@@ -122,7 +119,7 @@ function Footer(): JSX.Element {
const queryClient = useQueryClient();
const handleSaveAlert = useCallback((): void => {
const payload = buildCreateThresholdAlertRulePayload({
const payload = buildCreateAlertRulePayload({
alertType,
basicAlertState,
thresholdState,

View File

@@ -18,6 +18,8 @@ import { EQueryType } from 'types/common/dashboard';
import { BuildCreateAlertRulePayloadArgs } from '../types';
import {
buildCreateAlertRulePayload,
buildCreateAnomalyAlertRulePayload,
buildCreateThresholdAlertRulePayload,
getAlertOnAbsentProps,
getEnforceMinimumDatapointsProps,
@@ -550,4 +552,115 @@ describe('Footer utils', () => {
},
);
});
describe('buildCreateAnomalyAlertRulePayload', () => {
const mockCreateAlertContextState = createMockAlertContextState();
const ANOMALY_PAYLOAD_ARGS: BuildCreateAlertRulePayloadArgs = {
basicAlertState: mockCreateAlertContextState.alertState,
thresholdState: {
...mockCreateAlertContextState.thresholdState,
thresholds: [
{
...mockCreateAlertContextState.thresholdState.thresholds[0],
thresholdValue: 3,
},
],
},
advancedOptions: mockCreateAlertContextState.advancedOptions,
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
notificationSettings: mockCreateAlertContextState.notificationSettings,
query: initialQueriesMap.metrics,
alertType: AlertTypes.ANOMALY_BASED_ALERT,
};
it('builds a v2alpha1 anomaly rule payload', () => {
const props = buildCreateAnomalyAlertRulePayload(ANOMALY_PAYLOAD_ARGS);
expect(props.ruleType).toBe('anomaly_rule');
expect(props.schemaVersion).toBe('v2alpha1');
expect(props.version).toBe('v5');
// The stored alertType is metric based; anomaly is a rule type
expect(props.alertType).toBe('METRIC_BASED_ALERT');
expect(props.condition.algorithm).toBe(
ANOMALY_PAYLOAD_ARGS.thresholdState.algorithm,
);
expect(props.condition.seasonality).toBe(
ANOMALY_PAYLOAD_ARGS.thresholdState.seasonality,
);
expect(props.condition.selectedQueryName).toBe(
ANOMALY_PAYLOAD_ARGS.thresholdState.selectedQuery,
);
// Evaluation comes from the anomaly condition's own window
expect(props.evaluation).toStrictEqual({
kind: 'rolling',
spec: {
evalWindow: ANOMALY_PAYLOAD_ARGS.thresholdState.evaluationWindow,
frequency: '1m',
},
});
});
it('keeps the target positive for the above operator', () => {
const props = buildCreateAnomalyAlertRulePayload({
...ANOMALY_PAYLOAD_ARGS,
thresholdState: {
...ANOMALY_PAYLOAD_ARGS.thresholdState,
operator: 'above',
},
});
expect(props.condition.thresholds?.spec[0].target).toBe(3);
expect(props.condition.thresholds?.spec[0].op).toBe('above');
});
it.each([['below'], ['2']])(
'negates the target for the below operator (%s)',
(op) => {
const props = buildCreateAnomalyAlertRulePayload({
...ANOMALY_PAYLOAD_ARGS,
thresholdState: {
...ANOMALY_PAYLOAD_ARGS.thresholdState,
operator: op,
},
});
expect(props.condition.thresholds?.spec[0].target).toBe(-3);
expect(props.condition.thresholds?.spec[0].op).toBe(op);
},
);
it('keeps the target positive for the outside_bounds operator', () => {
const props = buildCreateAnomalyAlertRulePayload({
...ANOMALY_PAYLOAD_ARGS,
thresholdState: {
...ANOMALY_PAYLOAD_ARGS.thresholdState,
operator: 'outside_bounds',
},
});
expect(props.condition.thresholds?.spec[0].target).toBe(3);
});
});
describe('buildCreateAlertRulePayload', () => {
const mockCreateAlertContextState = createMockAlertContextState();
const args: BuildCreateAlertRulePayloadArgs = {
basicAlertState: mockCreateAlertContextState.alertState,
thresholdState: mockCreateAlertContextState.thresholdState,
advancedOptions: mockCreateAlertContextState.advancedOptions,
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
notificationSettings: mockCreateAlertContextState.notificationSettings,
query: initialQueriesMap.metrics,
alertType: mockCreateAlertContextState.alertType,
};
it('builds an anomaly payload for anomaly based alerts', () => {
const props = buildCreateAlertRulePayload({
...args,
alertType: AlertTypes.ANOMALY_BASED_ALERT,
});
expect(props.ruleType).toBe('anomaly_rule');
});
it('builds a threshold payload for other alert types', () => {
const props = buildCreateAlertRulePayload(args);
expect(props.ruleType).toBe('threshold_rule');
});
});
});

View File

@@ -2,6 +2,7 @@ import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
BasicThreshold,
PostableAlertRuleV2,
@@ -11,9 +12,11 @@ import { compositeQueryToQueryEnvelope } from 'utils/compositeQueryToQueryEnvelo
import {
AdvancedOptionsState,
AlertThresholdOperator,
EvaluationWindowState,
NotificationSettingsState,
} from '../context/types';
import { normalizeOperator } from '../utils';
import { BuildCreateAlertRulePayloadArgs } from './types';
// Get formatted time/unit pairs for create alert api payload
@@ -288,16 +291,15 @@ export function buildCreateThresholdAlertRulePayload(
}
// Build Create Anomaly Alert Rule Payload
// TODO: Update this function before enabling anomaly alert rule creation
export function buildCreateAnomalyAlertRulePayload(
args: BuildCreateAlertRulePayloadArgs,
): PostableAlertRuleV2 {
const {
alertType,
basicAlertState,
thresholdState,
query,
notificationSettings,
evaluationWindow,
advancedOptions,
} = args;
@@ -313,19 +315,55 @@ export function buildCreateAnomalyAlertRulePayload(
unit: basicAlertState.yAxisUnit,
});
// v2alpha1 thresholds are literal: "3 deviations below the predicted data"
// means the anomaly z-score must drop under -3, so the target is negated
// for the below operator (the deviations input is always positive).
const isBelowOperator =
normalizeOperator(thresholdState.operator) ===
AlertThresholdOperator.IS_BELOW;
const thresholds: BasicThreshold[] = thresholdState.thresholds.map(
(threshold) => {
const deviations = Math.abs(parseFloat(threshold.thresholdValue.toString()));
return {
name: threshold.label,
target: isBelowOperator ? -deviations : deviations,
matchType: thresholdState.matchType,
op: thresholdState.operator,
channels: threshold.channels,
targetUnit: threshold.unit,
};
},
);
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
const enforceMinimumDatapointsProps =
getEnforceMinimumDatapointsProps(advancedOptions);
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
const notificationSettingsProps =
getNotificationSettingsProps(notificationSettings);
// The anomaly condition carries its own evaluation window
// ("during the last X"), so the evaluation is always a rolling window.
const frequency = getFormattedTimeValue(
advancedOptions.evaluationCadence.default.value,
advancedOptions.evaluationCadence.default.timeUnit,
);
return {
alert: basicAlertState.name,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
alertType,
alertType:
alertType === AlertTypes.ANOMALY_BASED_ALERT
? AlertTypes.METRICS_BASED_ALERT
: alertType,
condition: {
thresholds: {
kind: 'basic',
spec: thresholds,
},
compositeQuery,
selectedQueryName: thresholdState.selectedQuery,
algorithm: thresholdState.algorithm,
seasonality: thresholdState.seasonality,
...alertOnAbsentProps,
...enforceMinimumDatapointsProps,
},
@@ -335,9 +373,25 @@ export function buildCreateAnomalyAlertRulePayload(
summary: notificationSettings.description,
},
notificationSettings: notificationSettingsProps,
evaluation: evaluationProps,
version: '',
schemaVersion: '',
evaluation: {
kind: 'rolling',
spec: {
evalWindow: thresholdState.evaluationWindow,
frequency,
},
},
version: 'v5',
schemaVersion: 'v2alpha1',
source: window?.location.toString(),
};
}
// Build the create/test alert rule payload for the selected alert type
export function buildCreateAlertRulePayload(
args: BuildCreateAlertRulePayloadArgs,
): PostableAlertRuleV2 {
if (args.alertType === AlertTypes.ANOMALY_BASED_ALERT) {
return buildCreateAnomalyAlertRulePayload(args);
}
return buildCreateThresholdAlertRulePayload(args);
}

View File

@@ -316,6 +316,60 @@ describe('CreateAlertV2 utils', () => {
});
});
describe('getThresholdStateFromAlertDef for anomaly rules', () => {
const anomalyAlertDef: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
ruleType: 'anomaly_rule',
condition: {
...defaultPostableAlertRuleV2.condition,
algorithm: 'standard',
seasonality: 'daily',
selectedQueryName: 'A',
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target: -3,
targetUnit: '',
channels: ['email'],
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
op: AlertThresholdOperator.IS_BELOW,
},
],
},
},
evaluation: {
kind: 'rolling',
spec: {
evalWindow: '1h0m0s',
frequency: '1m',
},
},
};
it('shows the absolute deviation value for negative anomaly targets', () => {
const props = getThresholdStateFromAlertDef(anomalyAlertDef);
expect(props.thresholds[0].thresholdValue).toBe(3);
expect(props.operator).toBe(AlertThresholdOperator.IS_BELOW);
});
it('hydrates the anomaly evaluation window, algorithm and seasonality', () => {
const props = getThresholdStateFromAlertDef(anomalyAlertDef);
expect(props.evaluationWindow).toBe('1h0m0s');
expect(props.algorithm).toBe('standard');
expect(props.seasonality).toBe('daily');
});
it('does not touch the target for non-anomaly rules', () => {
const props = getThresholdStateFromAlertDef({
...anomalyAlertDef,
ruleType: 'threshold_rule',
});
expect(props.thresholds[0].thresholdValue).toBe(-3);
});
});
describe('normalizeOperator', () => {
it.each([
['1', AlertThresholdOperator.IS_ABOVE],

View File

@@ -239,6 +239,39 @@ describe('CreateAlertV2 Context Utils', () => {
});
});
it('should set evaluation window', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_EVALUATION_WINDOW',
payload: TimeDuration.ONE_HOUR,
});
expect(result).toStrictEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
evaluationWindow: TimeDuration.ONE_HOUR,
});
});
it('should set algorithm', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_ALGORITHM',
payload: Algorithm.STANDARD,
});
expect(result).toStrictEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
algorithm: Algorithm.STANDARD,
});
});
it('should set seasonality', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_SEASONALITY',
payload: Seasonality.WEEKLY,
});
expect(result).toStrictEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
seasonality: Seasonality.WEEKLY,
});
});
it('should set thresholds', () => {
const newThresholds = [
{

View File

@@ -124,6 +124,12 @@ export const alertThresholdReducer = (
return { ...state, operator: action.payload };
case 'SET_MATCH_TYPE':
return { ...state, matchType: action.payload };
case 'SET_EVALUATION_WINDOW':
return { ...state, evaluationWindow: action.payload };
case 'SET_ALGORITHM':
return { ...state, algorithm: action.payload };
case 'SET_SEASONALITY':
return { ...state, seasonality: action.payload };
case 'SET_THRESHOLDS':
return { ...state, thresholds: action.payload };
case 'RESET':

View File

@@ -4,6 +4,7 @@ import { Spin } from 'antd';
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { getRandomColor } from 'container/ExplorerOptions/utils';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { v4 } from 'uuid';
@@ -303,13 +304,19 @@ export function normalizeMatchType(
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AlertThresholdState {
// Anomaly targets are stored as literal z-scores (negative for the below
// operator); the deviations select always shows the positive value.
const isAnomalyRule =
alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
return {
...INITIAL_ALERT_THRESHOLD_STATE,
thresholds:
alertDef.condition.thresholds?.spec.map((threshold) => ({
id: v4(),
label: threshold.name,
thresholdValue: threshold.target,
thresholdValue: isAnomalyRule
? Math.abs(threshold.target)
: threshold.target,
recoveryThresholdValue: null,
unit: threshold.targetUnit,
color: getColorForThreshold(threshold.name),
@@ -321,6 +328,18 @@ export function getThresholdStateFromAlertDef(
matchType:
alertDef.condition.thresholds?.spec[0].matchType ||
AlertThresholdMatchType.AT_LEAST_ONCE,
...(isAnomalyRule
? {
evaluationWindow:
alertDef.evaluation?.spec?.evalWindow ||
INITIAL_ALERT_THRESHOLD_STATE.evaluationWindow,
algorithm:
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
seasonality:
alertDef.condition.seasonality ||
INITIAL_ALERT_THRESHOLD_STATE.seasonality,
}
: {}),
};
}

View File

@@ -139,31 +139,21 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
</ColumnHeader>
),
accessorFn: (row): number => row.currentNodes,
width: { min: 210 },
width: { min: 180 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => (
<GroupedStatusCounts
items={[
{
value: row.readyNodes,
label: 'Ready',
color: Color.BG_FOREST_500,
},
{
value: row.currentNodes,
label: 'Current',
color: Color.BG_ROBIN_500,
color: Color.BG_FOREST_500,
},
{
value: row.desiredNodes,
label: 'Desired',
color: Color.BG_SAKURA_400,
},
{
value: row.misscheduledNodes,
label: 'Misscheduled',
color: Color.BG_AMBER_500,
color: Color.BG_ROBIN_500,
},
]}
/>
@@ -322,30 +312,6 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
);
},
},
{
id: 'ready_nodes',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#ready">
Ready Nodes
</ColumnHeader>
),
accessorFn: (row): number => row.readyNodes,
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const readyNodes = value as number;
return (
<ValidateColumnValueWrapper
value={readyNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="ready node"
>
<TanStackTable.Text>{readyNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'current_nodes',
header: (): React.ReactNode => (
@@ -394,28 +360,4 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
);
},
},
{
id: 'misscheduled_nodes',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#misscheduled">
Misscheduled Nodes
</ColumnHeader>
),
accessorFn: (row): number => row.misscheduledNodes,
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const misscheduledNodes = value as number;
return (
<ValidateColumnValueWrapper
value={misscheduledNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="misscheduled node"
>
<TanStackTable.Text>{misscheduledNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -3,13 +3,3 @@
flex-direction: column;
gap: var(--spacing-8);
}
.tabLabel {
display: inline-flex;
align-items: center;
gap: 8px;
}
.tabBadge {
margin: 0;
}

View File

@@ -1,58 +1,30 @@
import { Badge } from '@signozhq/ui/badge';
import { Tabs } from '@signozhq/ui/tabs';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import { parseAsStringEnum, useQueryState } from 'nuqs';
import { MODEL_COSTS_TAB, TAB_KEY, UNPRICED_MODELS_TAB } from './constants';
import styles from './LLMObservabilityModelPricing.module.scss';
import ModelCostTabPanel from './ModelCostTabPanel';
import UnpricedModelsTab from './UnpricedModelsTab';
import styles from './LLMObservabilityModelPricing.module.scss';
function LLMObservabilityModelPricing(): JSX.Element {
const [activeTab, setActiveTab] = useQueryState(
TAB_KEY,
parseAsStringEnum([MODEL_COSTS_TAB, UNPRICED_MODELS_TAB]).withDefault(
MODEL_COSTS_TAB,
),
);
// Count powers the tab badge; deduped with the tab's own fetch by react-query.
const { data } = useListUnmappedLLMModels();
const unpricedCount = data?.data?.items?.length ?? 0;
return (
<div
className={styles.llmObservabilityModelPricing}
data-testid="llm-observability-model-pricing-page"
>
<Tabs
value={activeTab}
onChange={(key): void => {
void setActiveTab(key as typeof activeTab);
}}
// Model costs is the only enabled tab for now, so default to it. When
// the unpriced-models tab lands in a later PR.
defaultValue="model-costs"
items={[
{
key: MODEL_COSTS_TAB,
key: 'model-costs',
label: 'Model costs',
children: <ModelCostTabPanel />,
},
{
key: UNPRICED_MODELS_TAB,
label: (
<span className={styles.tabLabel}>
Unpriced models
{unpricedCount > 0 && (
<Badge
variant="default"
className={styles.tabBadge}
data-testid="unpriced-models-count"
>
{unpricedCount}
</Badge>
)}
</span>
),
children: <UnpricedModelsTab />,
// Unpriced-models tab lands in a later PR.
key: 'unpriced-models',
label: 'Unpriced models',
disabled: true,
children: null,
},
]}
/>

View File

@@ -3,7 +3,6 @@ import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
getListUnmappedLLMModelsQueryKey,
useCreateOrUpdateLLMPricingRules,
} from 'api/generated/services/llmpricingrules';
@@ -11,16 +10,9 @@ import {
EMPTY_DRAFT,
TOAST_MODEL_COST_ADDED,
TOAST_MODEL_COST_UPDATED,
} from 'container/LLMObservability/Settings/ModelPricing/constants';
import type {
DrawerDraft,
DrawerMode,
PricingRule,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import {
buildRulePayload,
draftFromRule,
} from 'container/LLMObservability/Settings/ModelPricing/utils';
} from '../../../../constants';
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
import { buildRulePayload, draftFromRule } from '../../../../utils';
interface UseModelCostDrawerResult {
isOpen: boolean;
@@ -46,26 +38,17 @@ export function useModelCostDrawer(): UseModelCostDrawerResult {
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
useCreateOrUpdateLLMPricingRules();
// Adding pricing can also resolve a model that was showing up as unpriced, so
// refresh the unmapped list (and its tab-badge count) alongside the rules list.
const invalidateList = useCallback(async (): Promise<void> => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
}),
queryClient.invalidateQueries({
queryKey: getListUnmappedLLMModelsQueryKey(),
}),
]);
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
}, [queryClient]);
// prefillModelName seeds the billing model ID when adding from an unpriced
// model row, so the user only has to fill in pricing.
const openForAdd = useCallback((prefillModelName?: string): void => {
const openForAdd = useCallback((): void => {
setMode('add');
setInitialDraft({
...EMPTY_DRAFT,
modelName: prefillModelName ?? '',
modelName: '',
patterns: [],
});
setSelectedRuleId(null);

View File

@@ -25,6 +25,7 @@
.drawerSurface {
padding: var(--spacing-7);
border-radius: 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
}

View File

@@ -1,27 +0,0 @@
.unpricedModelsTab {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.banner {
display: flex;
align-items: flex-start;
gap: var(--spacing-5);
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 30%, transparent);
background: color-mix(in srgb, var(--bg-amber-400) 8%, transparent);
color: var(--bg-amber-300, var(--bg-amber-400));
}
.bannerIcon {
flex-shrink: 0;
margin-top: var(--spacing-1);
}
.error {
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
}

View File

@@ -1,132 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { TriangleAlert } from '@signozhq/icons';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import styles from './UnpricedModelsTab.module.scss';
import ModelCostDrawer, {
useModelCostDrawer,
} from '../ModelCostTabPanel/components/ModelCostDrawer';
import type { PricingRule, UnpricedModel } from '../types';
import MapConfirmDialog from './components/MapConfirmDialog';
import type { UnpricedColumnsConfig } from './components/UnpricedModelsTable/TableConfig';
import UnpricedModelsTable from './components/UnpricedModelsTable';
import { useUnpricedModelMapping } from './hooks/useUnpricedModelMapping';
import { usePendingMappingStore } from './usePendingMappingStore';
function UnpricedModelsTab(): JSX.Element {
const { data, isLoading, isError } = useListUnmappedLLMModels();
const { user } = useAppContext();
const [canManagePricing] = useComponentPermission(
['manage_llm_pricing'],
user.role,
);
const models: UnpricedModel[] = useMemo(() => data?.data?.items || [], [data]);
// Picking a billing model stages a single mapping for confirmation; the
// mapping only commits once the user confirms in the dialog. Kept in a store
// (not local state) so the row's memoized select trigger can mirror the pick —
// see usePendingMappingStore.
const pendingMapping = usePendingMappingStore((state) => state.pending);
const setPending = usePendingMappingStore((state) => state.setPending);
const clearPending = usePendingMappingStore((state) => state.clearPending);
const { mapModel, isSaving } = useUnpricedModelMapping();
// Reset any staged mapping when leaving the tab so a stale pick doesn't reopen
// the dialog on remount (the store outlives this component).
useEffect(() => (): void => clearPending(), [clearPending]);
// Reuses the "Model costs" add/edit drawer to define brand-new pricing for a
// model that has no matching billing model to map onto. Saving resolves the
// model, so it drops off this tab (the drawer invalidates the unmapped list).
const drawer = useModelCostDrawer();
const onRequestMap = useCallback(
(model: UnpricedModel, rule: PricingRule): void => {
setPending({ model, rule });
},
[setPending],
);
const onConfirmMap = useCallback(async (): Promise<void> => {
if (!pendingMapping) {
return;
}
const didSave = await mapModel(pendingMapping);
if (didSave) {
clearPending();
}
}, [mapModel, pendingMapping, clearPending]);
// openForAdd is stable (useCallback with no deps); drawer itself is a fresh
// object each render, so depend on the method, not the object.
const { openForAdd } = drawer;
const onCreateNew = useCallback(
(modelName: string): void => openForAdd(modelName),
[openForAdd],
);
const columnsConfig = useMemo<UnpricedColumnsConfig>(
() => ({
canManage: canManagePricing,
onRequestMap,
onCreateNew,
}),
[canManagePricing, onRequestMap, onCreateNew],
);
return (
<div className={styles.unpricedModelsTab}>
<div className={styles.banner}>
<TriangleAlert size="sm" className={styles.bannerIcon} />
<Typography.Text as="span" size="small" color="warning">
Models detected in traces without pricing. Map each to a billing model or
create pricing so estimated cost can be computed.
</Typography.Text>
</div>
{isError && (
<div className={styles.error}>
<Typography.Text as="p" size="small" color="danger" role="alert">
Failed to load unpriced models. Please try again.
</Typography.Text>
</div>
)}
<UnpricedModelsTable
models={models}
isLoading={isLoading}
columnsConfig={columnsConfig}
/>
{pendingMapping && (
<MapConfirmDialog
open
model={pendingMapping.model}
rule={pendingMapping.rule}
isSaving={isSaving}
onConfirm={onConfirmMap}
onCancel={clearPending}
/>
)}
{drawer.isOpen && (
<ModelCostDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
initialDraft={drawer.initialDraft}
onClose={drawer.close}
onSave={drawer.save}
isSaving={drawer.isSaving}
saveError={drawer.saveError}
canManage={canManagePricing}
/>
)}
</div>
);
}
export default UnpricedModelsTab;

View File

@@ -1,180 +0,0 @@
import type { LlmpricingruletypesUpdatableLLMPricingRulesDTO } from 'api/generated/services/sigNoz.schemas';
import {
LLM_PRICING_ENDPOINT,
LLM_UNMAPPED_ENDPOINT,
makeListResponse,
makeUnmappedResponse,
mockRules,
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import UnpricedModelsTab from '../UnpricedModelsTab';
const toastSuccess = jest.fn();
const toastError = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: (...args: unknown[]): void => toastSuccess(...args),
error: (...args: unknown[]): void => toastError(...args),
},
}));
const MODEL = 'gpt-4o-mini-2024-07-18';
function setup(): void {
server.use(
rest.get(LLM_UNMAPPED_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeUnmappedResponse())),
),
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeListResponse(mockRules))),
),
);
}
// Picks a billing model in a row's dropdown: open the combobox, then click the
// option. The dropdown fetches its options from the rules list (mocked above).
async function selectRule(
user: ReturnType<typeof userEvent.setup>,
modelName: string,
ruleId: string,
): Promise<void> {
await user.click(screen.getByTestId(`map-to-select-${modelName}`));
await user.click(await screen.findByTestId(`map-to-option-${ruleId}`));
}
describe('UnpricedModelsTab (integration)', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
setup();
});
afterEach(() => {
server.resetHandlers();
toastSuccess.mockClear();
toastError.mockClear();
});
it('opens the confirm dialog with the target rule pricing when a model is picked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
// Picking a rule stages the mapping in the confirm dialog.
await selectRule(user, MODEL, 'rule-openai');
const confirmItem = await screen.findByTestId(
`unpriced-map-confirm-item-${MODEL}`,
);
expect(confirmItem).toBeInTheDocument();
// Shows the target billing model + its pricing so the user can eyeball it.
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
expect(screen.getByText('$3.00')).toBeInTheDocument();
expect(screen.getByText('$9.00')).toBeInTheDocument();
// While the dialog is open, the row's trigger mirrors the staged pick
// instead of the placeholder.
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
expect(
within(trigger).getByText('openai:gpt-4o ($3.00/$9.00)'),
).toBeInTheDocument();
});
it('reverts the row trigger to the placeholder when the mapping is cancelled', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
await selectRule(user, MODEL, 'rule-openai');
expect(
within(screen.getByTestId(`map-to-select-${MODEL}`)).getByText(
'openai:gpt-4o ($3.00/$9.00)',
),
).toBeInTheDocument();
await user.click(await screen.findByTestId('unpriced-map-cancel-btn'));
await waitFor(() => {
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
expect(
within(trigger).getByText('Select / Create a pricing model'),
).toBeInTheDocument();
});
});
it('commits the mapping in one request when confirmed', async () => {
const sent: LlmpricingruletypesUpdatableLLMPricingRulesDTO[] = [];
server.use(
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
sent.push(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
await selectRule(user, MODEL, 'rule-openai');
await user.click(await screen.findByTestId('unpriced-map-confirm-btn'));
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0].rules).toHaveLength(1);
expect(sent[0].rules?.[0].modelPattern).toContain(MODEL);
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith('Mapped model'),
);
// Dialog closes on success.
await waitFor(() =>
expect(
screen.queryByTestId(`unpriced-map-confirm-item-${MODEL}`),
).not.toBeInTheDocument(),
);
});
it('cancels the mapping without committing', async () => {
const sent: LlmpricingruletypesUpdatableLLMPricingRulesDTO[] = [];
server.use(
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
sent.push(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
await selectRule(user, MODEL, 'rule-openai');
await user.click(await screen.findByTestId('unpriced-map-cancel-btn'));
await waitFor(() =>
expect(
screen.queryByTestId(`unpriced-map-confirm-item-${MODEL}`),
).not.toBeInTheDocument(),
);
expect(sent).toHaveLength(0);
});
it('opens the add-cost drawer prefilled when creating pricing for a model', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
// Open the row's dropdown and take the "Create pricing for …" escape hatch
// instead of mapping onto an existing billing model.
await user.click(screen.getByTestId(`map-to-select-${MODEL}`));
await user.click(await screen.findByTestId(`map-to-create-${MODEL}`));
// The shared add-cost drawer opens with the model name prefilled.
const drawerTitle = await screen.findByText('Add model cost');
expect(drawerTitle).toBeInTheDocument();
expect(screen.getByTestId('drawer-model-id-input')).toHaveValue(MODEL);
});
});

View File

@@ -1,40 +0,0 @@
.body {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.mapping {
display: flex;
align-items: center;
gap: var(--spacing-4);
flex-wrap: wrap;
}
.arrow {
flex-shrink: 0;
opacity: 0.6;
}
.pricing {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-6);
border-radius: var(--radius-2);
border: 1px solid var(--l2-border);
background: var(--l3-background);
}
.pricingRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
}
.footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-4);
}

View File

@@ -1,124 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
import { ArrowRight, Check, Link2, X } from '@signozhq/icons';
import { startCase } from 'lodash-es';
import styles from './MapConfirmDialog.module.scss';
import type {
PricingRule,
UnpricedModel,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
} from 'container/LLMObservability/Settings/ModelPricing/utils';
interface MapConfirmDialogProps {
open: boolean;
model: UnpricedModel;
rule: PricingRule;
isSaving: boolean;
onConfirm: () => void;
onCancel: () => void;
}
// Per-row confirm step before mapping an unpriced model onto an existing billing
// model. Mapping appends the span's model name as a match pattern on the chosen
// rule, so the model inherits that rule's pricing — shown here so the user can
// eyeball the rates before committing. Dismissible (outside click / close), since
// mapping is reversible (re-map or edit the rule's patterns afterwards).
function MapConfirmDialog({
open,
model,
rule,
isSaving,
onConfirm,
onCancel,
}: MapConfirmDialogProps): JSX.Element {
const extraBuckets = getExtraBuckets(rule);
const pricingRows = [
{ key: 'input', label: 'Input / 1M', value: rule.pricing?.input },
{ key: 'output', label: 'Output / 1M', value: rule.pricing?.output },
...extraBuckets.map((bucket) => ({
key: bucket.key,
label: startCase(bucket.key),
value: bucket.pricePerMillion,
})),
];
const footer = (
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={onCancel}
disabled={isSaving}
prefix={<X size={12} />}
testId="unpriced-map-cancel-btn"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
loading={isSaving}
onClick={onConfirm}
prefix={<Check size={12} />}
testId="unpriced-map-confirm-btn"
>
Map model
</Button>
</div>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onCancel();
}
}}
width="base"
title="Map to billing model"
titleIcon={<Link2 size={16} />}
footer={footer}
testId="unpriced-map-confirm-dialog"
>
<div className={styles.body}>
<Typography.Text as="p" size="small" color="muted">
Spans from this model will be priced using the selected billing model.
</Typography.Text>
<div className={styles.mapping}>
<Typography.Text
weight="semibold"
testId={`unpriced-map-confirm-item-${model.modelName}`}
>
{model.modelName}
</Typography.Text>
<ArrowRight size={14} className={styles.arrow} />
<Typography.Text weight="semibold">{getCanonicalId(rule)}</Typography.Text>
</div>
<div className={styles.pricing}>
{pricingRows.map((row) => (
<div className={styles.pricingRow} key={row.key}>
<Typography.Text as="span" size="small" color="muted">
{row.label}
</Typography.Text>
<Typography.Text as="span" size="small" weight="semibold">
{formatPricePerMillion(row.value)}
</Typography.Text>
</div>
))}
</div>
</div>
</DialogWrapper>
);
}
export default MapConfirmDialog;

View File

@@ -1,27 +0,0 @@
.mapToCell {
display: flex;
align-items: center;
gap: var(--spacing-1);
min-width: 0;
}
.mapToSelect {
width: 100%;
max-width: 280px;
}
.mapToDropdown {
width: 280px;
}
.skeletonList {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-4);
}
.skeletonRow {
display: block;
width: 100%;
}

View File

@@ -1,138 +0,0 @@
import { useState } from 'react';
import {
Combobox,
ComboboxCommand,
ComboboxContent,
ComboboxCreateItem,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
} from '@signozhq/ui/combobox';
import { Plus } from '@signozhq/icons';
import { Skeleton } from 'antd';
import styles from './MapToBillingModelSelect.module.scss';
import { RULE_OPTIONS_LIMIT } from 'container/LLMObservability/Settings/ModelPricing/constants';
import type { PricingRule } from 'container/LLMObservability/Settings/ModelPricing/types';
import { getRuleOptionLabel } from 'container/LLMObservability/Settings/ModelPricing/utils';
import { usePendingMappingLabel } from 'container/LLMObservability/Settings/ModelPricing/UnpricedModelsTab/usePendingMappingStore';
import { useMapToBillingModelSearch } from './useMapToBillingModelSearch';
// One placeholder row per fetched option, so the skeleton height matches the
// loaded list. Stable keys derived from the fetch limit.
const SKELETON_ROW_KEYS = Array.from(
{ length: RULE_OPTIONS_LIMIT },
(_, index) => `skeleton-${index}`,
);
interface MapToBillingModelSelectProps {
modelName: string;
disabled: boolean;
onSelect: (rule: PricingRule) => void;
onCreateNew: () => void;
}
// Searchable, server-paged dropdown for picking the billing model an unpriced
// model maps onto. Only RULE_OPTIONS_LIMIT rules are fetched at a time; typing
// narrows the set via the rules API rather than client-side filtering, so cmdk's
// own filter is disabled (shouldFilter={false}). The dropdown is a pure picker —
// choosing a rule hands it up to the confirm dialog rather than persisting a
// selection here. The trigger only mirrors the staged pick (read from the
// pending-mapping store) while that dialog is open, reverting on confirm/cancel.
function MapToBillingModelSelect({
modelName,
disabled,
onSelect,
onCreateNew,
}: MapToBillingModelSelectProps): JSX.Element {
const [open, setOpen] = useState(false);
const { searchText, setSearchText, rules, rulesById, isFetching } =
useMapToBillingModelSearch(open);
const selectedLabel = usePendingMappingLabel(modelName);
const handleSelect = (ruleId: string): void => {
const rule = rulesById.get(ruleId);
if (rule) {
onSelect(rule);
}
setOpen(false);
};
const handleCreateNew = (): void => {
setOpen(false);
onCreateNew();
};
return (
<div className={styles.mapToCell}>
<Combobox open={open} onOpenChange={setOpen}>
<ComboboxTrigger
className={styles.mapToSelect}
disabled={disabled}
placeholder="Select / Create a pricing model"
value={selectedLabel}
testId={`map-to-select-${modelName}`}
/>
<ComboboxContent className={styles.mapToDropdown}>
<ComboboxCommand shouldFilter={false}>
<ComboboxInput
value={searchText}
onValueChange={setSearchText}
placeholder="Search billing models"
testId={`map-to-search-${modelName}`}
/>
<ComboboxList>
{rules.map((rule) => (
<ComboboxItem
key={rule.id}
value={rule.id}
onSelect={(): void => handleSelect(rule.id)}
data-testid={`map-to-option-${rule.id}`}
>
{getRuleOptionLabel(rule)}
</ComboboxItem>
))}
{isFetching && (
<div
className={styles.skeletonList}
data-testid={`map-to-loading-${modelName}`}
>
{SKELETON_ROW_KEYS.map((key) => (
<Skeleton.Input
key={key}
active
block
size="small"
className={styles.skeletonRow}
/>
))}
</div>
)}
{!isFetching && rules.length === 0 && (
<ComboboxEmpty>No billing models found</ComboboxEmpty>
)}
</ComboboxList>
{/* Kept outside ComboboxList so it stays pinned as a footer while the
options scroll. Escape hatch when no existing billing model fits:
define this model's own pricing rather than mapping onto another. */}
<ComboboxSeparator alwaysRender />
<ComboboxCreateItem
inputValue={modelName}
value={`create-pricing-${modelName}`}
prefix={<Plus size={14} />}
onSelect={handleCreateNew}
testId={`map-to-create-${modelName}`}
>
Create pricing for &quot;{modelName}&quot;
</ComboboxCreateItem>
</ComboboxCommand>
</ComboboxContent>
</Combobox>
</div>
);
}
export default MapToBillingModelSelect;

View File

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

View File

@@ -1,44 +0,0 @@
import { useMemo, useState } from 'react';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import useDebounce from 'hooks/useDebounce';
import {
RULE_OPTIONS_LIMIT,
SEARCH_DEBOUNCE_MS,
} from 'container/LLMObservability/Settings/ModelPricing/constants';
import type { PricingRule } from 'container/LLMObservability/Settings/ModelPricing/types';
interface UseMapToBillingModelSearch {
searchText: string;
setSearchText: (value: string) => void;
rules: PricingRule[];
// Fetched rules keyed by id, so a pick can resolve to its full rule object.
rulesById: Map<string, PricingRule>;
isFetching: boolean;
}
// Server-side search for the per-row "Map to billing model" dropdown. Only
// RULE_OPTIONS_LIMIT rules are fetched at a time; typing narrows the set via the
// rules API's `q` param instead of filtering client-side. The fetch is gated on
// `enabled` (the dropdown's open state) so closed rows don't fetch, and
// react-query dedupes identical query keys so rows sharing a term hit the network
// once.
export function useMapToBillingModelSearch(
enabled: boolean,
): UseMapToBillingModelSearch {
const [searchText, setSearchText] = useState('');
const debouncedSearch = useDebounce(searchText, SEARCH_DEBOUNCE_MS);
const { data, isFetching } = useListLLMPricingRules(
{ offset: 0, limit: RULE_OPTIONS_LIMIT, q: debouncedSearch || undefined },
{ query: { enabled } },
);
const rules = useMemo<PricingRule[]>(() => data?.data?.items || [], [data]);
const rulesById = useMemo(
() => new Map(rules.map((rule) => [rule.id, rule])),
[rules],
);
return { searchText, setSearchText, rules, rulesById, isFetching };
}

View File

@@ -1,2 +0,0 @@
export { getUnpricedModelsColumns } from './table.config';
export type { UnpricedColumnsConfig } from './table.config';

View File

@@ -1,88 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import type { TableColumnDef } from 'components/TanStackTableView';
import MapToBillingModelSelect from 'container/LLMObservability/Settings/ModelPricing/UnpricedModelsTab/components/MapToBillingModelSelect';
import type {
PricingRule,
UnpricedModel,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import { formatSpanCount } from 'container/LLMObservability/Settings/ModelPricing/utils';
import styles from './tableConfig.module.scss';
export interface UnpricedColumnsConfig {
canManage: boolean;
// Picking a billing model requests a mapping — the tab opens a confirm dialog
// and commits that single mapping there (no batch/selection state here).
onRequestMap: (model: UnpricedModel, rule: PricingRule) => void;
// Opens the add-cost drawer prefilled with the row's model name.
onCreateNew: (modelName: string) => void;
}
// Column definitions for the unpriced-models TanStackTable. Sorting is off — the
// unmapped-models endpoint returns the full set in one shot with no ordering knob.
// Each row's action is self-contained: pick a billing model to map onto (confirmed
// in a dialog) or create pricing for the model, so there's no bulk-save column.
export function getUnpricedModelsColumns({
canManage,
onRequestMap,
onCreateNew,
}: UnpricedColumnsConfig): TableColumnDef<UnpricedModel>[] {
return [
{
id: 'model',
header: 'Model (from spans)',
accessorFn: (row): string => row.modelName,
width: { min: 240, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text
weight="semibold"
truncate={1}
testId={`unpriced-model-name-${row.modelName}`}
>
{row.modelName}
</Typography.Text>
),
},
{
id: 'provider',
header: 'Provider',
width: { min: 140 },
enableMove: false,
cell: ({ row }): string => row.provider || 'Unknown',
},
{
id: 'spans',
header: 'Spans',
width: { min: 100 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Badge
color="cherry"
variant="outline"
className={styles.spansBadge}
data-testid={`unpriced-spans-${row.modelName}`}
>
{formatSpanCount(row.spanCount)}
</Badge>
),
},
{
id: 'mapTo',
header: 'Map to billing model',
width: { min: 280, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<MapToBillingModelSelect
modelName={row.modelName}
disabled={!canManage}
onSelect={(rule): void => onRequestMap(row, rule)}
onCreateNew={(): void => onCreateNew(row.modelName)}
/>
),
},
];
}

View File

@@ -1,4 +0,0 @@
.spansBadge {
margin: 0;
font-family: var(--code-font-family, monospace);
}

View File

@@ -1,22 +0,0 @@
// Capped scroll viewport — mirrors ModelCostsTable so the unpriced tab doesn't
// shift on load, but uses max-height so a short list stays compact instead of
// reserving the full viewport. The extra offset over the model-costs table
// accounts for the banner toolbar above.
.unpricedModelsTable {
--tanstack-table-row-height: 48px;
max-height: calc(100vh - 220px);
overflow-y: auto;
:global(table) tbody tr {
cursor: default;
}
}
.unpricedModelsEmpty {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,56 +0,0 @@
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { SKELETON_ROW_COUNT } from 'container/LLMObservability/Settings/ModelPricing/constants';
import type { UnpricedModel } from 'container/LLMObservability/Settings/ModelPricing/types';
import styles from './UnpricedModelsTable.module.scss';
import {
getUnpricedModelsColumns,
type UnpricedColumnsConfig,
} from './TableConfig';
interface UnpricedModelsTableProps {
models: UnpricedModel[];
isLoading: boolean;
columnsConfig: UnpricedColumnsConfig;
}
// The unmapped-models endpoint returns the full set in one response, so there's
// no pagination here — just a content-height list. Virtual scroll is disabled
// because the set is small and bounded.
function UnpricedModelsTable({
models,
isLoading,
columnsConfig,
}: UnpricedModelsTableProps): JSX.Element {
const columns = useMemo(
() => getUnpricedModelsColumns(columnsConfig),
[columnsConfig],
);
if (!isLoading && models.length === 0) {
return (
<div
className={styles.unpricedModelsEmpty}
data-testid="unpriced-models-empty"
>
All models in your traces are priced.
</div>
);
}
return (
<TanStackTable<UnpricedModel>
className={styles.unpricedModelsTable}
data={models}
columns={columns}
isLoading={isLoading}
skeletonRowCount={SKELETON_ROW_COUNT}
getRowKey={(row): string => row.modelName}
disableVirtualScroll
testId="unpriced-models-table"
/>
);
}
export default UnpricedModelsTable;

View File

@@ -1,70 +0,0 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
getListUnmappedLLMModelsQueryKey,
useCreateOrUpdateLLMPricingRules,
} from 'api/generated/services/llmpricingrules';
import type {
PricingRule,
UnpricedModel,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import { buildPatternMappingPayload } from 'container/LLMObservability/Settings/ModelPricing/utils';
// A single row's choice: map this unpriced model onto this billing rule.
export interface UnpricedModelMapping {
model: UnpricedModel;
rule: PricingRule;
}
interface UseUnpricedModelMappingResult {
// Commits a single mapping in one request. Resolves true on success so the
// caller can close the confirm dialog and clear its staged pick.
mapModel: (mapping: UnpricedModelMapping) => Promise<boolean>;
// True while the save is in flight, so the confirm button can spin.
isSaving: boolean;
}
// Maps an unpriced model onto an existing pricing rule. There's no dedicated
// "edit" endpoint — mapping reuses CreateOrUpdate (PUT), appending the model name
// as a match pattern on the chosen rule so the model inherits its pricing. Both
// the unmapped list and the rules list are invalidated on success so the mapped
// model drops out of this tab immediately.
export function useUnpricedModelMapping(): UseUnpricedModelMappingResult {
const queryClient = useQueryClient();
const [isSaving, setIsSaving] = useState(false);
const { mutateAsync: createOrUpdate } = useCreateOrUpdateLLMPricingRules();
const mapModel = useCallback(
async ({ model, rule }: UnpricedModelMapping): Promise<boolean> => {
const payload = buildPatternMappingPayload(rule, model.modelName);
setIsSaving(true);
try {
await createOrUpdate({ data: { rules: [payload] } });
await Promise.all([
queryClient.invalidateQueries({
queryKey: getListUnmappedLLMModelsQueryKey(),
}),
queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
}),
]);
toast.success('Mapped model');
return true;
} catch (error) {
const message = error instanceof Error ? error.message : 'Mapping failed';
toast.error(message);
return false;
} finally {
setIsSaving(false);
}
},
[createOrUpdate, queryClient],
);
return { mapModel, isSaving };
}

View File

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

View File

@@ -1,39 +0,0 @@
import { create } from 'zustand';
import type { PricingRule, UnpricedModel } from '../types';
import { getRuleOptionLabel } from '../utils';
// A model + the billing rule it's about to be mapped onto, held while the confirm
// dialog is open.
export interface PendingMapping {
model: UnpricedModel;
rule: PricingRule;
}
interface PendingMappingState {
pending: PendingMapping | null;
setPending: (mapping: PendingMapping) => void;
clearPending: () => void;
}
// The staged mapping lives in a store (not component state/props) because the
// row's select trigger, which mirrors the staged pick, sits inside a memoized
// TanStackTable cell whose value is cached per row — a prop/context change would
// not re-render it, but a store subscription does. Picking a billing model stages
// a single mapping for confirmation; it only commits once confirmed in the dialog.
export const usePendingMappingStore = create<PendingMappingState>((set) => ({
pending: null,
setPending: (mapping): void => set({ pending: mapping }),
clearPending: (): void => set({ pending: null }),
}));
// Label to show on the given row's select trigger while its mapping is staged, or
// undefined when nothing is staged for that row (falls back to the placeholder).
export const usePendingMappingLabel = (modelName: string): string | undefined =>
usePendingMappingStore((state) => {
const { pending } = state;
if (!pending || pending.model.modelName !== modelName) {
return undefined;
}
return getRuleOptionLabel(pending.rule);
});

View File

@@ -2,19 +2,14 @@ import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type ListLLMPricingRules200,
type ListUnmappedLLMModels200,
} from 'api/generated/services/sigNoz.schemas';
import type { PricingRule, UnpricedModel } from '../types';
import type { PricingRule } from '../types';
// Endpoint glob used by MSW handlers. The generated client hits a relative
// `/api/v1/llm_pricing_rules`, so the `*` prefix matches regardless of base URL.
export const LLM_PRICING_ENDPOINT = '*/api/v1/llm_pricing_rules';
export const LLM_PRICING_RULE_ENDPOINT = '*/api/v1/llm_pricing_rules/:id';
// Distinct path (extra segment), so it needs its own handler — the list glob
// above does not match it.
export const LLM_UNMAPPED_ENDPOINT =
'*/api/v1/llm_pricing_rules/unmapped_models';
// Builds a valid pricing rule, with overrides merged shallowly. Pricing is
// replaced wholesale when provided so callers can shape cache buckets freely.
@@ -65,26 +60,6 @@ export const mockRules: PricingRule[] = [
}),
];
// Unpriced models seen in traces with no matching pricing rule.
export const mockUnpricedModels: UnpricedModel[] = [
{ modelName: 'gpt-4o-mini-2024-07-18', provider: 'openai', spanCount: 18400 },
{
modelName: 'claude-3-7-sonnet-20250219',
provider: 'anthropic',
spanCount: 9200,
},
];
// Wraps unpriced models in the envelope the unmapped-models query reads.
export function makeUnmappedResponse(
items: UnpricedModel[] = mockUnpricedModels,
): ListUnmappedLLMModels200 {
return {
status: 'success',
data: { items },
};
}
// Wraps items in the list response envelope the list query reads
// (`data.data.items` / `data.data.total`).
export function makeListResponse(

View File

@@ -34,13 +34,6 @@ export const SOURCE_FILTER_TO_IS_OVERRIDE: Record<
// loaded page renders — otherwise the table height jumps on load.
export const SKELETON_ROW_COUNT = PAGE_SIZE;
export const RULE_OPTIONS_LIMIT = 10;
// URL-backed key for the active tab on the model-pricing page.
export const TAB_KEY = 'tab';
export const MODEL_COSTS_TAB = 'model-costs';
export const UNPRICED_MODELS_TAB = 'unpriced-models';
export const PROVIDER_OPTIONS = [
{ value: 'OpenAI', label: 'OpenAI' },
{ value: 'Anthropic', label: 'Anthropic' },
@@ -69,7 +62,6 @@ export const EMPTY_DRAFT: DrawerDraft = {
provider: 'OpenAI',
patterns: [],
isOverride: true,
enabled: true,
pricing: {
input: null,
output: null,

View File

@@ -1,14 +1,10 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
type LlmpricingruletypesLLMPricingRuleDTO,
type LlmpricingruletypesUnmappedModelDTO,
} from 'api/generated/services/sigNoz.schemas';
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
// A model seen in trace data (gen_ai.request.model) that no pricing rule matches.
export type UnpricedModel = LlmpricingruletypesUnmappedModelDTO;
export interface ExtraBucket {
key: string;
pricePerMillion: number;
@@ -33,7 +29,6 @@ export interface DrawerDraft {
provider: string;
patterns: string[];
isOverride: boolean;
enabled: boolean;
pricing: {
input: number | null;
output: number | null;

View File

@@ -86,7 +86,6 @@ export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
provider: rule.provider,
patterns: rule.modelPattern || [],
isOverride: !!rule.isOverride,
enabled: rule.enabled,
pricing: {
input: rule.pricing?.input ?? 0,
output: rule.pricing?.output ?? 0,
@@ -130,7 +129,7 @@ export const buildRulePayload = (
provider: draft.provider.trim(),
modelPattern: draft.patterns,
isOverride: draft.isOverride,
enabled: draft.enabled,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: buildPricingPayload(draft),
});
@@ -162,38 +161,3 @@ export const validatePricing = (
}
return true;
};
const spanCountFormatter = new Intl.NumberFormat('en', {
notation: 'compact',
maximumFractionDigits: 1,
});
export const formatSpanCount = (count: number): string =>
spanCountFormatter.format(count);
// Label for the "Map to billing model" dropdown, e.g. "openai:gpt-4o ($15.00/$60.00)".
export const getRuleOptionLabel = (rule: PricingRule): string =>
`${getCanonicalId(rule)} (${formatPricePerMillion(
rule.pricing?.input,
)}/${formatPricePerMillion(rule.pricing?.output)})`;
export const buildPatternMappingPayload = (
rule: PricingRule,
modelName: string,
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => {
const existing = rule.modelPattern ?? [];
const modelPattern = existing.includes(modelName)
? existing
: [...existing, modelName];
return {
id: rule.id,
sourceId: rule.sourceId,
modelName: rule.modelName,
provider: rule.provider,
modelPattern,
isOverride: rule.isOverride,
enabled: rule.enabled,
unit: rule.unit,
pricing: rule.pricing,
};
};

View File

@@ -191,6 +191,14 @@
min-height: 0;
overflow-y: visible;
.time-series-view-container-header {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 12px;
flex-shrink: 0;
}
.time-series-view {
flex-shrink: 0;
height: 65vh;

View File

@@ -32,6 +32,7 @@ import {
getListQuery,
getQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
@@ -460,17 +461,18 @@ function LogsExplorerViewsContainer({
)}
{selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && (
<div className="time-series-view-container">
<div className="time-series-view-container-header">
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
</div>
<TimeSeriesView
isLoading={isLoading || isFetching}
data={data}
isError={isError}
error={error as APIError}
yAxisUnit={yAxisUnit}
onYAxisUnitChange={onUnitChange}
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
dataSource={DataSource.LOGS}
setWarning={setWarning}
allowExport
/>
</div>
)}

View File

@@ -311,7 +311,6 @@ function TimeSeries({
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
allowExport
/>
</div>
);

View File

@@ -3,14 +3,6 @@
min-height: 350px;
padding: 0px 12px;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
flex-shrink: 0;
}
.ant-card-body {
height: 50vh;
min-height: 350px;

View File

@@ -16,7 +16,6 @@ import Uplot from 'components/Uplot';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
@@ -42,14 +41,11 @@ import { SuccessResponse, Warning } from 'types/api';
import { LegendPosition } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import TimeseriesExportMenu from './TimeseriesExportMenu';
import './TimeSeriesView.styles.scss';
function TimeSeriesView({
@@ -63,8 +59,6 @@ function TimeSeriesView({
setWarning,
panelType = PANEL_TYPES.TIME_SERIES,
stackBarChart = false,
allowExport = false,
onYAxisUnitChange,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -250,33 +244,10 @@ function TimeSeriesView({
[baseChartOptions, stackedBands],
);
const showExport = allowExport && !!data?.rawV5Response;
const showHeader = showExport || !!onYAxisUnitChange;
return (
<div className="time-series-view">
{isError && error && <ErrorInPlace error={error as APIError} />}
{showHeader && (
<div className="time-series-view__header">
<div>
{onYAxisUnitChange && (
<BuilderUnitsFilter onChange={onYAxisUnitChange} yAxisUnit={yAxisUnit} />
)}
</div>
{showExport && data?.rawV5Response && (
<TimeseriesExportMenu
dataSource={dataSource}
yAxisUnit={yAxisUnit}
queryResponse={data.rawV5Response}
query={currentQuery}
legendMap={data.legendMap}
fileName={`${dataSource}-timeseries`}
/>
)}
</div>
)}
<div
className="graph-container"
style={{ height: '100%', width: '100%' }}
@@ -324,11 +295,7 @@ function TimeSeriesView({
}
interface TimeSeriesViewProps {
data?: SuccessResponse<MetricRangePayloadProps> & {
warning?: Warning;
rawV5Response?: QueryRangeResponseV5;
legendMap?: Record<string, string>;
};
data?: SuccessResponse<MetricRangePayloadProps> & { warning?: Warning };
yAxisUnit?: string;
isLoading: boolean;
isError: boolean;
@@ -338,11 +305,6 @@ interface TimeSeriesViewProps {
setWarning?: Dispatch<SetStateAction<Warning | undefined>>;
panelType?: PANEL_TYPES;
stackBarChart?: boolean;
// Opt-in: render the client-side export menu (Logs explorer for now).
allowExport?: boolean;
// Opt-in: render the y-axis unit selector in the header (views without their
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
onYAxisUnitChange?: (value: string) => void;
}
TimeSeriesView.defaultProps = {

View File

@@ -1,33 +0,0 @@
.timeseries-export-popover {
width: 240px;
padding: 0 12px 12px 12px;
.title {
display: flex;
color: var(--l1-foreground);
font-family: Inter;
font-size: var(--periscope-font-size-small);
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
margin-bottom: 8px;
}
.export-format {
padding: 12px 4px;
display: flex;
flex-direction: column;
// radio option labels — radix popover content inherits the root font
// size; pin to the app's 13px base the antd popover used to impose
label {
font-size: var(--periscope-font-size-base);
}
}
.export-button {
width: 100%;
}
}

View File

@@ -1,91 +0,0 @@
import { Download } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { useClientExport } from 'hooks/useExportData/useClientExport';
import { ExportFormat } from 'lib/exportData/types';
import { useCallback, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import './TimeseriesExportMenu.styles.scss';
interface TimeseriesExportMenuProps {
dataSource: DataSource;
queryResponse: QueryRangeResponseV5;
query?: Query;
yAxisUnit?: string;
legendMap?: Record<string, string>;
fileName?: string;
}
// Download menu for in-memory timeseries data (client-side serialization).
// The raw/list backend export keeps its own menu in DownloadOptionsMenu.
export default function TimeseriesExportMenu({
dataSource,
queryResponse,
query,
yAxisUnit,
legendMap,
fileName,
}: TimeseriesExportMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const { isExporting, handleExport: handleClientExport } = useClientExport({
response: queryResponse,
query,
yAxisUnit,
legendMap,
fileName,
});
const handleExport = useCallback((): void => {
setIsPopoverOpen(false);
handleClientExport({ format: exportFormat as ExportFormat });
}, [exportFormat, handleClientExport]);
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<TooltipSimple title="Download">
<PopoverTrigger asChild>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Download"
data-testid={`timeseries-export-${dataSource}`}
disabled={isExporting}
loading={isExporting}
>
<Download size={14} />
</Button>
</PopoverTrigger>
</TooltipSimple>
<PopoverContent align="end" className="timeseries-export-popover">
<div className="export-format">
<Typography.Text className="title">FORMAT</Typography.Text>
<RadioGroup value={exportFormat} onChange={setExportFormat}>
<RadioGroupItem value={ExportFormat.Csv}>csv</RadioGroupItem>
<RadioGroupItem value={ExportFormat.Jsonl}>jsonl</RadioGroupItem>
</RadioGroup>
</div>
<Button
variant="solid"
color="primary"
className="export-button"
onClick={handleExport}
disabled={isExporting}
loading={isExporting}
prefix={<Download size={16} />}
>
Export
</Button>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,138 +0,0 @@
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import store from 'store';
import { DataSource } from 'types/common/queryBuilder';
import TimeSeriesView from '../TimeSeriesView';
jest.mock('components/Uplot', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="uplot-chart" />,
}));
jest.mock('../TimeseriesExportMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="timeseries-export-menu" />,
}));
jest.mock('container/QueryBuilder/filters/BuilderUnitsFilter', () => ({
BuilderUnitsFilter: (): JSX.Element => (
<div data-testid="builder-units-filter" />
),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({ currentQuery: null }),
}));
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
getUPlotChartOptions: (): unknown => ({}),
}));
jest.mock('lib/uPlotLib/utils/getUplotChartData', () => ({
getUPlotChartData: (): number[][] => [
[1, 2],
[3, 4],
],
}));
jest.mock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
() => ({ stackSeries: (): unknown => ({ data: [], bands: [] }) }),
);
jest.mock('container/GridCardLayout/GridCard/utils', () => ({
getLocalStorageGraphVisibilityState: (): unknown => ({
graphVisibilityStates: [],
}),
}));
jest.mock('providers/Timezone', () => ({
useTimezone: (): unknown => ({ timezone: { value: 'UTC' } }),
}));
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: (): unknown => ({ width: 800, height: 400 }),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockStore = configureStore([])({ ...store.getState() });
const rawV5Response = {
type: 'time_series',
data: { results: [] },
meta: {},
};
function makeData(withRawV5: boolean): any {
return {
statusCode: 200,
error: null,
message: '',
payload: { data: { result: [], resultType: '' } },
...(withRawV5 ? { rawV5Response, legendMap: {} } : {}),
};
}
function renderView(props: {
allowExport?: boolean;
withRawV5?: boolean;
onYAxisUnitChange?: (value: string) => void;
}): ReturnType<typeof render> {
const { allowExport, withRawV5 = true, onYAxisUnitChange } = props;
return render(
<Provider store={mockStore}>
<MemoryRouter>
<TimeSeriesView
isLoading={false}
isError={false}
isFilterApplied
dataSource={DataSource.LOGS}
data={makeData(withRawV5)}
allowExport={allowExport}
onYAxisUnitChange={onYAxisUnitChange}
/>
</MemoryRouter>
</Provider>,
);
}
describe('TimeSeriesView header gating', () => {
it('renders the export menu when allowExport is set and raw V5 data is present', () => {
const { queryByTestId } = renderView({ allowExport: true });
expect(queryByTestId('timeseries-export-menu')).toBeInTheDocument();
});
it('renders no export menu without allowExport', () => {
const { queryByTestId } = renderView({});
expect(queryByTestId('timeseries-export-menu')).not.toBeInTheDocument();
});
it('renders no export menu when the raw V5 response is missing', () => {
const { queryByTestId } = renderView({ allowExport: true, withRawV5: false });
expect(queryByTestId('timeseries-export-menu')).not.toBeInTheDocument();
});
it('renders the unit selector only when onYAxisUnitChange is passed', () => {
const withUnit = renderView({ onYAxisUnitChange: jest.fn() });
expect(withUnit.queryByTestId('builder-units-filter')).toBeInTheDocument();
withUnit.unmount();
const withoutUnit = renderView({ allowExport: true });
expect(
withoutUnit.queryByTestId('builder-units-filter'),
).not.toBeInTheDocument();
});
it('renders no header row when neither export nor unit selector is enabled', () => {
const { container } = renderView({ withRawV5: false });
expect(container.querySelector('.time-series-view__header')).toBeNull();
});
});

View File

@@ -1,84 +0,0 @@
import { fireEvent, render, screen } from 'tests/test-utils';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import TimeseriesExportMenu from '../TimeseriesExportMenu';
const mockHandleExport = jest.fn();
let mockIsExporting = false;
jest.mock('hooks/useExportData/useClientExport', () => ({
useClientExport: (): unknown => ({
isExporting: mockIsExporting,
handleExport: mockHandleExport,
}),
}));
const response = {
type: 'time_series',
data: { results: [] },
meta: {},
} as unknown as QueryRangeResponseV5;
const TEST_ID = `timeseries-export-${DataSource.LOGS}`;
function renderMenu(): void {
render(
<TimeseriesExportMenu
dataSource={DataSource.LOGS}
queryResponse={response}
fileName="logs-timeseries"
/>,
);
}
describe('TimeseriesExportMenu', () => {
beforeEach(() => {
mockHandleExport.mockReset();
mockIsExporting = false;
});
it('renders the download trigger button', () => {
renderMenu();
expect(screen.getByTestId(TEST_ID)).toBeInTheDocument();
});
it('shows only format options — no shape, row-count, or column controls', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'csv' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'jsonl' })).toBeInTheDocument();
expect(screen.queryByText('Number of Rows')).not.toBeInTheDocument();
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'long' })).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'wide' })).not.toBeInTheDocument();
});
it('exports as csv by default', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
fireEvent.click(screen.getByText('Export'));
expect(mockHandleExport).toHaveBeenCalledTimes(1);
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'csv' });
});
it('exports as jsonl when selected', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
fireEvent.click(screen.getByRole('radio', { name: 'jsonl' }));
fireEvent.click(screen.getByText('Export'));
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'jsonl' });
});
it('disables the trigger while an export is in progress', () => {
mockIsExporting = true;
renderMenu();
expect(screen.getByTestId(TEST_ID)).toBeDisabled();
});
});

View File

@@ -1,41 +1,10 @@
import { SuccessResponse } from 'types/api/index';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
type ConvertibleData = SuccessResponse<MetricRangePayloadProps> & {
rawV5Response?: QueryRangeResponseV5;
};
// Applies the same ns→ms conversion to the raw V5 tree, so client-side export
// serializes the values the chart displays (not the original nanoseconds).
function convertRawV5ValuesToMs(
response: QueryRangeResponseV5,
): QueryRangeResponseV5 {
if (response.type !== 'time_series') {
return response;
}
const results = (response.data.results as TimeSeriesData[]).map((result) => ({
...result,
aggregations: (result.aggregations ?? []).map((bucket) => ({
...bucket,
series: (bucket.series ?? []).map((series) => ({
...series,
values: (series.values ?? []).map((value) => ({
...value,
value: value.value / 1000000,
})),
})),
})),
}));
return { ...response, data: { ...response.data, results } };
}
export const convertDataValueToMs = (
data?: ConvertibleData,
): ConvertibleData | undefined => {
data?: SuccessResponse<MetricRangePayloadProps>,
): SuccessResponse<MetricRangePayloadProps> | undefined => {
const convertedData = data;
const convertedResult: QueryData[] = data?.payload?.data?.result
@@ -53,11 +22,5 @@ export const convertDataValueToMs = (
convertedData.payload.data.result = convertedResult;
}
if (convertedData?.rawV5Response) {
convertedData.rawV5Response = convertRawV5ValuesToMs(
convertedData.rawV5Response,
);
}
return convertedData;
};

View File

@@ -4,8 +4,6 @@ export function useIntersectionObserver<T extends HTMLElement>(
ref: RefObject<T>,
options?: IntersectionObserverInit,
isObserverOnce?: boolean,
/** Defer observation by this many ms to let a transient mount layout settle. */
startDelayMs = 0,
): boolean {
const [isIntersecting, setIntersecting] = useState(false);
@@ -25,28 +23,16 @@ export function useIntersectionObserver<T extends HTMLElement>(
}
}, options);
const startObserving = (): void => {
if (currentReference) {
observer.observe(currentReference);
}
};
let timer: ReturnType<typeof setTimeout> | undefined;
if (startDelayMs > 0) {
timer = setTimeout(startObserving, startDelayMs);
} else {
startObserving();
if (currentReference) {
observer.observe(currentReference);
}
return (): void => {
if (timer) {
clearTimeout(timer);
}
if (currentReference) {
observer.unobserve(currentReference);
}
};
}, [ref, options, isObserverOnce, startDelayMs]);
}, [ref, options, isObserverOnce]);
return isIntersecting;
}

View File

@@ -22,11 +22,7 @@ import { SuccessResponseV2, Warning } from 'types/api';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import {
ExecStats,
MetricRangePayloadV5,
QueryRangeResponseV5,
} from 'types/api/v5/queryRange';
import { ExecStats, MetricRangePayloadV5 } from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
@@ -196,8 +192,6 @@ export async function GetMetricQueryRange(
| SuccessResponseV2<MetricRangePayloadV5>;
let warning: Warning | undefined;
let meta: ExecStats | undefined;
// Raw V5 response, kept before it's converted to legacy — powers client-side export.
let rawV5Response: QueryRangeResponseV5 | undefined;
const panelType = props.originalGraphType || props.graphType;
@@ -274,8 +268,6 @@ export async function GetMetricQueryRange(
endTime: props.end * 1000,
});
rawV5Response = publicResponse.data.data;
// Convert V5 response to legacy format for components
response = convertV5ResponseToLegacy(
{
@@ -296,8 +288,6 @@ export async function GetMetricQueryRange(
headers,
);
rawV5Response = v5Response.data.data;
// Convert V5 response to legacy format for components
response = convertV5ResponseToLegacy(
{
@@ -376,8 +366,6 @@ export async function GetMetricQueryRange(
...response,
warning,
meta,
rawV5Response,
legendMap,
};
}

View File

@@ -3,7 +3,6 @@ import { EQueryType } from 'types/common/dashboard';
import {
buildVariableReferencePattern,
containsAnyVariableReference,
extractQueryTextStrings,
getVariableReferencesInQuery,
textContainsVariableReference,
@@ -449,25 +448,3 @@ describe('getVariableReferencesInQuery', () => {
expect(getVariableReferencesInQuery(query, [])).toStrictEqual([]);
});
});
describe('containsAnyVariableReference', () => {
it.each([
['SELECT count() FROM t WHERE service = $service.name', true],
['up{env="$deployment_environment"}', true],
['{{.service_name}}', true],
['{{ service_name }}', true],
['[[service_name]]', true],
['$_private', true],
])('detects a reference in %p', (text, expected) => {
expect(containsAnyVariableReference(text)).toBe(expected);
});
it.each([
['SELECT count() FROM t WHERE x = 1', false],
['rate(http_requests[$__interval])', false],
['SELECT $1 FROM t', false],
['', false],
])('does not falsely match %p', (text, expected) => {
expect(containsAnyVariableReference(text)).toBe(expected);
});
});

View File

@@ -1,4 +1,4 @@
import { escapeRegExp, isArray } from 'lodash-es';
import { isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
@@ -33,23 +33,6 @@ export function textContainsVariableReference(
return buildVariableReferencePattern(variableName).test(text);
}
/**
* Matches *any* variable reference in a recognized syntax without knowing the
* name: `{{name}}`, `{{.name}}`, `[[name]]`, or `$name`. The `$` form excludes
* `$__…` macros and positional `$1` so built-ins don't read as variables.
*/
const ANY_VARIABLE_REFERENCE =
/\{\{\s*\.?[\w.]+\s*\}\}|\[\[\s*[\w.]+\s*\]\]|\$(?!__)[a-zA-Z_][\w.]*/;
/**
* Returns true if `text` contains a reference to any variable. Use when the set
* of variable names isn't known yet (e.g. before the fetch context initializes),
* so a name-based {@link textContainsVariableReference} check can't run.
*/
export function containsAnyVariableReference(text: string): boolean {
return !!text && ANY_VARIABLE_REFERENCE.test(text);
}
/**
* Extracts all text strings from a widget Query that could contain variable
* references. Covers:
@@ -151,52 +134,3 @@ export function getVariableReferencesInQuery(
texts.some((text) => textContainsVariableReference(text, name)),
);
}
/**
* Rewrites every reference to `oldName` in `text` to `newName`, preserving the
* surrounding syntax for each recognized form ({{.x}}, {{x}}, $x, [[x]]). Used
* when a variable is renamed so its usages across queries stay valid.
*/
export function rewriteVariableReferences(
text: string,
oldName: string,
newName: string,
): string {
if (!text || !oldName || oldName === newName) {
return text;
}
const name = escapeRegExp(oldName);
return text
.replace(
new RegExp(`(\\{\\{\\s*?\\.)${name}(\\s*?\\}\\})`, 'g'),
`$1${newName}$2`,
)
.replace(new RegExp(`(\\{\\{\\s*)${name}(\\s*\\}\\})`, 'g'), `$1${newName}$2`)
.replace(new RegExp(`\\$${name}\\b`, 'g'), `$${newName}`)
.replace(
new RegExp(`(\\[\\[\\s*)${name}(\\s*\\]\\])`, 'g'),
`$1${newName}$2`,
);
}
/**
* Best-effort removal of the clause that references `variableName` from an
* ` AND `-joined filter expression (e.g. a builder query's `filter.expression`).
* Any top-level `AND` part that references the variable is dropped. It does not
* understand `OR`/nested parentheses, so it is a starting point the user reviews
* before applying — never an automatic edit of raw PromQL/ClickHouse.
*/
export function removeVariableReferenceClause(
expression: string,
variableName: string,
): string {
if (!expression) {
return expression;
}
return expression
.split(' AND ')
.map((part) => part.trim())
.filter(Boolean)
.filter((part) => !textContainsVariableReference(part, variableName))
.join(' AND ');
}

View File

@@ -81,18 +81,6 @@ describe('exportTimeseriesData', () => {
]);
});
it('omits display-only format ids (short/none) from headers', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'a' }, [[1000, 1]])] }]),
];
const short = exportTimeseriesData({ data, yAxisUnit: 'short' });
expect(short.headers[short.headers.length - 1]).toBe('value');
const none = exportTimeseriesData({ data, yAxisUnit: 'none' });
expect(none.headers[none.headers.length - 1]).toBe('value');
});
it('multi-query: query is its own column; label keys are unioned', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'x' }, [[1000, 1]])] }]),

View File

@@ -98,16 +98,9 @@ function flatten(
return flat;
}
// Display-format ids, not physical units — meaningful on a chart axis
// (compact-number formatting) but misleading in an export header.
const DISPLAY_ONLY_UNITS = new Set(['short', 'none']);
// Appends the y-axis unit to the value header: `value` → `value (ms)`.
function withUnit(header: string, yAxisUnit?: string): string {
if (!yAxisUnit || DISPLAY_ONLY_UNITS.has(yAxisUnit)) {
return header;
}
return `${header} (${yAxisUnit})`;
return yAxisUnit ? `${header} (${yAxisUnit})` : header;
}
function toIso(timestamp: number): string {

View File

@@ -11,6 +11,7 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { CreateAlertProvider } from 'container/CreateAlertV2/context';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { useAlertRule } from 'providers/Alert';
@@ -85,11 +86,18 @@ function AlertDetails(): JSX.Element {
return <Spinner />;
}
// Anomaly rules are stored with a metric alertType; the editor's alert
// type is derived from the ruleType so the anomaly condition is shown.
const initialAlertType =
alertRuleDetails?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT
? AlertTypes.ANOMALY_BASED_ALERT
: (alertRuleDetails?.alertType as AlertTypes);
return (
<CreateAlertProvider
ruleId={ruleId || ''}
isEditMode
initialAlertType={alertRuleDetails?.alertType as AlertTypes}
initialAlertType={initialAlertType}
initialAlertState={initialAlertState}
>
<div

View File

@@ -1,86 +0,0 @@
.body {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 60vh;
overflow-y: auto;
}
.intro {
color: var(--l2-foreground);
font-size: 13px;
line-height: 1.5;
}
.rows {
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l1-background);
}
.rowHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sourceLabel {
color: var(--l1-foreground);
font-weight: 600;
font-size: 13px;
}
.kindTag {
color: var(--l2-foreground);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border: 1px solid var(--l2-border);
border-radius: 4px;
white-space: nowrap;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.fieldLabel {
color: var(--l2-foreground);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.textArea {
font-family: var(--font-mono, monospace);
font-size: 12px;
}
.disabled {
opacity: 0.6;
}
.warning {
color: var(--warning-foreground, #d97706);
font-size: 11px;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -1,159 +0,0 @@
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea + Checkbox have no @signozhq/ui equivalent yet
import { Checkbox, Input as AntdInput } from 'antd';
import cx from 'classnames';
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableImpactMode, VariableUsage } from '../variableUsages';
import { useVariableImpactState } from './useVariableImpactState';
import styles from './VariableImpactDialog.module.scss';
const KIND_LABEL: Record<VariableUsage['kind'], string> = {
builder: 'Query builder',
promql: 'PromQL',
clickhouse: 'ClickHouse',
variable: 'Variable',
};
interface VariableImpactDialogProps {
open: boolean;
mode: VariableImpactMode;
/** The variable being renamed/deleted (its current name). */
variableName: string;
/** The new name (rename mode only). */
newName?: string;
usages: VariableUsage[];
isLoading: boolean;
onConfirm: (resolvedUsages: VariableUsage[]) => void;
onClose: () => void;
}
/**
* Blocks a rename/delete of a referenced variable behind a review step: lists
* every usage across panel queries (builder / PromQL / ClickHouse) and other
* variables, shows the current vs resulting query, and lets the user edit each
* result or exclude it before applying.
*/
function VariableImpactDialog({
open,
mode,
variableName,
newName,
usages,
isLoading,
onConfirm,
onClose,
}: VariableImpactDialogProps): JSX.Element {
const { rows, setResultingText, toggleIncluded, resolvedUsages } =
useVariableImpactState(usages, open);
const isRename = mode === 'rename';
const count = usages.length;
const plural = count === 1 ? '' : 's';
const intro = isRename
? `$${variableName} is used in ${count} place${plural}. Review the updated queries before renaming to $${newName}.`
: `$${variableName} is used in ${count} place${plural}. Edit or remove each usage before deleting.`;
const footer = (
<div className={styles.footer}>
<Button
variant="solid"
color="secondary"
onClick={onClose}
testId="variable-impact-cancel"
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color={isRename ? 'primary' : 'destructive'}
loading={isLoading}
onClick={(): void => onConfirm(resolvedUsages)}
testId="variable-impact-confirm"
>
<Check size={12} />
{isRename ? 'Rename' : 'Delete'}
</Button>
</div>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={isRename ? `Rename $${variableName}` : `Delete $${variableName}`}
width="wide"
showCloseButton={false}
// Lift above the settings drawer (z ~1000); overlay off (it would only half-dim).
style={{ zIndex: 1100 }}
showOverlay={false}
footer={footer}
>
<div className={styles.body}>
<Typography.Text className={styles.intro}>{intro}</Typography.Text>
<div className={styles.rows}>
{rows.map((row) => {
const stillReferences =
row.included &&
textContainsVariableReference(row.resultingText, variableName);
return (
<div
key={row.id}
className={styles.row}
data-testid={`variable-impact-row-${row.id}`}
>
<div className={styles.rowHeader}>
<Checkbox
checked={row.included}
onChange={(): void => toggleIncluded(row.id)}
data-testid={`variable-impact-include-${row.id}`}
>
<span className={styles.sourceLabel}>{row.sourceLabel}</span>
</Checkbox>
<span className={styles.kindTag}>{KIND_LABEL[row.kind]}</span>
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>
Current
</Typography.Text>
<AntdInput.TextArea
className={styles.textArea}
value={row.currentText}
readOnly
autoSize={{ minRows: 1, maxRows: 4 }}
/>
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Result</Typography.Text>
<AntdInput.TextArea
className={cx(styles.textArea, !row.included && styles.disabled)}
value={row.resultingText}
disabled={!row.included}
autoSize={{ minRows: 1, maxRows: 4 }}
onChange={(e): void => setResultingText(row.id, e.target.value)}
data-testid={`variable-impact-result-${row.id}`}
/>
{stillReferences ? (
<Typography.Text className={styles.warning}>
Still references ${variableName}
</Typography.Text>
) : null}
</div>
</div>
);
})}
</div>
</div>
</DialogWrapper>
);
}
export default VariableImpactDialog;

View File

@@ -1,52 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import type { VariableUsage } from '../variableUsages';
/** A usage row plus whether its edit will be applied on confirm. */
export interface EditableVariableUsage extends VariableUsage {
included: boolean;
}
interface UseVariableImpactState {
rows: EditableVariableUsage[];
setResultingText: (id: string, text: string) => void;
toggleIncluded: (id: string) => void;
/** The included rows, as plain usages, to build the patch from. */
resolvedUsages: VariableUsage[];
}
/**
* Editable state for the impact dialog: a per-usage copy the user can edit
* (`resultingText`) and include/exclude before applying. Resets whenever the
* dialog (re)opens for a fresh usage set.
*/
export function useVariableImpactState(
usages: VariableUsage[],
open: boolean,
): UseVariableImpactState {
const [rows, setRows] = useState<EditableVariableUsage[]>([]);
useEffect(() => {
if (open) {
setRows(usages.map((usage) => ({ ...usage, included: true })));
}
}, [open, usages]);
const setResultingText = useCallback((id: string, text: string): void => {
setRows((prev) =>
prev.map((row) => (row.id === id ? { ...row, resultingText: text } : row)),
);
}, []);
const toggleIncluded = useCallback((id: string): void => {
setRows((prev) =>
prev.map((row) =>
row.id === id ? { ...row, included: !row.included } : row,
),
);
}, []);
const resolvedUsages: VariableUsage[] = rows.filter((row) => row.included);
return { rows, setResultingText, toggleIncluded, resolvedUsages };
}

View File

@@ -1,98 +0,0 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../variableFormModel';
import { findVariableUsages } from '../variableUsages';
// Identity adapter so `spec.variables` can be plain form models in the test.
jest.mock('../variableAdapters', () => ({
dtoToFormModel: (dto: unknown): unknown => dto,
}));
function variable(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
function builderPanel(name: string, expression: string): unknown {
return {
spec: {
display: { name },
queries: [
{
spec: {
plugin: { kind: 'signoz/BuilderQuery', spec: { filter: { expression } } },
},
},
],
},
};
}
function promqlPanel(name: string, query: string): unknown {
return {
spec: {
display: { name },
queries: [
{ spec: { plugin: { kind: 'signoz/PromQLQuery', spec: { query } } } },
],
},
};
}
function dashboard(
panels: Record<string, unknown>,
variables: VariableFormModel[],
): DashboardtypesGettableDashboardV2DTO {
return {
spec: { panels, variables },
} as unknown as DashboardtypesGettableDashboardV2DTO;
}
describe('findVariableUsages', () => {
const dash = dashboard(
{
p1: builderPanel('Panel One', "service IN $svc AND env = 'prod'"),
p2: promqlPanel('Panel Two', 'up{s="$svc"}'),
p3: builderPanel('Unrelated', "env = 'prod'"),
},
[
variable({ name: 'svc', type: 'QUERY' }),
variable({
name: 'other',
type: 'QUERY',
queryValue: 'SELECT x WHERE s = $svc',
}),
variable({ name: 'plain', type: 'QUERY', queryValue: 'SELECT y' }),
],
);
it('finds panel (builder + promql) and variable usages, skipping unrelated ones', () => {
const usages = findVariableUsages(dash, 'svc', 'rename', 'zone');
const ids = usages.map((u) => u.id).sort();
expect(ids).toStrictEqual(['panel:p1:0', 'panel:p2:0', 'variable:other:0']);
});
it('rewrites references for a rename across all kinds', () => {
const usages = findVariableUsages(dash, 'svc', 'rename', 'zone');
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
expect(byId['panel:p1:0']).toBe("service IN $zone AND env = 'prod'");
expect(byId['panel:p2:0']).toBe('up{s="$zone"}');
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $zone');
});
it('strips builder clauses on delete but leaves raw/variable queries for review', () => {
const usages = findVariableUsages(dash, 'svc', 'delete');
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
// Builder: the clause referencing $svc is dropped.
expect(byId['panel:p1:0']).toBe("env = 'prod'");
// Raw PromQL + variable query: unchanged (user edits).
expect(byId['panel:p2:0']).toBe('up{s="$svc"}');
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $svc');
});
it('returns nothing for an unreferenced variable', () => {
expect(findVariableUsages(dash, 'nope', 'delete')).toStrictEqual([]);
});
});

View File

@@ -17,17 +17,7 @@ import {
emptyVariableFormModel,
type VariableFormModel,
} from './variableFormModel';
import {
applyVariableQueryEdits,
buildVariableImpactPatch,
} from './variableImpactPatch';
import {
findVariableUsages,
type VariableImpactMode,
type VariableUsage,
} from './variableUsages';
import VariableForm from './VariableForm/VariableForm';
import VariableImpactDialog from './VariableImpactDialog/VariableImpactDialog';
import VariablesList from './VariablesList';
import styles from './Variables.module.scss';
import AddVariableButton from './components/AddVariableButton';
@@ -69,16 +59,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
null,
);
const [applyToAllIndex, setApplyToAllIndex] = useState<number | null>(null);
// A pending rename/delete that touches other queries — resolved via the impact
// dialog before it is applied. `nextVariables` is the array to persist (with the
// rename/delete already applied), before any variable-query edits.
const [impact, setImpact] = useState<{
mode: VariableImpactMode;
variableName: string;
newName?: string;
usages: VariableUsage[];
nextVariables: VariableFormModel[];
} | null>(null);
const editingFormModel: VariableFormModel | null = useMemo(() => {
if (!isEditing) {
@@ -124,38 +104,12 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
formModel: VariableFormModel,
selectedPanelIds: string[],
): void => {
const editingIndex = isEditing?.type === 'edit' ? isEditing.index : null;
const oldName = editingIndex !== null ? variables[editingIndex].name : null;
const next = [...variables];
if (isEditing?.type === 'new') {
next.push(formModel);
} else if (editingIndex !== null) {
next[editingIndex] = formModel;
} else if (isEditing?.type === 'edit') {
next[isEditing.index] = formModel;
}
// A rename that other queries/variables reference must be reviewed first, so
// the references are rewritten alongside the rename (never left dangling).
if (oldName && oldName !== formModel.name) {
const usages = findVariableUsages(
dashboard,
oldName,
'rename',
formModel.name,
);
if (usages.length > 0) {
setIsEditing(null);
setImpact({
mode: 'rename',
variableName: oldName,
newName: formModel.name,
usages,
nextVariables: next,
});
return;
}
}
setIsEditing(null);
setVariables(next);
void (async (): Promise<void> => {
@@ -195,57 +149,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
setConfirmDeleteIndex(null);
};
// Delete requested from the list: if the variable is referenced anywhere, block
// and open the impact dialog; otherwise fall through to the simple confirm.
const requestDelete = (index: number): void => {
const usages = findVariableUsages(dashboard, variables[index].name, 'delete');
if (usages.length > 0) {
setImpact({
mode: 'delete',
variableName: variables[index].name,
usages,
nextVariables: variables.filter((_, i) => i !== index),
});
return;
}
setConfirmDeleteIndex(index);
};
// Applies a resolved rename/delete: the variables array (rename/delete + edited
// variable queries) and each touched panel's queries, in one atomic patch.
const handleImpactConfirm = async (
resolvedUsages: VariableUsage[],
): Promise<void> => {
if (!impact) {
return;
}
const nextVariables = applyVariableQueryEdits(
impact.nextVariables,
resolvedUsages,
);
const ops = buildVariableImpactPatch(
dashboard,
nextVariables,
resolvedUsages,
);
setVariables(nextVariables);
try {
await patchAsync(ops);
toast.success(
impact.mode === 'rename'
? `Renamed to $${impact.newName}`
: `Deleted $${impact.variableName}`,
);
} catch {
toast.error(
impact.mode === 'rename'
? 'Could not rename the variable'
: 'Could not delete the variable',
);
}
setImpact(null);
};
const applyToAllVariable =
applyToAllIndex === null ? null : variables[applyToAllIndex];
@@ -299,7 +202,7 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setIsEditing({ type: 'edit', index })}
onRequestDelete={requestDelete}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
@@ -317,16 +220,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
onConfirm={(): void => void handleConfirmApplyToAll()}
onClose={(): void => setApplyToAllIndex(null)}
/>
<VariableImpactDialog
open={impact !== null}
mode={impact?.mode ?? 'delete'}
variableName={impact?.variableName ?? ''}
newName={impact?.newName}
usages={impact?.usages ?? []}
isLoading={isPatching}
onConfirm={(resolved): void => void handleImpactConfirm(resolved)}
onClose={(): void => setImpact(null)}
/>
</div>
);
}

View File

@@ -1,123 +0,0 @@
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
DashboardtypesQueryDTO,
Querybuildertypesv5CompositeQueryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { cloneDeep } from 'lodash-es';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableFormModel';
import { buildVariablesPatch } from './variablePatchOps';
import type { VariableUsage, VariableUsageKind } from './variableUsages';
/** Minimal writable view of an envelope spec's reference-bearing fields. */
interface WritableSpec {
query?: string;
filter?: { expression?: string };
}
/** Writes the resolved text into the spec's builder filter or raw query field. */
function writeSpecText(
spec: WritableSpec,
kind: VariableUsageKind,
text: string,
): void {
if (kind === 'builder') {
spec.filter = { ...(spec.filter ?? {}), expression: text };
} else {
spec.query = text;
}
}
/** Applies one panel usage's edited text into a (cloned) queries array in place. */
function applyPanelUsage(
queries: DashboardtypesQueryDTO[],
usage: VariableUsage,
): void {
const plugin = queries[0]?.spec?.plugin;
if (!plugin?.spec) {
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
const envelope = (composite.queries ?? [])[usage.envelopeIndex];
if (envelope?.spec) {
writeSpecText(
envelope.spec as WritableSpec,
usage.kind,
usage.resultingText,
);
}
} else {
// Bare BuilderQuery / PromQLQuery / ClickHouseSQL — the plugin spec is the
// single envelope (index 0).
writeSpecText(plugin.spec as WritableSpec, usage.kind, usage.resultingText);
}
}
/**
* Applies the variable-definition usages' edited text back into the matching
* variable's `queryValue`, so a renamed/deleted variable's references inside
* another query variable are updated alongside the panels.
*/
export function applyVariableQueryEdits(
variables: VariableFormModel[],
usages: VariableUsage[],
): VariableFormModel[] {
const edits = new Map(
usages
.filter((usage) => usage.sourceType === 'variable')
.map((usage) => [usage.sourceId, usage.resultingText]),
);
if (edits.size === 0) {
return variables;
}
return variables.map((variable) =>
edits.has(variable.name)
? { ...variable, queryValue: edits.get(variable.name) as string }
: variable,
);
}
/**
* Builds the atomic JSON-Patch for a variable rename/delete impact: replaces the
* whole variables array (which the caller has already updated for the rename/
* delete and any variable-query edits) and replaces each touched panel's queries
* with the user's resolved text.
*/
export function buildVariableImpactPatch(
dashboard: DashboardtypesGettableDashboardV2DTO,
nextVariables: VariableFormModel[],
usages: VariableUsage[],
): DashboardtypesJSONPatchOperationDTO[] {
const ops: DashboardtypesJSONPatchOperationDTO[] = [
...buildVariablesPatch(nextVariables.map(formModelToDto)),
];
const panels = dashboard.spec.panels ?? {};
const byPanel = new Map<string, VariableUsage[]>();
usages
.filter((usage) => usage.sourceType === 'panel')
.forEach((usage) => {
const list = byPanel.get(usage.sourceId) ?? [];
list.push(usage);
byPanel.set(usage.sourceId, list);
});
byPanel.forEach((list, panelId) => {
const panel = panels[panelId];
if (!panel?.spec?.queries?.length) {
return;
}
const queries = cloneDeep(panel.spec.queries);
list.forEach((usage) => applyPanelUsage(queries, usage));
ops.push({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: `/spec/panels/${panelId}/spec/queries`,
value: queries,
});
});
return ops;
}

View File

@@ -1,163 +0,0 @@
import type {
DashboardtypesGettableDashboardV2DTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
removeVariableReferenceClause,
rewriteVariableReferences,
textContainsVariableReference,
} from 'lib/dashboardVariables/variableReference';
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
import { dtoToFormModel } from './variableAdapters';
/** The kind of query text a variable is referenced from. */
export type VariableUsageKind =
| 'builder'
| 'promql'
| 'clickhouse'
| 'variable';
/** Whether the impact is a rename (rewrite refs) or a delete (remove refs). */
export type VariableImpactMode = 'rename' | 'delete';
/**
* One place a variable is referenced — a panel query's builder filter expression,
* a PromQL/ClickHouse query string, or another variable's query definition. Each
* usage is a single editable text field: `currentText` is what exists today,
* `resultingText` is the proposed rewrite (rename) or removal (delete) the user
* can review and edit before applying.
*/
export interface VariableUsage {
/** Stable key: `${sourceType}:${sourceId}:${envelopeIndex}`. */
id: string;
sourceType: 'panel' | 'variable';
/** Panel id or referencing variable's name. */
sourceId: string;
/** Human label: panel display name or `$variableName`. */
sourceLabel: string;
kind: VariableUsageKind;
/** Index into the panel's query envelopes (0 for a variable definition). */
envelopeIndex: number;
currentText: string;
resultingText: string;
}
/** The reference-bearing text + kind for one query envelope, if any. */
function envelopeReferenceText(
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): { kind: VariableUsageKind; text: string } | null {
const spec = envelope.spec as
| { query?: string; filter?: { expression?: string } }
| undefined;
if (envelope.type === 'builder_query') {
const text = spec?.filter?.expression;
return typeof text === 'string' ? { kind: 'builder', text } : null;
}
if (envelope.type === 'promql') {
return typeof spec?.query === 'string'
? { kind: 'promql', text: spec.query }
: null;
}
if (envelope.type === 'clickhouse_sql') {
return typeof spec?.query === 'string'
? { kind: 'clickhouse', text: spec.query }
: null;
}
return null;
}
/** The proposed text after a rename (rewrite) or delete (best-effort removal). */
function computeResultingText(
kind: VariableUsageKind,
text: string,
variableName: string,
mode: VariableImpactMode,
newName: string,
): string {
if (mode === 'rename') {
return rewriteVariableReferences(text, variableName, newName);
}
// delete: only builder filter clauses can be safely auto-stripped; raw PromQL/
// ClickHouse and variable queries are left for the user to edit.
return kind === 'builder'
? removeVariableReferenceClause(text, variableName)
: text;
}
/**
* Finds every usage of `variableName` across the dashboard's panel queries
* (builder / PromQL / ClickHouse) and other variables' query definitions, with a
* proposed `resultingText` for the given mode. Consumed by the impact dialog that
* blocks a rename/delete until the user resolves each usage.
*/
export function findVariableUsages(
dashboard: DashboardtypesGettableDashboardV2DTO,
variableName: string,
mode: VariableImpactMode,
newName = '',
): VariableUsage[] {
if (!variableName) {
return [];
}
const usages: VariableUsage[] = [];
const spec = dashboard.spec;
Object.entries(spec.panels ?? {}).forEach(([panelId, panel]) => {
const queries = panel?.spec?.queries;
if (!queries?.length) {
return;
}
toQueryEnvelopes(queries).forEach((envelope, index) => {
const ref = envelopeReferenceText(envelope);
if (!ref || !textContainsVariableReference(ref.text, variableName)) {
return;
}
usages.push({
id: `panel:${panelId}:${index}`,
sourceType: 'panel',
sourceId: panelId,
sourceLabel: panel.spec?.display?.name || panelId,
kind: ref.kind,
envelopeIndex: index,
currentText: ref.text,
resultingText: computeResultingText(
ref.kind,
ref.text,
variableName,
mode,
newName,
),
});
});
});
(spec.variables ?? []).map(dtoToFormModel).forEach((variable) => {
if (
variable.name === variableName ||
variable.type !== 'QUERY' ||
!variable.queryValue ||
!textContainsVariableReference(variable.queryValue, variableName)
) {
return;
}
usages.push({
id: `variable:${variable.name}:0`,
sourceType: 'variable',
sourceId: variable.name,
sourceLabel: `$${variable.name}`,
kind: 'variable',
envelopeIndex: 0,
currentText: variable.queryValue,
resultingText: computeResultingText(
'variable',
variable.queryValue,
variableName,
mode,
newName,
),
});
});
return usages;
}

View File

@@ -92,7 +92,6 @@ function Panel({
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}

View File

@@ -23,8 +23,6 @@ interface PanelBodyProps {
panelId: string;
data: PanelQueryData;
isFetching: boolean;
/** Panel not yet scrolled into view — its fetch is deferred, so show the loader rather than NoData. */
isVisible?: boolean;
/** Showing a prior page's data while the next loads; forwarded so list renderers can show skeletons. */
isPreviousData?: boolean;
error: Error | null;
@@ -56,7 +54,6 @@ function PanelBody({
panelId,
data,
isFetching,
isVisible,
isPreviousData,
error,
refetch,
@@ -108,9 +105,9 @@ function PanelBody({
);
}
// Full-panel loader on first fetch or while the fetch is deferred (off-screen); a refetch
// over existing data keeps the renderer mounted, empty data loads via NoData.
if ((isFetching || isVisible === false) && !hasData) {
// Full-panel loader only on first fetch; a refetch over existing data keeps the renderer
// mounted (e.g. list page change). A refetch over empty data loads via NoData.
if (isFetching && !hasData) {
return <PanelLoader />;
}

View File

@@ -79,21 +79,6 @@ describe('PanelBody', () => {
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('shows the loader while the fetch is deferred (panel not yet scrolled into view)', () => {
render(
<PanelBody
{...baseProps}
panel={runnablePanel()}
data={{} as PanelQueryData}
isFetching={false}
isVisible={false}
/>,
);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('keeps the renderer mounted during a refetch over existing data (e.g. list page change)', () => {
render(
<PanelBody

View File

@@ -113,8 +113,8 @@ function ViewPanelModalHeader({
/>
<Button
size="icon"
variant="outlined"
color="secondary"
variant="solid"
color="primary"
onClick={onRefresh}
disabled={isFetching}
aria-label="Refresh"

View File

@@ -22,7 +22,6 @@ function SectionGrid({
sections,
}: SectionGridProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({

View File

@@ -10,10 +10,6 @@ const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
rootMargin: '200px',
};
// Start observing after RGL's mount unfold settles, so a panel that only
// transiently overlaps the viewport during layout doesn't fire a throwaway fetch.
const OBSERVER_START_DELAY_MS = 350;
interface SectionGridItemProps {
panel: DashboardtypesPanelDTO;
panelId: string;
@@ -21,9 +17,9 @@ interface SectionGridItemProps {
}
/**
* Lazy-loads a single panel: tracks its live viewport intersection and passes it to
* the presentational Panel as `isVisible`, so a board of many panels only fetches
* (and refetches on time change / auto-refresh) what's on screen.
* Lazy-loads a single panel: watches its own viewport intersection (latched) and
* passes it to the presentational Panel as `isVisible`, so a board of many panels
* only fetches what's on screen.
*/
function SectionGridItem({
panel,
@@ -34,10 +30,7 @@ function SectionGridItem({
const isVisible = useIntersectionObserver(
containerRef,
VIEWPORT_OBSERVER_OPTIONS,
// Not once: track the live viewport so a time change / auto-refresh only
// refetches on-screen panels (off-screen ones stay query-disabled).
false,
OBSERVER_START_DELAY_MS,
true,
);
useScrollIntoView(panelId, containerRef);

View File

@@ -23,7 +23,7 @@ describe('useScrollIntoView', () => {
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
block: 'start',
});
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});
@@ -38,7 +38,7 @@ describe('useScrollIntoView', () => {
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
block: 'start',
});
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});

View File

@@ -9,7 +9,7 @@ import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
export function useScrollIntoView(
id: string,
ref: RefObject<HTMLElement>,
block: ScrollLogicalPosition = 'center',
block: ScrollLogicalPosition = 'start',
): void {
const scrollTargetId = useScrollIntoViewStore((s) => s.scrollTargetId);
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);

View File

@@ -1,17 +1,15 @@
import { useMemo } from 'react';
import { SolidInfoCircle } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
import { Tooltip } from 'antd';
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import CustomSelector from './selectors/CustomSelector';
import DynamicSelector from './selectors/DynamicSelector';
import QuerySelector from './selectors/QuerySelector';
import TextSelector from './selectors/TextSelector';
import VariableValueControl from './selectors/VariableValueControl';
import { useVariableFetchState } from './useVariableFetchState';
import styles from './VariablesBar.module.scss';
import VariableTooltip from './VariableTooltip';
interface VariableSelectorProps {
variable: VariableFormModel;
@@ -34,63 +32,50 @@ function VariableSelector({
onChange,
onAutoSelect,
}: VariableSelectorProps): JSX.Element {
// Dependency links shown in the hover tooltip: variables this one's query
// references (dependsOn) and query variables that reference this one (usedBy).
const { dependsOn, usedBy } = useMemo(() => {
const references = (text: string | undefined, name: string): boolean =>
!!text && !!name && textContainsVariableReference(text, name);
return {
dependsOn:
variable.type === 'QUERY'
? variables
.filter(
(v) =>
v.name !== variable.name && references(variable.queryValue, v.name),
)
.map((v) => v.name)
: [],
usedBy: variables
.filter(
(v) =>
v.type === 'QUERY' &&
v.name !== variable.name &&
references(v.queryValue, variable.name),
)
.map((v) => v.name),
};
}, [variable, variables]);
const hasTooltip =
!!variable.description || dependsOn.length > 0 || usedBy.length > 0;
// Surface the fetch on the bar itself: a bar flush along the control's bottom
// edge while a QUERY/DYNAMIC variable is loading (or waiting on a parent), so the
// user sees options are being fetched without opening the dropdown.
const { isVariableFetching, isVariableWaiting } = useVariableFetchState(
variable.name,
);
const isFetchingOptions =
(variable.type === 'QUERY' || variable.type === 'DYNAMIC') &&
(isVariableFetching || isVariableWaiting);
const renderControl = (): JSX.Element =>
variable.type === 'TEXT' ? (
<TextSelector
selection={selection}
defaultValue={variable.textValue}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>
) : (
<VariableValueControl
variable={variable}
variables={variables}
selections={selections}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
const renderControl = (): JSX.Element => {
switch (variable.type) {
case 'TEXT':
return (
<TextSelector
selection={selection}
defaultValue={variable.textValue}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>
);
case 'QUERY':
return (
<QuerySelector
variable={variable}
selections={selections}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
case 'DYNAMIC':
return (
<DynamicSelector
variable={variable}
variables={variables}
selections={selections}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
case 'CUSTOM':
default:
return (
<CustomSelector
variable={variable}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
}
};
return (
<div
@@ -99,29 +84,14 @@ function VariableSelector({
>
<Typography.Text className={styles.variableName}>
${variable.name}
{hasTooltip ? (
<Tooltip
title={
<VariableTooltip
description={variable.description}
dependsOn={dependsOn}
usedBy={usedBy}
/>
}
>
{variable.description ? (
<Tooltip title={variable.description}>
<SolidInfoCircle className={styles.infoIcon} size={14} />
</Tooltip>
) : null}
</Typography.Text>
<div className={styles.variableValue}>{renderControl()}</div>
{isFetchingOptions ? (
<span
className={styles.loadingBar}
data-testid={`variable-loading-${variable.name}`}
/>
) : null}
</div>
);
}

View File

@@ -1,67 +0,0 @@
import cx from 'classnames';
import styles from './VariablesBar.module.scss';
interface VariableTooltipProps {
description?: string;
/** Variables this one references (its query depends on their values). */
dependsOn: string[];
/** Variables whose queries reference this one. */
usedBy: string[];
}
/** Hover-tooltip body for a variable: its description plus its dependencies. */
function VariableTooltip({
description,
dependsOn,
usedBy,
}: VariableTooltipProps): JSX.Element {
const hasDependencies = dependsOn.length > 0 || usedBy.length > 0;
return (
<div className={styles.tooltipContent}>
{description ? (
<div className={styles.tooltipDescription}>{description}</div>
) : null}
{hasDependencies ? (
<>
{description ? <div className={styles.tooltipDivider} /> : null}
{dependsOn.length > 0 ? (
<div className={styles.tooltipSection}>
<div className={cx(styles.tooltipLabel, styles.dependsColor)}>
Depends on
</div>
<div className={styles.tooltipRefs}>
{dependsOn.map((name) => (
<span
key={name}
className={cx(styles.tooltipRef, styles.dependsColor)}
>
${name}
</span>
))}
</div>
</div>
) : null}
{usedBy.length > 0 ? (
<div className={styles.tooltipSection}>
<div className={cx(styles.tooltipLabel, styles.usedByColor)}>
Used by
</div>
<div className={styles.tooltipRefs}>
{usedBy.map((name) => (
<span key={name} className={cx(styles.tooltipRef, styles.usedByColor)}>
${name}
</span>
))}
</div>
</div>
) : null}
</>
) : null}
</div>
);
}
export default VariableTooltip;

View File

@@ -73,57 +73,10 @@
}
.variableItem {
position: relative;
display: flex;
align-items: center;
}
// Loading indicator: an indeterminate bar flush along the control's bottom edge,
// full width and overlaying the border so it reads as the input's own edge rather
// than a separate element. Non-interactive so the name/description stays hoverable.
.loadingBar {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 2px;
overflow: hidden;
border-radius: 0 0 2px 2px;
background: color-mix(in srgb, var(--bg-robin-500) 20%, transparent);
pointer-events: none;
}
.loadingBar::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 40%;
border-radius: 2px;
background: var(--bg-robin-500);
animation: variable-loading-slide 1.1s ease-in-out infinite;
}
@keyframes variable-loading-slide {
0% {
left: -40%;
}
100% {
left: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.loadingBar::after {
left: 0;
width: 100%;
animation: none;
opacity: 0.7;
}
}
.variableName {
display: flex;
min-width: 56px;
@@ -134,7 +87,7 @@
border: 1px solid var(--l3-border);
border-radius: 2px 0 0 2px;
background: var(--l3-background);
color: var(--bg-robin-400);
color: var(--bg-robin-300);
font-family: Inter;
font-size: 12px;
font-weight: 400;
@@ -144,63 +97,11 @@
.infoIcon {
display: inline-flex;
margin-left: 6px;
margin-left: 2px;
color: var(--l2-foreground);
vertical-align: middle;
}
.tooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 240px;
}
.tooltipDescription {
font-size: 12px;
line-height: 1.5;
}
// Divider and labels use the tooltip's own text color at reduced opacity so they
// read on the tooltip surface in either theme without hard-coding a palette.
.tooltipDivider {
height: 1px;
background: currentColor;
opacity: 0.16;
}
.tooltipSection {
display: flex;
flex-direction: column;
gap: 4px;
}
.tooltipLabel {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.tooltipRefs {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tooltipRef {
font-size: 12px;
}
// Directional colors: parents (Depends on) in forest, children (Used by) in amber.
.dependsColor {
color: var(--bg-forest-500);
}
.usedByColor {
color: var(--bg-amber-500);
}
.variableValue {
display: flex;
min-width: 120px;

View File

@@ -1,153 +0,0 @@
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import {
configuredDefaultValue,
reconcileWithOptions,
resolveDefaultSelection,
} from '../resolveVariableSelection';
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
describe('resolveDefaultSelection', () => {
it('TEXT: uses defaultValue, then textValue, else empty string', () => {
expect(
resolveDefaultSelection(model({ type: 'TEXT', defaultValue: 'd' })),
).toStrictEqual({ value: 'd', allSelected: false });
expect(
resolveDefaultSelection(model({ type: 'TEXT', textValue: 't' })),
).toStrictEqual({ value: 't', allSelected: false });
expect(resolveDefaultSelection(model({ type: 'TEXT' }))).toStrictEqual({
value: '',
allSelected: false,
});
});
it('list: ALL when allowAll (multi + showAllOption) and no default', () => {
expect(
resolveDefaultSelection(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
),
).toStrictEqual({ value: null, allSelected: true });
});
it('list: ALL sentinel default → ALL', () => {
expect(
resolveDefaultSelection(
model({ type: 'CUSTOM', multiSelect: true, defaultValue: '__ALL__' }),
),
).toStrictEqual({ value: null, allSelected: true });
});
it('list: configured default wins over ALL default', () => {
expect(
resolveDefaultSelection(
model({
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'x',
}),
),
).toStrictEqual({ value: ['x'], allSelected: false });
});
it('list: no default and no allowAll → empty placeholder (filled after fetch)', () => {
expect(resolveDefaultSelection(model({ type: 'QUERY' }))).toStrictEqual({
value: '',
allSelected: false,
});
expect(
resolveDefaultSelection(model({ type: 'QUERY', multiSelect: true })),
).toStrictEqual({ value: [], allSelected: false });
});
});
describe('reconcileWithOptions', () => {
it('leaves a valid single selection untouched (local-first)', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY' }),
{ value: 'b', allSelected: false },
['a', 'b'],
),
).toBeNull();
});
it('materializes query ALL to the full option array', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
{ value: null, allSelected: true },
['a', 'b'],
),
).toStrictEqual({ value: ['a', 'b'], allSelected: true });
});
it('does not materialize dynamic ALL (sends __all__)', () => {
expect(
reconcileWithOptions(
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
{ value: null, allSelected: true },
['a', 'b'],
),
).toBeNull();
});
it('keeps the still-valid subset when options re-scope', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY', multiSelect: true }),
{ value: ['a', 'b', 'c'], allSelected: false },
['a', 'b', 'd'],
),
).toStrictEqual({ value: ['a', 'b'], allSelected: false });
});
it('falls back to the configured default (else first) when invalid', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY', defaultValue: 'b' }),
{ value: '', allSelected: false },
['a', 'b', 'c'],
),
).toStrictEqual({ value: 'b', allSelected: false });
expect(
reconcileWithOptions(
model({ type: 'QUERY' }),
{ value: '', allSelected: false },
['a', 'b'],
),
).toStrictEqual({ value: 'a', allSelected: false });
});
it('does nothing while options are empty', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY' }),
{ value: '', allSelected: false },
[],
),
).toBeNull();
});
});
describe('configuredDefaultValue', () => {
it('TEXT: textValue fallback; list: defaultValue only (no ALL synthesis)', () => {
expect(configuredDefaultValue(model({ type: 'TEXT', textValue: 't' }))).toBe(
't',
);
expect(
configuredDefaultValue(model({ type: 'QUERY', defaultValue: 'x' })),
).toBe('x');
// ALL-by-default list variable is not expanded here (options unknown).
expect(
configuredDefaultValue(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,31 @@
import { withVariablesSearch } from '../variablesUrlState';
jest.mock('nuqs', () => ({
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
}));
describe('withVariablesSearch', () => {
const current = `?compositeQuery=abc&variables=${encodeURIComponent(
'{"env":"prod"}',
)}`;
it('returns the base unchanged when the current search has no variables', () => {
expect(withVariablesSearch('', '?compositeQuery=abc')).toBe('');
expect(withVariablesSearch('?panelKind=signoz/TablePanel', '')).toBe(
'?panelKind=signoz/TablePanel',
);
});
it('carries only the variables param onto an empty base', () => {
const result = withVariablesSearch('', current);
expect(new URLSearchParams(result).get('variables')).toBe('{"env":"prod"}');
expect(new URLSearchParams(result).get('compositeQuery')).toBeNull();
});
it('appends the variables param to existing base params', () => {
const result = withVariablesSearch('?panelKind=signoz/TablePanel', current);
const params = new URLSearchParams(result);
expect(params.get('panelKind')).toBe('signoz/TablePanel');
expect(params.get('variables')).toBe('{"env":"prod"}');
});
});

View File

@@ -1,183 +0,0 @@
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type {
SelectedVariableValue,
VariableSelection,
} from './selectionTypes';
import { ALL_SELECTED } from './variablesUrlState';
/**
* Single source of truth for "what value does this variable hold?", shared by the
* three surfaces that used to each own a divergent copy of the rule:
* - {@link resolveDefaultSelection} — the seed-time default (no options yet).
* - {@link reconcileWithOptions} — the post-fetch reconcile (options known).
* - {@link configuredDefaultValue} — the payload fallback when nothing is picked.
*
* Keeping them here means the variable bar, the fetch gate and the panel-query
* payload can never disagree about a variable's default (the previous split
* produced "bar shows ALL while the query omits the variable").
*/
/** An "every option selected" (ALL) selection. */
const ALL_SELECTION: VariableSelection = { value: null, allSelected: true };
/** The `defaultValue` reduced to a single string, or undefined when unset. */
function firstConfiguredDefault(model: VariableFormModel): string | undefined {
const def = model.defaultValue;
if (Array.isArray(def)) {
return def.length > 0 ? String(def[0]) : undefined;
}
if (typeof def === 'string' && def !== '') {
return def;
}
return undefined;
}
/** Whether the configured default marks the ALL sentinel. */
function isAllDefault(def: VariableFormModel['defaultValue']): boolean {
return (
def === ALL_SELECTED ||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
);
}
function isValidSingle(
value: SelectedVariableValue,
options: string[],
): boolean {
return (
!Array.isArray(value) &&
value !== '' &&
value !== null &&
value !== undefined &&
options.includes(String(value))
);
}
/** The configured default (or first option) as a fresh selection. */
function fillDefault(
model: VariableFormModel,
options: string[],
): VariableSelection {
const fallback = firstConfiguredDefault(model);
const initial = fallback && options.includes(fallback) ? fallback : options[0];
return {
value: model.multiSelect ? [initial] : initial,
allSelected: false,
};
}
/**
* For an ALL selection, the value to materialize (or null when unchanged).
* Dynamic ALL travels as the `__all__` wire sentinel and renders ALL from the
* flag, so it needs no materialized value. Query/custom ALL must carry the full
* option array (the payload builder cannot expand it) — keep it in sync.
*/
function materializeAll(
model: VariableFormModel,
options: string[],
current: SelectedVariableValue,
): VariableSelection | null {
if (!model.multiSelect || model.type === 'DYNAMIC') {
return null;
}
const alreadyFull =
Array.isArray(current) &&
current.length === options.length &&
current.every((c) => options.includes(String(c)));
return alreadyFull ? null : { value: options, allSelected: true };
}
/**
* The seed-time default for a variable, before any options are fetched.
* - TEXT: the configured default (`defaultValue` → `textValue`), else empty.
* - CUSTOM/QUERY/DYNAMIC: the configured default; else ALL when allowAll is on;
* else a placeholder that {@link reconcileWithOptions} fills with the first
* option once the options resolve.
*/
export function resolveDefaultSelection(
model: VariableFormModel,
): VariableSelection {
if (model.type === 'TEXT') {
return {
value: firstConfiguredDefault(model) ?? model.textValue ?? '',
allSelected: false,
};
}
const def = model.defaultValue;
if (isAllDefault(def)) {
return ALL_SELECTION;
}
if (Array.isArray(def) && def.length > 0) {
return { value: def, allSelected: false };
}
if (typeof def === 'string' && def !== '') {
return { value: model.multiSelect ? [def] : def, allSelected: false };
}
if (model.multiSelect && model.showAllOption) {
return ALL_SELECTION;
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
/**
* Reconciles a variable's current selection against its freshly-fetched options.
* Returns the next selection, or null when nothing should change (a valid pick is
* left untouched — local-first). Behaviour, in order:
* - materialize ALL to the full option set (query/custom);
* - keep a still-valid multi-select subset, dropping only invalid entries;
* - otherwise auto-pick the default (or first option) so dependent variables and
* panels always resolve against a usable value.
*/
export function reconcileWithOptions(
model: VariableFormModel,
current: VariableSelection,
options: string[],
): VariableSelection | null {
if (options.length === 0) {
return null;
}
if (current.allSelected) {
return materializeAll(model, options, current.value);
}
if (
model.multiSelect &&
Array.isArray(current.value) &&
current.value.length > 0
) {
const valid = current.value.map(String).filter((c) => options.includes(c));
if (valid.length === current.value.length) {
return null;
}
return valid.length > 0
? { value: valid, allSelected: false }
: fillDefault(model, options);
}
if (!model.multiSelect && isValidSingle(current.value, options)) {
return null;
}
return fillDefault(model, options);
}
/**
* The value to send for a variable when the user has made no selection yet
* (the payload fallback). Mirrors the configured default only — an ALL-by-default
* list variable resolves to `undefined` here (its concrete values are carried by
* the materialized selection once options are known), so it is omitted until then
* rather than sent wrong.
*/
export function configuredDefaultValue(
model: VariableFormModel,
): SelectedVariableValue | undefined {
if (model.type === 'TEXT') {
return firstConfiguredDefault(model) ?? model.textValue ?? undefined;
}
const def = model.defaultValue;
if (Array.isArray(def)) {
return def.length > 0 ? def : undefined;
}
return def || undefined;
}

View File

@@ -1,4 +1,3 @@
import type { VariableType } from '../DashboardSettings/Variables/variableFormModel';
import type {
SelectedVariableValue,
VariableSelection,
@@ -20,32 +19,6 @@ export function isResolved(selection?: VariableSelection): boolean {
return value !== '' && value !== null && value !== undefined;
}
/**
* Whether a selection carries a value usable when scheduling a dependent
* variable/panel fetch. Unlike {@link isResolved}, a QUERY/CUSTOM ALL counts only
* once materialized into the concrete array (an unmaterialized ALL isn't usable),
* while a DYNAMIC ALL is usable immediately via the `__all__` sentinel.
*/
export function hasUsableValue(
selection: VariableSelection | undefined,
type: VariableType | undefined,
): boolean {
if (!selection) {
return false;
}
if (selection.allSelected) {
if (type === 'DYNAMIC') {
return true;
}
return Array.isArray(selection.value) && selection.value.length > 0;
}
const { value } = selection;
if (Array.isArray(value)) {
return value.length > 0;
}
return value !== '' && value !== null && value !== undefined;
}
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
export function selectionToPayload(
selection: VariableSelectionMap,

View File

@@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection } from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface CustomSelectorProps {
variable: VariableFormModel;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* Custom-variable options come from the comma-separated `customValue` (no fetch),
* but still auto-select a default/first option so the variable is never left blank.
*/
function CustomSelector({
variable,
selection,
onChange,
onAutoSelect,
}: CustomSelectorProps): JSX.Element {
const options = useMemo(
() =>
sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String),
[variable.customValue, variable.sort],
);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default CustomSelector;

View File

@@ -0,0 +1,140 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import {
signalForApi,
sortValuesByOrder,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import { useVariableFetchState } from '../useVariableFetchState';
import ValueSelector from './ValueSelector';
interface DynamicSelectorProps {
variable: VariableFormModel;
/** All variables + current selections, to scope options by sibling dynamics. */
variables: VariableFormModel[];
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/** Batched auto-selection fill applied when options resolve. */
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* Dynamic-variable options sourced from live telemetry field values for the
* chosen signal + attribute, scoped by the other dynamic variables' selections
* (so e.g. `pod` narrows to the chosen `namespace`). WHEN to fetch is owned by
* the runtime fetch engine: dynamics fetch together once the query variables have
* values, and refetch (via a `cycleId` bump) whenever any variable value changes.
*/
function DynamicSelector({
variable,
variables,
selections,
selection,
onChange,
onAutoSelect,
}: DynamicSelectorProps): JSX.Element {
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
[variables, selections, variable.name],
);
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const { data, isFetching, error, refetch } = useQuery(
[
'dashboard-variable-dynamic',
variable.name,
variable.dynamicSignal,
variable.dynamicAttribute,
existingQuery,
minTime,
maxTime,
variableFetchCycleId,
],
() =>
getFieldValues(
signalForApi(variable.dynamicSignal),
variable.dynamicAttribute,
undefined,
minTime,
maxTime,
existingQuery || undefined,
),
{
enabled:
!!variable.dynamicAttribute &&
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; 0 under auto-refresh so entries don't pile up (V1 parity).
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const options = useMemo(() => {
const payload = data?.data;
const values =
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
return sortValuesByOrder(values, variable.sort).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching || isVariableWaiting}
errorMessage={error ? (error as Error).message || null : null}
onRetry={(): void => {
void refetch();
}}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default DynamicSelector;

View File

@@ -0,0 +1,127 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { selectionToPayload } from '../selectionUtils';
import { useAutoSelect } from '../useAutoSelect';
import { useVariableFetchState } from '../useVariableFetchState';
import ValueSelector from './ValueSelector';
interface QuerySelectorProps {
variable: VariableFormModel;
/** All current selections, fed to the query as `{ name: value }`. */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/** Batched auto-selection fill applied when options resolve. */
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* Query-driven options. WHEN to fetch is owned by the runtime fetch engine
* (`variableFetchSlice`): the query is `enabled` while this variable is fetching
* (or settled-after-a-first-fetch, so a cycle bump re-runs it), and the engine's
* per-variable `cycleId` keys the request — so a parent's value change refetches
* only the dependent variables, in dependency order. The current selections feed
* the request payload but are deliberately NOT in the key (V1 parity).
*/
function QuerySelector({
variable,
selections,
selection,
onChange,
onAutoSelect,
}: QuerySelectorProps): JSX.Element {
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const { data, isFetching, error, refetch } = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
minTime,
maxTime,
variableFetchCycleId,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; 0 under auto-refresh so entries don't pile up (V1 parity).
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const options = useMemo(() => {
if (!data || data.statusCode !== 200 || !data.payload) {
return [] as string[];
}
return sortValuesByOrder(
data.payload.variableValues ?? [],
variable.sort,
).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching || isVariableWaiting}
errorMessage={error ? (error as Error).message || null : null}
onRetry={(): void => {
void refetch();
}}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default QuerySelector;

View File

@@ -1,59 +0,0 @@
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
import { useVariableOptions } from './useVariableOptions';
interface VariableValueControlProps {
variable: VariableFormModel;
/** All variables (Dynamic scopes its options by sibling selections). */
variables: VariableFormModel[];
/** All current selections (fed to the Query request payload). */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/** Batched auto-selection fill applied when options resolve. */
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* The single value picker for QUERY / CUSTOM / DYNAMIC variables. Options + fetch
* state come from {@link useVariableOptions}; this component only reconciles the
* selection against the options and renders — the view is decoupled from how the
* options are sourced (Container/Presentational).
*/
function VariableValueControl({
variable,
variables,
selections,
selection,
onChange,
onAutoSelect,
}: VariableValueControlProps): JSX.Element {
const { options, loading, errorMessage, onRetry } = useVariableOptions(
variable,
variables,
selections,
);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default VariableValueControl;

View File

@@ -1,213 +0,0 @@
import { useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import {
signalForApi,
sortValuesByOrder,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
import type { VariableSelectionMap } from '../selectionTypes';
import { selectionToPayload } from '../selectionUtils';
import { useVariableFetchState } from '../useVariableFetchState';
export interface VariableOptions {
options: string[];
loading: boolean;
errorMessage: string | null;
onRetry?: () => void;
}
/**
* The option list for a list variable (QUERY / CUSTOM / DYNAMIC), plus its loading
* and error state — the single place the three list types get their options.
* QUERY/DYNAMIC fetch via react-query (WHEN owned by the fetch engine: `enabled`
* gated on the variable's fetch state, keyed by `cycleId`, never by the current
* selections or time — those feed the fetchers (which read the current time at
* call), so the debounced fetch cycle drives refetches). CUSTOM is parsed
* synchronously from its comma list. TEXT never reaches here (it has no options).
*/
export function useVariableOptions(
variable: VariableFormModel,
variables: VariableFormModel[],
selections: VariableSelectionMap,
): VariableOptions {
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
// Bound cache churn: 0 under auto-refresh so entries don't pile up (V1 parity).
const cacheTime = isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED;
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const setVariableResolvedEmpty = useDashboardStore(
(s) => s.setVariableResolvedEmpty,
);
// Fetch while this variable is actively fetching, or once settled after a first
// fetch (so a `cycleId` bump re-runs it). Combined with a per-type guard below.
const canFetch =
isVariableFetching || (isVariableSettled && hasVariableFetchedOnce);
// QUERY — options from the test-run endpoint; selections feed the payload, not the key.
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const queryResult = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
variableFetchCycleId,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{
enabled: variable.type === 'QUERY' && canFetch,
refetchOnWindowFocus: false,
cacheTime,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
// DYNAMIC — telemetry field values scoped by sibling dynamics via `existingQuery`
// (fed to the fetcher only, not the key — see DynamicSelector history).
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
[variables, selections, variable.name],
);
const dynamicResult = useQuery(
[
'dashboard-variable-dynamic',
variable.name,
variable.dynamicSignal,
variable.dynamicAttribute,
variableFetchCycleId,
],
() =>
getFieldValues(
signalForApi(variable.dynamicSignal),
variable.dynamicAttribute,
undefined,
minTime,
maxTime,
existingQuery || undefined,
),
{
enabled:
variable.type === 'DYNAMIC' && !!variable.dynamicAttribute && canFetch,
refetchOnWindowFocus: false,
cacheTime,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const queryOptions = useMemo(() => {
const data = queryResult.data;
if (!data || data.statusCode !== 200 || !data.payload) {
return [] as string[];
}
return sortValuesByOrder(
data.payload.variableValues ?? [],
variable.sort,
).map(String);
}, [queryResult.data, variable.sort]);
const dynamicOptions = useMemo(() => {
const data = dynamicResult.data?.data;
const values = data?.normalizedValues ?? data?.values?.StringValues ?? [];
return sortValuesByOrder(values, variable.sort).map(String);
}, [dynamicResult.data, variable.sort]);
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: ([] as string[]),
[variable.type, variable.customValue, variable.sort],
);
// Flag a variable that settled with zero options so dependent panels fall through
// to "no data" instead of waiting forever. hasFetchedOnce excludes the pre-fetch state.
const effectiveOptions =
variable.type === 'DYNAMIC' ? dynamicOptions : queryOptions;
useEffect(() => {
if (variable.type !== 'QUERY' && variable.type !== 'DYNAMIC') {
return;
}
setVariableResolvedEmpty(
variable.name,
hasVariableFetchedOnce &&
!isVariableFetching &&
effectiveOptions.length === 0,
);
}, [
variable.type,
variable.name,
hasVariableFetchedOnce,
isVariableFetching,
effectiveOptions.length,
setVariableResolvedEmpty,
]);
if (variable.type === 'CUSTOM') {
return { options: customOptions, loading: false, errorMessage: null };
}
if (variable.type === 'DYNAMIC') {
return {
options: dynamicOptions,
loading: dynamicResult.isFetching || isVariableWaiting,
errorMessage: dynamicResult.error
? (dynamicResult.error as Error).message || null
: null,
onRetry: (): void => {
void dynamicResult.refetch();
},
};
}
return {
options: queryOptions,
loading: queryResult.isFetching || isVariableWaiting,
errorMessage: queryResult.error
? (queryResult.error as Error).message || null
: null,
onRetry: (): void => {
void queryResult.refetch();
},
};
}

View File

@@ -1,14 +1,61 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import { reconcileWithOptions } from './resolveVariableSelection';
import type { VariableSelection } from './selectionTypes';
import type {
SelectedVariableValue,
VariableSelection,
} from './selectionTypes';
/** The variable's default (or first option) as a fresh selection. */
function fillDefault(
variable: VariableFormModel,
options: string[],
): VariableSelection {
const dv = variable.defaultValue;
const fallback = Array.isArray(dv) ? dv[0] : dv;
const initial = fallback && options.includes(fallback) ? fallback : options[0];
return {
value: variable.multiSelect ? [initial] : initial,
allSelected: false,
};
}
/** For an all-selected variable, the value to materialize (or null if unchanged). */
function reconcileAllSelected(
variable: VariableFormModel,
options: string[],
current: SelectedVariableValue,
): VariableSelection | null {
// Dynamic ALL travels as the `__all__` wire sentinel and shows ALL from the
// flag, so it needs no materialized value. Query/custom ALL must carry the full
// option array (the payload builder can't expand it) — keep it in sync.
if (!variable.multiSelect || variable.type === 'DYNAMIC') {
return null;
}
const alreadyFull =
Array.isArray(current) &&
current.length === options.length &&
current.every((c) => options.includes(String(c)));
return alreadyFull ? null : { value: options, allSelected: true };
}
function isValidSingle(
current: SelectedVariableValue,
options: string[],
): boolean {
return (
!Array.isArray(current) &&
current !== '' &&
current !== null &&
current !== undefined &&
options.includes(String(current))
);
}
/**
* Reconciles a variable's selection with its freshly-fetched options and fires
* `onAutoSelect` only when the value must change. The reconcile rule lives in
* {@link reconcileWithOptions} (shared with seed + payload defaulting) so the bar
* and the panel query can never disagree about a variable's default.
* Reconciles a variable's selection with its freshly-fetched options: materialize
* ALL to the full set, keep a still-valid multi-select subset, else auto-pick the
* default (or first option) so dependent children always have a usable value.
*/
export function useAutoSelect(
variable: VariableFormModel,
@@ -17,10 +64,36 @@ export function useAutoSelect(
onAutoSelect: (selection: VariableSelection) => void,
): void {
useEffect(() => {
const next = reconcileWithOptions(variable, selection, options);
if (next) {
onAutoSelect(next);
if (options.length === 0) {
return;
}
const current = selection.value;
if (selection.allSelected) {
const next = reconcileAllSelected(variable, options, current);
if (next) {
onAutoSelect(next);
}
return;
}
if (variable.multiSelect && Array.isArray(current) && current.length > 0) {
const valid = current.map(String).filter((c) => options.includes(c));
if (valid.length === current.length) {
return;
}
onAutoSelect(
valid.length > 0
? { value: valid, allSelected: false }
: fillDefault(variable, options),
);
return;
}
if (!variable.multiSelect && isValidSingle(current, options)) {
return;
}
onAutoSelect(fillDefault(variable, options));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]);
}

View File

@@ -6,7 +6,6 @@ import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters'
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import { resolveDefaultSelection } from './resolveVariableSelection';
import type {
SelectedVariableValue,
VariableSelection,
@@ -18,6 +17,26 @@ import {
} from './variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
function defaultSelection(model: VariableFormModel): VariableSelection {
const def = model.defaultValue;
if (
def === ALL_SELECTED ||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
) {
return { value: null, allSelected: true };
}
if (Array.isArray(def) && def.length > 0) {
return { value: def, allSelected: false };
}
if (typeof def === 'string' && def !== '') {
return { value: model.multiSelect ? [def] : def, allSelected: false };
}
if (model.multiSelect && model.showAllOption) {
return { value: null, allSelected: true };
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
function fromUrlValue(
@@ -69,20 +88,12 @@ export function useSeedVariableSelection(
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
const stored = selection[variable.name];
if (urlValue !== undefined) {
const fromUrl = fromUrlValue(urlValue, variable);
// When the URL carries only the ALL sentinel but the store already holds
// the materialized full-option array, reuse it — avoids the re-fetch +
// re-materialize round-trip (and its dependent-refetch cascade) on load.
seeded[variable.name] =
fromUrl.allSelected && stored?.allSelected && Array.isArray(stored.value)
? stored
: fromUrl;
} else if (stored) {
seeded[variable.name] = stored;
seeded[variable.name] = fromUrlValue(urlValue, variable);
} else if (selection[variable.name]) {
seeded[variable.name] = selection[variable.name];
} else {
seeded[variable.name] = resolveDefaultSelection(variable);
seeded[variable.name] = defaultSelection(variable);
}
});
setVariableValues(dashboardId, seeded);
@@ -105,10 +116,8 @@ export function useSeedVariableSelection(
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once per dashboard/variable set; the URL is read as of that moment
}, [dashboardId, variables]);
// Always init the context (even with no variables) so panels can tell "ready, none"
// from "not ready yet"; also clears it when the last variable is removed.
useEffect(() => {
if (!dashboardId) {
if (!dashboardId || variables.length === 0) {
return;
}
const names = variables

View File

@@ -11,14 +11,9 @@ import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import { useSeedVariableSelection } from './useSeedVariableSelection';
import { doAllQueryVariablesHaveValues } from './variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
/**
* Debounce for the fetch cycle, so the on-load time-range settle (default → saved)
* and rapid time-picker changes collapse into one cycle instead of double-fetching.
*/
const FETCH_CYCLE_DEBOUNCE_MS = 250;
interface UseVariableSelection {
variables: VariableFormModel[];
selection: VariableSelectionMap;
@@ -53,10 +48,9 @@ export function useVariableSelection(
(s) => s.enqueueDescendantsBatch,
);
const { minTime, maxTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// Latest selection, read by the fetch-cycle effect without subscribing to it
// (so a value change doesn't re-trigger a full fetch cycle).
@@ -68,39 +62,20 @@ export function useVariableSelection(
variablesUrlParser.withOptions({ history: 'replace' }),
);
// Start a full fetch cycle on load / dependency-order / time change, debounced so
// the initial time-window settle (and rapid time changes) collapse into ONE cycle
// instead of double-fetching every variable. Variables stay disabled until the
// cycle runs, so the transient window is never fetched. A value change instead
// goes through `enqueueDescendants` — immediate, not this effect.
// Start a full fetch cycle on load / dependency-order / time change. A value
// change instead goes through `enqueueDescendants`, not this effect.
const orderKey = `${fetchContext.queryVariableOrder.join(
',',
)}|${fetchContext.dynamicVariableOrder.join(',')}`;
// Key on the time *selection*, not raw min/max: a relative range recomputes those
// as `now` drifts, which shouldn't refetch. The fetchers still read current time.
const timeKey =
selectedTime === 'custom' ? `custom:${minTime}-${maxTime}` : selectedTime;
// A re-mount re-runs this effect with the same key, which enqueueFetchAll skips.
const fetchCycleKey = `${dashboardId}|${orderKey}|${timeKey}`;
const fetchCycleTimer = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return undefined;
return;
}
if (fetchCycleTimer.current) {
clearTimeout(fetchCycleTimer.current);
}
fetchCycleTimer.current = setTimeout(
() => enqueueFetchAll(fetchCycleKey),
FETCH_CYCLE_DEBOUNCE_MS,
enqueueFetchAll(
doAllQueryVariablesHaveValues(variables, selectionRef.current),
);
return (): void => {
if (fetchCycleTimer.current) {
clearTimeout(fetchCycleTimer.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, fetchCycleKey]);
}, [dashboardId, orderKey, minTime, maxTime]);
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {

View File

@@ -4,6 +4,8 @@ import type {
VariableFormModel,
VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from './selectionTypes';
import { isResolved } from './selectionUtils';
/**
* Inter-variable dependency graph for runtime selection. A QUERY variable
@@ -240,3 +242,17 @@ export function deriveFetchContext(
dynamicVariableOrder,
};
}
/**
* Whether every QUERY variable already has a usable selection — decides at load
* time whether dynamic variables may fetch immediately or must wait for the
* query variables to settle first (V1 parity).
*/
export function doAllQueryVariablesHaveValues(
variables: VariableFormModel[],
selection: VariableSelectionMap,
): boolean {
return variables
.filter((v) => v.type === 'QUERY')
.every((v) => isResolved(selection[v.name]));
}

View File

@@ -1,3 +1,4 @@
import { QueryParams } from 'constants/query';
import { parseAsJson } from 'nuqs';
import type { SelectedVariableValue } from './selectionTypes';
@@ -13,3 +14,21 @@ export const variablesUrlParser = parseAsJson<
? (v as Record<string, SelectedVariableValue>)
: null,
);
/**
* Extends a search string with the current `?variables=` param (unchanged when
* absent), so the dashboard ↔ editor handoff keeps the selection in the URL and
* it survives a refresh (V1 parity).
*/
export function withVariablesSearch(
base: string,
currentSearch: string,
): string {
const value = new URLSearchParams(currentSearch).get(QueryParams.variables);
if (!value) {
return base;
}
const params = new URLSearchParams(base);
params.set(QueryParams.variables, value);
return `?${params.toString()}`;
}

View File

@@ -1,11 +1,12 @@
import { useCallback, useState } from 'react';
import { generatePath } from 'react-router-dom';
import { generatePath, useLocation } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
import type { PanelKind } from '../Panels/types/panelKind';
import { useDashboardStore } from '../store/useDashboardStore';
import { withVariablesSearch } from '../VariablesBar/variablesUrlState';
interface UseCreatePanelResult {
isPickerOpen: boolean;
@@ -25,6 +26,7 @@ interface UseCreatePanelResult {
*/
export function useCreatePanel(): UseCreatePanelResult {
const { safeNavigate } = useSafeNavigate();
const { search } = useLocation();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const [isPickerOpen, setIsPickerOpen] = useState(false);
@@ -48,10 +50,11 @@ export function useCreatePanel(): UseCreatePanelResult {
panelId: NEW_PANEL_ID,
});
const target = targetIndex ?? layoutIndex;
// Variable selection is read from the persisted store, not the URL.
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
safeNavigate(
`${path}${withVariablesSearch(newPanelSearch(panelKind, target), search)}`,
);
},
[safeNavigate, dashboardId, layoutIndex],
[safeNavigate, dashboardId, layoutIndex, search],
);
return {

View File

@@ -57,8 +57,5 @@ export function useGetQueryRangeV5({
retry: retryUnlessClientError,
keepPreviousData,
cacheTime,
// A resolved window is immutable per key, so a panel scrolled back into view
// serves cache instead of refetching; a key change or manual refetch still runs.
staleTime: Infinity,
});
}

View File

@@ -1,51 +1,23 @@
import { hasUsableValue } from '../VariablesBar/selectionUtils';
import { VariableFetchState } from '../store/slices/variableFetchSlice';
import { isResolved } from '../VariablesBar/selectionUtils';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
/**
* Whether a panel should stay loading because a QUERY/DYNAMIC variable it references
* isn't ready to substitute. A concrete pick (not ALL) and a DYNAMIC ALL are ready
* immediately; an unselected value or a QUERY/CUSTOM ALL waits while it's still
* resolving, then until it settles with a value — so a panel on a chain holds until
* the last variable it depends on resolves. A fetch error or a settled-empty variable
* releases it (no value is coming — render rather than hang).
* True while a panel should stay in its loading state because a variable it
* references is still loading/waiting and has no usable value yet — i.e. the
* first load. Once the variable has a value, a later change no longer blocks the
* panel (it refetches over stale data instead). V1 parity with
* `useIsPanelWaitingOnVariable`.
*/
export function useIsPanelWaitingOnVariable(names: string[]): boolean {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const variableTypes = useDashboardStore(
(s) => s.variableFetchContext?.variableTypes,
);
const fetchStates = useDashboardStore((s) => s.variableFetchStates);
const resolvedEmpty = useDashboardStore((s) => s.variableResolvedEmpty);
const states = useDashboardStore((s) => s.variableFetchStates);
const selection = useDashboardStore(selectVariableValues(dashboardId));
return names.some((name) => {
const type = variableTypes?.[name];
if (type !== 'QUERY' && type !== 'DYNAMIC') {
return false;
}
const value = selection[name];
// A concrete pick is authoritative; a DYNAMIC ALL is the stable `__all__`
// sentinel — both ready without waiting.
if (value && !value.allSelected && hasUsableValue(value, type)) {
return false;
}
if (type === 'DYNAMIC' && value?.allSelected) {
return false;
}
// Unselected, or a QUERY/CUSTOM ALL whose array the fetch produces: wait while
// resolving, then until it settles with a usable value.
const state = fetchStates[name];
if (
state === VariableFetchState.Waiting ||
state === VariableFetchState.Loading
) {
return true;
}
if (hasUsableValue(value, type)) {
return false;
}
return state !== VariableFetchState.Error && !resolvedEmpty[name];
const state = states[name];
const inFlight =
state === 'loading' || state === 'revalidating' || state === 'waiting';
return isResolved(selection[name]) ? false : inFlight;
});
}

View File

@@ -1,16 +1,17 @@
import { useCallback } from 'react';
import { generatePath } from 'react-router-dom';
import { generatePath, useLocation } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
import { useDashboardStore } from '../store/useDashboardStore';
import { withVariablesSearch } from '../VariablesBar/variablesUrlState';
/**
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
* caller can open the editor with just the panel id. Variable selection is read from the
* persisted store (localStorage), not carried in the URL. The optional `handoffState` is
* caller can open the editor with just the panel id. The `?variables=` selection is carried
* along (V1 parity) so it survives a refresh of the editor. The optional `handoffState` is
* passed as router location state — the View modal uses it to hand its drilldown-edited spec
* off to the editor (view → edit) so the editor opens on those edits rather than the saved
* panel.
@@ -20,15 +21,19 @@ export function useOpenPanelEditor(): (
handoffState?: PanelEditorHandoffState,
) => void {
const { safeNavigate } = useSafeNavigate();
const { search } = useLocation();
const dashboardId = useDashboardStore((s) => s.dashboardId);
return useCallback(
(panelId: string, handoffState?: PanelEditorHandoffState): void => {
safeNavigate(
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
`${generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId,
})}${withVariablesSearch('', search)}`,
handoffState ? { state: handoffState } : undefined,
);
},
[safeNavigate, dashboardId],
[safeNavigate, dashboardId, search],
);
}

View File

@@ -19,10 +19,7 @@ import {
} from '../queryV5/buildQueryRangeRequest';
import type { PanelPagination, PanelQueryData } from '../queryV5/types';
import { getRawResults } from '../queryV5/v5ResponseData';
import {
getReferencedVariables,
queryReferencesAnyVariable,
} from '../queryV5/getReferencedVariables';
import { getReferencedVariables } from '../queryV5/getReferencedVariables';
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
@@ -60,9 +57,9 @@ export interface PanelQueryTimeOverride {
export interface UsePanelQueryResult {
/** Raw V5 fetch result — response + the request that produced it. */
data: PanelQueryData;
/** First fetch only (no cached data yet), OR waiting on an unresolved referenced variable — drives the full-panel loader. A background refetch does NOT set this; use `isFetching`. */
/** First fetch only (no cached data yet) — drives the full-panel loader. A background refetch does NOT set this; use `isFetching`. */
isLoading: boolean;
/** Any request in flight (including a background refetch over stale data), OR waiting on an unresolved referenced variable — drives the loader / "refreshing" affordance, never a blank panel. */
/** Any request in flight, including a background refetch over stale data — drives a "refreshing" affordance, never a blank panel. */
isFetching: boolean;
/** Showing a prior page's data (keepPreviousData) while the next page loads — list renderers swap in skeleton rows. */
isPreviousData: boolean;
@@ -134,13 +131,6 @@ export function usePanelQuery({
return getReferencedVariables(queries, allNames);
}, [queries, fetchContext]);
// Detected without the fetch context, so the gate below can hold even before it
// initializes.
const hasVariableReference = useMemo(
() => queryReferencesAnyVariable(queries),
[queries],
);
const scopedVariables = useMemo(() => {
const scoped: typeof variables = {};
referencedVariableNames.forEach((name) => {
@@ -151,11 +141,11 @@ export function usePanelQuery({
return scoped;
}, [variables, referencedVariableNames]);
// Hold until referenced variables resolve; also hold before the context is ready
// (we can't yet know which variables to substitute, so firing would drop `$var`s).
const isWaitingOnVariable =
useIsPanelWaitingOnVariable(referencedVariableNames) ||
(hasVariableReference && !fetchContext);
// First-load gate: hold the panel in its loading state until every referenced
// variable has resolved a value.
const isWaitingOnVariable = useIsPanelWaitingOnVariable(
referencedVariableNames,
);
// `visualization` exists only on variants that declare it — read via `in` narrowing over the
// generated union (no cast). `fillSpans` (TimeSeries/Bar only) → formatOptions.fillGaps.
@@ -319,10 +309,8 @@ export function usePanelQuery({
return {
data,
// A disabled (waiting-on-variable) query reports neither loading nor fetching, so
// fold the wait in — else the panel body falls through to "No data" mid-load.
isLoading: isWaitingOnVariable || response.isLoading,
isFetching: isWaitingOnVariable || response.isFetching,
isLoading: response.isLoading,
isFetching: response.isFetching,
isPreviousData: response.isPreviousData,
error: response.error ?? null,
refetch: response.refetch,

View File

@@ -33,11 +33,6 @@ function DashboardContainer({
document.title = name;
}, [name]);
// Store is app-level and outlives the page: clear transient variable fetch state on
// unmount so the next visit doesn't inherit stale states / climbing cycle ids.
const resetVariableFetch = useDashboardStore((s) => s.resetVariableFetch);
useEffect(() => resetVariableFetch, [resetVariableFetch]);
const fullScreenHandle = useFullScreenHandle();
const { isLocked, canEditDashboard } = useDashboardEditGuard(dashboard);

Some files were not shown because too many files have changed in this diff Show More