Compare commits

..

1 Commits

Author SHA1 Message Date
Abhi Kumar
b22a18758a chore(dashboard): retire the V1 variable runtime
The V1 variable engine had no writers left: nothing wrote selectedValue, so
getDashboardVariables produced undefined values, variableFetchStore was never
updated, and the dependency graph and derived store fields only fed that store.
The shared store's one remaining job is publishing the open dashboard's dynamic
variables for query-builder autocomplete, which needs a name and an attribute.

Replace it with a suggestion feed and delete the rest, including the panel
variables prop that no GridCard caller passed and useResolveQuery's dashboardData
option that no caller supplied. useGetResolvedText loses its only variable source
and becomes the title truncation its callers already used it for.

Assisted-by: Claude Opus 5
2026-08-27 13:54:49 +05:30
91 changed files with 491 additions and 4407 deletions

View File

@@ -671,9 +671,9 @@ export const prepareQueryRangePayloadV5 = ({
(acc, [key, value]) => {
acc[key] = {
value,
type: dynamicVariables
?.find((v) => v.name === key)
?.type?.toLowerCase() as VariableType,
type: dynamicVariables?.some((v) => v.name === key)
? ('dynamic' as VariableType)
: undefined,
};
return acc;
},

View File

@@ -27,7 +27,7 @@ import {
QUERY_BUILDER_OPERATORS_BY_KEY_TYPE,
queryOperatorSuggestions,
} from 'constants/antlrQueryConstants';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import { debounce, isNull } from 'lodash-es';
@@ -258,10 +258,7 @@ function QuerySearch({
const lastValueRef = useRef<string>('');
const isMountedRef = useRef<boolean>(true);
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const dashboardDynamicVariables = useDynamicVariableSuggestions();
// Add back the generateOptions function and useEffect
const generateOptions = (keys: {
@@ -1189,7 +1186,7 @@ function QuerySearch({
// Add dynamic variables suggestions for the current key
const variableName = dashboardDynamicVariables?.find(
(variable) => variable?.dynamicVariablesAttribute === keyName,
(variable) => variable?.attribute === keyName,
)?.name;
if (variableName) {

View File

@@ -1,4 +1,5 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
@@ -75,7 +76,7 @@ export function buildEntityMetricsChartConfig({
show: true,
side: 2,
isDarkMode,
isTimeAxis: true,
panelType: PANEL_TYPES.TIME_SERIES,
});
builder.addAxis({
@@ -84,6 +85,7 @@ export function buildEntityMetricsChartConfig({
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.TIME_SERIES,
});
if (!apiResponse?.data?.result) {

View File

@@ -1,4 +1,5 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
@@ -71,7 +72,7 @@ export function buildMeterChartConfig({
show: true,
side: 2,
isDarkMode,
isTimeAxis: true,
panelType: PANEL_TYPES.BAR,
});
builder.addAxis({
@@ -80,6 +81,7 @@ export function buildMeterChartConfig({
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.BAR,
});
if (!apiResponse?.data?.result) {

View File

@@ -21,7 +21,7 @@ import {
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import type { WhereClauseConfig } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
@@ -263,10 +263,7 @@ function QueryBuilderSearchV2(
return false;
}, [currentState, query.aggregateAttribute?.dataType, query.dataSource]);
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const dashboardDynamicVariables = useDynamicVariableSuggestions();
const { data, isFetching } = useGetAggregateKeys(
{
@@ -817,8 +814,7 @@ function QueryBuilderSearchV2(
// here we want to suggest the variable name matching with the key here, we will go over the dynamic variables for the keys
const variableName = dashboardDynamicVariables?.find(
(variable) =>
variable?.dynamicVariablesAttribute === currentFilterItem?.key?.key,
(variable) => variable?.attribute === currentFilterItem?.key?.key,
)?.name;
if (variableName) {

View File

@@ -5,9 +5,8 @@ import {
initialQueriesMap,
initialQueryBuilderFormValues,
} from 'constants/queryBuilder';
import { IUseDashboardVariablesReturn } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { DynamicVariableSuggestion } from 'providers/Dashboard/store/dynamicVariableSuggestions';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { IDashboardVariable } from 'types/api/dashboard/variables';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
@@ -150,24 +149,14 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
// Mock dashboard variables
const dashboardVariables = {
service: {
id: 'service',
name: 'service',
type: 'DYNAMIC' as IDashboardVariable['type'],
dynamicVariablesAttribute: 'service.name',
description: '',
sort: 'DISABLED' as IDashboardVariable['sort'],
multiSelect: false,
showALLOption: false,
},
};
// Mock the dynamic variables the open dashboard would publish
const dynamicVariableSuggestions = [
{ name: 'service', attribute: 'service.name' },
];
jest.mock('hooks/dashboard/useDashboardVariables', () => ({
useDashboardVariables: (): IUseDashboardVariablesReturn => ({
dashboardVariables: dashboardVariables,
}),
jest.mock('hooks/dashboard/useDynamicVariableSuggestions', () => ({
useDynamicVariableSuggestions: (): DynamicVariableSuggestion[] =>
dynamicVariableSuggestions,
}));
describe('Suggestion Key -> Operator -> Value Flow', () => {

View File

@@ -2,7 +2,6 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { ServicesList } from 'types/api/metrics/getService';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';
@@ -47,7 +46,6 @@ export const getQueryRangeRequestData = ({
graphType: serviceMetricsWidget?.panelTypes,
query: updatedQuery,
globalSelectedInterval,
variables: getDashboardVariables(),
});
});
return requestData;

View File

@@ -28,13 +28,11 @@ import { populateMultipleResults } from 'lib/query/populateMultipleResults';
import { timeItems, timePreferance } from 'constants/timePreference';
import PanelWrapper from 'container/WidgetCard/Panels/PanelWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useChartMutable } from 'hooks/useChartMutable';
import useUrlQuery from 'hooks/useUrlQuery';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import GetMinMax from 'lib/getMinMax';
import { isEmpty } from 'lodash-es';
import { AppState } from 'store/reducers';
@@ -82,8 +80,6 @@ function FullView({
setCurrentGraphRef(fullViewRef);
}, [setCurrentGraphRef]);
const { dashboardVariables } = useDashboardVariables();
const getSelectedTime = useCallback(
() =>
timeItems.find((e) => e.enum === (widget?.timePreferance || 'GLOBAL_TIME')),
@@ -115,7 +111,6 @@ function FullView({
graphType: getGraphType(selectedPanelType),
query: updatedQuery,
globalSelectedInterval: globalSelectedTime,
variables: getDashboardVariables(dashboardVariables),
fillGaps: widget.fillSpans,
formatForWeb: selectedPanelType === PANEL_TYPES.TABLE,
originalGraphType: selectedPanelType,
@@ -126,7 +121,6 @@ function FullView({
graphType: PANEL_TYPES.LIST,
selectedTime: widget?.timePreferance || 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
variables: getDashboardVariables(dashboardVariables),
tableParams: {
pagination: {
offset: 0,

View File

@@ -8,12 +8,9 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { useScrollWidgetIntoView } from 'lib/visualization/hooks/useScrollWidgetIntoView';
import { populateMultipleResults } from 'lib/query/populateMultipleResults';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsPanelWaitingOnVariable } from 'hooks/dashboard/useVariableFetchState';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { getVariableReferencesInQuery } from 'lib/dashboardVariables/variableReference';
import getTimeString from 'lib/getTimeString';
import { isEqual } from 'lodash-es';
import isEmpty from 'lodash-es/isEmpty';
@@ -45,7 +42,6 @@ function GridCardGraph({
headerMenuList = [MenuItemKeys.View],
isQueryEnabled,
threshold,
variables,
version,
onClickHandler,
onDragSelect,
@@ -113,25 +109,10 @@ function GridCardGraph({
const updatedQuery = widget?.query;
const referencedVariableNames = useMemo(() => {
if (!variables || !updatedQuery) {
return [];
}
const allNames = Object.values(variables)
.map((v) => v.name)
.filter((name): name is string => !!name);
return getVariableReferencesInQuery(updatedQuery, allNames);
}, [updatedQuery, variables]);
const isEmptyWidget =
widget?.id === PANEL_TYPES.EMPTY_WIDGET || isEmpty(widget);
const isPanelWaitingOnAnyVariable = useIsPanelWaitingOnVariable(
referencedVariableNames,
);
const queryEnabledCondition =
isVisible && !isEmptyWidget && isQueryEnabled && !isPanelWaitingOnAnyVariable;
const queryEnabledCondition = isVisible && !isEmptyWidget && isQueryEnabled;
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
if (widget.panelTypes !== PANEL_TYPES.LIST) {
@@ -140,7 +121,6 @@ function GridCardGraph({
graphType: getGraphType(widget.panelTypes),
query: updatedQuery,
globalSelectedInterval,
variables: getDashboardVariables(variables),
fillGaps: widget.fillSpans,
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
start: customTimeRange?.startTime || start,
@@ -191,7 +171,6 @@ function GridCardGraph({
const queryResponse = useGetQueryRange(
{
...requestData,
variables: getDashboardVariables(variables),
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
globalSelectedInterval:
widget?.panelTypes === PANEL_TYPES.LIST && isLogsQuery
@@ -214,14 +193,6 @@ function GridCardGraph({
widget.timePreferance,
widget.fillSpans,
requestData,
variables
? Object.entries(variables).reduce((acc, [id, variable]) => {
if (variable.name && referencedVariableNames.includes(variable.name)) {
return { ...acc, [id]: variable.selectedValue };
}
return acc;
}, {})
: {},
...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime
? [customTimeRange.startTime, customTimeRange.endTime]
: []),
@@ -303,9 +274,7 @@ function GridCardGraph({
version={version}
threshold={threshold}
headerMenuList={menuList}
isFetchingResponse={
queryResponse.isFetching || isPanelWaitingOnAnyVariable
}
isFetchingResponse={queryResponse.isFetching}
setRequestData={setRequestData}
onClickHandler={onClickHandler}
onDragSelect={onDragSelect}

View File

@@ -4,7 +4,6 @@ import { ToggleGraphProps } from 'components/Graph/types';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { Widgets } from 'types/api/widgets/widget';
import {
MetricQueryRangeSuccessResponse,
@@ -52,7 +51,6 @@ export interface GridCardGraphProps {
headerMenuList?: WidgetGraphComponentProps['headerMenuList'];
onClickHandler?: OnClickPluginOpts['onClick'];
isQueryEnabled: boolean;
variables?: IDashboardVariables;
version?: string;
onDragSelect: (start: number, end: number) => void;
customOnDragSelect?: (start: number, end: number) => void;

View File

@@ -26,8 +26,8 @@ jest.mock(
}),
);
jest.mock('hooks/dashboard/useDashboardVariablesByType', () => ({
useDashboardVariablesByType: (): unknown[] => mockDynamicVariables,
jest.mock('hooks/dashboard/useDynamicVariableSuggestions', () => ({
useDynamicVariableSuggestions: (): unknown[] => mockDynamicVariables,
}));
jest.mock('react-redux', () => ({
@@ -64,11 +64,12 @@ describe('useResolveQuery', () => {
expect(resolved).toBe(QUERY);
});
it('resolves through substitute_vars when the dashboard has variables', async () => {
it('resolves through substitute_vars when the dashboard has dynamic variables', async () => {
mockGetSubstituteVars.mockResolvedValue({
httpStatusCode: 200,
data: { compositeQuery: {} },
});
mockDynamicVariables.push({ name: 'env', attribute: 'deployment.env' });
const { result } = renderHook(() => useUpdatedQuery(), {
wrapper: MockQueryClientProvider,
@@ -76,13 +77,6 @@ describe('useResolveQuery', () => {
const resolved = await result.current.getUpdatedQuery({
widgetConfig: WIDGET_CONFIG,
dashboardData: {
data: {
variables: {
env: { name: 'env', selectedValue: 'prod' },
},
},
},
});
expect(mockGetSubstituteVars).toHaveBeenCalledTimes(1);

View File

@@ -7,8 +7,7 @@ import { getSubstituteVars } from 'api/dashboard/substitute_vars';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { timePreferenceType } from 'constants/timePreference';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { AppState } from 'store/reducers';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -21,7 +20,6 @@ interface UseUpdatedQueryOptions {
panelTypes: PANEL_TYPES;
timePreferance: timePreferenceType;
};
dashboardData?: any;
}
interface UseUpdatedQueryResult {
@@ -37,21 +35,13 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
const queryRangeMutation = useMutation(getSubstituteVars);
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const dashboardDynamicVariables = useDynamicVariableSuggestions();
const getUpdatedQuery = useCallback(
async ({
widgetConfig,
dashboardData,
}: UseUpdatedQueryOptions): Promise<Query> => {
const variables = getDashboardVariables(dashboardData?.data?.variables);
async ({ widgetConfig }: UseUpdatedQueryOptions): Promise<Query> => {
// `/substitute_vars` only rewrites `$variable` references, so on surfaces with no
// dashboard behind them (APM, Celery, API monitoring) the round-trip is a no-op.
if (isEmpty(variables) && isEmpty(dashboardDynamicVariables)) {
if (isEmpty(dashboardDynamicVariables)) {
return widgetConfig.query;
}
@@ -61,7 +51,6 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
graphType: getGraphType(widgetConfig.panelTypes),
selectedTime: widgetConfig.timePreferance,
globalSelectedInterval,
variables,
originalGraphType: widgetConfig.panelTypes,
dynamicVariables: dashboardDynamicVariables,
});

View File

@@ -1,242 +1,40 @@
import React from 'react';
import { renderHook } from '@testing-library/react';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import useGetResolvedText from '../useGetResolvedText';
// Create a mock function that we can modify per test
let mockDashboardVariables: IDashboardVariables = {};
// Mock the useDashboardVariables hook
jest.mock('hooks/dashboard/useDashboardVariables', () => ({
useDashboardVariables: jest.fn(() => ({
dashboardVariables: mockDashboardVariables,
})),
}));
import useGetResolvedText from 'hooks/dashboard/useGetResolvedText';
describe('useGetResolvedText', () => {
const SERVICE_VAR = 'test, app +2-|-test, app, frontend, env';
const SEVERITY_VAR = 'DEBUG, INFO-|-DEBUG, INFO';
const EXPECTED_FULL_TEXT =
'Logs count in test, app, frontend, env in DEBUG, INFO';
const TRUNCATED_SERVICE = 'test, app +2';
const TEXT_TEMPLATE = 'Logs count in $service.name in $severity';
const renderHookWithProps = (
props: {
text: string | React.ReactNode;
maxLength?: number;
matcher?: string;
},
variables?: Record<string, string | number | boolean>,
): any => {
if (variables) {
mockDashboardVariables = Object.entries(
variables,
).reduce<IDashboardVariables>((acc, [key, value]) => {
acc[key] = {
id: key,
name: key,
description: '',
type: 'CUSTOM' as const,
sort: 'DISABLED' as const,
multiSelect: false,
showALLOption: false,
selectedValue: value,
};
return acc;
}, {});
} else {
mockDashboardVariables = {};
}
return renderHook(() => useGetResolvedText(props));
};
it('should resolve variables with truncated and full text', () => {
const text = TEXT_TEMPLATE;
const variables = {
'service.name': SERVICE_VAR,
severity: SEVERITY_VAR,
};
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.truncatedText).toBe(
`Logs count in ${TRUNCATED_SERVICE} in DEBUG, INFO`,
it('returns the text unchanged when it fits within maxLength', () => {
const { result } = renderHook(() =>
useGetResolvedText({ text: 'Logs count', maxLength: 100 }),
);
expect(result.current.fullText).toBe(EXPECTED_FULL_TEXT);
expect(result.current.fullText).toBe('Logs count');
expect(result.current.truncatedText).toBe('Logs count');
});
it('should handle text with maxLength truncation', () => {
const text = TEXT_TEMPLATE;
const variables = {
'service.name': SERVICE_VAR,
severity: SEVERITY_VAR,
};
it('returns the text unchanged when no maxLength is given', () => {
const text = 'a'.repeat(200);
const { result } = renderHook(() => useGetResolvedText({ text }));
const { result } = renderHookWithProps({ text, maxLength: 20 }, variables);
expect(result.current.truncatedText).toBe('Logs count in test, a...');
expect(result.current.fullText).toBe(EXPECTED_FULL_TEXT);
});
it('should handle multiple occurrences of the same variable', () => {
const text = 'Logs count in $service.name and $service.name';
const variables = {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.truncatedText).toBe(
'Logs count in test, app +2 and test, app +2',
);
expect(result.current.fullText).toBe(
'Logs count in test, app, frontend, env and test, app, frontend, env',
);
});
it('should handle different variable formats', () => {
const text =
'Logs in $service.name, {{service.name}}, [[service.name]] - $dyn-service.name';
const variables = {
'service.name': SERVICE_VAR,
'$dyn-service.name': 'dyn-1, dyn-2',
};
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.truncatedText).toBe(
'Logs in test, app +2, test, app +2, test, app +2 - dyn-1, dyn-2',
);
expect(result.current.fullText).toBe(
'Logs in test, app, frontend, env, test, app, frontend, env, test, app, frontend, env - dyn-1, dyn-2',
);
});
it('should handle custom matcher', () => {
const text = 'Logs count in #service.name in #severity';
const variables = {
'service.name': SERVICE_VAR,
severity: SEVERITY_VAR,
};
const { result } = renderHookWithProps({ text, matcher: '#' }, variables);
expect(result.current.truncatedText).toBe(
'Logs count in test, app +2 in DEBUG, INFO',
);
expect(result.current.fullText).toBe(EXPECTED_FULL_TEXT);
});
it('should handle non-string variable values', () => {
const text = 'Count: $count, Active: $active';
const variables = {
count: 42,
active: true,
};
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.fullText).toBe('Count: 42, Active: true');
expect(result.current.truncatedText).toBe('Count: 42, Active: true');
});
it('should keep original text for undefined variables', () => {
const text = 'Logs count in $service.name in $unknown';
const variables = {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.truncatedText).toBe(
'Logs count in test, app +2 in $unknown',
);
expect(result.current.fullText).toBe(
'Logs count in test, app, frontend, env in $unknown',
);
});
it('should handle non-string text input (ReactNode)', () => {
const reactNodeText = <div>Test ReactNode</div>;
const variables = {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps(
{
text: reactNodeText,
},
variables,
);
// Should return the ReactNode unchanged
expect(result.current.fullText).toBe(reactNodeText);
expect(result.current.truncatedText).toBe(reactNodeText);
});
it('should handle number input', () => {
const text = 123;
const variables = {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps(
{
text,
},
variables,
);
// Should return the number unchanged
expect(result.current.fullText).toBe(text);
expect(result.current.truncatedText).toBe(text);
});
it('should handle boolean input', () => {
const text = true;
const variables = {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps(
{
text,
},
variables,
it('truncates to maxLength with an ellipsis and keeps the full text', () => {
const { result } = renderHook(() =>
useGetResolvedText({ text: 'Logs count in production', maxLength: 20 }),
);
// Should return the boolean unchanged
expect(result.current.fullText).toBe(text);
expect(result.current.truncatedText).toBe(text);
expect(result.current.truncatedText).toBe('Logs count in pro...');
expect(result.current.truncatedText).toHaveLength(20);
expect(result.current.fullText).toBe('Logs count in production');
});
it('should handle complex variable names with improved patterns', () => {
const text = 'API: $api.v1.endpoint Config: $config.database.host';
const variables = {
'api.v1.endpoint': '/users',
'config.database.host': 'localhost:5432',
};
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.fullText).toBe('API: /users Config: localhost:5432');
expect(result.current.truncatedText).toBe(
'API: /users Config: localhost:5432',
it('passes non-string content through untouched', () => {
const node = <span>title</span>;
const { result } = renderHook(() =>
useGetResolvedText({ text: node, maxLength: 2 }),
);
});
it('should stop at punctuation boundaries correctly', () => {
const text = 'Status: $service.name, Error: $error.type;';
const variables = {
'service.name': 'web-api',
'error.type': 'timeout',
};
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.fullText).toBe('Status: web-api, Error: timeout;');
expect(result.current.truncatedText).toBe('Status: web-api, Error: timeout;');
expect(result.current.fullText).toBe(node);
expect(result.current.truncatedText).toBe(node);
});
});

View File

@@ -1,351 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { dashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import { IDashboardVariablesStoreState } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import {
VariableFetchState,
variableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { IDashboardVariable } from 'types/api/dashboard/variables';
import { useIsPanelWaitingOnVariable } from '../useVariableFetchState';
function makeVariable(
overrides: Partial<IDashboardVariable> & { id: string },
): IDashboardVariable {
return {
name: overrides.id,
description: '',
type: 'QUERY',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
...overrides,
};
}
function resetStores(): void {
variableFetchStore.set(() => ({
states: {},
lastUpdated: {},
cycleIds: {},
}));
dashboardVariablesStore.set(() => ({
dashboardId: '',
variables: {},
sortedVariablesArray: [],
dependencyData: null,
variableTypes: {},
dynamicVariableOrder: [],
}));
}
function setFetchStates(states: Record<string, VariableFetchState>): void {
variableFetchStore.set(() => ({
states,
lastUpdated: {},
cycleIds: {},
}));
}
function setDashboardVariables(
overrides: Partial<IDashboardVariablesStoreState>,
): void {
dashboardVariablesStore.set(() => ({
dashboardId: '',
variables: {},
sortedVariablesArray: [],
dependencyData: null,
variableTypes: {},
dynamicVariableOrder: [],
...overrides,
}));
}
describe('useIsPanelWaitingOnVariable', () => {
beforeEach(() => {
resetStores();
});
it('should return false when variableNames is empty', () => {
const { result } = renderHook(() => useIsPanelWaitingOnVariable([]));
expect(result.current).toBe(false);
});
it('should return false when all referenced variables are idle', () => {
setFetchStates({ a: 'idle', b: 'idle' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: 'val1' }),
b: makeVariable({ id: 'b', selectedValue: 'val2' }),
},
variableTypes: { a: 'QUERY', b: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a', 'b']));
expect(result.current).toBe(false);
});
it('should return true when a variable is loading with empty selectedValue', () => {
setFetchStates({ a: 'loading' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: undefined }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(true);
});
it('should return true when a variable is waiting with empty selectedValue', () => {
setFetchStates({ a: 'waiting' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: '' }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(true);
});
it('should return true when a variable is revalidating with empty selectedValue', () => {
setFetchStates({ a: 'revalidating' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: undefined }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(true);
});
it('should return false when a variable is loading but has a selectedValue', () => {
setFetchStates({ a: 'loading' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: 'some-value' }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(false);
});
it('should return false for DYNAMIC variable with allSelected=true that is loading but has a selectedValue', () => {
setFetchStates({ dyn: 'loading' });
setDashboardVariables({
variables: {
dyn: makeVariable({
id: 'dyn',
type: 'DYNAMIC',
selectedValue: 'some-val',
allSelected: true,
}),
},
variableTypes: { dyn: 'DYNAMIC' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
expect(result.current).toBe(false);
});
it('should return false for DYNAMIC variable with allSelected=true that is waiting but has a selectedValue', () => {
setFetchStates({ dyn: 'waiting' });
setDashboardVariables({
variables: {
dyn: makeVariable({
id: 'dyn',
type: 'DYNAMIC',
selectedValue: 'val',
allSelected: true,
}),
},
variableTypes: { dyn: 'DYNAMIC' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
expect(result.current).toBe(false);
});
it('should return false for DYNAMIC variable with allSelected=true that is idle', () => {
setFetchStates({ dyn: 'idle' });
setDashboardVariables({
variables: {
dyn: makeVariable({
id: 'dyn',
type: 'DYNAMIC',
selectedValue: 'val',
allSelected: true,
}),
},
variableTypes: { dyn: 'DYNAMIC' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
expect(result.current).toBe(false);
});
it('should return false for non-DYNAMIC variable with allSelected=false and non-empty value that is loading', () => {
setFetchStates({ a: 'loading' });
setDashboardVariables({
variables: {
a: makeVariable({
id: 'a',
selectedValue: 'val',
allSelected: false,
}),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(false);
});
it('should return true if any one of multiple variables is blocking', () => {
setFetchStates({ a: 'idle', b: 'loading' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: 'val' }),
b: makeVariable({ id: 'b', selectedValue: undefined }),
},
variableTypes: { a: 'QUERY', b: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a', 'b']));
expect(result.current).toBe(true);
});
it('should return false when variable has no entry in fetch store (treated as idle)', () => {
setFetchStates({}); // no state entry for 'a'
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: 'val' }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(false);
});
it('should return false when variable is in error state with empty selectedValue', () => {
setFetchStates({ a: 'error' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: undefined }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(false);
});
it('should react to store updates', () => {
setFetchStates({ a: 'loading' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: undefined }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(true);
// Simulate variable fetch completing
act(() => {
variableFetchStore.update((d) => {
d.states.a = 'idle';
});
});
expect(result.current).toBe(false);
});
it('should handle DYNAMIC variable with allSelected=false and empty selectedValue as blocking', () => {
setFetchStates({ dyn: 'loading' });
setDashboardVariables({
variables: {
dyn: makeVariable({
id: 'dyn',
type: 'DYNAMIC',
selectedValue: undefined,
allSelected: false,
}),
},
variableTypes: { dyn: 'DYNAMIC' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
expect(result.current).toBe(true);
});
it('should handle variable with array selectedValue as non-blocking when loading', () => {
setFetchStates({ a: 'loading' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: ['val1', 'val2'] }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(false);
});
it('should handle variable with empty array selectedValue as blocking when loading', () => {
setFetchStates({ a: 'loading' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: [] }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(true);
});
it('should find variable by name when store key differs from variable name', () => {
setFetchStates({ myVar: 'loading' });
setDashboardVariables({
variables: {
'uuid-abc-123': makeVariable({
id: 'uuid-abc-123',
name: 'myVar',
selectedValue: undefined,
}),
},
variableTypes: { myVar: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['myVar']));
expect(result.current).toBe(true);
});
it('should respect selectedValue when store key differs from variable name', () => {
// When the variable has a value, it should not block even if loading
setFetchStates({ myVar: 'loading' });
setDashboardVariables({
variables: {
'uuid-abc-123': makeVariable({
id: 'uuid-abc-123',
name: 'myVar',
selectedValue: 'production',
}),
},
variableTypes: { myVar: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['myVar']));
expect(result.current).toBe(false);
});
});

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -42,38 +41,10 @@ function useContextVariables({
// ! To be noted: This customVariables is not Dashboard Custom Variables
customVariables,
}: UseContextVariablesProps): UseContextVariablesResult {
const { dashboardVariables } = useDashboardVariables();
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// Extract dashboard variables
const processedDashboardVariables = useMemo(() => {
return Object.entries(dashboardVariables)
.filter(([, value]) => value.name)
.map(([, value]) => {
let processedValue: string | number | boolean;
let isArray = false;
if (Array.isArray(value.selectedValue)) {
processedValue = value.selectedValue.join(', ');
isArray = true;
} else if (value.selectedValue != null) {
processedValue = value.selectedValue;
} else {
processedValue = '';
}
return {
name: value.name || '',
value: processedValue,
source: 'dashboard' as const,
isArray,
originalValue: value.selectedValue,
};
});
}, [dashboardVariables]);
// Extract global variables
const globalVariables = useMemo(
() => [
@@ -109,12 +80,8 @@ function useContextVariables({
// Combine all variables
const allVariables = useMemo(
() => [
...processedDashboardVariables,
...globalVariables,
...customVariablesList,
],
[processedDashboardVariables, globalVariables, customVariablesList],
() => [...globalVariables, ...customVariablesList],
[globalVariables, customVariablesList],
);
// Create processed variables with truncation logic

View File

@@ -1,40 +0,0 @@
import { useCallback, useRef, useSyncExternalStore } from 'react';
import { dashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import {
IDashboardVariablesStoreState,
IUseDashboardVariablesReturn,
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
/**
* Generic selector hook for dashboard variables store
* Allows granular subscriptions to any part of the store state
*
* @example
* ! Select top-level field
* const variables = useDashboardVariablesSelector(s => s.variables);
*
* ! Select specific variable
* const fooVar = useDashboardVariablesSelector(s => s.variables['foo']);
*
* ! Select derived value
* const hasVariables = useDashboardVariablesSelector(s => Object.keys(s.variables).length > 0);
*/
export const useDashboardVariablesSelector = <T>(
selector: (state: IDashboardVariablesStoreState) => T,
): T => {
const selectorRef = useRef(selector);
selectorRef.current = selector;
const getSnapshot = useCallback(
() => selectorRef.current(dashboardVariablesStore.getSnapshot()),
[],
);
return useSyncExternalStore(dashboardVariablesStore.subscribe, getSnapshot);
};
export const useDashboardVariables = (): IUseDashboardVariablesReturn => {
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
return { dashboardVariables };
};

View File

@@ -1,30 +0,0 @@
import { useMemo } from 'react';
import {
IDashboardVariable,
TVariableQueryType,
} from 'types/api/dashboard/variables';
import { useDashboardVariables } from './useDashboardVariables';
export function useDashboardVariablesByType(
variableType: TVariableQueryType,
returnType: 'values',
): IDashboardVariable[];
export function useDashboardVariablesByType(
variableType: TVariableQueryType,
returnType?: 'entries',
): [string, IDashboardVariable][];
export function useDashboardVariablesByType(
variableType: TVariableQueryType,
returnType?: 'values' | 'entries',
): IDashboardVariable[] | [string, IDashboardVariable][] {
const { dashboardVariables } = useDashboardVariables();
return useMemo(() => {
const entries = Object.entries(dashboardVariables || {}).filter(
(entry): entry is [string, IDashboardVariable] =>
Boolean(entry[1].name) && entry[1].type === variableType,
);
return returnType === 'values' ? entries.map(([, value]) => value) : entries;
}, [dashboardVariables, variableType, returnType]);
}

View File

@@ -0,0 +1,17 @@
import { useSyncExternalStore } from 'react';
import {
DynamicVariableSuggestion,
dynamicVariableSuggestionsStore,
} from 'providers/Dashboard/store/dynamicVariableSuggestions';
/**
* Dynamic variables published by the dashboard currently open, so the query
* builder can offer `$variable` as a value for the key each one backs. Empty on
* surfaces with no dashboard behind them (APM, Celery, messaging queues).
*/
export function useDynamicVariableSuggestions(): DynamicVariableSuggestion[] {
return useSyncExternalStore(
dynamicVariableSuggestionsStore.subscribe,
dynamicVariableSuggestionsStore.getSnapshot,
);
}

View File

@@ -1,18 +1,8 @@
// this hook is used to get the resolved text of a variable, lets say we have a text - "Logs count in $service.name in $severity and $service.name and $severity $service.name"
// and the values of service.name and severity are "service1" and "error" respectively, then the resolved text should be "Logs count in service1 in error and service1 and error service1"
// is case of the multiple variables value, make them comma separated
// also have a prop saying max length post that you should truncate the text with "..."
// return value should be a full text string, and a truncated text string (if max length is provided)
import { ReactNode, useCallback, useMemo } from 'react';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { ReactNode, useMemo } from 'react';
interface UseGetResolvedTextProps {
text: string | ReactNode;
variables?: Record<string, string | number | boolean>;
maxLength?: number;
matcher?: string;
maxValues?: number; // Maximum number of values to show before adding +n more
}
interface ResolvedTextResult {
@@ -20,173 +10,23 @@ interface ResolvedTextResult {
truncatedText: string | ReactNode;
}
/**
* Returns a panel title alongside a copy truncated to `maxLength`, so a card can
* show the short form and keep the full string for its tooltip. Non-string content
* passes through untouched.
*/
function useGetResolvedText({
text,
maxLength,
matcher = '$',
maxValues = 2, // Default to showing 2 values before +n more
}: UseGetResolvedTextProps): ResolvedTextResult {
const { dashboardVariables } = useDashboardVariables();
const isString = typeof text === 'string';
const processedDashboardVariables = useMemo(() => {
return Object.entries(dashboardVariables).reduce<
Record<string, string | number | boolean>
>((acc, [, value]) => {
if (!value.name) {
return acc;
}
// Handle array values
if (Array.isArray(value.selectedValue)) {
acc[value.name] = value.selectedValue.join(', ');
} else if (value.selectedValue != null) {
acc[value.name] = value.selectedValue;
}
return acc;
}, {});
}, [dashboardVariables]);
// Process array values to add +n more notation for truncated text
const processedVariables = useMemo(() => {
const result: Record<string, string> = {};
Object.entries(processedDashboardVariables).forEach(([key, value]) => {
// If the value contains array data (comma-separated string), format it with +n more
if (
typeof value === 'string' &&
!value.includes('-|-') &&
value.includes(',')
) {
const values = value.split(',').map((v) => v.trim());
if (values.length > maxValues) {
const visibleValues = values.slice(0, maxValues);
const remainingCount = values.length - maxValues;
result[key] = `${visibleValues.join(
', ',
)} +${remainingCount}-|-${values.join(', ')}`;
} else {
result[key] = `${values.join(', ')}-|-${values.join(', ')}`;
}
} else {
// For values already formatted with -|- or non-array values
result[key] = String(value);
}
});
return result;
}, [processedDashboardVariables, maxValues]);
const combinedPattern = useMemo(() => {
const escapedMatcher = matcher.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const variablePatterns = [
`\\{\\{\\s*?\\.([^\\s}]+?)\\s*?\\}\\}`, // {{.var}}
`\\{\\{\\s*([^\\s}]+?)\\s*\\}\\}`, // {{var}}
`${escapedMatcher}([^\\s.,;)\\]}>]+(?:\\.[^\\s.,;)\\]}>]+)*)`, // $var.name.path - allows dots but stops at punctuation
`\\[\\[\\s*([^\\s\\]]+?)\\s*\\]\\]`, // [[var]]
];
return new RegExp(variablePatterns.join('|'), 'g');
}, [matcher]);
const extractVarName = useCallback(
(match: string): string => {
// Extract variable name from different formats
const varNamePattern = '[a-zA-Z_\\-][a-zA-Z0-9_.\\-]*';
if (match.startsWith('{{')) {
const dotMatch = match.match(
new RegExp(`\\{\\{\\s*\\.(${varNamePattern})\\s*\\}\\}`),
);
if (dotMatch) {
return dotMatch[1].trim();
}
const normalMatch = match.match(
new RegExp(`\\{\\{\\s*(${varNamePattern})\\s*\\}\\}`),
);
if (normalMatch) {
return normalMatch[1].trim();
}
} else if (match.startsWith('[[')) {
const bracketMatch = match.match(
new RegExp(`\\[\\[\\s*(${varNamePattern})\\s*\\]\\]`),
);
if (bracketMatch) {
return bracketMatch[1].trim();
}
} else if (match.startsWith(matcher)) {
// For $ variables, we always want to strip the prefix
// unless the full match exists in processedVariables
const withoutPrefix = match.substring(matcher.length).trim();
const fullMatch = match.trim();
// If the full match (with prefix) exists, use it
if (processedVariables[fullMatch] !== undefined) {
return fullMatch;
}
// Otherwise return without prefix
return withoutPrefix;
}
return match;
},
[matcher, processedVariables],
);
const fullText = useMemo(() => {
if (!isString) {
return text;
}
return (text as string)?.replace(combinedPattern, (match) => {
const varName = extractVarName(match);
const value = processedVariables[varName];
if (value != null) {
const parts = value.split('-|-');
return parts.length > 1 ? parts[1] : value;
}
return match;
});
}, [text, processedVariables, combinedPattern, extractVarName, isString]);
const truncatedText = useMemo(() => {
if (!isString) {
if (typeof text !== 'string' || !maxLength || text.length <= maxLength) {
return text;
}
return `${text.substring(0, maxLength - 3)}...`;
}, [text, maxLength]);
const result = (text as string)?.replace(combinedPattern, (match) => {
const varName = extractVarName(match);
const value = processedVariables[varName];
if (value != null) {
const parts = value.split('-|-');
return parts[0] || value;
}
return match;
});
if (maxLength && result.length > maxLength) {
// For the specific test case
if (maxLength === 20 && result.startsWith('Logs count in')) {
return 'Logs count in test, a...';
}
// General case
return `${result.substring(0, maxLength - 3)}...`;
}
return result;
}, [
text,
processedVariables,
combinedPattern,
maxLength,
extractVarName,
isString,
]);
return {
fullText,
truncatedText,
};
return { fullText: text, truncatedText };
}
export default useGetResolvedText;

View File

@@ -1,151 +0,0 @@
import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react';
import isEmpty from 'lodash-es/isEmpty';
import {
IVariableFetchStoreState,
VariableFetchState,
variableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { useDashboardVariablesSelector } from './useDashboardVariables';
/**
* Generic selector hook for the variable fetch store.
* Same pattern as useDashboardVariablesSelector.
*/
const useVariableFetchSelector = <T>(
selector: (state: IVariableFetchStoreState) => T,
): T => {
const selectorRef = useRef(selector);
selectorRef.current = selector;
const getSnapshot = useCallback(
() => selectorRef.current(variableFetchStore.getSnapshot()),
[],
);
return useSyncExternalStore(variableFetchStore.subscribe, getSnapshot);
};
interface UseVariableFetchStateReturn {
/** The current fetch state for this variable */
variableFetchState: VariableFetchState;
/** Current fetch cycle — include in react-query keys to auto-cancel stale requests */
variableFetchCycleId: number;
/** True if this variable is idle (not waiting and not fetching) */
isVariableSettled: boolean;
/** True if this variable is actively fetching (loading or revalidating) */
isVariableFetching: boolean;
/** True if this variable has completed at least one fetch cycle */
hasVariableFetchedOnce: boolean;
/** True if any parent variable hasn't settled yet */
isVariableWaitingForDependencies: boolean;
/** Message describing what this variable is waiting on, or null if not waiting */
variableDependencyWaitMessage?: string;
}
/**
* Per-variable hook that exposes the fetch state of a single variable.
* Reusable by both variable input components and panel components.
*
* Subscribes to both variableFetchStore (for states) and
* dashboardVariablesStore (for parent graph) to compute derived values.
*/
export function useVariableFetchState(
variableName: string,
): UseVariableFetchStateReturn {
// This variable's fetch state (loading, waiting, idle, etc.)
const variableFetchState = useVariableFetchSelector(
(s) => s.states[variableName] || 'idle',
) as VariableFetchState;
// All variable states — needed to check if parent variables are still in-flight
const allStates = useVariableFetchSelector((s) => s.states);
// Parent dependency graph — maps each variable to its direct parents
// e.g. { "childVariable": ["parentVariable"] } means "childVariable" depends on "parentVariable"
const parentGraph = useDashboardVariablesSelector(
(s) => s.dependencyData?.parentDependencyGraph,
);
// Timestamp of last successful fetch — 0 means never fetched
const lastUpdated = useVariableFetchSelector(
(s) => s.lastUpdated[variableName] || 0,
);
// Per-variable cycle counter — used as part of react-query keys
// so changing it auto-cancels stale requests for this variable only
const variableFetchCycleId = useVariableFetchSelector(
(s) => s.cycleIds[variableName] || 0,
);
const isVariableSettled = variableFetchState === 'idle';
const isVariableFetching =
variableFetchState === 'loading' || variableFetchState === 'revalidating';
// True after at least one successful fetch — used to show stale data while revalidating
const hasVariableFetchedOnce = lastUpdated > 0;
// Variable type — needed to differentiate waiting messages
const variableType = useDashboardVariablesSelector(
(s) => s.variableTypes[variableName],
);
// Parent variable names that haven't settled yet
const unsettledParents = useMemo(() => {
const parents = parentGraph?.[variableName] || [];
return parents.filter((p) => (allStates[p] || 'idle') !== 'idle');
}, [parentGraph, variableName, allStates]);
const isVariableWaitingForDependencies = unsettledParents.length > 0;
const variableDependencyWaitMessage = useMemo(() => {
if (variableFetchState !== 'waiting') {
return;
}
if (variableType === 'DYNAMIC') {
return 'Waiting for all query variable options to load.';
}
if (unsettledParents.length === 0) {
return;
}
const quoted = unsettledParents.map((p) => `"${p}"`);
const names =
quoted.length > 1
? `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}`
: quoted[0];
return `Waiting for options of ${names} to load.`;
}, [variableFetchState, variableType, unsettledParents]);
return {
variableFetchState,
isVariableSettled,
isVariableWaitingForDependencies,
variableDependencyWaitMessage,
isVariableFetching,
hasVariableFetchedOnce,
variableFetchCycleId,
};
}
export function useIsPanelWaitingOnVariable(variableNames: string[]): boolean {
const states = useVariableFetchSelector((s) => s.states);
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
return variableNames.some((name) => {
const variableFetchState = states[name];
const variableData = Object.values(dashboardVariables).find(
(v) => v.name === name,
);
const { selectedValue } = variableData || {};
const isVariableInFetchingOrWaitingState =
variableFetchState === 'loading' ||
variableFetchState === 'revalidating' ||
variableFetchState === 'waiting';
return isEmpty(selectedValue) ? isVariableInFetchingOrWaitingState : false;
});
}

View File

@@ -32,12 +32,8 @@ jest.mock(
}),
);
jest.mock('hooks/dashboard/useDashboardVariables', () => ({
useDashboardVariables: (): unknown => ({ dashboardVariables: {} }),
}));
jest.mock('hooks/dashboard/useDashboardVariablesByType', () => ({
useDashboardVariablesByType: (): unknown => ({}),
jest.mock('hooks/dashboard/useDynamicVariableSuggestions', () => ({
useDynamicVariableSuggestions: (): unknown[] => [],
}));
jest.mock('hooks/useNotifications', () => ({
@@ -46,10 +42,6 @@ jest.mock('hooks/useNotifications', () => ({
}),
}));
jest.mock('lib/dashboardVariables/getDashboardVariables', () => ({
getDashboardVariables: (): unknown => ({}),
}));
jest.mock('utils/getGraphType', () => ({
getGraphType: jest.fn().mockReturnValue('time_series'),
}));

View File

@@ -11,10 +11,8 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { MenuItemKeys } from 'container/WidgetCard/Header/contants';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
import { useNotifications } from 'hooks/useNotifications';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { isEmpty } from 'lodash-es';
import { AppState } from 'store/reducers';
@@ -38,11 +36,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const { notifications } = useNotifications();
const { dashboardVariables } = useDashboardVariables();
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const dashboardDynamicVariables = useDynamicVariableSuggestions();
return useCallback(() => {
if (!widget) {
@@ -68,7 +62,6 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
globalSelectedInterval,
graphType: getGraphType(widget.panelTypes),
selectedTime: widget.timePreferance,
variables: getDashboardVariables(dashboardVariables),
originalGraphType: widget.panelTypes,
dynamicVariables: dashboardDynamicVariables,
});
@@ -107,7 +100,6 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
globalSelectedInterval,
notifications,
queryRangeMutation,
dashboardVariables,
dashboardDynamicVariables,
widget,
]);

View File

@@ -5,7 +5,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { updateBarStepInterval } from 'container/WidgetCard/utils';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
import {
GetMetricQueryRange,
GetQueryResultsProps,
@@ -33,10 +33,7 @@ export const useGetQueryRange: UseGetQueryRange = (
options,
headers,
) => {
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const dashboardDynamicVariables = useDynamicVariableSuggestions();
const newRequestData: GetQueryResultsProps = useMemo(() => {
const firstQueryData = requestData.query.builder?.queryData[0];

View File

@@ -17,8 +17,8 @@ import {
import { Pagination } from 'hooks/queryPagination';
import { convertNewDataToOld } from 'lib/newQueryBuilder/convertNewDataToOld';
import { isEmpty } from 'lodash-es';
import { DynamicVariableSuggestion } from 'providers/Dashboard/store/dynamicVariableSuggestions';
import { SuccessResponseV2, Warning } from 'types/api';
import { IDashboardVariable } from 'types/api/dashboard/variables';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import {
@@ -179,7 +179,7 @@ export const getLegend = (
export async function GetMetricQueryRange(
props: GetQueryResultsProps,
version: string,
dynamicVariables?: IDashboardVariable[],
dynamicVariables?: DynamicVariableSuggestion[],
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<MetricQueryRangeSuccessResponse> {
@@ -364,5 +364,5 @@ export interface GetQueryResultsProps {
end?: number;
step?: number;
originalGraphType?: PANEL_TYPES;
dynamicVariables?: IDashboardVariable[];
dynamicVariables?: DynamicVariableSuggestion[];
}

View File

@@ -1,239 +0,0 @@
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import { IDependencyData } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { IDashboardVariable } from 'types/api/dashboard/variables';
/**
* Inter-variable dependency graph over the shared dashboard-variables store. A
* QUERY variable "depends on" another when its query text references that
* variable, so changing a value must refetch its dependents.
*
* Keyed on `IDashboardVariable`. The V2 editor has a parallel implementation
* over its own flat form model in
* `pages/DashboardPage/DashboardContainer/VariablesBar/utils/variableDependencies.ts`.
*/
export type VariableGraph = Record<string, string[]>;
/** Names of QUERY variables whose query references `variableName`. */
const getDependentVariablesBasedOnVariableName = (
variableName: string,
variables: IDashboardVariable[],
): string[] => {
if (!variables || !Array.isArray(variables)) {
return [];
}
return variables
.map((variable) => {
if (variable.type === 'QUERY') {
const queryValue = variable.queryValue || '';
if (textContainsVariableReference(queryValue, variableName)) {
return variable.name;
}
}
return null;
})
.filter((val): val is string => val !== null);
};
/** variable name → its direct dependents (children). */
export const buildDependencies = (
variables: IDashboardVariable[],
): VariableGraph => {
const graph: VariableGraph = {};
// Initialize empty arrays for all variables first
variables.forEach((variable) => {
if (variable.name) {
graph[variable.name] = [];
}
});
// For each QUERY variable, add it as a dependent to its referenced variables
variables.forEach((variable) => {
if (variable.name) {
const dependentVariables = getDependentVariablesBasedOnVariableName(
variable.name,
variables,
);
// For each referenced variable, add the current query as a dependent
graph[variable.name] = dependentVariables;
}
});
return graph;
};
/** Invert a child graph into a parent graph. */
export const buildParentDependencyGraph = (
graph: VariableGraph,
): VariableGraph => {
const parentGraph: VariableGraph = {};
// Initialize empty arrays for all nodes
Object.keys(graph).forEach((node) => {
parentGraph[node] = [];
});
// For each node and its children in the original graph
Object.entries(graph).forEach(([node, children]) => {
// For each child, add the current node as its parent
children.forEach((child) => {
if (!parentGraph[child]) {
parentGraph[child] = [];
}
parentGraph[child].push(node);
});
});
return parentGraph;
};
const collectCyclePath = (
graph: VariableGraph,
start: string,
end: string,
): string[] => {
const path: string[] = [];
let current = start;
const findParent = (node: string): string | undefined =>
Object.keys(graph).find((key) => graph[key]?.includes(node));
while (current !== end) {
const parent = findParent(current);
if (!parent) {
break;
}
path.push(parent);
current = parent;
}
return [start, ...path];
};
const detectCycle = (
graph: VariableGraph,
node: string,
visited: Set<string>,
recStack: Set<string>,
): string[] | null => {
if (!visited.has(node)) {
visited.add(node);
recStack.add(node);
const neighbors = graph[node] || [];
let cycleNodes: string[] | null = null;
neighbors.some((neighbor) => {
if (!visited.has(neighbor)) {
const foundCycle = detectCycle(graph, neighbor, visited, recStack);
if (foundCycle) {
cycleNodes = foundCycle;
return true;
}
} else if (recStack.has(neighbor)) {
// Found a cycle, collect the cycle nodes
cycleNodes = collectCyclePath(graph, node, neighbor);
return true;
}
return false;
});
if (cycleNodes) {
return cycleNodes;
}
}
recStack.delete(node);
return null;
};
/** Topological order, parent graph, transitive descendants and cycle info. */
export const buildDependencyGraph = (
dependencies: VariableGraph,
// eslint-disable-next-line sonarjs/cognitive-complexity
): IDependencyData => {
const inDegree: Record<string, number> = {};
const adjList: VariableGraph = {};
// Initialize in-degree and adjacency list
Object.keys(dependencies).forEach((node) => {
if (!inDegree[node]) {
inDegree[node] = 0;
}
if (!adjList[node]) {
adjList[node] = [];
}
dependencies[node]?.forEach((child) => {
if (!inDegree[child]) {
inDegree[child] = 0;
}
inDegree[child]++;
adjList[node].push(child);
});
});
// Detect cycles
const visited = new Set<string>();
const recStack = new Set<string>();
let cycleNodes: string[] | undefined;
Object.keys(dependencies).some((node) => {
if (!visited.has(node)) {
const foundCycle = detectCycle(dependencies, node, visited, recStack);
if (foundCycle) {
cycleNodes = foundCycle;
return true;
}
}
return false;
});
// Topological sort using Kahn's Algorithm
const queue: string[] = Object.keys(inDegree).filter(
(node) => inDegree[node] === 0,
);
const topologicalOrder: string[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) {
break;
}
topologicalOrder.push(current);
adjList[current]?.forEach((neighbor) => {
inDegree[neighbor]--;
if (inDegree[neighbor] === 0) {
queue.push(neighbor);
}
});
}
const hasCycle = topologicalOrder.length !== Object.keys(dependencies)?.length;
// Pre-compute transitive descendants by walking topological order in reverse.
// Each node's transitive descendants = direct children + their transitive descendants.
const transitiveDescendants: VariableGraph = {};
for (let i = topologicalOrder.length - 1; i >= 0; i--) {
const node = topologicalOrder[i];
const desc = new Set<string>();
for (const child of adjList[node] || []) {
desc.add(child);
for (const d of transitiveDescendants[child] || []) {
desc.add(d);
}
}
transitiveDescendants[node] = Array.from(desc);
}
return {
order: topologicalOrder,
graph: adjList,
parentDependencyGraph: buildParentDependencyGraph(adjList),
transitiveDescendants,
hasCycle,
cycleNodes,
};
};

View File

@@ -1,41 +0,0 @@
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import store from 'store';
export const getDashboardVariables = (
variables?: IDashboardVariables,
): Record<string, unknown> => {
if (!variables) {
return {};
}
try {
const { globalTime } = store.getState();
const { start, end } = getStartEndRangeTime({
type: 'GLOBAL_TIME',
interval: globalTime.selectedTime,
});
const variablesTuple: Record<string, unknown> = {
SIGNOZ_START_TIME: parseInt(start, 10) * 1e3,
SIGNOZ_END_TIME: parseInt(end, 10) * 1e3,
};
Object.entries(variables).forEach(([, value]) => {
if (value?.name) {
variablesTuple[value.name] =
value?.type === 'DYNAMIC' &&
value?.allSelected &&
value?.showALLOption &&
value?.multiSelect
? '__all__'
: value?.selectedValue;
}
});
return variablesTuple;
} catch (e) {
console.error(e);
}
return {};
};

View File

@@ -1,4 +1,5 @@
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Axis } from 'uplot';
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
@@ -6,6 +7,11 @@ import getGridColor from '../../uPlotLib/utils/getGridColor';
import { buildYAxisSizeCalculator } from '../utils/axis';
import { AxisProps, ConfigBuilder } from './types';
const PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT = [
PANEL_TYPES.TIME_SERIES,
PANEL_TYPES.BAR,
];
/**
* Builder for uPlot axis configuration
* Handles creation and merging of axis settings
@@ -61,9 +67,12 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
* Build values formatter for X-axis (time)
*/
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
const { isTimeAxis } = this.props;
const { panelType } = this.props;
if (isTimeAxis) {
if (
panelType &&
PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT.includes(panelType)
) {
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
}

View File

@@ -1,4 +1,5 @@
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
import type uPlot from 'uplot';
@@ -136,11 +137,11 @@ describe('UPlotAxisBuilder', () => {
});
});
it('uses time-based X-axis values formatter when the caller declares a time axis', () => {
it('uses time-based X-axis values formatter for time-series like panels', () => {
const builder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
isTimeAxis: true,
panelType: PANEL_TYPES.TIME_SERIES,
}),
);
@@ -149,11 +150,11 @@ describe('UPlotAxisBuilder', () => {
expect(config.values).toBe(uPlotXAxisValuesFormat);
});
it('does not attach X-axis datetime formatter for a non-time axis', () => {
it('does not attach X-axis datetime formatter when panel type is not supported', () => {
const builder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
isTimeAxis: false,
panelType: PANEL_TYPES.LIST, // not in PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT
}),
);
@@ -289,9 +290,22 @@ describe('UPlotAxisBuilder', () => {
expect(config.space).toBe(50);
});
it('omits the X-axis datetime formatter when no time axis is declared', () => {
const builder = new UPlotAxisBuilder(createAxisProps({ scaleKey: 'x' }));
expect(builder.getConfig().values).toBeUndefined();
it('includes PANEL_TYPES.BAR and PANEL_TYPES.TIME_SERIES in X-axis datetime formatter', () => {
const barBuilder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
panelType: PANEL_TYPES.BAR,
}),
);
expect(barBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
const timeSeriesBuilder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
panelType: PANEL_TYPES.TIME_SERIES,
}),
);
expect(timeSeriesBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
});
it('should return the existing size when cycleNum > 1', () => {

View File

@@ -1,4 +1,5 @@
import { PrecisionOption } from 'components/Graph/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Series } from 'uplot';
import { ThresholdsDrawHookOptions } from '../hooks/types';
@@ -52,50 +53,31 @@ export interface ConfigBuilderProps {
* Props for configuring an axis
*/
export interface AxisProps {
/** Scale this axis is drawn against — `'x'` / `'y'`, matching an `addScale` key. Also
* selects the default tick formatter and sizing (x: time, y: value + unit). */
scaleKey: string;
/** Axis title drawn alongside the ticks; omitted when there's nothing to name. */
label?: string;
/** Render the axis at all; false keeps the scale but draws no ticks or labels. */
show?: boolean;
/** Which edge of the plot the axis sits on: 0 | 1 | 2 | 3 — top, right, bottom, left. */
side?: 0 | 1 | 2 | 3;
/** Tick/label color. Defaults to black or white from `isDarkMode`. */
side?: 0 | 1 | 2 | 3; // top, right, bottom, left
stroke?: string;
/** Partial override of the grid lines; unset keys fall back to the theme defaults. */
grid?: {
stroke?: string;
width?: number;
show?: boolean;
};
/** Partial override of the tick marks; provided as-is to uPlot when set. */
ticks?: {
stroke?: string;
width?: number;
show?: boolean;
size?: number;
};
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
values?: uPlot.Axis.Values;
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
gap?: number;
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */
size?: uPlot.Axis.Size;
formatValue?: (v: number) => string;
space?: number; // Space for log scale axes
/** Picks the dark or light default for stroke and grid color. */
isDarkMode?: boolean;
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
isLogScale?: boolean;
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`). */
yAxisUnit?: string;
/**
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
* rather than inferred from a panel type — a chart whose x axis is buckets or categories
* (histogram) leaves it off.
*/
isTimeAxis?: boolean;
/** Decimal places for y axis tick values; unset lets the unit formatter decide. */
panelType?: PANEL_TYPES;
decimalPrecision?: PrecisionOption;
}

View File

@@ -124,9 +124,7 @@ export function buildBaseConfig({
side: 2,
isDarkMode,
isLogScale,
// Graph and bar plot time on X; every other panel type here does not.
isTimeAxis:
panelType === PANEL_TYPES.TIME_SERIES || panelType === PANEL_TYPES.BAR,
panelType,
});
builder.addAxis({
@@ -136,6 +134,7 @@ export function buildBaseConfig({
isDarkMode,
isLogScale,
yAxisUnit,
panelType,
});
return builder;

View File

@@ -13,6 +13,7 @@ import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.sche
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import TextToolTip from 'components/TextToolTip';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ClickHouseQueryContainer from 'container/QueryBuilder/rawQueryEditors/ClickHouse';
import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
@@ -63,12 +64,8 @@ function PanelEditorQueryBuilder({
footer,
stickyHeader = true,
}: PanelEditorQueryBuilderProps): JSX.Element {
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
// builder offers for this kind comes from the kind's own declaration.
// The shared QueryBuilderV2 / list-view checks still speak the legacy PANEL_TYPES.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
// Raw rows: the builder drops its aggregation controls, and with them the trace
// operator that combines aggregated trace queries (V1 parity).
const isListViewPanel = panelKind === 'signoz/ListPanel';
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
@@ -115,9 +112,9 @@ function PanelEditorQueryBuilder({
<QueryBuilderV2
panelType={panelType}
filterConfigs={filterConfigs}
showTraceOperator={!isListViewPanel}
showTraceOperator={panelType !== PANEL_TYPES.LIST}
version="v3"
isListViewPanel={isListViewPanel}
isListViewPanel={panelType === PANEL_TYPES.LIST}
queryComponents={{}}
signalSourceChangeEnabled
savePreviousQuery
@@ -151,7 +148,7 @@ function PanelEditorQueryBuilder({
),
children: queryTypeComponents[queryType].component,
}));
}, [panelKind, panelType, filterConfigs, isDarkMode, isListViewPanel]);
}, [panelKind, panelType, filterConfigs, isDarkMode]);
return (
<div

View File

@@ -60,7 +60,6 @@ function renderBuilder(
function lastQueryBuilderProps(): {
panelType: string;
isListViewPanel: boolean;
showTraceOperator: boolean;
filterConfigs: unknown;
} {
const calls = mockQueryBuilderV2.mock.calls;
@@ -116,9 +115,6 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
const props = lastQueryBuilderProps();
expect(props.panelType).toBe('graph');
expect(props.isListViewPanel).toBe(false);
// The trace operator combines aggregated trace queries, so it rides along with
// the aggregation controls.
expect(props.showTraceOperator).toBe(true);
expect(props.filterConfigs).toStrictEqual({});
});
@@ -128,7 +124,6 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
const props = lastQueryBuilderProps();
expect(props.panelType).toBe('list');
expect(props.isListViewPanel).toBe(true);
expect(props.showTraceOperator).toBe(false);
expect(props.filterConfigs).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },

View File

@@ -1,15 +1,12 @@
import { Spline } from '@signozhq/icons';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QueryTypeTag from 'components/QueryTypeTag/QueryTypeTag';
import { EQueryType } from 'types/common/dashboard';
interface PlotTagProps {
/** Authoring mode of the panel's query; undefined when no query exists yet. */
queryType: EQueryType | undefined;
/**
* Panel shows raw rows rather than a plot, so naming the mode the rows were
* "plotted with" would be wrong.
*/
isListViewPanel: boolean;
panelType: PANEL_TYPES;
className?: string;
}
@@ -20,10 +17,10 @@ interface PlotTagProps {
*/
function PlotTag({
queryType,
isListViewPanel,
panelType,
className,
}: PlotTagProps): JSX.Element | null {
if (queryType === undefined || isListViewPanel) {
if (queryType === undefined || panelType === PANEL_TYPES.LIST) {
return null;
}

View File

@@ -7,6 +7,7 @@ import PanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsL
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { getPanelQueryType } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getPanelQueryType';
import type {
@@ -71,6 +72,7 @@ function PreviewPane({
onClick,
enableDrillDown,
}: PreviewPaneProps): JSX.Element {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const queryType = getPanelQueryType(panel);
// Search term is ephemeral preview state, threaded to header + renderer but
@@ -84,7 +86,7 @@ function PreviewPane({
<div className={styles.header}>
<PlotTag
queryType={queryType}
isListViewPanel={panel.spec.plugin.kind === 'signoz/ListPanel'}
panelType={panelType}
className={styles.queryType}
/>
<div className={styles.dateTimeSelector}>

View File

@@ -1,22 +1,30 @@
import { render, screen } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import PlotTag from '../PlotTag';
describe('PlotTag', () => {
it('renders the resolved query mode', () => {
render(<PlotTag queryType={EQueryType.PROM} isListViewPanel={false} />);
render(
<PlotTag queryType={EQueryType.PROM} panelType={PANEL_TYPES.TIME_SERIES} />,
);
expect(screen.getByTestId('panel-editor-plot-tag')).toBeInTheDocument();
expect(screen.getByText('PromQL')).toBeInTheDocument();
});
it('renders nothing when there is no query yet', () => {
render(<PlotTag queryType={undefined} isListViewPanel={false} />);
render(<PlotTag queryType={undefined} panelType={PANEL_TYPES.TIME_SERIES} />);
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
});
it('renders nothing for a list panel (query mode is irrelevant)', () => {
render(<PlotTag queryType={EQueryType.QUERY_BUILDER} isListViewPanel />);
it('renders nothing for list panels (query mode is irrelevant)', () => {
render(
<PlotTag
queryType={EQueryType.QUERY_BUILDER}
panelType={PANEL_TYPES.LIST}
/>,
);
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
});
});

View File

@@ -4,10 +4,7 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import {
getPanelDefinition,
isPanelKindSupported,
} from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
@@ -94,9 +91,8 @@ export function usePanelEditSession({
const query = usePanelQuery({
panel: draft,
panelId,
queryCapabilities: panelDefinition.queryCapabilities,
time,
enabled: isPanelKindSupported(panelKind),
enabled: !!panelDefinition,
});
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({

View File

@@ -6,7 +6,7 @@ import type {
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
handleQueryChange,
type PartialPanelTypes,
@@ -146,7 +146,7 @@ export function usePanelTypeSwitch({
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
const nextQuery =
newKind === 'signoz/ListPanel'
newPanelType === PANEL_TYPES.LIST
? withDefaultListOrder(transformed)
: transformed;
const signal = getBuilderQueries(currentSpec.queries)[0]

View File

@@ -1,14 +1,7 @@
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { UNSUPPORTED_PANEL } from '../kinds/UnsupportedPanel/definition';
import { getPanelDefinition, isPanelKindSupported } from '../registry';
import type { PanelQueryCapabilities } from '../types/panelCapabilities';
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
import {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
@@ -22,7 +15,6 @@ import type { PanelKind } from '../types/panelKind';
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
const { logs, traces, metrics } = TelemetrytypesSignalDTO;
const { time_series, scalar, raw } = Querybuildertypesv5RequestTypeDTO;
const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
@@ -45,117 +37,9 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
'signoz/ListPanel': [logs, traces],
};
// Exhaustive over PanelKind, so a new kind can't ship without stating how its request is
// shaped — the check that used to be implicit in a legacy PANEL_TYPES switch.
const EXPECTED_QUERY_CAPABILITIES: Record<PanelKind, PanelQueryCapabilities> = {
'signoz/TimeSeriesPanel': {
requestType: time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Bar bins client-side, so it asks for a widened step interval over a raw series.
'signoz/BarChartPanel': {
requestType: time_series,
formatTableResultForUI: false,
bucketedStepInterval: true,
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/HistogramPanel': {
requestType: time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/NumberPanel': {
requestType: scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/PieChartPanel': {
requestType: scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Only Table asks the server to transpose its scalar result into UI rows.
'signoz/TablePanel': {
requestType: scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Only List reads raw rows, pages them server-side, and needs an order tiebreaker.
'signoz/ListPanel': {
requestType: raw,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: true,
serverPaginated: true,
},
};
const ALL_KINDS = Object.keys(EXPECTED_QUERY_TYPES) as PanelKind[];
describe('panel capabilities guard', () => {
describe('query capabilities', () => {
it.each(ALL_KINDS)('declares how %s shapes its request', (kind) => {
expect(getPanelDefinition(kind).queryCapabilities).toStrictEqual(
EXPECTED_QUERY_CAPABILITIES[kind],
);
});
});
// A dashboard spec written by a newer SigNoz can name a kind this build has no
// definition for. The registry answers with UNSUPPORTED_PANEL rather than nothing, so
// every guard below reads it without first proving a definition exists.
describe('a kind this build cannot render', () => {
const unknownKind = 'signoz/SomeFutureKindPanel' as PanelKind;
it('is not reported as supported', () => {
expect(isPanelKindSupported(unknownKind)).toBe(false);
expect(isPanelKindSupported('signoz/TimeSeriesPanel')).toBe(true);
});
it('still resolves to a definition', () => {
expect(getPanelDefinition(unknownKind)).toBe(UNSUPPORTED_PANEL);
});
it('declares nothing, so it is never offered as authorable', () => {
expect(getSupportedSignals(unknownKind)).toStrictEqual([]);
expect(getSupportedQueryTypes(unknownKind)).toStrictEqual([]);
expect(isSignalSupported(unknownKind, logs)).toBe(false);
expect(
isPanelCombinationValid({ kind: unknownKind, queryType: QUERY_BUILDER }),
).toBe(false);
expect(getHiddenQueryBuilderFields(unknownKind, logs)).toStrictEqual({});
expect(getPanelDefinition(unknownKind).sections).toStrictEqual([]);
});
it('offers no actions', () => {
expect(getPanelDefinition(unknownKind).actions).toStrictEqual(
NO_PANEL_ACTIONS,
);
expect(NO_PANEL_ACTIONS.view).toBe(false);
expect(NO_PANEL_ACTIONS.edit).toBe(false);
expect(NO_PANEL_ACTIONS.drilldown).toBe(false);
});
it('carries an inert query shape, so a stray request can do no harm', () => {
const { queryCapabilities } = getPanelDefinition(unknownKind);
expect(queryCapabilities.requestType).toBe(time_series);
expect(queryCapabilities.serverPaginated).toBe(false);
expect(queryCapabilities.formatTableResultForUI).toBe(false);
});
});
describe('query type support', () => {
it.each(ALL_KINDS)('declares the expected query types for %s', (kind) => {
expect(getSupportedQueryTypes(kind)).toStrictEqual(

View File

@@ -20,12 +20,8 @@ interface NoDataProps {
isFetching?: boolean;
/** When provided, renders a Retry button that re-runs the query. */
onRetry?: () => void;
/**
* The panel this empty state stands in for. Every renderer has it, and it decides
* whether the global "Extend time range" action applies (a panel locked to a fixed
* time preference can't be widened by it) as well as what the action events report.
*/
panel: DashboardtypesPanelDTO;
/** Hides the global "Extend time range" action when this panel is locked to a fixed time preference. */
panel?: DashboardtypesPanelDTO;
'data-testid'?: string;
}
@@ -47,17 +43,19 @@ function NoData({
const globalExtend = useExtendTimeWindow();
// The View modal's local extender wins; the global one only applies to a panel that
// follows the ambient window (a fixed preference can't be widened by it).
const hasFixedTimePreference = panel
? panelHasFixedTimePreference(panel)
: false;
const activeExtend =
viewExtend ?? (panelHasFixedTimePreference(panel) ? undefined : globalExtend);
viewExtend ?? (hasFixedTimePreference ? undefined : globalExtend);
if (isFetching) {
return <PanelLoader />;
}
// `panelType` stays on the event so existing reports keep resolving; `panelKind` is the
// V2 identity, and the only one that can tell two kinds sharing a panel type apart.
const panelKind = panel.spec.plugin.kind;
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const panelType = panel
? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind]
: undefined;
const extendAction: PanelMessageAction | undefined =
activeExtend?.canExtend && activeExtend.actionLabel
@@ -67,7 +65,6 @@ function NoData({
void logEvent(DashboardDetailEvents.NoDataAction, {
action: 'extendTime',
panelType,
panelKind,
});
activeExtend.extend();
},
@@ -82,7 +79,6 @@ function NoData({
void logEvent(DashboardDetailEvents.NoDataAction, {
action: 'retry',
panelType,
panelKind,
});
onRetry();
},

View File

@@ -33,12 +33,7 @@ function panelWith(
timePreference?: DashboardtypesTimePreferenceDTO,
): DashboardtypesPanelDTO {
return {
spec: {
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { visualization: { timePreference } },
},
},
spec: { plugin: { spec: { visualization: { timePreference } } } },
} as unknown as DashboardtypesPanelDTO;
}
@@ -49,7 +44,7 @@ describe('NoData', () => {
});
it('renders the empty-state title and hint', () => {
render(<NoData panel={panelWith()} />);
render(<NoData />);
expect(screen.getByTestId('panel-no-data')).toBeInTheDocument();
expect(screen.getByText('No data in this time range')).toBeInTheDocument();
@@ -60,7 +55,7 @@ describe('NoData', () => {
it('offers to extend the window as the primary action', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData panel={panelWith()} />);
render(<NoData />);
const action = screen.getByTestId('panel-no-data-action');
expect(action).toHaveTextContent('Extend time range');
@@ -73,7 +68,7 @@ describe('NoData', () => {
it('renders both Extend (primary) and Retry (secondary) when a retry handler is given', () => {
const onRetry = jest.fn();
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData onRetry={onRetry} panel={panelWith()} />);
render(<NoData onRetry={onRetry} />);
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
'Extend time range',
@@ -87,7 +82,7 @@ describe('NoData', () => {
it('falls back to Retry as the sole action when the window cannot be widened', () => {
const onRetry = jest.fn();
render(<NoData onRetry={onRetry} panel={panelWith()} />);
render(<NoData onRetry={onRetry} />);
const action = screen.getByTestId('panel-no-data-action');
expect(action).toHaveTextContent('Retry');
@@ -106,7 +101,7 @@ describe('NoData', () => {
useViewPanelStore.setState({
viewPanelExtendWindow: extender({ extend: storeExtend }),
});
render(<NoData panel={panelWith()} />);
render(<NoData />);
fireEvent.click(screen.getByTestId('panel-no-data-action'));
expect(storeExtend).toHaveBeenCalledTimes(1);
@@ -114,7 +109,7 @@ describe('NoData', () => {
});
it('renders no action when nothing can be widened and no retry handler', () => {
render(<NoData panel={panelWith()} />);
render(<NoData />);
expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument();
expect(
@@ -124,7 +119,7 @@ describe('NoData', () => {
it('shows the panel loader (not the empty state) while refetching', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData isFetching panel={panelWith()} />);
render(<NoData isFetching />);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('panel-no-data')).not.toBeInTheDocument();
@@ -133,7 +128,7 @@ describe('NoData', () => {
it('honours the data-testid override for the number panel', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData data-testid="number-panel-no-data" panel={panelWith()} />);
render(<NoData data-testid="number-panel-no-data" />);
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
});

View File

@@ -1,10 +1,7 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
@@ -23,15 +20,6 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
// Bars are binned client-side from a raw time series, so the request asks for a
// step interval wide enough to keep the bar count readable (V1 parity).
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: true,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,5 +1,6 @@
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'lib/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
@@ -46,7 +47,7 @@ export function buildBarChartConfig({
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
isTimeAxis: true,
panelType: PANEL_TYPES.BAR,
isDarkMode,
timezone,
panelMode,

View File

@@ -1,10 +1,7 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
@@ -23,15 +20,6 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
// Buckets are computed client-side from the raw series, so the request is a plain
// time series — the bucket count is a display concern, not a query one.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,5 +1,6 @@
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'lib/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
@@ -43,7 +44,7 @@ export function buildHistogramConfig({
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
isTimeAxis: false,
panelType: PANEL_TYPES.HISTOGRAM,
isDarkMode,
timezone,
panelMode,

View File

@@ -1,10 +1,7 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
@@ -33,15 +30,6 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
},
},
sections,
// The only kind reading raw rows: they page server-side, and the sort needs a
// tiebreaker so a duplicated sort key can't repeat or skip a row across pages.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.raw,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: true,
serverPaginated: true,
},
actions: {
view: true,
edit: true,

View File

@@ -1,10 +1,7 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
@@ -23,13 +20,6 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,10 +1,7 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
@@ -19,13 +16,6 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,10 +1,7 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
@@ -19,14 +16,6 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
queryBuilderFields: {},
// The only kind that asks the server to transpose its scalar result into UI rows.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
actions: {
view: true,

View File

@@ -1,10 +1,7 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
@@ -23,13 +20,6 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,5 +1,6 @@
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'lib/visualization/panels/types';
import {
buildBaseConfig,
@@ -65,7 +66,7 @@ export function buildTimeSeriesConfig({
}: BuildTimeSeriesConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
isTimeAxis: true,
panelType: PANEL_TYPES.TIME_SERIES,
isDarkMode,
timezone,
panelMode,

View File

@@ -1,26 +0,0 @@
import { CircleHelp } from '@signozhq/icons';
import PanelMessage from '../../components/PanelMessage/PanelMessage';
import PanelStyles from '../../panel.module.scss';
/**
* Body for a panel whose kind this build has no renderer for — a spec written by a newer
* SigNoz names a visualization that didn't exist when this client shipped. Says so in
* place of the chart, so the panel keeps its slot in the layout instead of leaving a hole.
*/
function UnsupportedPanelRenderer(): JSX.Element {
return (
<div
data-testid="unsupported-panel-renderer"
className={PanelStyles.panelContainer}
>
<PanelMessage
icon={<CircleHelp size={18} />}
title="Unsupported panel type"
description="This panel was built with a newer version of SigNoz. Upgrade to view it."
/>
</div>
);
}
export default UnsupportedPanelRenderer;

View File

@@ -1,34 +0,0 @@
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import {
NO_PANEL_ACTIONS,
type RenderablePanelDefinition,
} from '../../types/panelDefinition';
import Renderer from './Renderer';
/**
* Stand-in definition for a kind that isn't in the registry, so `getPanelDefinition`
* always resolves and no caller has to branch on a missing one. It declares nothing: no
* signals, no query types, no config sections and no actions — an unknown kind can't be
* queried, configured or acted on, only shown as unsupported.
*
* `kind` carries a sentinel that no API enum value can collide with; the cast is the one
* place this definition steps outside `PanelKind`.
*/
export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
kind: '<unsupported>' as RenderablePanelDefinition['kind'],
displayName: 'Unsupported panel',
Renderer,
sections: [],
supportedSignals: [],
supportedQueryTypes: [],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: NO_PANEL_ACTIONS,
};

View File

@@ -5,7 +5,6 @@ import { definition as PieChart } from './kinds/PieChartPanel/definition';
import { definition as TimeSeries } from './kinds/TimeSeriesPanel/definition';
import { definition as Table } from './kinds/TablePanel/definition';
import { definition as List } from './kinds/ListPanel/definition';
import { UNSUPPORTED_PANEL } from './kinds/UnsupportedPanel/definition';
import type {
PanelRegistry,
RenderablePanelDefinition,
@@ -23,24 +22,8 @@ export const PANELS: PanelRegistry = {
[List.kind]: List,
};
/**
* Whether this build can render the kind. `PanelKind` spans every kind the API declares,
* but a dashboard spec written by a newer SigNoz can name one this client has never heard
* of — so ask before doing work on a panel's behalf, such as fetching its data.
*/
export function isPanelKindSupported(kind: PanelKind): boolean {
return kind in PANELS;
}
/**
* The definition for a kind — always one. An unregistered kind resolves to
* {@link UNSUPPORTED_PANEL}, which declares no capabilities and renders as unsupported, so
* callers read a definition's fields without first proving it exists.
*/
export function getPanelDefinition(kind: PanelKind): RenderablePanelDefinition {
// Single intentional cast widening the per-kind Renderer to the kind-agnostic
// prop surface (a per-kind renderer can't be statically validated against the union).
return (
(PANELS[kind] as RenderablePanelDefinition | undefined) ?? UNSUPPORTED_PANEL
);
return PANELS[kind] as RenderablePanelDefinition;
}

View File

@@ -1,7 +1,4 @@
import {
Querybuildertypesv5RequestTypeDTO,
type TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
/**
@@ -21,30 +18,3 @@ export type FilterConfigsPartial = NonNullable<
export type QueryBuilderFieldRule = {
default?: FilterConfigsPartial;
} & Partial<Record<TelemetrytypesSignalDTO, FilterConfigsPartial>>;
/**
* How a kind's query-range request is shaped. Declared per-kind in
* `kinds/<Kind>/definition.ts` and read through the capabilities guard, so no V2 code
* has to translate a panel kind into the legacy `PANEL_TYPES` enum to answer these.
*/
export interface PanelQueryCapabilities {
/** V5 request type the panel's data comes back as. */
requestType: Querybuildertypesv5RequestTypeDTO;
/** Server transposes the scalar result into UI table rows (`formatOptions.formatTableResultForUI`). */
formatTableResultForUI: boolean;
/**
* Widen the step interval to cap how many buckets come back — kinds that bin
* client-side from a raw time series rather than plotting every point.
*/
bucketedStepInterval: boolean;
/**
* Append a deterministic tiebreaker to the query's `order` so offset paging over raw
* rows can't repeat or skip a row when the sort key has duplicates.
*/
orderTiebreaker: boolean;
/**
* Rows page server-side via `offset`/`limit`. AND-ed at the call site with "the query
* carries no explicit limit" — an explicit limit means the user asked for a fixed set.
*/
serverPaginated: boolean;
}

View File

@@ -5,10 +5,7 @@ import type { EQueryType } from 'types/common/dashboard';
import type { SectionConfig } from './sections';
import type { AnyPanelInteractionProps } from './interactions';
import type { PanelKind } from './panelKind';
import type {
PanelQueryCapabilities,
QueryBuilderFieldRule,
} from './panelCapabilities';
import type { QueryBuilderFieldRule } from './panelCapabilities';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
/** Export formats offered under the single "Download" action. */
@@ -42,24 +39,6 @@ export interface PanelActionCapabilities {
drilldown: boolean;
}
/**
* No actions at all — for a kind this build can't render, where every action would act on
* a panel body that isn't there. See `UNSUPPORTED_PANEL`.
*/
export const NO_PANEL_ACTIONS: PanelActionCapabilities = {
view: false,
edit: false,
clone: false,
download: {
[DownloadFormat.CSV]: false,
[DownloadFormat.PNG]: false,
[DownloadFormat.SVG]: false,
},
createAlert: false,
search: false,
drilldown: false,
};
export interface PanelDefinition<K extends PanelKind = PanelKind> {
kind: K;
displayName: string;
@@ -71,8 +50,6 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
supportedQueryTypes: EQueryType[];
/** Query-builder fields this kind hides/disables, optionally per signal (`{}` hides none). */
queryBuilderFields: QueryBuilderFieldRule;
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
queryCapabilities: PanelQueryCapabilities;
actions: PanelActionCapabilities;
}

View File

@@ -1,7 +1,7 @@
import { buildDefaultQueries } from '../buildDefaultQueries';
describe('buildDefaultQueries', () => {
it('seeds a list panel with a runnable logs query ordered by timestamp desc', () => {
it('seeds a List panel with a runnable logs query ordered by timestamp desc', () => {
const queries = buildDefaultQueries('signoz/ListPanel');
expect(queries).toHaveLength(1);
@@ -13,7 +13,7 @@ describe('buildDefaultQueries', () => {
expect(serialized.toLowerCase()).toContain('logs');
});
it('seeds a list panel without a limit so it pages server-side by default', () => {
it('seeds a List panel without a limit so it pages server-side by default', () => {
const queries = buildDefaultQueries('signoz/ListPanel');
// A limit would make usePanelQuery treat the panel as a static, unpaged list.
@@ -21,7 +21,7 @@ describe('buildDefaultQueries', () => {
expect(spec.limit).toBeUndefined();
});
it('seeds no query for plotted kinds (they seed from the builder)', () => {
it('seeds no query for non-List kinds (they seed from the builder)', () => {
expect(buildDefaultQueries('signoz/TimeSeriesPanel')).toStrictEqual([]);
expect(buildDefaultQueries('signoz/NumberPanel')).toStrictEqual([]);
});

View File

@@ -3,6 +3,7 @@ import type {
DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'lib/visualization/panels/types';
import onClickPlugin, {
OnClickPluginOpts,
@@ -25,11 +26,7 @@ import {
*/
export interface BuildBaseConfigArgs {
panelId: string;
/**
* X axis plots timestamps, so its ticks format as dates/times. Each kind states this
* for itself — a bucketed x axis (histogram) passes false.
*/
isTimeAxis: boolean;
panelType: PANEL_TYPES;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
@@ -66,7 +63,7 @@ export interface BuildBaseConfigArgs {
*/
export function buildBaseConfig({
panelId,
isTimeAxis,
panelType,
isDarkMode,
timezone,
panelMode,
@@ -136,7 +133,7 @@ export function buildBaseConfig({
side: 2,
isDarkMode,
isLogScale,
isTimeAxis,
panelType,
});
builder.addAxis({
@@ -146,6 +143,7 @@ export function buildBaseConfig({
isDarkMode,
isLogScale,
yAxisUnit,
panelType,
});
return builder;

View File

@@ -1,15 +1,14 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { listViewInitialLogQuery } from 'constants/queryBuilder';
import { listViewInitialLogQuery, PANEL_TYPES } from 'constants/queryBuilder';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import { PANEL_KIND_TO_PANEL_TYPE, type PanelKind } from '../types/panelKind';
/** Seed query for a new panel. Only a list panel needs one (logs, timestamp desc) so its
/** Seed query for a new panel. Only List needs one (logs, timestamp desc) so its
* preview runs on open; other kinds start empty and seed from the builder. */
export function buildDefaultQueries(kind: PanelKind): DashboardtypesQueryDTO[] {
if (kind !== 'signoz/ListPanel') {
return [];
if (PANEL_KIND_TO_PANEL_TYPE[kind] === PANEL_TYPES.LIST) {
return toPerses(listViewInitialLogQuery, PANEL_TYPES.LIST);
}
// `toPerses` pivots through the V1 `Query`, which is still keyed by panel type.
return toPerses(listViewInitialLogQuery, PANEL_KIND_TO_PANEL_TYPE[kind]);
return [];
}

View File

@@ -1,10 +1,7 @@
import { useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ContextMenu from 'periscope/components/ContextMenu';
import {
getPanelDefinition,
isPanelKindSupported,
} from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import {
getPanelTimePreference,
panelTimePreferenceLabel,
@@ -53,22 +50,15 @@ function Panel({
// Header search: only kinds that declare it render the box. The term is owned
// here and threaded to both the header (input) and renderer (filter).
const searchable = panelDefinition.actions.search;
const searchable = !!panelDefinition?.actions.search;
const [searchTerm, setSearchTerm] = useState('');
// Only an explicit false defers the fetch: `isVisible` is undefined wherever no
// observer reports visibility (the View modal, the editor preview), and those panels
// are on screen by construction.
const isOffScreen = isVisible === false;
const { data, isFetching, isPreviousData, error, refetch, pagination } =
usePanelQuery({
panel,
panelId,
queryCapabilities: panelDefinition.queryCapabilities,
// Lazy: fetch once on screen, and never for a kind this build can't render —
// the data would have nothing to render into.
enabled: isPanelKindSupported(panelKind) && !isOffScreen,
// Lazy: fetch only once on screen (undefined → visible) and a renderer exists.
enabled: !!panelDefinition && isVisible !== false,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
@@ -95,23 +85,25 @@ function Panel({
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
<PanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
/>
{panelDefinition && (
<PanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
/>
)}
<ContextMenu {...drilldown.contextMenuProps} />
</div>
);

View File

@@ -0,0 +1,64 @@
import { type KeyboardEvent, useCallback } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import styles from './ViewPanelModal.module.scss';
interface ViewPanelQueryBuilderProps {
panelType: PANEL_TYPES;
/** Preview fetch in flight — drives the Run/Cancel button state. */
isLoadingQueries: boolean;
/** Run the current query (Run Query button / ⌘↵). */
onStageRunQuery: () => void;
/** Abort the in-flight preview fetch. */
onCancelQuery: () => void;
}
/**
* Drilldown query editor for the View modal. Mirrors V1's FullView: the query builder
* rows + a "Run Query" button, with NO query-type tabs (ClickHouse/PromQL) — drilldown
* is query-builder only, exactly as V1.
*/
function ViewPanelQueryBuilder({
panelType,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
}: ViewPanelQueryBuilderProps): JSX.Element {
const handleKeyDownCapture = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
onStageRunQuery();
}
},
[onStageRunQuery],
);
return (
<div
className={styles.queryBuilder}
data-testid="view-panel-query-builder"
onKeyDownCapture={handleKeyDownCapture}
role="presentation"
>
<QueryBuilderV2
panelType={panelType}
version="v3"
isListViewPanel={panelType === PANEL_TYPES.LIST}
signalSourceChangeEnabled
/>
<div className={styles.queryBuilderToolbar}>
<RightToolbarActions
handleCancelQuery={onCancelQuery}
onStageRunQuery={onStageRunQuery}
isLoadingQueries={isLoadingQueries}
/>
</div>
</div>
);
}
export default ViewPanelQueryBuilder;

View File

@@ -162,9 +162,7 @@ describe('useCreateAlertFromPanel', () => {
expect(mockBuildQueryRangeRequest).toHaveBeenCalledWith(
expect.objectContaining({
queries: panel.spec.queries,
queryCapabilities: expect.objectContaining({
requestType: 'time_series',
}),
panelType: PANEL_TYPES.TIME_SERIES,
variables: { service: { type: 'query', value: 'checkout' } },
}),
);

View File

@@ -83,7 +83,6 @@ export function useClonePanel({
void logEvent(DashboardDetailEvents.PanelAction, {
action: 'clone',
panelType: PANEL_KIND_TO_PANEL_TYPE[source.panel.spec.plugin.kind],
panelKind: source.panel.spec.plugin.kind,
panelId,
...eventMeta,
});

View File

@@ -15,7 +15,6 @@ import { buildQueryRangeRequest } from 'pages/DashboardPage/DashboardContainer/q
import { envelopesToQuery } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
import { selectResolvedVariables } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -48,15 +47,11 @@ export function useCreateAlertFromPanel(): (
return useCallback(
(panel: DashboardtypesPanelDTO, panelId: string): void => {
const panelKind = panel.spec.plugin.kind;
// Alerts are a V1 surface: the query pivots through the V1 `Query` shape and the
// URL carries a legacy panel type, so this flow keeps translating.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
void logEvent(DashboardDetailEvents.PanelAction, {
action: 'createAlerts',
panelType,
panelKind,
...eventMeta,
widgetId: panelId,
queryType: getPanelQueryType(panel),
@@ -70,7 +65,7 @@ export function useCreateAlertFromPanel(): (
// Redux global time is nanoseconds; the request DTO takes epoch ms.
const request = buildQueryRangeRequest({
queries: panel.spec.queries,
queryCapabilities: getPanelDefinition(panelKind).queryCapabilities,
panelType,
startMs: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
variables,

View File

@@ -44,7 +44,6 @@ export function useDeletePanel({
}
const removed = section.items.find((i) => i.id === panelId);
const removedKind = removed?.panel?.spec.plugin.kind;
const nextItems = section.items.filter((i) => i.id !== panelId);
try {
await patchAsync([
@@ -53,15 +52,9 @@ export function useDeletePanel({
]);
void logEvent(DashboardDetailEvents.PanelAction, {
action: 'delete',
// An item ref can outlive its panel, so both fields go on together or
// not at all: `panelType` keeps existing reports resolving, `panelKind`
// is the V2 identity.
...(removedKind
? {
panelType: PANEL_KIND_TO_PANEL_TYPE[removedKind],
panelKind: removedKind,
}
: {}),
panelType: removed?.panel
? PANEL_KIND_TO_PANEL_TYPE[removed.panel.spec.plugin.kind]
: undefined,
panelId,
...eventMeta,
});

View File

@@ -43,7 +43,6 @@ export function useDownloadPanelCsv({
void logEvent(DashboardDetailEvents.PanelExported, {
format: 'csv',
panelType: PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
panelKind: panel.spec.plugin.kind,
});
}, [canDownloadCsv, fileName, panel, data]);
}

View File

@@ -128,14 +128,11 @@ export function useDrilldown(
const onPanelClick = useCallback(
(payload: DrilldownClickPayload): void => {
void logEvent(DashboardDetailEvents.DrilldownOpened, {
panelType,
panelKind: kind,
});
void logEvent(DashboardDetailEvents.DrilldownOpened, { panelType });
setSubMenu(DrilldownSubMenu.Base);
onClick(payload.coordinates, payload.context);
},
[onClick, panelType, kind],
[onClick, panelType],
);
const handleClose = useCallback((): void => {
@@ -179,8 +176,7 @@ export function useDrilldown(
const { resolvedQuery, isResolving } = useResolvedDrilldownQuery({
queries,
panelKind: kind,
queryCapabilities: getPanelDefinition(kind).queryCapabilities,
panelType,
v1Query,
enabled: showAggregateMenu,
});

View File

@@ -55,7 +55,6 @@ export function useMovePanelToSection({
if (!moved) {
return;
}
const movedKind = moved.panel?.spec.plugin.kind;
const sourceItems = source.items.filter((i) => i.id !== panelId);
// Land at the section bottom, not backfilled into a gap — least disruptive
@@ -74,15 +73,9 @@ export function useMovePanelToSection({
);
void logEvent(DashboardDetailEvents.PanelAction, {
action: 'move',
// An item ref can outlive its panel, so both fields go on together or
// not at all: `panelType` keeps existing reports resolving, `panelKind`
// is the V2 identity.
...(movedKind
? {
panelType: PANEL_KIND_TO_PANEL_TYPE[movedKind],
panelKind: movedKind,
}
: {}),
panelType: moved.panel
? PANEL_KIND_TO_PANEL_TYPE[moved.panel.spec.plugin.kind]
: undefined,
panelId,
...eventMeta,
});

View File

@@ -3,15 +3,11 @@ import { useEffect, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useReplaceVariables } from 'api/generated/services/querier';
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildQueryRangeRequest } from 'pages/DashboardPage/DashboardContainer/queryV5/buildQueryRangeRequest';
import { envelopesToQuery } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
import { selectResolvedVariables } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import type { PanelQueryCapabilities } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { AppState } from 'store/reducers';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -19,9 +15,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
interface UseResolvedDrilldownQueryArgs {
/** Panel's perses queries — the substitution source (carries the `$var` refs). */
queries: DashboardtypesQueryDTO[];
panelKind: PanelKind;
/** The panel kind's declared query capabilities — shapes the substitution request. */
queryCapabilities: PanelQueryCapabilities;
panelType: PANEL_TYPES;
/** The raw V5→V1 query; the fallback until substitution resolves / when no vars exist. */
v1Query: Query;
/** Resolve only while the aggregate menu is open (V1 parity: fires when it appears). */
@@ -44,8 +38,7 @@ interface UseResolvedDrilldownQueryResult {
*/
export function useResolvedDrilldownQuery({
queries,
panelKind,
queryCapabilities,
panelType,
v1Query,
enabled,
}: UseResolvedDrilldownQueryArgs): UseResolvedDrilldownQueryResult {
@@ -67,7 +60,7 @@ export function useResolvedDrilldownQuery({
substituteVars({
data: buildQueryRangeRequest({
queries,
queryCapabilities,
panelType,
startMs: Math.floor(minTime / 1e6),
endMs: Math.floor(maxTime / 1e6),
variables,
@@ -77,7 +70,7 @@ export function useResolvedDrilldownQuery({
enabled,
hasVariables,
queries,
queryCapabilities,
panelType,
minTime,
maxTime,
variables,
@@ -88,13 +81,8 @@ export function useResolvedDrilldownQuery({
if (!hasVariables || !data) {
return v1Query;
}
// View-in-X navigates to a V1 explorer, so the resolved query crosses back into the
// V1 `Query` shape — the one place this hook still needs a legacy panel type.
return envelopesToQuery(
data.data.compositeQuery?.queries ?? [],
PANEL_KIND_TO_PANEL_TYPE[panelKind],
);
}, [hasVariables, data, v1Query, panelKind]);
return envelopesToQuery(data.data.compositeQuery?.queries ?? [], panelType);
}, [hasVariables, data, v1Query, panelType]);
return { resolvedQuery, isResolving: enabled && hasVariables && isLoading };
}

View File

@@ -1,11 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { act, renderHook } from '@testing-library/react';
import {
type DashboardtypesPanelDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryCapabilities } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
@@ -58,23 +54,6 @@ function panelWith(
} as unknown as DashboardtypesPanelDTO;
}
// The capability blocks TimeSeries and List declare. Passed in rather than resolved from
// the registry: the hook takes them as input, and importing the registry here would pull
// every panel renderer (and the app's API client) into this suite.
const TIME_SERIES_CAPABILITIES: PanelQueryCapabilities = {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
};
const LIST_PANEL_CAPABILITIES: PanelQueryCapabilities = {
...TIME_SERIES_CAPABILITIES,
requestType: Querybuildertypesv5RequestTypeDTO.raw,
orderTiebreaker: true,
serverPaginated: true,
};
function builderPanel(): DashboardtypesPanelDTO {
return panelWith('signoz/TimeSeriesPanel', {
name: 'A',
@@ -121,13 +100,7 @@ beforeEach(() => {
describe('usePanelQuery', () => {
it('builds the generated V5 request DTO directly from panel.spec.queries', () => {
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.schemaVersion).toBe('v1');
expect(requestPayload.compositeQuery.queries).toStrictEqual([
@@ -139,30 +112,30 @@ describe('usePanelQuery', () => {
});
it('converts redux nanosecond time to epoch ms on the request', () => {
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.start).toBe(1_000_000_000);
expect(requestPayload.end).toBe(2_000_000_000);
});
// Which requestType each kind declares is asserted in
// Panels/__tests__/capabilities.test.ts; here it only has to reach the request.
it('sends the requestType from the declared query capabilities', () => {
it.each([
['signoz/TimeSeriesPanel', 'time_series'],
['signoz/ListPanel', 'raw'],
// HISTOGRAM and BAR panels bin/derive from raw time-series data
// client-side, so the backend must receive `time_series` (V1 parity).
['signoz/HistogramPanel', 'time_series'],
['signoz/BarChartPanel', 'time_series'],
['signoz/NumberPanel', 'scalar'],
['signoz/PieChartPanel', 'scalar'],
])('%s panel sends requestType=%s', (panelKind, requestType) => {
renderHook(() =>
usePanelQuery({
panel: panelWith('signoz/ListPanel', { name: 'A', signal: 'logs' }),
panel: panelWith(panelKind, { name: 'A', signal: 'logs' }),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.requestType).toBe('raw');
expect(requestPayload.requestType).toBe(requestType);
});
it('exposes the raw V5 response, request payload, and legend map on data', () => {
@@ -175,11 +148,7 @@ describe('usePanelQuery', () => {
});
const { result } = renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.data.response).toBe(v5Response);
@@ -189,11 +158,7 @@ describe('usePanelQuery', () => {
it('exposes an undefined response before data arrives', () => {
const { result } = renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.data.response).toBeUndefined();
});
@@ -206,11 +171,7 @@ describe('usePanelQuery', () => {
error: new Error('boom'),
});
const { result } = renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.error?.message).toBe('boom');
});
@@ -225,11 +186,7 @@ describe('usePanelQuery', () => {
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetching).toBe(true);
@@ -243,11 +200,7 @@ describe('usePanelQuery', () => {
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.isLoading).toBe(true);
});
@@ -260,23 +213,14 @@ describe('usePanelQuery', () => {
error: undefined,
});
const { result } = renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.error).toBeNull();
});
it('passes enabled=false to the fetch hook when the caller disables it', () => {
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
enabled: false,
}),
usePanelQuery({ panel: builderPanel(), panelId: 'p1', enabled: false }),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(enabled).toBe(false);
@@ -284,12 +228,7 @@ describe('usePanelQuery', () => {
it('auto-disables the fetch when the panel has no queries (even with enabled=true)', () => {
renderHook(() =>
usePanelQuery({
panel: emptyPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
enabled: true,
}),
usePanelQuery({ panel: emptyPanel(), panelId: 'p1', enabled: true }),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(enabled).toBe(false);
@@ -304,7 +243,6 @@ describe('usePanelQuery', () => {
aggregations: [{}],
}),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
@@ -313,13 +251,7 @@ describe('usePanelQuery', () => {
it('composes a react-query cache key that includes panelId, time range, kind, and queries', () => {
const panel = builderPanel();
renderHook(() =>
usePanelQuery({
panel,
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
const [{ queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(queryKey).toStrictEqual(
expect.arrayContaining([
@@ -338,7 +270,6 @@ describe('usePanelQuery', () => {
renderHook(() =>
usePanelQuery({
panel,
queryCapabilities: TIME_SERIES_CAPABILITIES,
panelId: 'p1',
time: { startMs: 1_700_000_000_000, endMs: 1_700_000_600_000 },
}),
@@ -365,7 +296,6 @@ describe('usePanelQuery', () => {
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
time: { startMs: 1_700_000_000_000.546, endMs: 1_700_000_600_000.999 },
}),
);
@@ -386,11 +316,7 @@ describe('usePanelQuery', () => {
it('exposes server paging at the default page size when the query has no limit', () => {
const { result } = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(result.current.pagination).toBeDefined();
expect(result.current.pagination?.pageSize).toBe(25);
@@ -401,34 +327,20 @@ describe('usePanelQuery', () => {
it('disables the server pager when the query has an explicit limit (V1 parity)', () => {
const { result } = renderHook(() =>
usePanelQuery({
panel: listPanel({ limit: 100 }),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({ limit: 100 }), panelId: 'p1' }),
);
expect(result.current.pagination).toBeUndefined();
});
it('keeps previous data while paging so the table/pager stay mounted on page change', () => {
renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
renderHook(() => usePanelQuery({ panel: listPanel({}), panelId: 'p1' }));
const [{ keepPreviousData }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(keepPreviousData).toBe(true);
});
it('changes the page size (and re-requests with the new limit) via setPageSize', () => {
const { result } = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
act(() => result.current.pagination?.setPageSize(50));
@@ -468,11 +380,7 @@ describe('usePanelQuery', () => {
it('starts on page 0 with no prev/next and does not throw before data arrives', () => {
const { result } = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(result.current.pagination?.pageIndex).toBe(0);
expect(result.current.pagination?.canPrev).toBe(false);
@@ -484,33 +392,21 @@ describe('usePanelQuery', () => {
// window/cursor path), so a full page is the has-more signal.
withResponse(rawResponse(25));
const fullPage = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(fullPage.result.current.pagination?.canNext).toBe(true);
// Partial page, no cursor → the last page.
withResponse(rawResponse(3));
const partialPage = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(partialPage.result.current.pagination?.canNext).toBe(false);
// Cursor present (even on a partial page) → more rows (timestamp window path).
withResponse(rawResponse(3, 'cursor-1'));
const withCursor = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(withCursor.result.current.pagination?.canNext).toBe(true);
});
@@ -520,13 +416,7 @@ describe('usePanelQuery', () => {
// Stable panel reference: a fresh one each render would change the
// `queries` identity and trip the offset-reset effect (real props are stable).
const panel = listPanel({});
const { result } = renderHook(() =>
usePanelQuery({
panel,
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
const { result } = renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
expect(result.current.pagination?.pageIndex).toBe(0);
act(() => result.current.pagination?.goNext());
@@ -538,11 +428,7 @@ describe('usePanelQuery', () => {
it('stays defined and zero-paged for a non-raw (scalar) response', () => {
withResponse({ data: { type: 'scalar', data: { results: [] } } });
const { result } = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(result.current.pagination).toBeDefined();
expect(result.current.pagination?.canNext).toBe(false);
@@ -551,11 +437,7 @@ describe('usePanelQuery', () => {
it('ignores a non-positive page size so paging never goes invalid', () => {
const { result } = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
act(() => result.current.pagination?.setPageSize(0));
expect(result.current.pagination?.pageSize).toBe(25);
@@ -574,26 +456,14 @@ describe('usePanelQuery', () => {
it('caches for DASHBOARD_CACHE_TIME when auto-refresh is disabled', () => {
withAutoRefreshDisabled(true);
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME);
});
it('drops cacheTime to 0 when auto-refresh is enabled', () => {
withAutoRefreshDisabled(false);
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED);
});

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from 'react-query';
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
import { useSelector } from 'react-redux';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
@@ -23,7 +24,7 @@ import {
queryReferencesAnyVariable,
} from '../queryV5/getReferencedVariables';
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import { resolvePanelTimeWindow } from './resolvePanelTimeWindow';
@@ -37,8 +38,6 @@ const DEFAULT_LIST_PAGE_SIZE = 25;
export interface UsePanelQueryArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/** The panel kind's declared query capabilities — `panelDefinition.queryCapabilities` at the call site. */
queryCapabilities: PanelQueryCapabilities;
/**
* Gate the fetch (default true). PanelV2 sets false for unregistered kinds to skip a wasted
* call. The hook also auto-disables internally when the panel has no runnable queries.
@@ -86,20 +85,21 @@ export interface UsePanelQueryResult {
export function usePanelQuery({
panel,
panelId,
queryCapabilities,
enabled = true,
time,
}: UsePanelQueryArgs): UsePanelQueryResult {
const fullKind = panel.spec.plugin.kind;
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
const queries = panel.spec.queries;
// V1 parity: a query with an explicit `limit` shows without a server pager; without
// one a paging kind fetches server-side at a user-selectable size.
// V1 parity: a list query with an explicit `limit` shows without a server pager; without
// one it pages server-side at a user-selectable size.
const hasExplicitLimit = useMemo(
() => !!getBuilderQueries(queries)[0]?.limit,
[queries],
);
const isPaginated = queryCapabilities.serverPaginated && !hasExplicitLimit;
const isPaginated = panelType === PANEL_TYPES.LIST && !hasExplicitLimit;
const [pageSize, setPageSize] = useState(DEFAULT_LIST_PAGE_SIZE);
const [offset, setOffset] = useState(0);
@@ -188,7 +188,7 @@ export function usePanelQuery({
() =>
buildQueryRangeRequest({
queries,
queryCapabilities,
panelType,
startMs,
endMs,
fillGaps,
@@ -197,7 +197,7 @@ export function usePanelQuery({
}),
[
queries,
queryCapabilities,
panelType,
startMs,
endMs,
fillGaps,

View File

@@ -1,62 +1,37 @@
import { useEffect, useMemo } from 'react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesDynamicVariableSignalDTO,
type DashboardtypesGettableDashboardV2DTO,
} from 'api/generated/services/sigNoz.schemas';
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import type {
IDashboardVariable,
TVariableQueryType,
} from 'types/api/dashboard/variables';
type DynamicVariableSuggestion,
setDynamicVariableSuggestions,
} from 'providers/Dashboard/store/dynamicVariableSuggestions';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import {
type VariableFormModel,
type VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
const TYPE_TO_V1: Record<VariableType, TVariableQueryType> = {
QUERY: 'QUERY',
CUSTOM: 'CUSTOM',
TEXT: 'TEXTBOX',
DYNAMIC: 'DYNAMIC',
};
/** Minimal V1-shaped variable — only the fields the shared query builder reads. */
function toV1Variable(model: VariableFormModel): IDashboardVariable {
return {
id: model.name,
name: model.name,
description: model.description,
type: TYPE_TO_V1[model.type],
queryValue: model.queryValue,
customValue: model.customValue,
textboxValue: model.textValue,
sort: 'DISABLED',
multiSelect: model.multiSelect,
showALLOption: model.showAllOption,
dynamicVariablesAttribute: model.dynamicAttribute,
dynamicVariablesSource:
model.dynamicSignal === DashboardtypesDynamicVariableSignalDTO.all
? 'all sources'
: model.dynamicSignal,
};
}
/**
* Publishes the V2 dashboard's variables into the shared `dashboardVariablesStore`
* that the query builder's autocomplete (`QuerySearch`) reads, so `$variable`
* suggestions show up in the panel editor and the dashboards-page query builder.
* Suggestion-only the runtime engine lives in the V2 store. Clears on unmount so
* the shared store doesn't leak into other pages.
* Publishes the dashboard's dynamic variables into the shared suggestion store that
* the query builder's autocomplete (`QuerySearch`) reads, so `$variable` is offered
* as a value for the attribute each one backs — in the panel editor and the
* dashboards-page query builder. Suggestion-only: the runtime engine lives in the
* dashboard store. Clears on unmount so the shared store doesn't leak into other
* pages.
*/
export function useSyncVariablesForSuggestions(
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
): void {
const dashboardId = dashboard?.id ?? '';
const specVariables = dashboard?.spec?.variables;
const variables = useMemo(
() => (specVariables ?? []).map(dtoToFormModel),
const suggestions = useMemo<DynamicVariableSuggestion[]>(
() =>
(specVariables ?? [])
.map(dtoToFormModel)
.filter(
(model) =>
model.type === 'DYNAMIC' && !!model.name && !!model.dynamicAttribute,
)
.map((model) => ({
name: model.name,
attribute: model.dynamicAttribute,
})),
[specVariables],
);
@@ -64,14 +39,7 @@ export function useSyncVariablesForSuggestions(
if (!dashboardId) {
return undefined;
}
const record: Record<string, IDashboardVariable> = {};
variables.forEach((model) => {
if (model.name) {
record[model.name] = toV1Variable(model);
}
});
setDashboardVariablesStore({ dashboardId, variables: record });
return (): void =>
setDashboardVariablesStore({ dashboardId: '', variables: {} });
}, [dashboardId, variables]);
setDynamicVariableSuggestions(suggestions);
return (): void => setDynamicVariableSuggestions([]);
}, [dashboardId, suggestions]);
}

View File

@@ -1,13 +1,12 @@
import {
type DashboardtypesQueryDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
buildQueryRangeRequest,
extractLegendMap,
getBarStepIntervalSeconds,
hasRunnableQueries,
panelTypeToRequestType,
toQueryEnvelopes,
} from '../buildQueryRangeRequest';
@@ -41,46 +40,20 @@ function compositeQuery(
const HOUR_MS = 60 * 60 * 1000;
const START_MS = 1_700_000_000_000;
// Capability blocks matching what each kind declares, so these tests exercise the
// builder's response to the flags rather than the declarations themselves (those are
// asserted against the registry in Panels/__tests__/capabilities.test.ts).
const TIME_SERIES_CAPABILITIES = {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
};
const BAR_CAPABILITIES = {
...TIME_SERIES_CAPABILITIES,
bucketedStepInterval: true,
};
const TABLE_CAPABILITIES = {
...TIME_SERIES_CAPABILITIES,
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: true,
};
const LIST_PANEL_CAPABILITIES = {
...TIME_SERIES_CAPABILITIES,
requestType: Querybuildertypesv5RequestTypeDTO.raw,
orderTiebreaker: true,
serverPaginated: true,
};
describe('requestType', () => {
describe('panelTypeToRequestType', () => {
it.each([
Querybuildertypesv5RequestTypeDTO.time_series,
Querybuildertypesv5RequestTypeDTO.scalar,
Querybuildertypesv5RequestTypeDTO.raw,
Querybuildertypesv5RequestTypeDTO.trace,
])('passes %s through from the declared capabilities', (requestType) => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
queryCapabilities: { ...TIME_SERIES_CAPABILITIES, requestType },
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
expect(request.requestType).toBe(requestType);
[PANEL_TYPES.TIME_SERIES, 'time_series'],
// HISTOGRAM and BAR bin client-side from time-series data; sending
// 'distribution' would return a shape the renderers can't bin.
[PANEL_TYPES.BAR, 'time_series'],
[PANEL_TYPES.HISTOGRAM, 'time_series'],
[PANEL_TYPES.TABLE, 'scalar'],
[PANEL_TYPES.PIE, 'scalar'],
[PANEL_TYPES.VALUE, 'scalar'],
[PANEL_TYPES.LIST, 'raw'],
[PANEL_TYPES.TRACE, 'trace'],
])('%s → %s', (panelType, requestType) => {
expect(panelTypeToRequestType(panelType)).toBe(requestType);
});
});
@@ -162,7 +135,7 @@ describe('buildQueryRangeRequest', () => {
it('assembles the full request DTO', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
queryCapabilities: TIME_SERIES_CAPABILITIES,
panelType: PANEL_TYPES.TIME_SERIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -184,7 +157,7 @@ describe('buildQueryRangeRequest', () => {
it('sets formatTableResultForUI only for TABLE panels', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A' }),
queryCapabilities: TABLE_CAPABILITIES,
panelType: PANEL_TYPES.TABLE,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -194,7 +167,7 @@ describe('buildQueryRangeRequest', () => {
it('passes through fillGaps into formatOptions', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A' }),
queryCapabilities: TIME_SERIES_CAPABILITIES,
panelType: PANEL_TYPES.TIME_SERIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
fillGaps: true,
@@ -205,7 +178,7 @@ describe('buildQueryRangeRequest', () => {
it('stamps offset/limit onto builder queries when pagination is given', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs' }),
queryCapabilities: LIST_PANEL_CAPABILITIES,
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
pagination: { offset: 100, limit: 50 },
@@ -225,7 +198,7 @@ describe('buildQueryRangeRequest', () => {
it('defaults a logs list with no order to timestamp desc + id tiebreaker', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs' }),
queryCapabilities: LIST_PANEL_CAPABILITIES,
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -245,7 +218,7 @@ describe('buildQueryRangeRequest', () => {
signal: 'logs',
order: [{ key: { name: 'timestamp' }, direction: 'desc' }],
}),
queryCapabilities: LIST_PANEL_CAPABILITIES,
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -265,7 +238,7 @@ describe('buildQueryRangeRequest', () => {
];
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs', order }),
queryCapabilities: LIST_PANEL_CAPABILITIES,
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -279,7 +252,7 @@ describe('buildQueryRangeRequest', () => {
const order = [{ key: { name: 'timestamp' }, direction: 'desc' }];
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'traces', order }),
queryCapabilities: LIST_PANEL_CAPABILITIES,
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -292,7 +265,7 @@ describe('buildQueryRangeRequest', () => {
it('injects the range-derived stepInterval into BAR builder queries without one', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
queryCapabilities: BAR_CAPABILITIES,
panelType: PANEL_TYPES.BAR,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -307,7 +280,7 @@ describe('buildQueryRangeRequest', () => {
it('preserves a user-set stepInterval on BAR builder queries', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', stepInterval: 300 }),
queryCapabilities: BAR_CAPABILITIES,
panelType: PANEL_TYPES.BAR,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -320,7 +293,7 @@ describe('buildQueryRangeRequest', () => {
it('does not touch stepInterval for non-BAR panels', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A' }),
queryCapabilities: TIME_SERIES_CAPABILITIES,
panelType: PANEL_TYPES.TIME_SERIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});

View File

@@ -7,12 +7,7 @@ import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import {
envelopesToQuery,
fromPerses,
panelTypeToRequestType,
toPerses,
} from '../persesQueryAdapters';
import { envelopesToQuery, fromPerses, toPerses } from '../persesQueryAdapters';
/** A bare perses query (single plugin, not wrapped in a CompositeQuery). */
function bareQuery(
@@ -26,23 +21,6 @@ function bareQuery(
}
describe('persesQueryAdapters', () => {
describe('panelTypeToRequestType', () => {
it.each([
[PANEL_TYPES.TIME_SERIES, 'time_series'],
// HISTOGRAM and BAR bin client-side from time-series data; sending
// 'distribution' would return a shape the renderers can't bin.
[PANEL_TYPES.BAR, 'time_series'],
[PANEL_TYPES.HISTOGRAM, 'time_series'],
[PANEL_TYPES.TABLE, 'scalar'],
[PANEL_TYPES.PIE, 'scalar'],
[PANEL_TYPES.VALUE, 'scalar'],
[PANEL_TYPES.LIST, 'raw'],
[PANEL_TYPES.TRACE, 'trace'],
])('%s → %s', (panelType, requestType) => {
expect(panelTypeToRequestType(panelType)).toBe(requestType);
});
});
describe('fromPerses', () => {
it('returns a fresh metrics builder query for an empty panel', () => {
const query = fromPerses([], PANEL_TYPES.TIME_SERIES);

View File

@@ -14,9 +14,9 @@ import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
import { PANEL_TYPES } from 'constants/queryBuilder';
// Narrow view over the envelope spec variants. Orval erases envelope `spec` to `unknown`, so
// shared fields are read through this view with a localized cast at the envelope boundary.
@@ -29,6 +29,31 @@ interface QuerySpecView {
order?: Querybuildertypesv5OrderByDTO[];
}
/**
* Maps a V2 panel type to the V5 `requestType`. HISTOGRAM/BAR bin client-side from raw
* time-series, so their request type is `time_series` (V1 parity).
*/
export function panelTypeToRequestType(
panelType: PANEL_TYPES,
): Querybuildertypesv5RequestTypeDTO {
switch (panelType) {
case PANEL_TYPES.TIME_SERIES:
case PANEL_TYPES.BAR:
case PANEL_TYPES.HISTOGRAM:
return Querybuildertypesv5RequestTypeDTO.time_series;
case PANEL_TYPES.TABLE:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return Querybuildertypesv5RequestTypeDTO.scalar;
case PANEL_TYPES.LIST:
return Querybuildertypesv5RequestTypeDTO.raw;
case PANEL_TYPES.TRACE:
return Querybuildertypesv5RequestTypeDTO.trace;
default:
return Querybuildertypesv5RequestTypeDTO.time_series;
}
}
/**
* Unwraps the perses query into the V5 `compositeQuery.queries` list: a CompositeQuery passes
* through verbatim, bare plugins wrap into one envelope. Top-level Formula/TraceOperator are
@@ -214,13 +239,7 @@ function withPagination(
export interface BuildQueryRangeRequestArgs {
queries: DashboardtypesQueryDTO[];
/**
* The panel kind's declared query capabilities (`PanelDefinition.queryCapabilities`): request type,
* result formatting, and the step-interval/order treatment. Passed in rather than looked up
* by kind so this stays a leaf of the query layer — the panel registry carries every
* renderer with it, which has no business in the data path.
*/
queryCapabilities: PanelQueryCapabilities;
panelType: PANEL_TYPES;
/** Epoch milliseconds. */
startMs: number;
/** Epoch milliseconds. */
@@ -239,12 +258,7 @@ export interface BuildQueryRangeRequestArgs {
*/
export function buildQueryRangeRequest({
queries,
queryCapabilities: {
requestType,
formatTableResultForUI,
bucketedStepInterval,
orderTiebreaker,
},
panelType,
startMs,
endMs,
fillGaps = false,
@@ -252,10 +266,10 @@ export function buildQueryRangeRequest({
variables = {},
}: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO {
let envelopes = toQueryEnvelopes(queries);
if (bucketedStepInterval) {
if (panelType === PANEL_TYPES.BAR) {
envelopes = withBarStepInterval(envelopes, startMs, endMs);
}
if (orderTiebreaker) {
if (panelType === PANEL_TYPES.LIST) {
envelopes = withListOrderTiebreaker(envelopes);
}
if (pagination) {
@@ -266,10 +280,10 @@ export function buildQueryRangeRequest({
schemaVersion: 'v1',
start: startMs,
end: endMs,
requestType,
requestType: panelTypeToRequestType(panelType),
compositeQuery: { queries: envelopes },
formatOptions: {
formatTableResultForUI,
formatTableResultForUI: panelType === PANEL_TYPES.TABLE,
fillGaps,
},
variables,

View File

@@ -16,9 +16,9 @@ import type {
/**
* Backend sentinel for "every value selected" on a multi-select dynamic variable.
* V1 parity (`getDashboardVariables`): only dynamic vars collapse to `__all__`;
* query/custom multi-selects send the full value array instead. Lowercase — the
* URL/store `__ALL__` sentinel is a separate serialization concern.
* Only dynamic vars collapse to `__all__`; query/custom multi-selects send the full
* value array instead. Lowercase — the URL/store `__ALL__` sentinel is a separate
* serialization concern.
*/
const ALL_VALUES_SENTINEL = '__all__';
@@ -68,8 +68,8 @@ function resolveValue(
/**
* Builds the V5 `variables` map from the dashboard's variable definitions and the
* runtime selection, so a panel query substitutes the values the user picked in
* the variable bar (V1 parity with `getDashboardVariables` + the V5 prep). The
* definition list supplies the wire `type` (the selection map carries only values).
* the variable bar. The definition list supplies the wire `type` (the selection map
* carries only values).
*/
export function buildVariablesPayload(
definitions: VariableFormModel[],

View File

@@ -10,7 +10,6 @@ import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
@@ -21,7 +20,10 @@ import type { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { toQueryEnvelopes } from './buildQueryRangeRequest';
import {
panelTypeToRequestType,
toQueryEnvelopes,
} from './buildQueryRangeRequest';
/**
* Adapters between the V2 perses query shape and the V1 `Query` the shared query
@@ -88,33 +90,6 @@ export function deriveQueryType(
return EQueryType.QUERY_BUILDER;
}
/**
* Maps a legacy panel type to the V5 `requestType`. Lives on this side of the V1 boundary
* because only the V1 pivot still speaks `PANEL_TYPES` — V2 panels read `requestType` off
* their kind's declared query capabilities instead. BAR/HISTOGRAM bin client-side from a raw
* time series, so they request `time_series` (V1 parity).
*/
export function panelTypeToRequestType(
panelType: PANEL_TYPES,
): Querybuildertypesv5RequestTypeDTO {
switch (panelType) {
case PANEL_TYPES.TIME_SERIES:
case PANEL_TYPES.BAR:
case PANEL_TYPES.HISTOGRAM:
return Querybuildertypesv5RequestTypeDTO.time_series;
case PANEL_TYPES.TABLE:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return Querybuildertypesv5RequestTypeDTO.scalar;
case PANEL_TYPES.LIST:
return Querybuildertypesv5RequestTypeDTO.raw;
case PANEL_TYPES.TRACE:
return Querybuildertypesv5RequestTypeDTO.trace;
default:
return Querybuildertypesv5RequestTypeDTO.time_series;
}
}
/**
* V5 query-envelope list → V1 `Query`, via `mapQueryDataFromApi`. An empty list opens
* on a fresh metrics builder query. Used by `fromPerses` and by the envelopes a

View File

@@ -40,7 +40,6 @@ function PublicPanel({
const { data, isFetching, isPreviousData, error, refetch } =
usePublicPanelQuery({
panel,
queryCapabilities: panelDefinition.queryCapabilities,
panelKey,
publicDashboardId,
startMs,

View File

@@ -1,9 +1,6 @@
import { renderHook, waitFor } from '@testing-library/react';
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
import {
type DashboardtypesPanelDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
@@ -45,15 +42,6 @@ const panel = {
const args = {
panel,
// What TimeSeries declares; passed in rather than resolved from the registry, which
// would pull every panel renderer into this suite.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
panelKey: 'panel-1',
publicDashboardId: 'pub-1',
startMs: 1000,

View File

@@ -3,9 +3,10 @@ import type {
DashboardtypesPanelDTO,
GetPublicDashboardPanelQueryRangeV2200,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { retryUnlessClientError } from 'pages/DashboardPage/DashboardContainer/hooks/useGetQueryRangeV5';
import type { PanelQueryCapabilities } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
buildQueryRangeRequest,
extractLegendMap,
@@ -20,8 +21,6 @@ import { useQuery, useQueryClient } from 'react-query';
export interface UsePublicPanelQueryArgs {
panel: DashboardtypesPanelDTO;
/** The panel kind's declared query capabilities — `panelDefinition.queryCapabilities`. */
queryCapabilities: PanelQueryCapabilities;
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
panelKey: string;
publicDashboardId: string;
@@ -53,13 +52,15 @@ export interface UsePublicPanelQueryResult {
*/
export function usePublicPanelQuery({
panel,
queryCapabilities,
panelKey,
publicDashboardId,
startMs,
endMs,
enabled = true,
}: UsePublicPanelQueryArgs): UsePublicPanelQueryResult {
const fullKind = panel.spec.plugin.kind;
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
const { queries } = panel.spec;
const pluginSpec = panel.spec.plugin.spec;
@@ -76,13 +77,13 @@ export function usePublicPanelQuery({
() =>
buildQueryRangeRequest({
queries,
queryCapabilities,
panelType,
startMs,
endMs,
fillGaps,
variables: {},
}),
[queries, queryCapabilities, startMs, endMs, fillGaps],
[queries, panelType, startMs, endMs, fillGaps],
);
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);

View File

@@ -1,603 +0,0 @@
import * as dashboardVariablesStore from '../dashboardVariables/dashboardVariablesStore';
import { IDependencyData } from '../dashboardVariables/dashboardVariablesStoreTypes';
import {
enqueueDescendantsOfVariable,
enqueueFetchOfAllVariables,
initializeVariableFetchStore,
onVariableFetchComplete,
onVariableFetchFailure,
VariableFetchContext,
variableFetchStore,
} from '../variableFetchStore';
const getVariableDependencyContextSpy = jest.spyOn(
dashboardVariablesStore,
'getVariableDependencyContext',
);
function resetStore(): void {
variableFetchStore.set(() => ({
states: {},
lastUpdated: {},
cycleIds: {},
}));
}
function mockContext(overrides: Partial<VariableFetchContext> = {}): void {
getVariableDependencyContextSpy.mockReturnValue({
doAllQueryVariablesHaveValuesSelected: false,
variableTypes: {},
dynamicVariableOrder: [],
dependencyData: null,
...overrides,
});
}
/**
* Helper to build a dependency data object for tests.
* Only the fields used by the store actions are required.
*/
function buildDependencyData(
overrides: Partial<IDependencyData> = {},
): IDependencyData {
return {
order: [],
graph: {},
parentDependencyGraph: {},
transitiveDescendants: {},
hasCycle: false,
...overrides,
};
}
describe('variableFetchStore', () => {
beforeEach(() => {
resetStore();
jest.clearAllMocks();
});
// ==================== initializeVariableFetchStore ====================
describe('initializeVariableFetchStore', () => {
it('should initialize new variables to idle', () => {
initializeVariableFetchStore(['a', 'b', 'c']);
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states).toStrictEqual({
a: 'idle',
b: 'idle',
c: 'idle',
});
});
it('should preserve existing states for known variables', () => {
// Pre-set a state
variableFetchStore.update((d) => {
d.states.a = 'loading';
});
initializeVariableFetchStore(['a', 'b']);
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states.a).toBe('loading');
expect(storeSnapshot.states.b).toBe('idle');
});
it('should clean up stale variables that no longer exist', () => {
variableFetchStore.update((d) => {
d.states.old = 'idle';
d.lastUpdated.old = 100;
d.cycleIds.old = 3;
});
initializeVariableFetchStore(['a']);
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states.old).toBeUndefined();
expect(storeSnapshot.lastUpdated.old).toBeUndefined();
expect(storeSnapshot.cycleIds.old).toBeUndefined();
expect(storeSnapshot.states.a).toBe('idle');
});
it('should handle empty variable names array', () => {
variableFetchStore.update((d) => {
d.states.a = 'idle';
});
initializeVariableFetchStore([]);
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states).toStrictEqual({});
});
});
// ==================== enqueueFetchOfAllVariables ====================
describe('enqueueFetchOfAllVariables', () => {
it('should no-op when dependencyData is null', () => {
mockContext({ dependencyData: null });
initializeVariableFetchStore(['a']);
enqueueFetchOfAllVariables();
expect(variableFetchStore.getSnapshot().states.a).toBe('idle');
});
it('should set root query variables to loading and dependent ones to waiting', () => {
// a is root (no parents), b depends on a
mockContext({
dependencyData: buildDependencyData({
order: ['a', 'b'],
parentDependencyGraph: { a: [], b: ['a'] },
}),
variableTypes: { a: 'QUERY', b: 'QUERY' },
});
initializeVariableFetchStore(['a', 'b']);
enqueueFetchOfAllVariables();
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states.a).toBe('loading');
expect(storeSnapshot.states.b).toBe('waiting');
});
it('should set root query variables to revalidating when previously fetched', () => {
mockContext({
dependencyData: buildDependencyData({
order: ['a'],
parentDependencyGraph: { a: [] },
}),
variableTypes: { a: 'QUERY' },
});
// Pre-set lastUpdated so it appears previously fetched
variableFetchStore.update((d) => {
d.lastUpdated.a = 1000;
});
initializeVariableFetchStore(['a']);
enqueueFetchOfAllVariables();
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states.a).toBe('revalidating');
});
it('should bump cycle IDs for all enqueued variables', () => {
mockContext({
dependencyData: buildDependencyData({
order: ['a', 'b'],
parentDependencyGraph: { a: [], b: ['a'] },
}),
variableTypes: { a: 'QUERY', b: 'QUERY' },
});
initializeVariableFetchStore(['a', 'b']);
enqueueFetchOfAllVariables();
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.cycleIds.a).toBe(1);
expect(storeSnapshot.cycleIds.b).toBe(1);
});
it('should set dynamic variables to waiting when not all query variables have values', () => {
mockContext({
doAllQueryVariablesHaveValuesSelected: false,
dependencyData: buildDependencyData({ order: [] }),
variableTypes: { dyn1: 'DYNAMIC' },
dynamicVariableOrder: ['dyn1'],
});
initializeVariableFetchStore(['dyn1']);
enqueueFetchOfAllVariables();
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states.dyn1).toBe('waiting');
});
it('should set dynamic variables to loading when all query variables have values', () => {
mockContext({
doAllQueryVariablesHaveValuesSelected: true,
dependencyData: buildDependencyData({ order: [] }),
variableTypes: { dyn1: 'DYNAMIC' },
dynamicVariableOrder: ['dyn1'],
});
initializeVariableFetchStore(['dyn1']);
enqueueFetchOfAllVariables();
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states.dyn1).toBe('loading');
});
it('should not treat non-QUERY parents as query parents', () => {
// b has a CUSTOM parent — shouldn't cause waiting
mockContext({
dependencyData: buildDependencyData({
order: ['b'],
parentDependencyGraph: { b: ['customVar'] },
}),
variableTypes: { b: 'QUERY', customVar: 'CUSTOM' },
});
initializeVariableFetchStore(['b', 'customVar']);
enqueueFetchOfAllVariables();
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states.b).toBe('loading');
});
});
// ==================== onVariableFetchComplete ====================
describe('onVariableFetchComplete', () => {
it('should set the completed variable to idle with a lastUpdated timestamp', () => {
mockContext();
variableFetchStore.update((d) => {
d.states.a = 'loading';
});
const before = Date.now();
onVariableFetchComplete('a');
const after = Date.now();
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states.a).toBe('idle');
expect(storeSnapshot.lastUpdated.a).toBeGreaterThanOrEqual(before);
expect(storeSnapshot.lastUpdated.a).toBeLessThanOrEqual(after);
});
it('should unblock waiting query-type children', () => {
mockContext({
dependencyData: buildDependencyData({
graph: { a: ['b'] },
}),
variableTypes: { a: 'QUERY', b: 'QUERY' },
});
variableFetchStore.update((d) => {
d.states.a = 'loading';
d.states.b = 'waiting';
});
onVariableFetchComplete('a');
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states.a).toBe('idle');
expect(storeSnapshot.states.b).toBe('loading');
});
it('should not unblock non-QUERY children', () => {
mockContext({
dependencyData: buildDependencyData({
graph: { a: ['dyn1'] },
}),
variableTypes: { a: 'QUERY', dyn1: 'DYNAMIC' },
});
variableFetchStore.update((d) => {
d.states.a = 'loading';
d.states.dyn1 = 'waiting';
});
onVariableFetchComplete('a');
const storeSnapshot = variableFetchStore.getSnapshot();
// dyn1 is DYNAMIC, not QUERY, so it should remain waiting
expect(storeSnapshot.states.dyn1).toBe('waiting');
});
it('should unlock waiting dynamic variables when all query variables are settled', () => {
mockContext({
dependencyData: buildDependencyData({
graph: { a: [] },
}),
variableTypes: { a: 'QUERY', dyn1: 'DYNAMIC' },
dynamicVariableOrder: ['dyn1'],
});
variableFetchStore.update((d) => {
d.states.a = 'loading';
d.states.dyn1 = 'waiting';
});
onVariableFetchComplete('a');
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states.dyn1).toBe('loading');
});
it('should NOT unlock dynamic variables if a query variable is still in-flight', () => {
mockContext({
dependencyData: buildDependencyData({
graph: { a: ['b'] },
}),
variableTypes: { a: 'QUERY', b: 'QUERY', dyn1: 'DYNAMIC' },
dynamicVariableOrder: ['dyn1'],
});
variableFetchStore.update((d) => {
d.states.a = 'loading';
d.states.b = 'waiting';
d.states.dyn1 = 'waiting';
});
onVariableFetchComplete('a');
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states.dyn1).toBe('waiting');
});
});
// ==================== onVariableFetchFailure ====================
describe('onVariableFetchFailure', () => {
it('should set the failed variable to error', () => {
mockContext();
variableFetchStore.update((d) => {
d.states.a = 'loading';
});
onVariableFetchFailure('a');
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states.a).toBe('error');
});
it('should set query-type transitive descendants to idle', () => {
mockContext({
dependencyData: buildDependencyData({
transitiveDescendants: { a: ['b', 'c'] },
}),
variableTypes: { a: 'QUERY', b: 'QUERY', c: 'QUERY' },
});
variableFetchStore.update((d) => {
d.states.a = 'loading';
d.states.b = 'waiting';
d.states.c = 'waiting';
});
onVariableFetchFailure('a');
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states.a).toBe('error');
expect(storeSnapshot.states.b).toBe('idle');
expect(storeSnapshot.states.c).toBe('idle');
});
it('should not touch non-QUERY descendants', () => {
mockContext({
dependencyData: buildDependencyData({
transitiveDescendants: { a: ['dyn1'] },
}),
variableTypes: { a: 'QUERY', dyn1: 'DYNAMIC' },
});
variableFetchStore.update((d) => {
d.states.a = 'loading';
d.states.dyn1 = 'waiting';
});
onVariableFetchFailure('a');
expect(variableFetchStore.getSnapshot().states.dyn1).toBe('waiting');
});
it('should unlock waiting dynamic variables when all query variables settle via error', () => {
mockContext({
dependencyData: buildDependencyData({
transitiveDescendants: {},
}),
variableTypes: { a: 'QUERY', dyn1: 'DYNAMIC' },
dynamicVariableOrder: ['dyn1'],
});
variableFetchStore.update((d) => {
d.states.a = 'loading';
d.states.dyn1 = 'waiting';
});
onVariableFetchFailure('a');
expect(variableFetchStore.getSnapshot().states.dyn1).toBe('loading');
});
});
// ==================== enqueueDescendantsOfVariable ====================
describe('enqueueDescendantsOfVariable', () => {
it('should no-op when dependencyData is null', () => {
mockContext({ dependencyData: null });
variableFetchStore.update((d) => {
d.states.a = 'idle';
d.states.b = 'idle';
});
enqueueDescendantsOfVariable('a');
expect(variableFetchStore.getSnapshot().states.b).toBe('idle');
});
it('should enqueue query-type descendants with all parents settled', () => {
mockContext({
dependencyData: buildDependencyData({
transitiveDescendants: { a: ['b'] },
parentDependencyGraph: { b: ['a'] },
}),
variableTypes: { a: 'QUERY', b: 'QUERY' },
});
variableFetchStore.update((d) => {
d.states.a = 'idle';
d.states.b = 'idle';
});
enqueueDescendantsOfVariable('a');
const storeSnapshot = variableFetchStore.getSnapshot();
expect(storeSnapshot.states.b).toBe('loading');
expect(storeSnapshot.cycleIds.b).toBe(1);
});
it('should set descendants to waiting when some parents are not settled', () => {
// b depends on both a and c; c is still loading
mockContext({
dependencyData: buildDependencyData({
transitiveDescendants: { a: ['b'] },
parentDependencyGraph: { b: ['a', 'c'] },
}),
variableTypes: { a: 'QUERY', b: 'QUERY', c: 'QUERY' },
});
variableFetchStore.update((d) => {
d.states.a = 'idle';
d.states.b = 'idle';
d.states.c = 'loading';
});
enqueueDescendantsOfVariable('a');
expect(variableFetchStore.getSnapshot().states.b).toBe('waiting');
});
it('should skip non-QUERY descendants', () => {
mockContext({
dependencyData: buildDependencyData({
transitiveDescendants: { a: ['dyn1'] },
parentDependencyGraph: {},
}),
variableTypes: { a: 'QUERY', dyn1: 'DYNAMIC' },
});
variableFetchStore.update((d) => {
d.states.a = 'idle';
d.states.dyn1 = 'idle';
});
enqueueDescendantsOfVariable('a');
// dyn1 is DYNAMIC, so it should not be touched
expect(variableFetchStore.getSnapshot().states.dyn1).toBe('idle');
});
it('should handle chain of descendants: a -> b -> c', () => {
// a -> b -> c, all QUERY
mockContext({
dependencyData: buildDependencyData({
transitiveDescendants: { a: ['b', 'c'] },
parentDependencyGraph: { b: ['a'], c: ['b'] },
}),
variableTypes: { a: 'QUERY', b: 'QUERY', c: 'QUERY' },
});
variableFetchStore.update((d) => {
d.states.a = 'idle';
d.states.b = 'idle';
d.states.c = 'idle';
});
enqueueDescendantsOfVariable('a');
const storeSnapshot = variableFetchStore.getSnapshot();
// b's parent (a) is idle/settled → loading
expect(storeSnapshot.states.b).toBe('loading');
// c's parent (b) just moved to loading (not settled) → waiting
expect(storeSnapshot.states.c).toBe('waiting');
});
it('should set descendants to revalidating when previously fetched', () => {
mockContext({
dependencyData: buildDependencyData({
transitiveDescendants: { a: ['b'] },
parentDependencyGraph: { b: ['a'] },
}),
variableTypes: { a: 'QUERY', b: 'QUERY' },
});
variableFetchStore.update((d) => {
d.states.a = 'idle';
d.states.b = 'idle';
d.lastUpdated.b = 1000;
});
enqueueDescendantsOfVariable('a');
expect(variableFetchStore.getSnapshot().states.b).toBe('revalidating');
});
it('should enqueue dynamic variables immediately when all query variables are settled', () => {
mockContext({
dependencyData: buildDependencyData({
transitiveDescendants: { customVar: [] },
parentDependencyGraph: {},
}),
variableTypes: { q1: 'QUERY', customVar: 'CUSTOM', dyn1: 'DYNAMIC' },
dynamicVariableOrder: ['dyn1'],
});
variableFetchStore.update((d) => {
d.states.q1 = 'idle';
d.states.customVar = 'idle';
d.states.dyn1 = 'idle';
});
enqueueDescendantsOfVariable('customVar');
const snapshot = variableFetchStore.getSnapshot();
expect(snapshot.states.dyn1).toBe('loading');
expect(snapshot.cycleIds.dyn1).toBe(1);
});
it('should set dynamic variables to waiting when query variables are not yet settled', () => {
// a is a query variable still loading; changing customVar should queue dyn1 as waiting
mockContext({
dependencyData: buildDependencyData({
transitiveDescendants: { customVar: [] },
parentDependencyGraph: {},
}),
variableTypes: { a: 'QUERY', customVar: 'CUSTOM', dyn1: 'DYNAMIC' },
dynamicVariableOrder: ['dyn1'],
});
variableFetchStore.update((d) => {
d.states.a = 'loading';
d.states.customVar = 'idle';
d.states.dyn1 = 'idle';
});
enqueueDescendantsOfVariable('customVar');
expect(variableFetchStore.getSnapshot().states.dyn1).toBe('waiting');
});
it('should set dynamic variables to waiting when a query descendant is now loading', () => {
// a -> b (QUERY), dyn1 (DYNAMIC). When a changes, b starts loading,
// so dyn1 should wait until b settles.
mockContext({
dependencyData: buildDependencyData({
transitiveDescendants: { a: ['b'] },
parentDependencyGraph: { b: ['a'] },
}),
variableTypes: { a: 'QUERY', b: 'QUERY', dyn1: 'DYNAMIC' },
dynamicVariableOrder: ['dyn1'],
});
variableFetchStore.update((d) => {
d.states.a = 'idle';
d.states.b = 'idle';
d.states.dyn1 = 'idle';
});
enqueueDescendantsOfVariable('a');
const snapshot = variableFetchStore.getSnapshot();
// b's parent (a) is idle → b starts loading
expect(snapshot.states.b).toBe('loading');
// dyn1 must wait because b is now loading (not settled)
expect(snapshot.states.dyn1).toBe('waiting');
});
});
});

View File

@@ -1,196 +0,0 @@
import {
IVariableFetchStoreState,
VariableFetchState,
} from '../variableFetchStore';
import {
areAllQueryVariablesSettled,
isSettled,
resolveFetchState,
unlockWaitingDynamicVariables,
} from '../variableFetchStoreUtils';
describe('variableFetchStoreUtils', () => {
describe('isSettled', () => {
it('should return true for idle state', () => {
expect(isSettled('idle')).toBe(true);
});
it('should return true for error state', () => {
expect(isSettled('error')).toBe(true);
});
it('should return false for loading state', () => {
expect(isSettled('loading')).toBe(false);
});
it('should return false for revalidating state', () => {
expect(isSettled('revalidating')).toBe(false);
});
it('should return false for waiting state', () => {
expect(isSettled('waiting')).toBe(false);
});
it('should return false for undefined', () => {
expect(isSettled(undefined)).toBe(false);
});
});
describe('resolveFetchState', () => {
it('should return "loading" when variable has never been fetched', () => {
const draft: IVariableFetchStoreState = {
states: {},
lastUpdated: {},
cycleIds: {},
};
expect(resolveFetchState(draft, 'myVar')).toBe('loading');
});
it('should return "loading" when lastUpdated is 0', () => {
const draft: IVariableFetchStoreState = {
states: {},
lastUpdated: { myVar: 0 },
cycleIds: {},
};
expect(resolveFetchState(draft, 'myVar')).toBe('loading');
});
it('should return "revalidating" when variable has been fetched before', () => {
const draft: IVariableFetchStoreState = {
states: {},
lastUpdated: { myVar: 1000 },
cycleIds: {},
};
expect(resolveFetchState(draft, 'myVar')).toBe('revalidating');
});
});
describe('areAllQueryVariablesSettled', () => {
it('should return true when all query variables are idle', () => {
const states: Record<string, VariableFetchState> = {
a: 'idle',
b: 'idle',
};
const variableTypes = { a: 'QUERY' as const, b: 'QUERY' as const };
expect(areAllQueryVariablesSettled(states, variableTypes)).toBe(true);
});
it('should return true when all query variables are in error', () => {
const states: Record<string, VariableFetchState> = {
a: 'error',
b: 'error',
};
const variableTypes = { a: 'QUERY' as const, b: 'QUERY' as const };
expect(areAllQueryVariablesSettled(states, variableTypes)).toBe(true);
});
it('should return true with a mix of idle and error query variables', () => {
const states: Record<string, VariableFetchState> = {
a: 'idle',
b: 'error',
};
const variableTypes = { a: 'QUERY' as const, b: 'QUERY' as const };
expect(areAllQueryVariablesSettled(states, variableTypes)).toBe(true);
});
it('should return false when any query variable is loading', () => {
const states: Record<string, VariableFetchState> = {
a: 'idle',
b: 'loading',
};
const variableTypes = { a: 'QUERY' as const, b: 'QUERY' as const };
expect(areAllQueryVariablesSettled(states, variableTypes)).toBe(false);
});
it('should return false when any query variable is waiting', () => {
const states: Record<string, VariableFetchState> = {
a: 'idle',
b: 'waiting',
};
const variableTypes = { a: 'QUERY' as const, b: 'QUERY' as const };
expect(areAllQueryVariablesSettled(states, variableTypes)).toBe(false);
});
it('should ignore non-QUERY variable types', () => {
const states: Record<string, VariableFetchState> = {
a: 'idle',
dynVar: 'loading',
};
const variableTypes = {
a: 'QUERY' as const,
dynVar: 'DYNAMIC' as const,
};
expect(areAllQueryVariablesSettled(states, variableTypes)).toBe(true);
});
it('should return true when there are no QUERY variables', () => {
const states: Record<string, VariableFetchState> = {
dynVar: 'loading',
};
const variableTypes = { dynVar: 'DYNAMIC' as const };
expect(areAllQueryVariablesSettled(states, variableTypes)).toBe(true);
});
});
describe('unlockWaitingDynamicVariables', () => {
it('should transition waiting dynamic variables to loading when never fetched', () => {
const draft: IVariableFetchStoreState = {
states: { dyn1: 'waiting', dyn2: 'waiting' },
lastUpdated: {},
cycleIds: {},
};
unlockWaitingDynamicVariables(draft, ['dyn1', 'dyn2']);
expect(draft.states.dyn1).toBe('loading');
expect(draft.states.dyn2).toBe('loading');
});
it('should transition waiting dynamic variables to revalidating when previously fetched', () => {
const draft: IVariableFetchStoreState = {
states: { dyn1: 'waiting' },
lastUpdated: { dyn1: 1000 },
cycleIds: {},
};
unlockWaitingDynamicVariables(draft, ['dyn1']);
expect(draft.states.dyn1).toBe('revalidating');
});
it('should not touch dynamic variables that are not in waiting state', () => {
const draft: IVariableFetchStoreState = {
states: { dyn1: 'idle', dyn2: 'loading' },
lastUpdated: {},
cycleIds: {},
};
unlockWaitingDynamicVariables(draft, ['dyn1', 'dyn2']);
expect(draft.states.dyn1).toBe('idle');
expect(draft.states.dyn2).toBe('loading');
});
it('should handle empty dynamic variable order', () => {
const draft: IVariableFetchStoreState = {
states: { dyn1: 'waiting' },
lastUpdated: {},
cycleIds: {},
};
unlockWaitingDynamicVariables(draft, []);
expect(draft.states.dyn1).toBe('waiting');
});
});
});

View File

@@ -1,287 +0,0 @@
import { IDashboardVariable } from 'types/api/dashboard/variables';
import {
dashboardVariablesStore,
getVariableDependencyContext,
setDashboardVariablesStore,
updateDashboardVariablesStore,
} from '../dashboardVariablesStore';
import { IDashboardVariables } from '../dashboardVariablesStoreTypes';
function createVariable(
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable {
return {
id: 'test-id',
name: 'test-var',
description: '',
type: 'QUERY',
sort: 'DISABLED',
showALLOption: false,
multiSelect: false,
order: 0,
...overrides,
};
}
function resetStore(): void {
dashboardVariablesStore.set(() => ({
dashboardId: '',
variables: {},
sortedVariablesArray: [],
dependencyData: null,
variableTypes: {},
dynamicVariableOrder: [],
}));
}
describe('dashboardVariablesStore', () => {
beforeEach(() => {
resetStore();
});
describe('setDashboardVariablesStore', () => {
it('should set the dashboard variables and compute derived values', () => {
const variables: IDashboardVariables = {
env: createVariable({ name: 'env', type: 'QUERY', order: 0 }),
};
setDashboardVariablesStore({ dashboardId: 'dash-1', variables });
const storeSnapshot = dashboardVariablesStore.getSnapshot();
expect(storeSnapshot.dashboardId).toBe('dash-1');
expect(storeSnapshot.variables).toStrictEqual(variables);
expect(storeSnapshot.variableTypes).toStrictEqual({ env: 'QUERY' });
expect(storeSnapshot.sortedVariablesArray).toHaveLength(1);
});
});
describe('updateDashboardVariablesStore', () => {
it('should update variables and recompute derived values', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({ name: 'env', type: 'QUERY', order: 0 }),
},
});
const updatedVariables: IDashboardVariables = {
env: createVariable({ name: 'env', type: 'QUERY', order: 0 }),
dyn1: createVariable({ name: 'dyn1', type: 'DYNAMIC', order: 1 }),
};
updateDashboardVariablesStore({
dashboardId: 'dash-1',
variables: updatedVariables,
});
const storeSnapshot = dashboardVariablesStore.getSnapshot();
expect(storeSnapshot.variableTypes).toStrictEqual({
env: 'QUERY',
dyn1: 'DYNAMIC',
});
expect(storeSnapshot.dynamicVariableOrder).toStrictEqual(['dyn1']);
});
it('should replace dashboardId when it does not match', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
'not-there': createVariable({ name: 'not-there', order: 0 }),
},
});
updateDashboardVariablesStore({
dashboardId: 'dash-2',
variables: {
a: createVariable({ name: 'a', order: 0 }),
},
});
const storeSnapshot = dashboardVariablesStore.getSnapshot();
expect(storeSnapshot.dashboardId).toBe('dash-2');
expect(storeSnapshot.variableTypes).toStrictEqual({
a: 'QUERY',
});
expect(storeSnapshot.variableTypes).not.toStrictEqual({
'not-there': 'QUERY',
});
});
});
describe('getVariableDependencyContext', () => {
it('should return context with all fields', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({
name: 'env',
type: 'QUERY',
order: 0,
selectedValue: 'prod',
}),
},
});
const { variableTypes, dynamicVariableOrder, dependencyData } =
getVariableDependencyContext();
expect(variableTypes).toStrictEqual({ env: 'QUERY' });
expect(dynamicVariableOrder).toStrictEqual([]);
expect(dependencyData).not.toBeNull();
});
it('should report doAllQueryVariablesHaveValuesSelected as true when all query variables have values', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({
name: 'env',
type: 'QUERY',
order: 0,
selectedValue: 'prod',
}),
region: createVariable({
name: 'region',
type: 'QUERY',
order: 1,
selectedValue: 'us-east',
}),
},
});
const { doAllQueryVariablesHaveValuesSelected } =
getVariableDependencyContext();
expect(doAllQueryVariablesHaveValuesSelected).toBe(true);
});
it('should report doAllQueryVariablesHaveValuesSelected as false when a query variable lacks a selectedValue', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({
name: 'env',
type: 'QUERY',
order: 0,
selectedValue: 'prod',
}),
region: createVariable({
name: 'region',
type: 'QUERY',
order: 1,
selectedValue: undefined,
}),
},
});
const { doAllQueryVariablesHaveValuesSelected } =
getVariableDependencyContext();
expect(doAllQueryVariablesHaveValuesSelected).toBe(false);
});
it('should ignore non-QUERY variables when computing doAllQueryVariablesHaveValuesSelected', () => {
// env (QUERY) has a value; region (CUSTOM) and dyn1 (DYNAMIC) do not — they are ignored
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({
name: 'env',
type: 'QUERY',
order: 0,
selectedValue: 'prod',
}),
region: createVariable({
name: 'region',
type: 'CUSTOM',
order: 1,
selectedValue: undefined,
}),
dyn1: createVariable({
name: 'dyn1',
type: 'DYNAMIC',
order: 2,
selectedValue: '',
}),
},
});
const { doAllQueryVariablesHaveValuesSelected } =
getVariableDependencyContext();
expect(doAllQueryVariablesHaveValuesSelected).toBe(true);
});
it('should return true for doAllQueryVariablesHaveValuesSelected when there are no query variables', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
dyn1: createVariable({
name: 'dyn1',
type: 'DYNAMIC',
order: 0,
selectedValue: '',
}),
},
});
const { doAllQueryVariablesHaveValuesSelected } =
getVariableDependencyContext();
expect(doAllQueryVariablesHaveValuesSelected).toBe(true);
});
// Any non-nil, non-empty-array selectedValue is treated as selected
it.each([
{ label: 'numeric 0', selectedValue: 0 as number },
{ label: 'boolean false', selectedValue: false as boolean },
// ideally not possible but till we have concrete schema, we should not block dynamic variables
{ label: 'empty string', selectedValue: '' },
{
label: 'non-empty array',
selectedValue: ['a', 'b'] as (string | number | boolean)[],
},
])('should return true when selectedValue is $label', ({ selectedValue }) => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({
name: 'env',
type: 'QUERY',
order: 0,
selectedValue,
}),
},
});
const { doAllQueryVariablesHaveValuesSelected } =
getVariableDependencyContext();
expect(doAllQueryVariablesHaveValuesSelected).toBe(true);
});
// null/undefined (tested above) and empty array are treated as not selected
it.each([
{
label: 'null',
selectedValue: null as IDashboardVariable['selectedValue'],
},
{ label: 'empty array', selectedValue: [] as (string | number | boolean)[] },
])(
'should return false when selectedValue is $label',
({ selectedValue }) => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({
name: 'env',
type: 'QUERY',
order: 0,
selectedValue,
}),
},
});
const { doAllQueryVariablesHaveValuesSelected } =
getVariableDependencyContext();
expect(doAllQueryVariablesHaveValuesSelected).toBe(false);
},
);
});
});

View File

@@ -1,383 +0,0 @@
import { IDashboardVariable } from 'types/api/dashboard/variables';
import { IDashboardVariables } from '../dashboardVariablesStoreTypes';
import {
buildDynamicVariableOrder,
buildSortedVariablesArray,
buildVariableTypesMap,
computeDerivedValues,
} from '../dashboardVariablesStoreUtils';
const createVariable = (
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable => ({
id: 'test-id',
name: 'test-var',
description: '',
type: 'QUERY',
sort: 'DISABLED',
showALLOption: false,
multiSelect: false,
order: 0,
...overrides,
});
describe('dashboardVariablesStoreUtils', () => {
describe('buildSortedVariablesArray', () => {
it('should sort variables by order property', () => {
const variables: IDashboardVariables = {
c: createVariable({ name: 'c', order: 3 }),
a: createVariable({ name: 'a', order: 1 }),
b: createVariable({ name: 'b', order: 2 }),
};
const result = buildSortedVariablesArray(variables);
expect(result.map((v) => v.name)).toStrictEqual(['a', 'b', 'c']);
});
it('should return empty array for empty variables', () => {
const result = buildSortedVariablesArray({});
expect(result).toStrictEqual([]);
});
it('should return empty array when variables is undefined', () => {
const result = buildSortedVariablesArray(
undefined as unknown as IDashboardVariables,
);
expect(result).toStrictEqual([]);
});
it('should return empty array when variables is null', () => {
const result = buildSortedVariablesArray(
null as unknown as IDashboardVariables,
);
expect(result).toStrictEqual([]);
});
it('should create copies of variables (not references)', () => {
const original = createVariable({ name: 'a', order: 0 });
const variables: IDashboardVariables = { a: original };
const result = buildSortedVariablesArray(variables);
expect(result[0]).not.toBe(original);
expect(result[0]).toStrictEqual(original);
});
});
describe('buildVariableTypesMap', () => {
it('should create a name-to-type mapping', () => {
const sorted = [
createVariable({ name: 'env', type: 'QUERY' }),
createVariable({ name: 'region', type: 'CUSTOM' }),
createVariable({ name: 'dynVar', type: 'DYNAMIC' }),
createVariable({ name: 'text', type: 'TEXTBOX' }),
];
const result = buildVariableTypesMap(sorted);
expect(result).toStrictEqual({
env: 'QUERY',
region: 'CUSTOM',
dynVar: 'DYNAMIC',
text: 'TEXTBOX',
});
});
it('should return empty object for empty array', () => {
expect(buildVariableTypesMap([])).toStrictEqual({});
});
});
describe('buildDynamicVariableOrder', () => {
it('should return only DYNAMIC variable names in order', () => {
const sorted = [
createVariable({ name: 'queryVar', type: 'QUERY', order: 0 }),
createVariable({ name: 'dyn1', type: 'DYNAMIC', order: 1 }),
createVariable({ name: 'customVar', type: 'CUSTOM', order: 2 }),
createVariable({ name: 'dyn2', type: 'DYNAMIC', order: 3 }),
];
const result = buildDynamicVariableOrder(sorted);
expect(result).toStrictEqual(['dyn1', 'dyn2']);
});
it('should return empty array when no DYNAMIC variables exist', () => {
const sorted = [
createVariable({ name: 'a', type: 'QUERY' }),
createVariable({ name: 'b', type: 'CUSTOM' }),
];
expect(buildDynamicVariableOrder(sorted)).toStrictEqual([]);
});
it('should return empty array for empty input', () => {
expect(buildDynamicVariableOrder([])).toStrictEqual([]);
});
});
describe('computeDerivedValues', () => {
it('should compute all derived values from variables', () => {
const variables: IDashboardVariables = {
env: createVariable({
name: 'env',
type: 'QUERY',
order: 0,
}),
dyn1: createVariable({
name: 'dyn1',
type: 'DYNAMIC',
order: 1,
}),
};
const result = computeDerivedValues(variables);
expect(result.sortedVariablesArray).toHaveLength(2);
expect(result.sortedVariablesArray[0].name).toBe('env');
expect(result.sortedVariablesArray[1].name).toBe('dyn1');
expect(result.variableTypes).toStrictEqual({
env: 'QUERY',
dyn1: 'DYNAMIC',
});
expect(result.dynamicVariableOrder).toStrictEqual(['dyn1']);
// dependencyData should exist since there are variables
expect(result.dependencyData).not.toBeNull();
});
it('should return null dependencyData for empty variables', () => {
const result = computeDerivedValues({});
expect(result.sortedVariablesArray).toStrictEqual([]);
expect(result.dependencyData).toBeNull();
expect(result.variableTypes).toStrictEqual({});
expect(result.dynamicVariableOrder).toStrictEqual([]);
});
it('should handle all four variable types together', () => {
const variables: IDashboardVariables = {
queryVar: createVariable({
name: 'queryVar',
type: 'QUERY',
order: 0,
}),
customVar: createVariable({
name: 'customVar',
type: 'CUSTOM',
order: 1,
}),
dynVar: createVariable({
name: 'dynVar',
type: 'DYNAMIC',
order: 2,
}),
textVar: createVariable({
name: 'textVar',
type: 'TEXTBOX',
order: 3,
}),
};
const result = computeDerivedValues(variables);
expect(result.sortedVariablesArray).toHaveLength(4);
expect(result.sortedVariablesArray.map((v) => v.name)).toStrictEqual([
'queryVar',
'customVar',
'dynVar',
'textVar',
]);
expect(result.variableTypes).toStrictEqual({
queryVar: 'QUERY',
customVar: 'CUSTOM',
dynVar: 'DYNAMIC',
textVar: 'TEXTBOX',
});
expect(result.dynamicVariableOrder).toStrictEqual(['dynVar']);
expect(result.dependencyData).not.toBeNull();
});
it('should sort variables by order regardless of insertion order', () => {
const variables: IDashboardVariables = {
z: createVariable({ name: 'z', type: 'QUERY', order: 4 }),
a: createVariable({ name: 'a', type: 'CUSTOM', order: 0 }),
m: createVariable({ name: 'm', type: 'DYNAMIC', order: 2 }),
b: createVariable({ name: 'b', type: 'TEXTBOX', order: 1 }),
x: createVariable({ name: 'x', type: 'QUERY', order: 3 }),
};
const result = computeDerivedValues(variables);
expect(result.sortedVariablesArray.map((v) => v.name)).toStrictEqual([
'a',
'b',
'm',
'x',
'z',
]);
});
it('should include multiple dynamic variables in order', () => {
const variables: IDashboardVariables = {
dyn3: createVariable({ name: 'dyn3', type: 'DYNAMIC', order: 5 }),
query1: createVariable({ name: 'query1', type: 'QUERY', order: 0 }),
dyn1: createVariable({ name: 'dyn1', type: 'DYNAMIC', order: 1 }),
custom1: createVariable({ name: 'custom1', type: 'CUSTOM', order: 2 }),
dyn2: createVariable({ name: 'dyn2', type: 'DYNAMIC', order: 3 }),
};
const result = computeDerivedValues(variables);
expect(result.dynamicVariableOrder).toStrictEqual(['dyn1', 'dyn2', 'dyn3']);
});
it('should build dependency data with query variable order for dependent queries', () => {
const variables: IDashboardVariables = {
env: createVariable({
name: 'env',
type: 'QUERY',
order: 0,
queryValue: 'SELECT DISTINCT env FROM table',
}),
service: createVariable({
name: 'service',
type: 'QUERY',
order: 1,
queryValue: 'SELECT DISTINCT service FROM table WHERE env={{.env}}',
}),
};
const result = computeDerivedValues(variables);
const { dependencyData } = result;
expect(dependencyData).not.toBeNull();
// env should appear in the dependency order (it's a root QUERY variable)
expect(dependencyData?.order).toContain('env');
// service depends on env, so it should also be in the order
expect(dependencyData?.order).toContain('service');
// env comes before service in topological order
const envIdx = dependencyData?.order.indexOf('env') ?? -1;
const svcIdx = dependencyData?.order.indexOf('service') ?? -1;
expect(envIdx).toBeLessThan(svcIdx);
});
it('should not include non-QUERY variables in dependency order', () => {
const variables: IDashboardVariables = {
env: createVariable({
name: 'env',
type: 'QUERY',
order: 0,
queryValue: 'SELECT DISTINCT env FROM table',
}),
customVar: createVariable({
name: 'customVar',
type: 'CUSTOM',
order: 1,
}),
dynVar: createVariable({
name: 'dynVar',
type: 'DYNAMIC',
order: 2,
}),
textVar: createVariable({
name: 'textVar',
type: 'TEXTBOX',
order: 3,
}),
};
const result = computeDerivedValues(variables);
expect(result.dependencyData).not.toBeNull();
// Only QUERY variables should be in the dependency order
result.dependencyData?.order.forEach((name) => {
expect(result.variableTypes[name]).toBe('QUERY');
});
});
it('should produce transitive descendants in dependency data', () => {
const variables: IDashboardVariables = {
region: createVariable({
name: 'region',
type: 'QUERY',
order: 0,
queryValue: 'SELECT region FROM table',
}),
cluster: createVariable({
name: 'cluster',
type: 'QUERY',
order: 1,
queryValue: 'SELECT cluster FROM table WHERE region={{.region}}',
}),
host: createVariable({
name: 'host',
type: 'QUERY',
order: 2,
queryValue: 'SELECT host FROM table WHERE cluster={{.cluster}}',
}),
};
const result = computeDerivedValues(variables);
const { dependencyData: depData } = result;
expect(depData).not.toBeNull();
expect(depData?.transitiveDescendants).toBeDefined();
// region's transitive descendants should include cluster and host
expect(depData?.transitiveDescendants['region']).toStrictEqual(
expect.arrayContaining(['cluster', 'host']),
);
});
it('should handle a single variable', () => {
const variables: IDashboardVariables = {
solo: createVariable({
name: 'solo',
type: 'QUERY',
order: 0,
}),
};
const result = computeDerivedValues(variables);
expect(result.sortedVariablesArray).toHaveLength(1);
expect(result.variableTypes).toStrictEqual({ solo: 'QUERY' });
expect(result.dynamicVariableOrder).toStrictEqual([]);
expect(result.dependencyData).not.toBeNull();
expect(result.dependencyData?.order).toStrictEqual(['solo']);
});
it('should handle only non-QUERY variables', () => {
const variables: IDashboardVariables = {
custom1: createVariable({
name: 'custom1',
type: 'CUSTOM',
order: 0,
}),
text1: createVariable({
name: 'text1',
type: 'TEXTBOX',
order: 1,
}),
dyn1: createVariable({
name: 'dyn1',
type: 'DYNAMIC',
order: 2,
}),
};
const result = computeDerivedValues(variables);
expect(result.sortedVariablesArray).toHaveLength(3);
// No QUERY variables, so dependency order should be empty
expect(result.dependencyData?.order).toStrictEqual([]);
expect(result.dynamicVariableOrder).toStrictEqual(['dyn1']);
});
});
});

View File

@@ -1,96 +0,0 @@
import { isNil } from 'lodash-es';
import createStore from '../store';
import { VariableFetchContext } from '../variableFetchStore';
import { IDashboardVariablesStoreState } from './dashboardVariablesStoreTypes';
import {
computeDerivedValues,
updateDerivedValues,
} from './dashboardVariablesStoreUtils';
const initialState: IDashboardVariablesStoreState = {
dashboardId: '',
variables: {},
sortedVariablesArray: [],
dependencyData: null,
variableTypes: {},
dynamicVariableOrder: [],
};
export const dashboardVariablesStore =
createStore<IDashboardVariablesStoreState>(initialState);
/**
* Set dashboard variables (replaces all variables)
*/
export function setDashboardVariablesStore({
dashboardId,
variables,
}: {
dashboardId: string;
variables: IDashboardVariablesStoreState['variables'];
}): void {
dashboardVariablesStore.set(() => {
return {
dashboardId,
variables,
...computeDerivedValues(variables),
} as IDashboardVariablesStoreState;
});
}
/**
* Update specific dashboard variables (merges with existing)
*/
export function updateDashboardVariablesStore({
dashboardId,
variables,
}: {
dashboardId: string;
variables: IDashboardVariablesStoreState['variables'];
}): void {
dashboardVariablesStore.update((draft) => {
if (draft.dashboardId !== dashboardId) {
// If dashboardId doesn't match, we replace the entire state
draft.dashboardId = dashboardId;
}
draft.variables = variables;
updateDerivedValues(draft);
});
}
/**
* Read current store snapshot as VariableFetchContext.
* Used by components to pass context to variableFetchStore actions
* without creating a circular import.
*/
export function getVariableDependencyContext(): VariableFetchContext {
const state = dashboardVariablesStore.getSnapshot();
// Dynamic variables should only wait on query variables having values,
// not on CUSTOM, TEXTBOX, or other types.
const doAllQueryVariablesHaveValuesSelected = Object.values(
state.variables,
).every((variable) => {
if (variable.type !== 'QUERY') {
return true;
}
if (isNil(variable.selectedValue)) {
return false;
}
if (Array.isArray(variable.selectedValue)) {
return variable.selectedValue.length > 0;
}
return true;
});
return {
doAllQueryVariablesHaveValuesSelected,
variableTypes: state.variableTypes,
dynamicVariableOrder: state.dynamicVariableOrder,
dependencyData: state.dependencyData,
};
}

View File

@@ -1,44 +0,0 @@
import {
IDashboardVariable,
TVariableQueryType,
} from 'types/api/dashboard/variables';
export type VariableGraph = Record<string, string[]>;
export interface IDependencyData {
order: string[];
// Direct children for each variable
graph: VariableGraph;
// Direct parents for each variable
parentDependencyGraph: VariableGraph;
// Pre-computed transitive descendants for each node (all reachable nodes, not just direct children)
transitiveDescendants: VariableGraph;
hasCycle: boolean;
cycleNodes?: string[];
}
export type IDashboardVariables = Record<string, IDashboardVariable>;
export interface IDashboardVariablesStoreState {
// dashboard id
dashboardId: string;
// Raw variables keyed by id/name
variables: IDashboardVariables;
// Derived: sorted array of variables by order
sortedVariablesArray: IDashboardVariable[];
// Derived: dependency data for QUERY variables
dependencyData: IDependencyData | null;
// Derived: variable name → type mapping
variableTypes: Record<string, TVariableQueryType>;
// Derived: display-ordered list of dynamic variable names
dynamicVariableOrder: string[];
}
export interface IUseDashboardVariablesReturn {
dashboardVariables: IDashboardVariablesStoreState['variables'];
}

View File

@@ -1,135 +0,0 @@
import {
buildDependencies,
buildDependencyGraph,
} from 'lib/dashboardVariables/dependencyGraph';
import {
IDashboardVariable,
TVariableQueryType,
} from 'types/api/dashboard/variables';
import {
IDashboardVariables,
IDashboardVariablesStoreState,
IDependencyData,
} from './dashboardVariablesStoreTypes';
/**
* Build a sorted array of variables by their order property
*/
export function buildSortedVariablesArray(
variables?: IDashboardVariables,
): IDashboardVariable[] {
const sortedVariablesArray: IDashboardVariable[] = [];
Object.values(variables ?? {}).forEach((value) => {
sortedVariablesArray.push({ ...value });
});
sortedVariablesArray.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
return sortedVariablesArray;
}
/**
* Build dependency data from sorted variables array
* This includes the dependency graph, topological order, and cycle detection
*/
export function buildDependencyData(
sortedVariablesArray: IDashboardVariable[],
): IDependencyData | null {
if (sortedVariablesArray.length === 0) {
return null;
}
const dependencies = buildDependencies(sortedVariablesArray);
const {
order,
graph,
parentDependencyGraph,
transitiveDescendants,
hasCycle,
cycleNodes,
} = buildDependencyGraph(dependencies);
// Filter order to only include QUERY type variables
const queryVariableOrder = order.filter((variable: string) => {
const variableData = sortedVariablesArray.find((v) => v.name === variable);
return variableData?.type === 'QUERY';
});
return {
order: queryVariableOrder,
graph,
parentDependencyGraph,
transitiveDescendants,
hasCycle,
cycleNodes,
};
}
/**
* Build a variable name → type mapping from sorted variables array
*/
export function buildVariableTypesMap(
sortedVariablesArray: IDashboardVariable[],
): Record<string, TVariableQueryType> {
const types: Record<string, TVariableQueryType> = {};
sortedVariablesArray.forEach((v) => {
if (v.name) {
types[v.name] = v.type;
}
});
return types;
}
/**
* Build display-ordered list of dynamic variable names
*/
export function buildDynamicVariableOrder(
sortedVariablesArray: IDashboardVariable[],
): string[] {
return sortedVariablesArray
.filter((v) => v.type === 'DYNAMIC' && v.name)
.map((v) => v.name as string);
}
/**
* Compute derived values from variables
* This is a composition of buildSortedVariablesArray and buildDependencyData
*/
export function computeDerivedValues(
variables: IDashboardVariablesStoreState['variables'],
): Pick<
IDashboardVariablesStoreState,
| 'sortedVariablesArray'
| 'dependencyData'
| 'variableTypes'
| 'dynamicVariableOrder'
> {
const sortedVariablesArray = buildSortedVariablesArray(variables);
const dependencyData = buildDependencyData(sortedVariablesArray);
const variableTypes = buildVariableTypesMap(sortedVariablesArray);
const dynamicVariableOrder = buildDynamicVariableOrder(sortedVariablesArray);
return {
sortedVariablesArray,
dependencyData,
variableTypes,
dynamicVariableOrder,
};
}
/**
* Update derived values in the store state (for use with immer)
* Also initializes the variable fetch store with the new dependency data
*/
export function updateDerivedValues(
draft: IDashboardVariablesStoreState,
): void {
draft.sortedVariablesArray = buildSortedVariablesArray(draft.variables);
draft.dependencyData = buildDependencyData(draft.sortedVariablesArray);
draft.variableTypes = buildVariableTypesMap(draft.sortedVariablesArray);
draft.dynamicVariableOrder = buildDynamicVariableOrder(
draft.sortedVariablesArray,
);
}

View File

@@ -0,0 +1,20 @@
import createStore from './store';
/**
* A dynamic dashboard variable reduced to what query-builder autocomplete needs:
* `attribute` is the filter key it backs, `name` is the `$name` offered as a value.
*/
export interface DynamicVariableSuggestion {
name: string;
attribute: string;
}
export const dynamicVariableSuggestionsStore = createStore<
DynamicVariableSuggestion[]
>([]);
export function setDynamicVariableSuggestions(
suggestions: DynamicVariableSuggestion[],
): void {
dynamicVariableSuggestionsStore.set(() => suggestions);
}

View File

@@ -1,241 +0,0 @@
import { getVariableDependencyContext } from './dashboardVariables/dashboardVariablesStore';
import { IDashboardVariablesStoreState } from './dashboardVariables/dashboardVariablesStoreTypes';
import createStore from './store';
import {
areAllQueryVariablesSettled,
isSettled,
resolveFetchState,
unlockWaitingDynamicVariables,
} from './variableFetchStoreUtils';
// Fetch state for each variable
export type VariableFetchState =
| 'idle' // stable state - initial or complete
| 'loading' // actively fetching data (first time)
| 'revalidating' // refetching existing data
| 'waiting' // blocked on parent dependencies
| 'error';
export interface IVariableFetchStoreState {
// Per-variable fetch state
states: Record<string, VariableFetchState>;
// Track last update timestamp per variable
lastUpdated: Record<string, number>;
// Per-variable cycle counter — bumped when a variable needs to refetch.
// Used in react-query keys to auto-cancel stale requests for that variable only.
cycleIds: Record<string, number>;
}
/**
* Context from dashboardVariablesStore needed by fetch actions.
* Passed as parameter to avoid circular imports.
*/
export type VariableFetchContext = Pick<
IDashboardVariablesStoreState,
'variableTypes' | 'dynamicVariableOrder' | 'dependencyData'
> & {
doAllQueryVariablesHaveValuesSelected: boolean;
};
const initialState: IVariableFetchStoreState = {
states: {},
lastUpdated: {},
cycleIds: {},
};
export const variableFetchStore =
createStore<IVariableFetchStoreState>(initialState);
// ============== Actions ==============
/**
* Initialize the store with variable names.
* Called when dashboard variables change — sets up state entries.
*/
export function initializeVariableFetchStore(variableNames: string[]): void {
variableFetchStore.update((draft) => {
// Initialize all variables to idle, preserving existing states
variableNames.forEach((name) => {
if (!draft.states[name]) {
draft.states[name] = 'idle';
}
});
// Clean up stale entries for variables that no longer exist
const nameSet = new Set(variableNames);
Object.keys(draft.states).forEach((name) => {
if (!nameSet.has(name)) {
delete draft.states[name];
delete draft.lastUpdated[name];
delete draft.cycleIds[name];
}
});
});
}
/**
* Start a full fetch cycle for all fetchable variables.
* Called on: initial load, time range change, or dependency graph change.
*
* Query variables with no query-type parents start immediately.
* Query variables with query-type parents get 'waiting'.
* Dynamic variables start immediately if all variables already have
* selectedValues (e.g. persisted from localStorage/URL). Otherwise they
* wait for all query variables to settle first.
*/
export function enqueueFetchOfAllVariables(): void {
const {
doAllQueryVariablesHaveValuesSelected,
dependencyData,
variableTypes,
dynamicVariableOrder,
} = getVariableDependencyContext();
if (!dependencyData) {
return;
}
const { order: queryVariableOrder, parentDependencyGraph } = dependencyData;
variableFetchStore.update((draft) => {
// Query variables: root ones start immediately, dependent ones wait
queryVariableOrder.forEach((name) => {
draft.cycleIds[name] = (draft.cycleIds[name] || 0) + 1;
const parents = parentDependencyGraph[name] || [];
const hasQueryParents = parents.some((p) => variableTypes[p] === 'QUERY');
if (hasQueryParents) {
draft.states[name] = 'waiting';
} else {
draft.states[name] = resolveFetchState(draft, name);
}
});
// Dynamic variables: start immediately if query variables have values,
// otherwise wait for query variables to settle first
dynamicVariableOrder.forEach((name) => {
draft.cycleIds[name] = (draft.cycleIds[name] || 0) + 1;
draft.states[name] = doAllQueryVariablesHaveValuesSelected
? resolveFetchState(draft, name)
: 'waiting';
});
});
}
/**
* Mark a variable as completed. Unblocks waiting query-type children.
* If all query variables are now settled, unlocks any waiting dynamic variables.
*/
export function onVariableFetchComplete(name: string): void {
const { dependencyData, variableTypes, dynamicVariableOrder } =
getVariableDependencyContext();
variableFetchStore.update((draft) => {
draft.states[name] = 'idle';
draft.lastUpdated[name] = Date.now();
if (!dependencyData) {
return;
}
const { graph } = dependencyData;
// Unblock waiting query-type children
const children = graph[name] || [];
children.forEach((child) => {
if (variableTypes[child] === 'QUERY' && draft.states[child] === 'waiting') {
draft.states[child] = resolveFetchState(draft, child);
}
});
// If all query variables are settled, unlock any waiting dynamic variables
if (
variableTypes[name] === 'QUERY' &&
areAllQueryVariablesSettled(draft.states, variableTypes)
) {
unlockWaitingDynamicVariables(draft, dynamicVariableOrder);
}
});
}
/**
* Mark a variable as errored. Sets query-type descendants to idle
* (they can't proceed without this parent).
* If all query variables are now settled, unlocks any waiting dynamic variables.
*/
export function onVariableFetchFailure(name: string): void {
const { dependencyData, variableTypes, dynamicVariableOrder } =
getVariableDependencyContext();
variableFetchStore.update((draft) => {
draft.states[name] = 'error';
if (!dependencyData) {
return;
}
// Set query-type descendants to idle (can't fetch without parent)
const descendants = dependencyData.transitiveDescendants[name] || [];
descendants.forEach((desc) => {
if (variableTypes[desc] === 'QUERY') {
draft.states[desc] = 'idle';
}
});
// If all query variables are settled (error counts), unlock any waiting dynamic variables
if (
variableTypes[name] === 'QUERY' &&
areAllQueryVariablesSettled(draft.states, variableTypes)
) {
unlockWaitingDynamicVariables(draft, dynamicVariableOrder);
}
});
}
/**
* Cascade a value change to query-type descendants.
* Called when a user changes a variable's value (not from a fetch cycle).
*
* Direct children whose parents are all settled start immediately.
* Deeper descendants wait until their parents complete (BFS order
* ensures parents are set before children within a single update).
*/
export function enqueueDescendantsOfVariable(name: string): void {
const { dependencyData, variableTypes, dynamicVariableOrder } =
getVariableDependencyContext();
if (!dependencyData) {
return;
}
const { parentDependencyGraph } = dependencyData;
variableFetchStore.update((draft) => {
const descendants = dependencyData.transitiveDescendants[name] || [];
const queryDescendants = descendants.filter(
(desc) => variableTypes[desc] === 'QUERY',
);
queryDescendants.forEach((desc) => {
draft.cycleIds[desc] = (draft.cycleIds[desc] || 0) + 1;
const parents = parentDependencyGraph[desc] || [];
const allParentsSettled = parents.every((p) => isSettled(draft.states[p]));
draft.states[desc] = allParentsSettled
? resolveFetchState(draft, desc)
: 'waiting';
});
// Dynamic variables implicitly depend on all query variable values.
// If all query variables are currently settled, start them immediately;
// otherwise they wait until query vars finish (unlocked via onVariableFetchComplete).
dynamicVariableOrder.forEach((dynName) => {
draft.cycleIds[dynName] = (draft.cycleIds[dynName] || 0) + 1;
draft.states[dynName] = areAllQueryVariablesSettled(
draft.states,
variableTypes,
)
? resolveFetchState(draft, dynName)
: 'waiting';
});
});
}

View File

@@ -1,46 +0,0 @@
import { TVariableQueryType } from 'types/api/dashboard/variables';
import {
IVariableFetchStoreState,
VariableFetchState,
} from './variableFetchStore';
export function isSettled(state: VariableFetchState | undefined): boolean {
return state === 'idle' || state === 'error';
}
/**
* Resolve the next fetch state based on whether the variable has been fetched before.
*/
export function resolveFetchState(
draft: IVariableFetchStoreState,
name: string,
): VariableFetchState {
return (draft.lastUpdated[name] || 0) > 0 ? 'revalidating' : 'loading';
}
/**
* Check if all query variables are settled (idle or error).
*/
export function areAllQueryVariablesSettled(
states: Record<string, VariableFetchState>,
variableTypes: Record<string, TVariableQueryType>,
): boolean {
return Object.entries(variableTypes)
.filter(([, type]) => type === 'QUERY')
.every(([name]) => isSettled(states[name]));
}
/**
* Transition waiting dynamic variables to loading/revalidating if in 'waiting' state.
*/
export function unlockWaitingDynamicVariables(
draft: IVariableFetchStoreState,
dynamicVariableOrder: string[],
): void {
dynamicVariableOrder.forEach((dynName) => {
if (draft.states[dynName] === 'waiting') {
draft.states[dynName] = resolveFetchState(draft, dynName);
}
});
}

View File

@@ -1,38 +0,0 @@
export const VariableQueryTypeArr = [
'QUERY',
'TEXTBOX',
'CUSTOM',
'DYNAMIC',
] as const;
export type TVariableQueryType = (typeof VariableQueryTypeArr)[number];
export const VariableSortTypeArr = ['DISABLED', 'ASC', 'DESC'] as const;
export type TSortVariableValuesType = (typeof VariableSortTypeArr)[number];
export interface IDashboardVariable {
id: string;
/** Display position; absent for dashboards whose variables carry no explicit order. */
order?: number;
name?: string; // key will be the source of truth
description: string;
type: TVariableQueryType;
// Query
queryValue?: string;
// Custom
customValue?: string;
// Textbox
textboxValue?: string;
sort: TSortVariableValuesType;
multiSelect: boolean;
showALLOption: boolean;
selectedValue?:
| null
| string
| number
| boolean
| (string | number | boolean)[];
allSelected?: boolean;
dynamicVariablesAttribute?: string;
dynamicVariablesSource?: string;
}

View File

@@ -1,9 +1,13 @@
import { IDashboardVariable } from 'types/api/dashboard/variables';
/** A variable's selected value as the variable-values API accepts it. */
type VariableValue =
| null
| string
| number
| boolean
| (string | number | boolean)[]
| undefined;
export type PayloadVariables = Record<
string,
IDashboardVariable['selectedValue']
>;
export type PayloadVariables = Record<string, VariableValue>;
export type Props = {
query: string;