From 63b130ccd15a82560697f6f1a9f04a0dd22d2528 Mon Sep 17 00:00:00 2001 From: Abhi kumar Date: Fri, 4 Sep 2026 05:28:03 +0000 Subject: [PATCH 1/4] chore(codeowners): retarget the dashboard entries at their new paths (#12707) #### Description Post Dashboard v1 cleanup, codeowners update #### Additional Information Closes https://github.com/SigNoz/pulse-pod/issues/325 --- .github/CODEOWNERS | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 66069da246a..8cf4f31ee48 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -152,39 +152,29 @@ go.mod @therealpandey ## Dashboard Types -/frontend/src/api/types/dashboard/ @SigNoz/pulse-frontend +/frontend/src/types/api/dashboard/ @SigNoz/pulse-frontend +/frontend/src/types/api/widgets/ @SigNoz/pulse-frontend -## Dashboard List +## Widget Card -/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend -/frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend - -# Dashboard Widget Page - -/frontend/src/pages/DashboardWidget/ @SigNoz/pulse-frontend -/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend - -## Dashboard Page - -/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend -/frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend -/frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend +/frontend/src/container/WidgetCard/ @SigNoz/pulse-frontend ## Public Dashboard Page /frontend/src/pages/PublicDashboard/ @SigNoz/pulse-frontend -/frontend/src/container/PublicDashboardContainer/ @SigNoz/pulse-frontend ## Dashboard Libs + Components /frontend/src/lib/uPlotV2/ @SigNoz/pulse-frontend +/frontend/src/lib/visualization/ @SigNoz/pulse-frontend /frontend/src/lib/dashboard/ @SigNoz/pulse-frontend /frontend/src/lib/dashboardVariables/ @SigNoz/pulse-frontend /frontend/src/components/NewSelect/ @SigNoz/pulse-frontend -## Dashboard V2 -/frontend/src/pages/DashboardPageV2/ @SigNoz/pulse-frontend -/frontend/src/pages/DashboardsListPageV2/ @SigNoz/pulse-frontend +## Dashboard Pages + +/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend +/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend ## Infrastructure Monitoring /frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend From 717d37a945135e3a20eedf22aacb0a7a24c7415d Mon Sep 17 00:00:00 2001 From: Abhi kumar Date: Fri, 4 Sep 2026 06:31:38 +0000 Subject: [PATCH 2/4] chore(dashboard): retire the V1 variable runtime (#12710) 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. #### Description #### Issues closed by this PR Closes https://github.com/SigNoz/pulse-pod/issues/326 #### Screenshots / Screen Recordings #### Additional Information --- frontend/.oxlintrc.json | 5 +- .../queryRange/prepareQueryRangePayloadV5.ts | 37 +- .../QueryV2/QuerySearch/QuerySearch.tsx | 11 +- .../QueryBuilderSearchV2.tsx | 12 +- .../__test__/QueryBuilderSearchV2.test.tsx | 27 +- .../src/container/ServiceApplication/utils.ts | 2 - .../WidgetCard/Card/FullView/index.tsx | 6 - .../src/container/WidgetCard/Card/index.tsx | 35 +- .../src/container/WidgetCard/Card/types.ts | 2 - .../__tests__/useResolveQuery.test.tsx | 14 +- .../WidgetCard/hooks/useResolveQuery.ts | 37 +- .../__test__/useGetResolvedText.test.tsx | 244 +------ .../useIsPanelWaitingOnVariable.test.ts | 351 ---------- .../hooks/dashboard/useContextVariables.tsx | 37 +- .../hooks/dashboard/useDashboardVariables.ts | 40 -- .../dashboard/useDashboardVariablesByType.ts | 30 - .../useDynamicVariableSuggestions.ts | 13 + .../hooks/dashboard/useGetResolvedText.tsx | 180 +----- .../hooks/dashboard/useVariableFetchState.ts | 151 ----- .../__tests__/useCreateAlerts.test.tsx | 12 +- .../hooks/queryBuilder/useCreateAlerts.tsx | 30 +- .../hooks/queryBuilder/useGetQueryRange.ts | 7 +- frontend/src/lib/dashboard/getQueryResults.ts | 10 +- .../lib/dashboardVariables/dependencyGraph.ts | 239 ------- .../getDashboardVariables.ts | 41 -- .../hooks/useSyncVariablesForSuggestions.ts | 82 +-- .../queryV5/buildVariablesPayload.ts | 10 +- .../__tests__/variableFetchStore.test.ts | 603 ------------------ .../__tests__/variableFetchStoreUtils.test.ts | 196 ------ .../__tests__/dashboardVariablesStore.test.ts | 287 --------- .../dashboardVariablesStoreUtils.test.ts | 383 ----------- .../dashboardVariablesStore.ts | 96 --- .../dashboardVariablesStoreTypes.ts | 44 -- .../dashboardVariablesStoreUtils.ts | 138 ---- .../store/dynamicVariableSuggestions.ts | 25 + .../src/providers/Dashboard/store/store.ts | 43 -- .../Dashboard/store/variableFetchStore.ts | 241 ------- .../store/variableFetchStoreUtils.ts | 46 -- frontend/src/types/api/dashboard/variables.ts | 38 -- .../types/api/dashboard/variables/query.ts | 14 +- 40 files changed, 188 insertions(+), 3631 deletions(-) delete mode 100644 frontend/src/hooks/dashboard/__test__/useIsPanelWaitingOnVariable.test.ts delete mode 100644 frontend/src/hooks/dashboard/useDashboardVariables.ts delete mode 100644 frontend/src/hooks/dashboard/useDashboardVariablesByType.ts create mode 100644 frontend/src/hooks/dashboard/useDynamicVariableSuggestions.ts delete mode 100644 frontend/src/hooks/dashboard/useVariableFetchState.ts delete mode 100644 frontend/src/lib/dashboardVariables/dependencyGraph.ts delete mode 100644 frontend/src/lib/dashboardVariables/getDashboardVariables.ts delete mode 100644 frontend/src/providers/Dashboard/store/__tests__/variableFetchStore.test.ts delete mode 100644 frontend/src/providers/Dashboard/store/__tests__/variableFetchStoreUtils.test.ts delete mode 100644 frontend/src/providers/Dashboard/store/dashboardVariables/__tests__/dashboardVariablesStore.test.ts delete mode 100644 frontend/src/providers/Dashboard/store/dashboardVariables/__tests__/dashboardVariablesStoreUtils.test.ts delete mode 100644 frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStore.ts delete mode 100644 frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes.ts delete mode 100644 frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreUtils.ts create mode 100644 frontend/src/providers/Dashboard/store/dynamicVariableSuggestions.ts delete mode 100644 frontend/src/providers/Dashboard/store/store.ts delete mode 100644 frontend/src/providers/Dashboard/store/variableFetchStore.ts delete mode 100644 frontend/src/providers/Dashboard/store/variableFetchStoreUtils.ts delete mode 100644 frontend/src/types/api/dashboard/variables.ts diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json index 5f32f0716ea..5a51e9c4427 100644 --- a/frontend/.oxlintrc.json +++ b/frontend/.oxlintrc.json @@ -323,9 +323,10 @@ "name": "react", "importNames": [ "createContext", - "useContext" + "useContext", + "useSyncExternalStore" ], - "message": "[State mgmt] React Context is deprecated. Migrate shared state to Zustand." + "message": "[State mgmt] React Context and hand-rolled external stores are deprecated. Migrate shared state to Zustand." }, { "name": "immer", diff --git a/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts b/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts index 5e4f751bbb9..c45d62be3ae 100644 --- a/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts +++ b/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts @@ -6,6 +6,7 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults'; import getStartEndRangeTime from 'lib/getStartEndRangeTime'; import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi'; import { isEmpty } from 'lodash-es'; +import { DynamicVariableSuggestion } from 'providers/Dashboard/store/dynamicVariableSuggestions'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { IBuilderQuery, @@ -545,20 +546,22 @@ function reduceQueriesToObject(queryArray: any[]): { /** * Prepares V5 query range payload from GetQueryResultsProps */ -export const prepareQueryRangePayloadV5 = ({ - query, - globalSelectedInterval, - graphType, - selectedTime, - tableParams, - variables = {}, - start: startTime, - end: endTime, - formatForWeb, - originalGraphType, - fillGaps, - dynamicVariables, -}: GetQueryResultsProps): PrepareQueryRangePayloadV5Result => { +export const prepareQueryRangePayloadV5 = ( + { + query, + globalSelectedInterval, + graphType, + selectedTime, + tableParams, + variables = {}, + start: startTime, + end: endTime, + formatForWeb, + originalGraphType, + fillGaps, + }: GetQueryResultsProps, + dynamicVariables: DynamicVariableSuggestion[] = [], +): PrepareQueryRangePayloadV5Result => { let legendMap: Record = {}; const requestType = mapPanelTypeToRequestType(graphType); let queries: QueryEnvelope[] = []; @@ -671,9 +674,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; }, diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.tsx b/frontend/src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.tsx index d1080b81859..29ea70e8c80 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.tsx @@ -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(''); const isMountedRef = useRef(true); - const dashboardDynamicVariables = useDashboardVariablesByType( - 'DYNAMIC', - 'values', - ); + const dashboardDynamicVariables = useDynamicVariableSuggestions(); // Add back the generateOptions function and useEffect const generateOptions = (keys: { @@ -1188,8 +1185,8 @@ function QuerySearch({ ); // Add dynamic variables suggestions for the current key - const variableName = dashboardDynamicVariables?.find( - (variable) => variable?.dynamicVariablesAttribute === keyName, + const variableName = dashboardDynamicVariables.find( + (variable) => variable.attribute === keyName, )?.name; if (variableName) { diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx index 8ac8debf8f1..be2afa1c6dd 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx @@ -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( { @@ -816,9 +813,8 @@ function QueryBuilderSearchV2( values.push(...(attributeValues?.payload?.[key] || [])); // 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, + const variableName = dashboardDynamicVariables.find( + (variable) => variable.attribute === currentFilterItem?.key?.key, )?.name; if (variableName) { diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/__test__/QueryBuilderSearchV2.test.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/__test__/QueryBuilderSearchV2.test.tsx index bc80ecff6f6..15d5ca286a0 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/__test__/QueryBuilderSearchV2.test.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/__test__/QueryBuilderSearchV2.test.tsx @@ -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', () => { diff --git a/frontend/src/container/ServiceApplication/utils.ts b/frontend/src/container/ServiceApplication/utils.ts index 7fb31bdce7f..643ebd614cb 100644 --- a/frontend/src/container/ServiceApplication/utils.ts +++ b/frontend/src/container/ServiceApplication/utils.ts @@ -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; diff --git a/frontend/src/container/WidgetCard/Card/FullView/index.tsx b/frontend/src/container/WidgetCard/Card/FullView/index.tsx index 587d61ef1be..2458c7834cf 100644 --- a/frontend/src/container/WidgetCard/Card/FullView/index.tsx +++ b/frontend/src/container/WidgetCard/Card/FullView/index.tsx @@ -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, diff --git a/frontend/src/container/WidgetCard/Card/index.tsx b/frontend/src/container/WidgetCard/Card/index.tsx index 443d50a1f02..ac8dff319c9 100644 --- a/frontend/src/container/WidgetCard/Card/index.tsx +++ b/frontend/src/container/WidgetCard/Card/index.tsx @@ -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(() => { 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} diff --git a/frontend/src/container/WidgetCard/Card/types.ts b/frontend/src/container/WidgetCard/Card/types.ts index d6416234580..b65827d823e 100644 --- a/frontend/src/container/WidgetCard/Card/types.ts +++ b/frontend/src/container/WidgetCard/Card/types.ts @@ -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; diff --git a/frontend/src/container/WidgetCard/__tests__/useResolveQuery.test.tsx b/frontend/src/container/WidgetCard/__tests__/useResolveQuery.test.tsx index d87313dac4d..05056fcaccf 100644 --- a/frontend/src/container/WidgetCard/__tests__/useResolveQuery.test.tsx +++ b/frontend/src/container/WidgetCard/__tests__/useResolveQuery.test.tsx @@ -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); diff --git a/frontend/src/container/WidgetCard/hooks/useResolveQuery.ts b/frontend/src/container/WidgetCard/hooks/useResolveQuery.ts index 78938d1d5d6..3523b3fbb1b 100644 --- a/frontend/src/container/WidgetCard/hooks/useResolveQuery.ts +++ b/frontend/src/container/WidgetCard/hooks/useResolveQuery.ts @@ -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,34 +35,27 @@ function useUpdatedQuery(): UseUpdatedQueryResult { const queryRangeMutation = useMutation(getSubstituteVars); - const dashboardDynamicVariables = useDashboardVariablesByType( - 'DYNAMIC', - 'values', - ); + const dashboardDynamicVariables = useDynamicVariableSuggestions(); const getUpdatedQuery = useCallback( - async ({ - widgetConfig, - dashboardData, - }: UseUpdatedQueryOptions): Promise => { - const variables = getDashboardVariables(dashboardData?.data?.variables); - + async ({ widgetConfig }: UseUpdatedQueryOptions): Promise => { // `/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; } // Prepare query payload with resolved variables - const { queryPayload } = prepareQueryRangePayloadV5({ - query: widgetConfig.query, - graphType: getGraphType(widgetConfig.panelTypes), - selectedTime: widgetConfig.timePreferance, - globalSelectedInterval, - variables, - originalGraphType: widgetConfig.panelTypes, - dynamicVariables: dashboardDynamicVariables, - }); + const { queryPayload } = prepareQueryRangePayloadV5( + { + query: widgetConfig.query, + graphType: getGraphType(widgetConfig.panelTypes), + selectedTime: widgetConfig.timePreferance, + globalSelectedInterval, + originalGraphType: widgetConfig.panelTypes, + }, + dashboardDynamicVariables, + ); // Execute query and process results const queryResult = await queryRangeMutation.mutateAsync(queryPayload); diff --git a/frontend/src/hooks/dashboard/__test__/useGetResolvedText.test.tsx b/frontend/src/hooks/dashboard/__test__/useGetResolvedText.test.tsx index 540740c30bf..89fa102b001 100644 --- a/frontend/src/hooks/dashboard/__test__/useGetResolvedText.test.tsx +++ b/frontend/src/hooks/dashboard/__test__/useGetResolvedText.test.tsx @@ -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, - ): any => { - if (variables) { - mockDashboardVariables = Object.entries( - variables, - ).reduce((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`, - ); - expect(result.current.fullText).toBe(EXPECTED_FULL_TEXT); - }); - - it('should handle text with maxLength truncation', () => { - const text = TEXT_TEMPLATE; - const variables = { - 'service.name': SERVICE_VAR, - severity: SEVERITY_VAR, - }; - - 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('returns the text unchanged when it fits within maxLength', () => { + const { result } = renderHook(() => + useGetResolvedText({ text: 'Logs count', maxLength: 100 }), ); - }); - - 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', - ); + expect(result.current.fullText).toBe('Logs count'); + expect(result.current.truncatedText).toBe('Logs count'); }); - 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('returns the text unchanged when no maxLength is given', () => { + const text = 'a'.repeat(200); + const { result } = renderHook(() => useGetResolvedText({ 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 =
Test ReactNode
; - 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 = title; + 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); }); }); diff --git a/frontend/src/hooks/dashboard/__test__/useIsPanelWaitingOnVariable.test.ts b/frontend/src/hooks/dashboard/__test__/useIsPanelWaitingOnVariable.test.ts deleted file mode 100644 index 3a89e36d2ff..00000000000 --- a/frontend/src/hooks/dashboard/__test__/useIsPanelWaitingOnVariable.test.ts +++ /dev/null @@ -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 & { 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): void { - variableFetchStore.set(() => ({ - states, - lastUpdated: {}, - cycleIds: {}, - })); -} - -function setDashboardVariables( - overrides: Partial, -): 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); - }); -}); diff --git a/frontend/src/hooks/dashboard/useContextVariables.tsx b/frontend/src/hooks/dashboard/useContextVariables.tsx index 1b23ead766b..b5f8cb62bbf 100644 --- a/frontend/src/hooks/dashboard/useContextVariables.tsx +++ b/frontend/src/hooks/dashboard/useContextVariables.tsx @@ -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( (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 diff --git a/frontend/src/hooks/dashboard/useDashboardVariables.ts b/frontend/src/hooks/dashboard/useDashboardVariables.ts deleted file mode 100644 index 0d880d113fc..00000000000 --- a/frontend/src/hooks/dashboard/useDashboardVariables.ts +++ /dev/null @@ -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 = ( - 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 }; -}; diff --git a/frontend/src/hooks/dashboard/useDashboardVariablesByType.ts b/frontend/src/hooks/dashboard/useDashboardVariablesByType.ts deleted file mode 100644 index 86f1f479f7e..00000000000 --- a/frontend/src/hooks/dashboard/useDashboardVariablesByType.ts +++ /dev/null @@ -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]); -} diff --git a/frontend/src/hooks/dashboard/useDynamicVariableSuggestions.ts b/frontend/src/hooks/dashboard/useDynamicVariableSuggestions.ts new file mode 100644 index 00000000000..128f5a94263 --- /dev/null +++ b/frontend/src/hooks/dashboard/useDynamicVariableSuggestions.ts @@ -0,0 +1,13 @@ +import { + DynamicVariableSuggestion, + useDynamicVariableSuggestionsStore, +} 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 useDynamicVariableSuggestionsStore((state) => state.suggestions); +} diff --git a/frontend/src/hooks/dashboard/useGetResolvedText.tsx b/frontend/src/hooks/dashboard/useGetResolvedText.tsx index 7f1547dc0f3..9fc7fa3c1e8 100644 --- a/frontend/src/hooks/dashboard/useGetResolvedText.tsx +++ b/frontend/src/hooks/dashboard/useGetResolvedText.tsx @@ -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; 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 - >((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 = {}; - - 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; diff --git a/frontend/src/hooks/dashboard/useVariableFetchState.ts b/frontend/src/hooks/dashboard/useVariableFetchState.ts deleted file mode 100644 index 93d6cf1fdcf..00000000000 --- a/frontend/src/hooks/dashboard/useVariableFetchState.ts +++ /dev/null @@ -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 = ( - 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; - }); -} diff --git a/frontend/src/hooks/queryBuilder/__tests__/useCreateAlerts.test.tsx b/frontend/src/hooks/queryBuilder/__tests__/useCreateAlerts.test.tsx index da918bef3fe..93730ecdb85 100644 --- a/frontend/src/hooks/queryBuilder/__tests__/useCreateAlerts.test.tsx +++ b/frontend/src/hooks/queryBuilder/__tests__/useCreateAlerts.test.tsx @@ -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'), })); diff --git a/frontend/src/hooks/queryBuilder/useCreateAlerts.tsx b/frontend/src/hooks/queryBuilder/useCreateAlerts.tsx index a45a9ad2602..f7a01571d1d 100644 --- a/frontend/src/hooks/queryBuilder/useCreateAlerts.tsx +++ b/frontend/src/hooks/queryBuilder/useCreateAlerts.tsx @@ -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) { @@ -63,15 +57,16 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => { queryType: widget.query.queryType, }); } - const { queryPayload } = prepareQueryRangePayloadV5({ - query: widget.query, - globalSelectedInterval, - graphType: getGraphType(widget.panelTypes), - selectedTime: widget.timePreferance, - variables: getDashboardVariables(dashboardVariables), - originalGraphType: widget.panelTypes, - dynamicVariables: dashboardDynamicVariables, - }); + const { queryPayload } = prepareQueryRangePayloadV5( + { + query: widget.query, + globalSelectedInterval, + graphType: getGraphType(widget.panelTypes), + selectedTime: widget.timePreferance, + originalGraphType: widget.panelTypes, + }, + dashboardDynamicVariables, + ); queryRangeMutation.mutate(queryPayload, { onSuccess: (data) => { const updatedQuery = mapQueryDataFromApi(data.data.compositeQuery); @@ -107,7 +102,6 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => { globalSelectedInterval, notifications, queryRangeMutation, - dashboardVariables, dashboardDynamicVariables, widget, ]); diff --git a/frontend/src/hooks/queryBuilder/useGetQueryRange.ts b/frontend/src/hooks/queryBuilder/useGetQueryRange.ts index d949bf4832f..79f6d016dc9 100644 --- a/frontend/src/hooks/queryBuilder/useGetQueryRange.ts +++ b/frontend/src/hooks/queryBuilder/useGetQueryRange.ts @@ -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]; diff --git a/frontend/src/lib/dashboard/getQueryResults.ts b/frontend/src/lib/dashboard/getQueryResults.ts index 3b581e841f3..900a3f7f628 100644 --- a/frontend/src/lib/dashboard/getQueryResults.ts +++ b/frontend/src/lib/dashboard/getQueryResults.ts @@ -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, ): Promise { @@ -226,10 +226,7 @@ export async function GetMetricQueryRange( } if (version === ENTITY_VERSION_V5) { - const v5Result = prepareQueryRangePayloadV5({ - ...props, - dynamicVariables, - }); + const v5Result = prepareQueryRangePayloadV5(props, dynamicVariables); legendMap = v5Result.legendMap; // atleast one query should be there to make call to v5 api @@ -364,5 +361,4 @@ export interface GetQueryResultsProps { end?: number; step?: number; originalGraphType?: PANEL_TYPES; - dynamicVariables?: IDashboardVariable[]; } diff --git a/frontend/src/lib/dashboardVariables/dependencyGraph.ts b/frontend/src/lib/dashboardVariables/dependencyGraph.ts deleted file mode 100644 index 54ca3982b47..00000000000 --- a/frontend/src/lib/dashboardVariables/dependencyGraph.ts +++ /dev/null @@ -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; - -/** 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, - recStack: Set, -): 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 = {}; - 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(); - const recStack = new Set(); - 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(); - 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, - }; -}; diff --git a/frontend/src/lib/dashboardVariables/getDashboardVariables.ts b/frontend/src/lib/dashboardVariables/getDashboardVariables.ts deleted file mode 100644 index e90a92b35b1..00000000000 --- a/frontend/src/lib/dashboardVariables/getDashboardVariables.ts +++ /dev/null @@ -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 => { - if (!variables) { - return {}; - } - - try { - const { globalTime } = store.getState(); - const { start, end } = getStartEndRangeTime({ - type: 'GLOBAL_TIME', - interval: globalTime.selectedTime, - }); - - const variablesTuple: Record = { - 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 {}; -}; diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/hooks/useSyncVariablesForSuggestions.ts b/frontend/src/pages/DashboardPage/DashboardContainer/hooks/useSyncVariablesForSuggestions.ts index 0b86f39e194..47292cf3220 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/hooks/useSyncVariablesForSuggestions.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/hooks/useSyncVariablesForSuggestions.ts @@ -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 = { - 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( + () => + (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 = {}; - 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]); } diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/buildVariablesPayload.ts b/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/buildVariablesPayload.ts index 5731c1bfbfa..b8c33d09678 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/buildVariablesPayload.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/buildVariablesPayload.ts @@ -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[], diff --git a/frontend/src/providers/Dashboard/store/__tests__/variableFetchStore.test.ts b/frontend/src/providers/Dashboard/store/__tests__/variableFetchStore.test.ts deleted file mode 100644 index 4666d9231e4..00000000000 --- a/frontend/src/providers/Dashboard/store/__tests__/variableFetchStore.test.ts +++ /dev/null @@ -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 = {}): 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 { - 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'); - }); - }); -}); diff --git a/frontend/src/providers/Dashboard/store/__tests__/variableFetchStoreUtils.test.ts b/frontend/src/providers/Dashboard/store/__tests__/variableFetchStoreUtils.test.ts deleted file mode 100644 index accb7e2320b..00000000000 --- a/frontend/src/providers/Dashboard/store/__tests__/variableFetchStoreUtils.test.ts +++ /dev/null @@ -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 = { - 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 = { - 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 = { - 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 = { - 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 = { - 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 = { - 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 = { - 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'); - }); - }); -}); diff --git a/frontend/src/providers/Dashboard/store/dashboardVariables/__tests__/dashboardVariablesStore.test.ts b/frontend/src/providers/Dashboard/store/dashboardVariables/__tests__/dashboardVariablesStore.test.ts deleted file mode 100644 index 48ba70d9dd6..00000000000 --- a/frontend/src/providers/Dashboard/store/dashboardVariables/__tests__/dashboardVariablesStore.test.ts +++ /dev/null @@ -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 { - 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); - }, - ); - }); -}); diff --git a/frontend/src/providers/Dashboard/store/dashboardVariables/__tests__/dashboardVariablesStoreUtils.test.ts b/frontend/src/providers/Dashboard/store/dashboardVariables/__tests__/dashboardVariablesStoreUtils.test.ts deleted file mode 100644 index b203fc325b5..00000000000 --- a/frontend/src/providers/Dashboard/store/dashboardVariables/__tests__/dashboardVariablesStoreUtils.test.ts +++ /dev/null @@ -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 => ({ - 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']); - }); - }); -}); diff --git a/frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStore.ts b/frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStore.ts deleted file mode 100644 index 04670cd081d..00000000000 --- a/frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStore.ts +++ /dev/null @@ -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(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, - }; -} diff --git a/frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes.ts b/frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes.ts deleted file mode 100644 index f859c5ab0b8..00000000000 --- a/frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - IDashboardVariable, - TVariableQueryType, -} from 'types/api/dashboard/variables'; - -export type VariableGraph = Record; - -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; - -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; - - // Derived: display-ordered list of dynamic variable names - dynamicVariableOrder: string[]; -} - -export interface IUseDashboardVariablesReturn { - dashboardVariables: IDashboardVariablesStoreState['variables']; -} diff --git a/frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreUtils.ts b/frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreUtils.ts deleted file mode 100644 index 7833c6b7b8f..00000000000 --- a/frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreUtils.ts +++ /dev/null @@ -1,138 +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 }); - }); - - // `order` is optional because nothing sets it any more: the v2 API orders - // variables by array position, so the sort is a stable no-op that preserves it. - // Drop the sort along with `order` when IDashboardVariable goes. - 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 { - const types: Record = {}; - 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, - ); -} diff --git a/frontend/src/providers/Dashboard/store/dynamicVariableSuggestions.ts b/frontend/src/providers/Dashboard/store/dynamicVariableSuggestions.ts new file mode 100644 index 00000000000..3d2735bf207 --- /dev/null +++ b/frontend/src/providers/Dashboard/store/dynamicVariableSuggestions.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand'; + +/** + * 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; +} + +interface DynamicVariableSuggestionsState { + suggestions: DynamicVariableSuggestion[]; +} + +export const useDynamicVariableSuggestionsStore = + create(() => ({ + suggestions: [], + })); + +export function setDynamicVariableSuggestions( + suggestions: DynamicVariableSuggestion[], +): void { + useDynamicVariableSuggestionsStore.setState({ suggestions }); +} diff --git a/frontend/src/providers/Dashboard/store/store.ts b/frontend/src/providers/Dashboard/store/store.ts deleted file mode 100644 index 6550e375e4b..00000000000 --- a/frontend/src/providers/Dashboard/store/store.ts +++ /dev/null @@ -1,43 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -import { produce } from 'immer'; -type ListenerFn = () => void; - -export default function createStore(init: T): { - set: (setter: any) => void; - update: (updater: (draft: T) => void) => void; - subscribe: (listener: ListenerFn) => () => void; - getSnapshot: () => T; -} { - let listeners: ListenerFn[] = []; - let state = init; - - function emitChange(): void { - for (const listener of listeners) { - listener(); - } - } - - function set(setter: any): void { - state = produce(state, setter); - emitChange(); - } - - function update(updater: (draft: T) => void): void { - state = produce(state, updater); - emitChange(); - } - - return { - set, - update, - subscribe(listener: ListenerFn): () => void { - listeners = [...listeners, listener]; - return (): void => { - listeners = listeners.filter((l) => l !== listener); - }; - }, - getSnapshot(): T { - return state; - }, - }; -} diff --git a/frontend/src/providers/Dashboard/store/variableFetchStore.ts b/frontend/src/providers/Dashboard/store/variableFetchStore.ts deleted file mode 100644 index b7fd7e1dcc1..00000000000 --- a/frontend/src/providers/Dashboard/store/variableFetchStore.ts +++ /dev/null @@ -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; - - // Track last update timestamp per variable - lastUpdated: Record; - - // 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; -} - -/** - * 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(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'; - }); - }); -} diff --git a/frontend/src/providers/Dashboard/store/variableFetchStoreUtils.ts b/frontend/src/providers/Dashboard/store/variableFetchStoreUtils.ts deleted file mode 100644 index e4f52000fcd..00000000000 --- a/frontend/src/providers/Dashboard/store/variableFetchStoreUtils.ts +++ /dev/null @@ -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, - variableTypes: Record, -): 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); - } - }); -} diff --git a/frontend/src/types/api/dashboard/variables.ts b/frontend/src/types/api/dashboard/variables.ts deleted file mode 100644 index e18743ca9b9..00000000000 --- a/frontend/src/types/api/dashboard/variables.ts +++ /dev/null @@ -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. Nothing sets it now that the v2 API orders variables by array position. */ - 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; -} diff --git a/frontend/src/types/api/dashboard/variables/query.ts b/frontend/src/types/api/dashboard/variables/query.ts index e335093c211..fe0c56ed972 100644 --- a/frontend/src/types/api/dashboard/variables/query.ts +++ b/frontend/src/types/api/dashboard/variables/query.ts @@ -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; export type Props = { query: string; From e1ee38601679eec8e576929b67568762350dccff Mon Sep 17 00:00:00 2001 From: Abhi kumar Date: Fri, 4 Sep 2026 09:54:52 +0000 Subject: [PATCH 3/4] refactor(dashboards-v2): declare per-kind query capabilities instead of inferring them from panel types (#12559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### Description V2 panels answered "how does this panel's query behave?" by comparing against the legacy `PANEL_TYPES` enum. Each kind now declares it, so adding a kind means stating its behaviour once instead of finding every switch that should have mentioned it. - **Kinds declare their query behaviour** — request type, table formatting, step-interval and order treatment, paging, list-view authoring, trace operator. `buildQueryRangeRequest` takes that block, so the `PANEL_TYPES.BAR` / `.LIST` / `.TABLE` branches are gone. An exhaustive `Record` test means a new kind can't ship without declaring its request shape. - **The capabilities are passed in, not looked up.** The panel registry carries every renderer with it, so importing it into the data path drags the app's API client into anything that touches the request builder. The call sites already resolve the definition. - **The chart layer no longer infers a time axis from a panel type.** `UPlotAxisBuilder` decided X-axis date formatting from a hardcoded `[TIME_SERIES, BAR]` list, so a chart that plots time but isn't one of those two silently lost its formatted ticks — no type error, no failing test. Callers now declare `isTimeAxis`. - **`getPanelDefinition` always resolves.** It was typed to return a definition for any `PanelKind`, but the registry only holds registered kinds, and a spec from a newer SigNoz names one this build has never heard of. Callers coped by truthiness-checking a value the type said couldn't be falsy — a lint autofix had already deleted one such guard in `PublicPanel`. Unknown kinds now resolve to `UNSUPPORTED_PANEL`, which declares nothing and renders as unsupported; `isPanelKindSupported` is the separate question the lazy fetch and editor session actually needed. - **Analytics gained `panelKind`** on all seven panel events, alongside the existing `panelType` so current reports keep resolving. `panelType` can't distinguish two kinds that map onto it. - **Removed `ViewPanelQueryBuilder`** — no importers; the View modal renders `PanelEditorQueryBuilder`. It referenced a stylesheet class that no longer exists. Behaviour is unchanged for every registered kind. The one visible difference: a panel whose kind this build can't render now says so, instead of rendering a header above an empty body. #### Issues Closed Closes https://github.com/SigNoz/pulse-pod/issues/279 #### Additional Information - **Read it commit by commit** — each is one theme (declare / request path / axis / builder mode / analytics / registry), and the diff is mostly deletions once the declarations are in place. - The legacy enum still appears in ~28 V2 files, all of it *translation at a boundary* rather than a decision: the V1 `Query` pivot (`mapCompositeQueryFromQuery` writes `panelType` into `ICompositeMetricQuery`), URL params (`graphType` / `panelTypes` are a serialised contract), the shared `QueryBuilderV2` provider (where `panelType` is provider state read by its subcomponents), and analytics. A follow-up will quarantine those into a single boundary module with a lint rule keeping them there. - The last commit deletes `resolveQueryCapabilities`, added earlier in this branch: it existed only to absorb a missing definition, which the registry no longer produces. --- .../EntityMetrics/configBuilder.ts | 4 +- .../MeterExplorer/Explorer/configBuilder.ts | 4 +- .../lib/uPlotV2/config/UPlotAxisBuilder.ts | 13 +- .../config/__tests__/UPlotAxisBuilder.test.ts | 28 +-- frontend/src/lib/uPlotV2/config/types.ts | 29 ++- .../panels/utils/baseConfigBuilder.ts | 4 +- .../visualization/panels/utils/panelAxis.ts | 9 + .../PanelEditorQueryBuilder.tsx | 13 +- .../PanelEditorQueryBuilder.test.tsx | 5 + .../PanelEditor/PreviewPane/PlotTag.tsx | 16 +- .../PanelEditor/PreviewPane/PreviewPane.tsx | 8 +- .../PreviewPane/__tests__/PlotTag.test.tsx | 17 +- .../PanelEditor/hooks/usePanelEditSession.ts | 8 +- .../PanelEditor/hooks/usePanelTypeSwitch.ts | 4 +- .../Panels/__tests__/capabilities.test.ts | 118 +++++++++- .../Panels/components/NoData/NoData.tsx | 22 +- .../NoData/__tests__/NoData.test.tsx | 23 +- .../Panels/kinds/BarChartPanel/definition.ts | 14 +- .../kinds/BarChartPanel/utils/buildConfig.ts | 23 +- .../Panels/kinds/HistogramPanel/definition.ts | 14 +- .../kinds/HistogramPanel/utils/buildConfig.ts | 16 +- .../Panels/kinds/ListPanel/definition.ts | 14 +- .../Panels/kinds/NumberPanel/definition.ts | 12 +- .../Panels/kinds/PieChartPanel/definition.ts | 12 +- .../Panels/kinds/TablePanel/definition.ts | 13 +- .../kinds/TimeSeriesPanel/definition.ts | 12 +- .../TimeSeriesPanel/utils/buildConfig.ts | 19 +- .../kinds/UnsupportedPanel/Renderer.tsx | 26 +++ .../kinds/UnsupportedPanel/definition.ts | 34 +++ .../DashboardContainer/Panels/registry.ts | 19 +- .../Panels/types/panelCapabilities.ts | 32 ++- .../Panels/types/panelDefinition.ts | 25 ++- .../__tests__/buildDefaultQueries.test.ts | 6 +- .../Panels/utils/baseConfigBuilder.ts | 24 ++- .../Panels/utils/buildDefaultQueries.ts | 11 +- .../PanelsAndSectionsLayout/Panel/Panel.tsx | 56 ++--- .../ViewPanelModal/ViewPanelQueryBuilder.tsx | 64 ------ .../__tests__/useCreateAlertFromPanel.test.ts | 4 +- .../Panel/hooks/useClonePanel.ts | 1 + .../Panel/hooks/useCreateAlertFromPanel.ts | 9 +- .../Panel/hooks/useDeletePanel.ts | 13 +- .../Panel/hooks/useDownloadPanelCsv.ts | 1 + .../Panel/hooks/useDrilldown.tsx | 10 +- .../Panel/hooks/useMovePanelToSection.ts | 13 +- .../Panel/hooks/useResolvedDrilldownQuery.ts | 26 ++- .../hooks/__tests__/usePanelQuery.test.tsx | 204 ++++++++++++++---- .../DashboardContainer/hooks/usePanelQuery.ts | 18 +- .../__tests__/buildQueryRangeRequest.test.ts | 81 ++++--- .../__tests__/persesQueryAdapters.test.ts | 24 ++- .../queryV5/buildQueryRangeRequest.ts | 52 ++--- .../queryV5/persesQueryAdapters.ts | 33 ++- .../PublicPanel/PublicPanel.tsx | 1 + .../__tests__/usePublicPanelQuery.test.tsx | 14 +- .../hooks/usePublicPanelQuery.ts | 13 +- 54 files changed, 887 insertions(+), 401 deletions(-) create mode 100644 frontend/src/lib/visualization/panels/utils/panelAxis.ts create mode 100644 frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/UnsupportedPanel/Renderer.tsx create mode 100644 frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/UnsupportedPanel/definition.ts delete mode 100644 frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/ViewPanelModal/ViewPanelQueryBuilder.tsx diff --git a/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/configBuilder.ts b/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/configBuilder.ts index 2da06c07320..c08d1b0a7fb 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/configBuilder.ts +++ b/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/configBuilder.ts @@ -1,5 +1,4 @@ import { Timezone } from 'components/CustomTimePicker/timezoneUtils'; -import { PANEL_TYPES } from 'constants/queryBuilder'; import { getLegend } from 'lib/dashboard/getQueryResults'; import getLabelName from 'lib/getLabelName'; import { @@ -76,7 +75,7 @@ export function buildEntityMetricsChartConfig({ show: true, side: 2, isDarkMode, - panelType: PANEL_TYPES.TIME_SERIES, + isTimeAxis: true, }); builder.addAxis({ @@ -85,7 +84,6 @@ export function buildEntityMetricsChartConfig({ side: 3, isDarkMode, yAxisUnit, - panelType: PANEL_TYPES.TIME_SERIES, }); if (!apiResponse?.data?.result) { diff --git a/frontend/src/container/MeterExplorer/Explorer/configBuilder.ts b/frontend/src/container/MeterExplorer/Explorer/configBuilder.ts index 1369f27f28e..8e0bf208fbd 100644 --- a/frontend/src/container/MeterExplorer/Explorer/configBuilder.ts +++ b/frontend/src/container/MeterExplorer/Explorer/configBuilder.ts @@ -1,5 +1,4 @@ import { Timezone } from 'components/CustomTimePicker/timezoneUtils'; -import { PANEL_TYPES } from 'constants/queryBuilder'; import { getLegend } from 'lib/dashboard/getQueryResults'; import getLabelName from 'lib/getLabelName'; import { @@ -72,7 +71,7 @@ export function buildMeterChartConfig({ show: true, side: 2, isDarkMode, - panelType: PANEL_TYPES.BAR, + isTimeAxis: true, }); builder.addAxis({ @@ -81,7 +80,6 @@ export function buildMeterChartConfig({ side: 3, isDarkMode, yAxisUnit, - panelType: PANEL_TYPES.BAR, }); if (!apiResponse?.data?.result) { diff --git a/frontend/src/lib/uPlotV2/config/UPlotAxisBuilder.ts b/frontend/src/lib/uPlotV2/config/UPlotAxisBuilder.ts index 6da73e957eb..93d523381c7 100644 --- a/frontend/src/lib/uPlotV2/config/UPlotAxisBuilder.ts +++ b/frontend/src/lib/uPlotV2/config/UPlotAxisBuilder.ts @@ -1,5 +1,4 @@ import { getToolTipValue } from 'components/Graph/yAxisConfig'; -import { PANEL_TYPES } from 'constants/queryBuilder'; import uPlot, { Axis } from 'uplot'; import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants'; @@ -7,11 +6,6 @@ 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 @@ -67,12 +61,9 @@ export class UPlotAxisBuilder extends ConfigBuilder { * Build values formatter for X-axis (time) */ private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined { - const { panelType } = this.props; + const { isTimeAxis } = this.props; - if ( - panelType && - PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT.includes(panelType) - ) { + if (isTimeAxis) { return uPlotXAxisValuesFormat as uPlot.Axis.Values; } diff --git a/frontend/src/lib/uPlotV2/config/__tests__/UPlotAxisBuilder.test.ts b/frontend/src/lib/uPlotV2/config/__tests__/UPlotAxisBuilder.test.ts index 8f2b136821f..3db4d075d1f 100644 --- a/frontend/src/lib/uPlotV2/config/__tests__/UPlotAxisBuilder.test.ts +++ b/frontend/src/lib/uPlotV2/config/__tests__/UPlotAxisBuilder.test.ts @@ -1,5 +1,4 @@ import { getToolTipValue } from 'components/Graph/yAxisConfig'; -import { PANEL_TYPES } from 'constants/queryBuilder'; import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants'; import type uPlot from 'uplot'; @@ -137,11 +136,11 @@ describe('UPlotAxisBuilder', () => { }); }); - it('uses time-based X-axis values formatter for time-series like panels', () => { + it('uses time-based X-axis values formatter when the caller declares a time axis', () => { const builder = new UPlotAxisBuilder( createAxisProps({ scaleKey: 'x', - panelType: PANEL_TYPES.TIME_SERIES, + isTimeAxis: true, }), ); @@ -150,11 +149,11 @@ describe('UPlotAxisBuilder', () => { expect(config.values).toBe(uPlotXAxisValuesFormat); }); - it('does not attach X-axis datetime formatter when panel type is not supported', () => { + it('does not attach X-axis datetime formatter for a non-time axis', () => { const builder = new UPlotAxisBuilder( createAxisProps({ scaleKey: 'x', - panelType: PANEL_TYPES.LIST, // not in PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT + isTimeAxis: false, }), ); @@ -290,22 +289,9 @@ describe('UPlotAxisBuilder', () => { expect(config.space).toBe(50); }); - 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('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('should return the existing size when cycleNum > 1', () => { diff --git a/frontend/src/lib/uPlotV2/config/types.ts b/frontend/src/lib/uPlotV2/config/types.ts index a699d0b5c51..af3e38d952c 100644 --- a/frontend/src/lib/uPlotV2/config/types.ts +++ b/frontend/src/lib/uPlotV2/config/types.ts @@ -1,5 +1,4 @@ import { PrecisionOption } from 'components/Graph/types'; -import { PANEL_TYPES } from 'constants/queryBuilder'; import uPlot, { Series } from 'uplot'; import { ThresholdsDrawHookOptions } from '../hooks/types'; @@ -53,31 +52,51 @@ 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; - side?: 0 | 1 | 2 | 3; // top, right, bottom, left + /** 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`. */ 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 + /** Minimum pixels between ticks, capping how many uPlot draws. For log scale axes. */ + space?: number; + /** 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; - panelType?: PANEL_TYPES; + /** + * 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. */ decimalPrecision?: PrecisionOption; } diff --git a/frontend/src/lib/visualization/panels/utils/baseConfigBuilder.ts b/frontend/src/lib/visualization/panels/utils/baseConfigBuilder.ts index 240e82e0020..96cd1c19a05 100644 --- a/frontend/src/lib/visualization/panels/utils/baseConfigBuilder.ts +++ b/frontend/src/lib/visualization/panels/utils/baseConfigBuilder.ts @@ -14,6 +14,7 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import uPlot from 'uplot'; import { PanelMode } from 'lib/visualization/panels/types'; +import { plotsTimeOnXAxis } from 'lib/visualization/panels/utils/panelAxis'; export interface BaseConfigBuilderProps { id: string; @@ -124,7 +125,7 @@ export function buildBaseConfig({ side: 2, isDarkMode, isLogScale, - panelType, + isTimeAxis: plotsTimeOnXAxis(panelType), }); builder.addAxis({ @@ -134,7 +135,6 @@ export function buildBaseConfig({ isDarkMode, isLogScale, yAxisUnit, - panelType, }); return builder; diff --git a/frontend/src/lib/visualization/panels/utils/panelAxis.ts b/frontend/src/lib/visualization/panels/utils/panelAxis.ts new file mode 100644 index 00000000000..2ab4f6760b6 --- /dev/null +++ b/frontend/src/lib/visualization/panels/utils/panelAxis.ts @@ -0,0 +1,9 @@ +import { PANEL_TYPES } from 'constants/queryBuilder'; + +/** + * Whether the panel type plots time on X. Graph and bar do; the rest drawn through + * `buildBaseConfig` — histogram buckets, billing categories — plot a value there instead. + */ +export function plotsTimeOnXAxis(panelType: PANEL_TYPES): boolean { + return panelType === PANEL_TYPES.TIME_SERIES || panelType === PANEL_TYPES.BAR; +} diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder.tsx b/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder.tsx index 954ccc75794..b61b8e22d5a 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder.tsx +++ b/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder.tsx @@ -13,7 +13,6 @@ 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'; @@ -64,8 +63,12 @@ function PanelEditorQueryBuilder({ footer, stickyHeader = true, }: PanelEditorQueryBuilderProps): JSX.Element { - // The shared QueryBuilderV2 / list-view checks still speak the legacy PANEL_TYPES. + // The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the + // builder offers for this kind comes from the kind's own declaration. 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(); @@ -112,9 +115,9 @@ function PanelEditorQueryBuilder({ " chip for the editor preview; V2 counterpart of V1's - * PlotTag (duplicated per the split policy). Hidden for list panels and before a - * query exists, where the mode is irrelevant. - */ -function PlotTag({ - queryType, - panelType, - className, -}: PlotTagProps): JSX.Element | null { - if (queryType === undefined || panelType === PANEL_TYPES.LIST) { +function PlotTag({ queryType, className }: PlotTagProps): JSX.Element | null { + if (queryType === undefined) { return null; } diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/PreviewPane.tsx b/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/PreviewPane.tsx index 61f333e3682..8d367a6bf8a 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/PreviewPane.tsx +++ b/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/PreviewPane.tsx @@ -7,7 +7,6 @@ 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 { @@ -72,7 +71,6 @@ 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,11 +82,7 @@ function PreviewPane({
{!hideHeader && (
- +
diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/__tests__/PlotTag.test.tsx b/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/__tests__/PlotTag.test.tsx index 894b5fc4aa5..f439a2b3b1e 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/__tests__/PlotTag.test.tsx +++ b/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/__tests__/PlotTag.test.tsx @@ -1,30 +1,17 @@ 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( - , - ); + render(); expect(screen.getByTestId('panel-editor-plot-tag')).toBeInTheDocument(); expect(screen.getByText('PromQL')).toBeInTheDocument(); }); it('renders nothing when there is no query yet', () => { - render(); - expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument(); - }); - - it('renders nothing for list panels (query mode is irrelevant)', () => { - render( - , - ); + render(); expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditSession.ts b/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditSession.ts index 4ab408b5790..cba82b0d0b9 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditSession.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditSession.ts @@ -4,7 +4,10 @@ import type { TelemetrytypesSignalDTO, } from 'api/generated/services/sigNoz.schemas'; import type { PANEL_TYPES } from 'constants/queryBuilder'; -import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry'; +import { + getPanelDefinition, + isPanelKindSupported, +} from 'pages/DashboardPage/DashboardContainer/Panels/registry'; import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition'; import { PANEL_KIND_TO_PANEL_TYPE, @@ -91,8 +94,9 @@ export function usePanelEditSession({ const query = usePanelQuery({ panel: draft, panelId, + queryCapabilities: panelDefinition.queryCapabilities, time, - enabled: !!panelDefinition, + enabled: isPanelKindSupported(panelKind), }); const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({ diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch.ts b/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch.ts index f663a056856..74cc0fc9f25 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch.ts @@ -6,7 +6,7 @@ import type { DashboardtypesQueryDTO, TelemetrytypesSignalDTO, } from 'api/generated/services/sigNoz.schemas'; -import { PANEL_TYPES } from 'constants/queryBuilder'; +import type { 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 = - newPanelType === PANEL_TYPES.LIST + newKind === 'signoz/ListPanel' ? withDefaultListOrder(transformed) : transformed; const signal = getBuilderQueries(currentSpec.queries)[0] diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/__tests__/capabilities.test.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/__tests__/capabilities.test.ts index 93e0424c7c7..bd584ad69d3 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/__tests__/capabilities.test.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/__tests__/capabilities.test.ts @@ -1,7 +1,14 @@ -import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas'; +import { + Querybuildertypesv5RequestTypeDTO, + 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, @@ -15,6 +22,7 @@ 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 = { 'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM], @@ -37,9 +45,117 @@ const EXPECTED_SIGNALS: Record = { '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 = { + '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( diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/components/NoData/NoData.tsx b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/components/NoData/NoData.tsx index c3a8fca99c5..4c263e2aca2 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/components/NoData/NoData.tsx +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/components/NoData/NoData.tsx @@ -20,8 +20,12 @@ interface NoDataProps { isFetching?: boolean; /** When provided, renders a Retry button that re-runs the query. */ onRetry?: () => void; - /** Hides the global "Extend time range" action when this panel is locked to a fixed time preference. */ - panel?: DashboardtypesPanelDTO; + /** + * 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; 'data-testid'?: string; } @@ -43,19 +47,17 @@ 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 ?? (hasFixedTimePreference ? undefined : globalExtend); + viewExtend ?? (panelHasFixedTimePreference(panel) ? undefined : globalExtend); if (isFetching) { return ; } - const panelType = panel - ? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind] - : undefined; + // `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 extendAction: PanelMessageAction | undefined = activeExtend?.canExtend && activeExtend.actionLabel @@ -65,6 +67,7 @@ function NoData({ void logEvent(DashboardDetailEvents.NoDataAction, { action: 'extendTime', panelType, + panelKind, }); activeExtend.extend(); }, @@ -79,6 +82,7 @@ function NoData({ void logEvent(DashboardDetailEvents.NoDataAction, { action: 'retry', panelType, + panelKind, }); onRetry(); }, diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/components/NoData/__tests__/NoData.test.tsx b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/components/NoData/__tests__/NoData.test.tsx index af5ed3cfb79..e977f306eda 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/components/NoData/__tests__/NoData.test.tsx +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/components/NoData/__tests__/NoData.test.tsx @@ -33,7 +33,12 @@ function panelWith( timePreference?: DashboardtypesTimePreferenceDTO, ): DashboardtypesPanelDTO { return { - spec: { plugin: { spec: { visualization: { timePreference } } } }, + spec: { + plugin: { + kind: 'signoz/TimeSeriesPanel', + spec: { visualization: { timePreference } }, + }, + }, } as unknown as DashboardtypesPanelDTO; } @@ -44,7 +49,7 @@ describe('NoData', () => { }); it('renders the empty-state title and hint', () => { - render(); + render(); expect(screen.getByTestId('panel-no-data')).toBeInTheDocument(); expect(screen.getByText('No data in this time range')).toBeInTheDocument(); @@ -55,7 +60,7 @@ describe('NoData', () => { it('offers to extend the window as the primary action', () => { mockUseExtendTimeWindow.mockReturnValue(extender()); - render(); + render(); const action = screen.getByTestId('panel-no-data-action'); expect(action).toHaveTextContent('Extend time range'); @@ -68,7 +73,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(); + render(); expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent( 'Extend time range', @@ -82,7 +87,7 @@ describe('NoData', () => { it('falls back to Retry as the sole action when the window cannot be widened', () => { const onRetry = jest.fn(); - render(); + render(); const action = screen.getByTestId('panel-no-data-action'); expect(action).toHaveTextContent('Retry'); @@ -101,7 +106,7 @@ describe('NoData', () => { useViewPanelStore.setState({ viewPanelExtendWindow: extender({ extend: storeExtend }), }); - render(); + render(); fireEvent.click(screen.getByTestId('panel-no-data-action')); expect(storeExtend).toHaveBeenCalledTimes(1); @@ -109,7 +114,7 @@ describe('NoData', () => { }); it('renders no action when nothing can be widened and no retry handler', () => { - render(); + render(); expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument(); expect( @@ -119,7 +124,7 @@ describe('NoData', () => { it('shows the panel loader (not the empty state) while refetching', () => { mockUseExtendTimeWindow.mockReturnValue(extender()); - render(); + render(); expect(screen.getByTestId('panel-loading')).toBeInTheDocument(); expect(screen.queryByTestId('panel-no-data')).not.toBeInTheDocument(); @@ -128,7 +133,7 @@ describe('NoData', () => { it('honours the data-testid override for the number panel', () => { mockUseExtendTimeWindow.mockReturnValue(extender()); - render(); + render(); expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument(); }); diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/BarChartPanel/definition.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/BarChartPanel/definition.ts index 1d99f65ad04..1e5fbad4234 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/BarChartPanel/definition.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/BarChartPanel/definition.ts @@ -1,7 +1,10 @@ import type { PanelDefinition } from '../../types/panelDefinition'; import Renderer from './Renderer'; import { sections } from './sections'; -import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas'; +import { + Querybuildertypesv5RequestTypeDTO, + TelemetrytypesSignalDTO, +} from 'api/generated/services/sigNoz.schemas'; import { EQueryType } from 'types/common/dashboard'; export const definition: PanelDefinition<'signoz/BarChartPanel'> = { @@ -20,6 +23,15 @@ 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, diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/BarChartPanel/utils/buildConfig.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/BarChartPanel/utils/buildConfig.ts index 2cb2f09b332..264f40427c7 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/BarChartPanel/utils/buildConfig.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/BarChartPanel/utils/buildConfig.ts @@ -1,33 +1,22 @@ 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 { + buildBaseConfig, + type TimeAxisChromeArgs, +} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder'; import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel'; import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types'; import { toClickPluginPayload } from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData'; import getLabelName from 'lib/getLabelName'; -import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin'; import { DrawStyle } from 'lib/uPlotV2/config/types'; import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder'; import type { BuilderQuery } from 'types/api/v5/queryRange'; -export interface BuildBarChartConfigArgs { - panelId: string; +export interface BuildBarChartConfigArgs extends TimeAxisChromeArgs { spec: DashboardtypesBarChartPanelSpecDTO; /** Flat list of builder queries (see `getBuilderQueries`); powers per-query legend resolution. */ builderQueries: BuilderQuery[]; /** Flattened V5 series (see `flattenTimeSeries`). */ series: PanelSeries[]; - /** Per-query step intervals from the response exec stats. */ - stepIntervals?: Record; - isDarkMode: boolean; - timezone: Timezone; - panelMode: PanelMode; - onDragSelect?: (start: number, end: number) => void; - onClick?: OnClickPluginOpts['onClick']; - minTimeScale?: number; - maxTimeScale?: number; } /** Builds a `UPlotConfigBuilder` for a Bar chart panel: shared scaffolding, optional stacking, one bar series per result. */ @@ -47,7 +36,7 @@ export function buildBarChartConfig({ }: BuildBarChartConfigArgs): UPlotConfigBuilder { const builder = buildBaseConfig({ panelId, - panelType: PANEL_TYPES.BAR, + isTimeAxis: true, isDarkMode, timezone, panelMode, diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/HistogramPanel/definition.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/HistogramPanel/definition.ts index 276799fab43..26a5c9818d6 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/HistogramPanel/definition.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/HistogramPanel/definition.ts @@ -1,7 +1,10 @@ import type { PanelDefinition } from '../../types/panelDefinition'; import Renderer from './Renderer'; import { sections } from './sections'; -import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas'; +import { + Querybuildertypesv5RequestTypeDTO, + TelemetrytypesSignalDTO, +} from 'api/generated/services/sigNoz.schemas'; import { EQueryType } from 'types/common/dashboard'; export const definition: PanelDefinition<'signoz/HistogramPanel'> = { @@ -20,6 +23,15 @@ 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, diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/HistogramPanel/utils/buildConfig.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/HistogramPanel/utils/buildConfig.ts index 0bd144b0aaf..35b8493bad5 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/HistogramPanel/utils/buildConfig.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/HistogramPanel/utils/buildConfig.ts @@ -1,8 +1,8 @@ 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 { + buildBaseConfig, + type PanelChromeArgs, +} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder'; import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel'; import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types'; import getLabelName from 'lib/getLabelName'; @@ -16,16 +16,12 @@ const BAR_WIDTH_FACTOR = 1; const MERGED_SERIES_LINE_COLOR = '#3f5ecc'; const MERGED_SERIES_FILL_COLOR = '#4E74F8'; -export interface BuildHistogramConfigArgs { - panelId: string; +export interface BuildHistogramConfigArgs extends PanelChromeArgs { spec: DashboardtypesHistogramPanelSpecDTO; /** Builder queries on this panel — used to resolve per-series labels. */ builderQueries: BuilderQuery[]; /** Flattened V5 series (see `flattenTimeSeries`). */ series: PanelSeries[]; - isDarkMode: boolean; - timezone: Timezone; - panelMode: PanelMode; } /** @@ -44,7 +40,7 @@ export function buildHistogramConfig({ }: BuildHistogramConfigArgs): UPlotConfigBuilder { const builder = buildBaseConfig({ panelId, - panelType: PANEL_TYPES.HISTOGRAM, + isTimeAxis: false, isDarkMode, timezone, panelMode, diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/ListPanel/definition.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/ListPanel/definition.ts index 39fe40923fe..62af5fcda2a 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/ListPanel/definition.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/ListPanel/definition.ts @@ -1,7 +1,10 @@ import type { PanelDefinition } from '../../types/panelDefinition'; import Renderer from './Renderer'; import { sections } from './sections'; -import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas'; +import { + Querybuildertypesv5RequestTypeDTO, + TelemetrytypesSignalDTO, +} from 'api/generated/services/sigNoz.schemas'; import { OPERATORS } from 'constants/queryBuilder'; import { EQueryType } from 'types/common/dashboard'; @@ -30,6 +33,15 @@ 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, diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/NumberPanel/definition.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/NumberPanel/definition.ts index 5c2b7bbe94a..2a784a0bc7a 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/NumberPanel/definition.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/NumberPanel/definition.ts @@ -1,7 +1,10 @@ import type { PanelDefinition } from '../../types/panelDefinition'; import Renderer from './Renderer'; import { sections } from './sections'; -import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas'; +import { + Querybuildertypesv5RequestTypeDTO, + TelemetrytypesSignalDTO, +} from 'api/generated/services/sigNoz.schemas'; import { EQueryType } from 'types/common/dashboard'; export const definition: PanelDefinition<'signoz/NumberPanel'> = { @@ -20,6 +23,13 @@ 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, diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/PieChartPanel/definition.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/PieChartPanel/definition.ts index 2fd72f1db34..05d87c03d21 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/PieChartPanel/definition.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/PieChartPanel/definition.ts @@ -1,7 +1,10 @@ import type { PanelDefinition } from '../../types/panelDefinition'; import Renderer from './Renderer'; import { sections } from './sections'; -import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas'; +import { + Querybuildertypesv5RequestTypeDTO, + TelemetrytypesSignalDTO, +} from 'api/generated/services/sigNoz.schemas'; import { EQueryType } from 'types/common/dashboard'; export const definition: PanelDefinition<'signoz/PieChartPanel'> = { @@ -16,6 +19,13 @@ 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, diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/TablePanel/definition.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/TablePanel/definition.ts index 478a845def4..f756aaa72cf 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/TablePanel/definition.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/TablePanel/definition.ts @@ -1,7 +1,10 @@ import type { PanelDefinition } from '../../types/panelDefinition'; import Renderer from './Renderer'; import { sections } from './sections'; -import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas'; +import { + Querybuildertypesv5RequestTypeDTO, + TelemetrytypesSignalDTO, +} from 'api/generated/services/sigNoz.schemas'; import { EQueryType } from 'types/common/dashboard'; export const definition: PanelDefinition<'signoz/TablePanel'> = { @@ -16,6 +19,14 @@ 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, diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/TimeSeriesPanel/definition.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/TimeSeriesPanel/definition.ts index 0217960fcfb..b03485d934a 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/TimeSeriesPanel/definition.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/TimeSeriesPanel/definition.ts @@ -1,7 +1,10 @@ import type { PanelDefinition } from '../../types/panelDefinition'; import Renderer from './Renderer'; import { sections } from './sections'; -import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas'; +import { + Querybuildertypesv5RequestTypeDTO, + TelemetrytypesSignalDTO, +} from 'api/generated/services/sigNoz.schemas'; import { EQueryType } from 'types/common/dashboard'; export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = { @@ -20,6 +23,13 @@ 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, diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/TimeSeriesPanel/utils/buildConfig.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/TimeSeriesPanel/utils/buildConfig.ts index 8cc944ed5e8..97cba6f40c4 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/TimeSeriesPanel/utils/buildConfig.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/TimeSeriesPanel/utils/buildConfig.ts @@ -1,10 +1,8 @@ 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, minStepInterval, + type TimeAxisChromeArgs, } from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder'; import { FILL_MODE_MAP, @@ -19,7 +17,6 @@ import { toClickPluginPayload, } from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData'; import getLabelName from 'lib/getLabelName'; -import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin'; import { DrawStyle, FillMode, @@ -31,22 +28,12 @@ import type { BuilderQuery } from 'types/api/v5/queryRange'; const DEFAULT_POINT_SIZE = 5; -export interface BuildTimeSeriesConfigArgs { - panelId: string; +export interface BuildTimeSeriesConfigArgs extends TimeAxisChromeArgs { spec: DashboardtypesTimeSeriesPanelSpecDTO; /** Flat list of builder queries (see `getBuilderQueries`); powers per-query legend resolution. */ builderQueries: BuilderQuery[]; /** Flattened V5 series (see `flattenTimeSeries`). */ series: PanelSeries[]; - /** Per-query step intervals from the response exec stats. */ - stepIntervals?: Record; - isDarkMode: boolean; - timezone: Timezone; - panelMode: PanelMode; - onDragSelect?: (start: number, end: number) => void; - onClick?: OnClickPluginOpts['onClick']; - minTimeScale?: number; - maxTimeScale?: number; } /** Builds a `UPlotConfigBuilder` for a TimeSeries panel: shared scaffolding plus one series per result. */ @@ -66,7 +53,7 @@ export function buildTimeSeriesConfig({ }: BuildTimeSeriesConfigArgs): UPlotConfigBuilder { const builder = buildBaseConfig({ panelId, - panelType: PANEL_TYPES.TIME_SERIES, + isTimeAxis: true, isDarkMode, timezone, panelMode, diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/UnsupportedPanel/Renderer.tsx b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/UnsupportedPanel/Renderer.tsx new file mode 100644 index 00000000000..69785380b1e --- /dev/null +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/UnsupportedPanel/Renderer.tsx @@ -0,0 +1,26 @@ +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 ( +
+ } + title="Unsupported panel type" + description="This panel was built with a newer version of SigNoz. Upgrade to view it." + /> +
+ ); +} + +export default UnsupportedPanelRenderer; diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/UnsupportedPanel/definition.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/UnsupportedPanel/definition.ts new file mode 100644 index 00000000000..fca95f1e6f2 --- /dev/null +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/kinds/UnsupportedPanel/definition.ts @@ -0,0 +1,34 @@ +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: '' 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, +}; diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/registry.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/registry.ts index 4c3788c176e..063febf4ddb 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/registry.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/registry.ts @@ -5,6 +5,7 @@ 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, @@ -22,8 +23,24 @@ 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; + return ( + (PANELS[kind] as RenderablePanelDefinition | undefined) ?? UNSUPPORTED_PANEL + ); } diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities.ts index a5d20aa2c70..d524e131a3c 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities.ts @@ -1,4 +1,7 @@ -import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas'; +import { + Querybuildertypesv5RequestTypeDTO, + type TelemetrytypesSignalDTO, +} from 'api/generated/services/sigNoz.schemas'; import type { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces'; /** @@ -18,3 +21,30 @@ export type FilterConfigsPartial = NonNullable< export type QueryBuilderFieldRule = { default?: FilterConfigsPartial; } & Partial>; + +/** + * How a kind's query-range request is shaped. Declared per-kind in + * `kinds//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; +} diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition.ts index 930a852d60b..c376a1b12ba 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition.ts @@ -5,7 +5,10 @@ import type { EQueryType } from 'types/common/dashboard'; import type { SectionConfig } from './sections'; import type { AnyPanelInteractionProps } from './interactions'; import type { PanelKind } from './panelKind'; -import type { QueryBuilderFieldRule } from './panelCapabilities'; +import type { + PanelQueryCapabilities, + QueryBuilderFieldRule, +} from './panelCapabilities'; import type { BaseRendererProps, PanelRendererProps } from './rendererProps'; /** Export formats offered under the single "Download" action. */ @@ -39,6 +42,24 @@ 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 { kind: K; displayName: string; @@ -50,6 +71,8 @@ export interface PanelDefinition { 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; } diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/utils/__tests__/buildDefaultQueries.test.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/utils/__tests__/buildDefaultQueries.test.ts index c52e16bd637..48160895fb9 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/utils/__tests__/buildDefaultQueries.test.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/utils/__tests__/buildDefaultQueries.test.ts @@ -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 non-List kinds (they seed from the builder)', () => { + it('seeds no query for plotted kinds (they seed from the builder)', () => { expect(buildDefaultQueries('signoz/TimeSeriesPanel')).toStrictEqual([]); expect(buildDefaultQueries('signoz/NumberPanel')).toStrictEqual([]); }); diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder.ts index 442214a6785..f8d757fd3ad 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder.ts @@ -3,7 +3,6 @@ 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, @@ -26,7 +25,11 @@ import { */ export interface BuildBaseConfigArgs { panelId: string; - panelType: PANEL_TYPES; + /** + * 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; isDarkMode: boolean; timezone: Timezone; panelMode: PanelMode; @@ -56,6 +59,18 @@ export interface BuildBaseConfigArgs { onClick?: OnClickPluginOpts['onClick']; } +/** What a kind's build args pass straight through; the rest is derived from its spec. */ +export type PanelChromeArgs = Pick< + BuildBaseConfigArgs, + 'panelId' | 'isDarkMode' | 'timezone' | 'panelMode' +>; + +export type TimeAxisChromeArgs = PanelChromeArgs & + Pick< + BuildBaseConfigArgs, + 'stepIntervals' | 'minTimeScale' | 'maxTimeScale' | 'onDragSelect' | 'onClick' + >; + /** * Builds the panel-agnostic scaffolding of a uPlot chart (scales, thresholds, * axes, drag-to-zoom, click plugin). Callers then `addSeries`/`addPlugin` on the @@ -63,7 +78,7 @@ export interface BuildBaseConfigArgs { */ export function buildBaseConfig({ panelId, - panelType, + isTimeAxis, isDarkMode, timezone, panelMode, @@ -133,7 +148,7 @@ export function buildBaseConfig({ side: 2, isDarkMode, isLogScale, - panelType, + isTimeAxis, }); builder.addAxis({ @@ -143,7 +158,6 @@ export function buildBaseConfig({ isDarkMode, isLogScale, yAxisUnit, - panelType, }); return builder; diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/utils/buildDefaultQueries.ts b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/utils/buildDefaultQueries.ts index 08d25b6b685..1fcd11c1c6f 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/Panels/utils/buildDefaultQueries.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/Panels/utils/buildDefaultQueries.ts @@ -1,14 +1,15 @@ import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas'; -import { listViewInitialLogQuery, PANEL_TYPES } from 'constants/queryBuilder'; +import { listViewInitialLogQuery } 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 List needs one (logs, timestamp desc) so its +/** Seed query for a new panel. Only a list panel 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 (PANEL_KIND_TO_PANEL_TYPE[kind] === PANEL_TYPES.LIST) { - return toPerses(listViewInitialLogQuery, PANEL_TYPES.LIST); + if (kind !== 'signoz/ListPanel') { + return []; } - return []; + // `toPerses` pivots through the V1 `Query`, which is still keyed by panel type. + return toPerses(listViewInitialLogQuery, PANEL_KIND_TO_PANEL_TYPE[kind]); } diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.tsx b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.tsx index 1a3cefb5ee8..74c08b45967 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.tsx +++ b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.tsx @@ -1,7 +1,10 @@ import { useState } from 'react'; import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas'; import ContextMenu from 'periscope/components/ContextMenu'; -import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry'; +import { + getPanelDefinition, + isPanelKindSupported, +} from 'pages/DashboardPage/DashboardContainer/Panels/registry'; import { getPanelTimePreference, panelTimePreferenceLabel, @@ -50,15 +53,22 @@ 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, - // Lazy: fetch only once on screen (undefined → visible) and a renderer exists. - enabled: !!panelDefinition && isVisible !== false, + 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, }); const { onDragSelect, dashboardPreference } = usePanelInteractions(); @@ -67,7 +77,7 @@ function Panel({ return (
- {panelDefinition && ( - - )} +
); diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/ViewPanelModal/ViewPanelQueryBuilder.tsx b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/ViewPanelModal/ViewPanelQueryBuilder.tsx deleted file mode 100644 index 87d82ea4d0a..00000000000 --- a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/ViewPanelModal/ViewPanelQueryBuilder.tsx +++ /dev/null @@ -1,64 +0,0 @@ -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): void => { - if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { - event.preventDefault(); - event.stopPropagation(); - onStageRunQuery(); - } - }, - [onStageRunQuery], - ); - - return ( -
- -
- -
-
- ); -} - -export default ViewPanelQueryBuilder; diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/__tests__/useCreateAlertFromPanel.test.ts b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/__tests__/useCreateAlertFromPanel.test.ts index 793ef8d14b9..25831dbf4e0 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/__tests__/useCreateAlertFromPanel.test.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/__tests__/useCreateAlertFromPanel.test.ts @@ -162,7 +162,9 @@ describe('useCreateAlertFromPanel', () => { expect(mockBuildQueryRangeRequest).toHaveBeenCalledWith( expect.objectContaining({ queries: panel.spec.queries, - panelType: PANEL_TYPES.TIME_SERIES, + queryCapabilities: expect.objectContaining({ + requestType: 'time_series', + }), variables: { service: { type: 'query', value: 'checkout' } }, }), ); diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useClonePanel.ts b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useClonePanel.ts index 3c9a9a36f9d..a136a64a137 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useClonePanel.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useClonePanel.ts @@ -83,6 +83,7 @@ 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, }); diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel.ts b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel.ts index fdafd615ded..fc06c2fb95a 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel.ts @@ -15,6 +15,7 @@ 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'; @@ -47,11 +48,15 @@ export function useCreateAlertFromPanel(): ( return useCallback( (panel: DashboardtypesPanelDTO, panelId: string): void => { - const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind]; + 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]; void logEvent(DashboardDetailEvents.PanelAction, { action: 'createAlerts', panelType, + panelKind, ...eventMeta, widgetId: panelId, queryType: getPanelQueryType(panel), @@ -65,7 +70,7 @@ export function useCreateAlertFromPanel(): ( // Redux global time is nanoseconds; the request DTO takes epoch ms. const request = buildQueryRangeRequest({ queries: panel.spec.queries, - panelType, + queryCapabilities: getPanelDefinition(panelKind).queryCapabilities, startMs: Math.floor(minTime / NANO_SECOND_MULTIPLIER), endMs: Math.floor(maxTime / NANO_SECOND_MULTIPLIER), variables, diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDeletePanel.ts b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDeletePanel.ts index b1ee375f137..9b3219278cc 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDeletePanel.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDeletePanel.ts @@ -44,6 +44,7 @@ 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([ @@ -52,9 +53,15 @@ export function useDeletePanel({ ]); void logEvent(DashboardDetailEvents.PanelAction, { action: 'delete', - panelType: removed?.panel - ? PANEL_KIND_TO_PANEL_TYPE[removed.panel.spec.plugin.kind] - : undefined, + // 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, + } + : {}), panelId, ...eventMeta, }); diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDownloadPanelCsv.ts b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDownloadPanelCsv.ts index b07a813c020..dfa8d7ed42d 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDownloadPanelCsv.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDownloadPanelCsv.ts @@ -43,6 +43,7 @@ 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]); } diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDrilldown.tsx b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDrilldown.tsx index a833dddfe81..b1a29f88e4c 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDrilldown.tsx +++ b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDrilldown.tsx @@ -128,11 +128,14 @@ export function useDrilldown( const onPanelClick = useCallback( (payload: DrilldownClickPayload): void => { - void logEvent(DashboardDetailEvents.DrilldownOpened, { panelType }); + void logEvent(DashboardDetailEvents.DrilldownOpened, { + panelType, + panelKind: kind, + }); setSubMenu(DrilldownSubMenu.Base); onClick(payload.coordinates, payload.context); }, - [onClick, panelType], + [onClick, panelType, kind], ); const handleClose = useCallback((): void => { @@ -176,7 +179,8 @@ export function useDrilldown( const { resolvedQuery, isResolving } = useResolvedDrilldownQuery({ queries, - panelType, + panelKind: kind, + queryCapabilities: getPanelDefinition(kind).queryCapabilities, v1Query, enabled: showAggregateMenu, }); diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useMovePanelToSection.ts b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useMovePanelToSection.ts index 59ccb5b98b7..077f09cdff9 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useMovePanelToSection.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useMovePanelToSection.ts @@ -55,6 +55,7 @@ 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 @@ -73,9 +74,15 @@ export function useMovePanelToSection({ ); void logEvent(DashboardDetailEvents.PanelAction, { action: 'move', - panelType: moved.panel - ? PANEL_KIND_TO_PANEL_TYPE[moved.panel.spec.plugin.kind] - : undefined, + // 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, + } + : {}), panelId, ...eventMeta, }); diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useResolvedDrilldownQuery.ts b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useResolvedDrilldownQuery.ts index 02620e8f633..5981a7146d5 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useResolvedDrilldownQuery.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useResolvedDrilldownQuery.ts @@ -3,11 +3,15 @@ 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'; @@ -15,7 +19,9 @@ import { GlobalReducer } from 'types/reducer/globalTime'; interface UseResolvedDrilldownQueryArgs { /** Panel's perses queries — the substitution source (carries the `$var` refs). */ queries: DashboardtypesQueryDTO[]; - panelType: PANEL_TYPES; + panelKind: PanelKind; + /** The panel kind's declared query capabilities — shapes the substitution request. */ + queryCapabilities: PanelQueryCapabilities; /** 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). */ @@ -38,7 +44,8 @@ interface UseResolvedDrilldownQueryResult { */ export function useResolvedDrilldownQuery({ queries, - panelType, + panelKind, + queryCapabilities, v1Query, enabled, }: UseResolvedDrilldownQueryArgs): UseResolvedDrilldownQueryResult { @@ -60,7 +67,7 @@ export function useResolvedDrilldownQuery({ substituteVars({ data: buildQueryRangeRequest({ queries, - panelType, + queryCapabilities, startMs: Math.floor(minTime / 1e6), endMs: Math.floor(maxTime / 1e6), variables, @@ -70,7 +77,7 @@ export function useResolvedDrilldownQuery({ enabled, hasVariables, queries, - panelType, + queryCapabilities, minTime, maxTime, variables, @@ -81,8 +88,13 @@ export function useResolvedDrilldownQuery({ if (!hasVariables || !data) { return v1Query; } - return envelopesToQuery(data.data.compositeQuery?.queries ?? [], panelType); - }, [hasVariables, data, v1Query, panelType]); + // 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 { resolvedQuery, isResolving: enabled && hasVariables && isLoading }; } diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/hooks/__tests__/usePanelQuery.test.tsx b/frontend/src/pages/DashboardPage/DashboardContainer/hooks/__tests__/usePanelQuery.test.tsx index 12fb5446a19..0d1457c0c13 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/hooks/__tests__/usePanelQuery.test.tsx +++ b/frontend/src/pages/DashboardPage/DashboardContainer/hooks/__tests__/usePanelQuery.test.tsx @@ -1,7 +1,11 @@ // eslint-disable-next-line no-restricted-imports import { useSelector } from 'react-redux'; import { act, renderHook } from '@testing-library/react'; -import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas'; +import { + type DashboardtypesPanelDTO, + Querybuildertypesv5RequestTypeDTO, +} from 'api/generated/services/sigNoz.schemas'; +import type { PanelQueryCapabilities } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities'; import { DASHBOARD_CACHE_TIME, DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED, @@ -54,6 +58,23 @@ 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', @@ -100,7 +121,13 @@ beforeEach(() => { describe('usePanelQuery', () => { it('builds the generated V5 request DTO directly from panel.spec.queries', () => { - renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' })); + renderHook(() => + usePanelQuery({ + panel: builderPanel(), + panelId: 'p1', + queryCapabilities: TIME_SERIES_CAPABILITIES, + }), + ); const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0]; expect(requestPayload.schemaVersion).toBe('v1'); expect(requestPayload.compositeQuery.queries).toStrictEqual([ @@ -112,30 +139,30 @@ describe('usePanelQuery', () => { }); it('converts redux nanosecond time to epoch ms on the request', () => { - renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' })); + renderHook(() => + usePanelQuery({ + panel: builderPanel(), + panelId: 'p1', + queryCapabilities: TIME_SERIES_CAPABILITIES, + }), + ); const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0]; expect(requestPayload.start).toBe(1_000_000_000); expect(requestPayload.end).toBe(2_000_000_000); }); - 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) => { + // 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', () => { renderHook(() => usePanelQuery({ - panel: panelWith(panelKind, { name: 'A', signal: 'logs' }), + panel: panelWith('signoz/ListPanel', { name: 'A', signal: 'logs' }), panelId: 'p1', + queryCapabilities: LIST_PANEL_CAPABILITIES, }), ); const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0]; - expect(requestPayload.requestType).toBe(requestType); + expect(requestPayload.requestType).toBe('raw'); }); it('exposes the raw V5 response, request payload, and legend map on data', () => { @@ -148,7 +175,11 @@ describe('usePanelQuery', () => { }); const { result } = renderHook(() => - usePanelQuery({ panel: builderPanel(), panelId: 'p1' }), + usePanelQuery({ + panel: builderPanel(), + panelId: 'p1', + queryCapabilities: TIME_SERIES_CAPABILITIES, + }), ); expect(result.current.data.response).toBe(v5Response); @@ -158,7 +189,11 @@ describe('usePanelQuery', () => { it('exposes an undefined response before data arrives', () => { const { result } = renderHook(() => - usePanelQuery({ panel: builderPanel(), panelId: 'p1' }), + usePanelQuery({ + panel: builderPanel(), + panelId: 'p1', + queryCapabilities: TIME_SERIES_CAPABILITIES, + }), ); expect(result.current.data.response).toBeUndefined(); }); @@ -171,7 +206,11 @@ describe('usePanelQuery', () => { error: new Error('boom'), }); const { result } = renderHook(() => - usePanelQuery({ panel: builderPanel(), panelId: 'p1' }), + usePanelQuery({ + panel: builderPanel(), + panelId: 'p1', + queryCapabilities: TIME_SERIES_CAPABILITIES, + }), ); expect(result.current.error?.message).toBe('boom'); }); @@ -186,7 +225,11 @@ describe('usePanelQuery', () => { error: null, }); const { result } = renderHook(() => - usePanelQuery({ panel: builderPanel(), panelId: 'p1' }), + usePanelQuery({ + panel: builderPanel(), + panelId: 'p1', + queryCapabilities: TIME_SERIES_CAPABILITIES, + }), ); expect(result.current.isLoading).toBe(false); expect(result.current.isFetching).toBe(true); @@ -200,7 +243,11 @@ describe('usePanelQuery', () => { error: null, }); const { result } = renderHook(() => - usePanelQuery({ panel: builderPanel(), panelId: 'p1' }), + usePanelQuery({ + panel: builderPanel(), + panelId: 'p1', + queryCapabilities: TIME_SERIES_CAPABILITIES, + }), ); expect(result.current.isLoading).toBe(true); }); @@ -213,14 +260,23 @@ describe('usePanelQuery', () => { error: undefined, }); const { result } = renderHook(() => - usePanelQuery({ panel: builderPanel(), panelId: 'p1' }), + usePanelQuery({ + panel: builderPanel(), + panelId: 'p1', + queryCapabilities: TIME_SERIES_CAPABILITIES, + }), ); expect(result.current.error).toBeNull(); }); it('passes enabled=false to the fetch hook when the caller disables it', () => { renderHook(() => - usePanelQuery({ panel: builderPanel(), panelId: 'p1', enabled: false }), + usePanelQuery({ + panel: builderPanel(), + panelId: 'p1', + queryCapabilities: TIME_SERIES_CAPABILITIES, + enabled: false, + }), ); const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0]; expect(enabled).toBe(false); @@ -228,7 +284,12 @@ describe('usePanelQuery', () => { it('auto-disables the fetch when the panel has no queries (even with enabled=true)', () => { renderHook(() => - usePanelQuery({ panel: emptyPanel(), panelId: 'p1', enabled: true }), + usePanelQuery({ + panel: emptyPanel(), + panelId: 'p1', + queryCapabilities: TIME_SERIES_CAPABILITIES, + enabled: true, + }), ); const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0]; expect(enabled).toBe(false); @@ -243,6 +304,7 @@ describe('usePanelQuery', () => { aggregations: [{}], }), panelId: 'p1', + queryCapabilities: TIME_SERIES_CAPABILITIES, }), ); const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0]; @@ -251,7 +313,13 @@ 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' })); + renderHook(() => + usePanelQuery({ + panel, + panelId: 'p1', + queryCapabilities: TIME_SERIES_CAPABILITIES, + }), + ); const [{ queryKey }] = mockUseGetQueryRangeV5.mock.calls[0]; expect(queryKey).toStrictEqual( expect.arrayContaining([ @@ -270,6 +338,7 @@ describe('usePanelQuery', () => { renderHook(() => usePanelQuery({ panel, + queryCapabilities: TIME_SERIES_CAPABILITIES, panelId: 'p1', time: { startMs: 1_700_000_000_000, endMs: 1_700_000_600_000 }, }), @@ -296,6 +365,7 @@ 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 }, }), ); @@ -316,7 +386,11 @@ 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' }), + usePanelQuery({ + panel: listPanel({}), + panelId: 'p1', + queryCapabilities: LIST_PANEL_CAPABILITIES, + }), ); expect(result.current.pagination).toBeDefined(); expect(result.current.pagination?.pageSize).toBe(25); @@ -327,20 +401,34 @@ 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' }), + usePanelQuery({ + panel: listPanel({ limit: 100 }), + panelId: 'p1', + queryCapabilities: LIST_PANEL_CAPABILITIES, + }), ); 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' })); + renderHook(() => + usePanelQuery({ + panel: listPanel({}), + panelId: 'p1', + queryCapabilities: LIST_PANEL_CAPABILITIES, + }), + ); 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' }), + usePanelQuery({ + panel: listPanel({}), + panelId: 'p1', + queryCapabilities: LIST_PANEL_CAPABILITIES, + }), ); act(() => result.current.pagination?.setPageSize(50)); @@ -380,7 +468,11 @@ 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' }), + usePanelQuery({ + panel: listPanel({}), + panelId: 'p1', + queryCapabilities: LIST_PANEL_CAPABILITIES, + }), ); expect(result.current.pagination?.pageIndex).toBe(0); expect(result.current.pagination?.canPrev).toBe(false); @@ -392,21 +484,33 @@ 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' }), + usePanelQuery({ + panel: listPanel({}), + panelId: 'p1', + queryCapabilities: LIST_PANEL_CAPABILITIES, + }), ); 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' }), + usePanelQuery({ + panel: listPanel({}), + panelId: 'p1', + queryCapabilities: LIST_PANEL_CAPABILITIES, + }), ); 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' }), + usePanelQuery({ + panel: listPanel({}), + panelId: 'p1', + queryCapabilities: LIST_PANEL_CAPABILITIES, + }), ); expect(withCursor.result.current.pagination?.canNext).toBe(true); }); @@ -416,7 +520,13 @@ 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' })); + const { result } = renderHook(() => + usePanelQuery({ + panel, + panelId: 'p1', + queryCapabilities: LIST_PANEL_CAPABILITIES, + }), + ); expect(result.current.pagination?.pageIndex).toBe(0); act(() => result.current.pagination?.goNext()); @@ -428,7 +538,11 @@ 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' }), + usePanelQuery({ + panel: listPanel({}), + panelId: 'p1', + queryCapabilities: LIST_PANEL_CAPABILITIES, + }), ); expect(result.current.pagination).toBeDefined(); expect(result.current.pagination?.canNext).toBe(false); @@ -437,7 +551,11 @@ describe('usePanelQuery', () => { it('ignores a non-positive page size so paging never goes invalid', () => { const { result } = renderHook(() => - usePanelQuery({ panel: listPanel({}), panelId: 'p1' }), + usePanelQuery({ + panel: listPanel({}), + panelId: 'p1', + queryCapabilities: LIST_PANEL_CAPABILITIES, + }), ); act(() => result.current.pagination?.setPageSize(0)); expect(result.current.pagination?.pageSize).toBe(25); @@ -456,14 +574,26 @@ describe('usePanelQuery', () => { it('caches for DASHBOARD_CACHE_TIME when auto-refresh is disabled', () => { withAutoRefreshDisabled(true); - renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' })); + renderHook(() => + usePanelQuery({ + panel: builderPanel(), + panelId: 'p1', + queryCapabilities: TIME_SERIES_CAPABILITIES, + }), + ); 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' })); + renderHook(() => + usePanelQuery({ + panel: builderPanel(), + panelId: 'p1', + queryCapabilities: TIME_SERIES_CAPABILITIES, + }), + ); const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0]; expect(cacheTime).toBe(DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED); }); diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/hooks/usePanelQuery.ts b/frontend/src/pages/DashboardPage/DashboardContainer/hooks/usePanelQuery.ts index 01996f35d40..df95da525d4 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/hooks/usePanelQuery.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/hooks/usePanelQuery.ts @@ -3,7 +3,6 @@ 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, @@ -24,7 +23,7 @@ import { queryReferencesAnyVariable, } from '../queryV5/getReferencedVariables'; import { getBuilderQueries } from '../Panels/utils/getBuilderQueries'; -import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind'; +import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities'; import { selectResolvedVariables } from '../store/slices/variableSelectionSlice'; import { useDashboardStore } from '../store/useDashboardStore'; import { resolvePanelTimeWindow } from './resolvePanelTimeWindow'; @@ -38,6 +37,8 @@ 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. @@ -85,21 +86,20 @@ 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 list query with an explicit `limit` shows without a server pager; without - // one it pages server-side at a user-selectable size. + // 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. const hasExplicitLimit = useMemo( () => !!getBuilderQueries(queries)[0]?.limit, [queries], ); - const isPaginated = panelType === PANEL_TYPES.LIST && !hasExplicitLimit; + const isPaginated = queryCapabilities.serverPaginated && !hasExplicitLimit; const [pageSize, setPageSize] = useState(DEFAULT_LIST_PAGE_SIZE); const [offset, setOffset] = useState(0); @@ -188,7 +188,7 @@ export function usePanelQuery({ () => buildQueryRangeRequest({ queries, - panelType, + queryCapabilities, startMs, endMs, fillGaps, @@ -197,7 +197,7 @@ export function usePanelQuery({ }), [ queries, - panelType, + queryCapabilities, startMs, endMs, fillGaps, diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/__tests__/buildQueryRangeRequest.test.ts b/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/__tests__/buildQueryRangeRequest.test.ts index 8e323a6b9cc..b3f37e0df31 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/__tests__/buildQueryRangeRequest.test.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/__tests__/buildQueryRangeRequest.test.ts @@ -1,12 +1,13 @@ -import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas'; -import { PANEL_TYPES } from 'constants/queryBuilder'; +import { + type DashboardtypesQueryDTO, + Querybuildertypesv5RequestTypeDTO, +} from 'api/generated/services/sigNoz.schemas'; import { buildQueryRangeRequest, extractLegendMap, getBarStepIntervalSeconds, hasRunnableQueries, - panelTypeToRequestType, toQueryEnvelopes, } from '../buildQueryRangeRequest'; @@ -40,20 +41,46 @@ function compositeQuery( const HOUR_MS = 60 * 60 * 1000; const START_MS = 1_700_000_000_000; -describe('panelTypeToRequestType', () => { +// 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', () => { 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); + 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); }); }); @@ -135,7 +162,7 @@ describe('buildQueryRangeRequest', () => { it('assembles the full request DTO', () => { const request = buildQueryRangeRequest({ queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }), - panelType: PANEL_TYPES.TIME_SERIES, + queryCapabilities: TIME_SERIES_CAPABILITIES, startMs: START_MS, endMs: START_MS + HOUR_MS, }); @@ -157,7 +184,7 @@ describe('buildQueryRangeRequest', () => { it('sets formatTableResultForUI only for TABLE panels', () => { const request = buildQueryRangeRequest({ queries: bareBuilderQuery({ name: 'A' }), - panelType: PANEL_TYPES.TABLE, + queryCapabilities: TABLE_CAPABILITIES, startMs: START_MS, endMs: START_MS + HOUR_MS, }); @@ -167,7 +194,7 @@ describe('buildQueryRangeRequest', () => { it('passes through fillGaps into formatOptions', () => { const request = buildQueryRangeRequest({ queries: bareBuilderQuery({ name: 'A' }), - panelType: PANEL_TYPES.TIME_SERIES, + queryCapabilities: TIME_SERIES_CAPABILITIES, startMs: START_MS, endMs: START_MS + HOUR_MS, fillGaps: true, @@ -178,7 +205,7 @@ describe('buildQueryRangeRequest', () => { it('stamps offset/limit onto builder queries when pagination is given', () => { const request = buildQueryRangeRequest({ queries: bareBuilderQuery({ name: 'A', signal: 'logs' }), - panelType: PANEL_TYPES.LIST, + queryCapabilities: LIST_PANEL_CAPABILITIES, startMs: START_MS, endMs: START_MS + HOUR_MS, pagination: { offset: 100, limit: 50 }, @@ -198,7 +225,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' }), - panelType: PANEL_TYPES.LIST, + queryCapabilities: LIST_PANEL_CAPABILITIES, startMs: START_MS, endMs: START_MS + HOUR_MS, }); @@ -218,7 +245,7 @@ describe('buildQueryRangeRequest', () => { signal: 'logs', order: [{ key: { name: 'timestamp' }, direction: 'desc' }], }), - panelType: PANEL_TYPES.LIST, + queryCapabilities: LIST_PANEL_CAPABILITIES, startMs: START_MS, endMs: START_MS + HOUR_MS, }); @@ -238,7 +265,7 @@ describe('buildQueryRangeRequest', () => { ]; const request = buildQueryRangeRequest({ queries: bareBuilderQuery({ name: 'A', signal: 'logs', order }), - panelType: PANEL_TYPES.LIST, + queryCapabilities: LIST_PANEL_CAPABILITIES, startMs: START_MS, endMs: START_MS + HOUR_MS, }); @@ -252,7 +279,7 @@ describe('buildQueryRangeRequest', () => { const order = [{ key: { name: 'timestamp' }, direction: 'desc' }]; const request = buildQueryRangeRequest({ queries: bareBuilderQuery({ name: 'A', signal: 'traces', order }), - panelType: PANEL_TYPES.LIST, + queryCapabilities: LIST_PANEL_CAPABILITIES, startMs: START_MS, endMs: START_MS + HOUR_MS, }); @@ -265,7 +292,7 @@ describe('buildQueryRangeRequest', () => { it('injects the range-derived stepInterval into BAR builder queries without one', () => { const request = buildQueryRangeRequest({ queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }), - panelType: PANEL_TYPES.BAR, + queryCapabilities: BAR_CAPABILITIES, startMs: START_MS, endMs: START_MS + HOUR_MS, }); @@ -280,7 +307,7 @@ describe('buildQueryRangeRequest', () => { it('preserves a user-set stepInterval on BAR builder queries', () => { const request = buildQueryRangeRequest({ queries: bareBuilderQuery({ name: 'A', stepInterval: 300 }), - panelType: PANEL_TYPES.BAR, + queryCapabilities: BAR_CAPABILITIES, startMs: START_MS, endMs: START_MS + HOUR_MS, }); @@ -293,7 +320,7 @@ describe('buildQueryRangeRequest', () => { it('does not touch stepInterval for non-BAR panels', () => { const request = buildQueryRangeRequest({ queries: bareBuilderQuery({ name: 'A' }), - panelType: PANEL_TYPES.TIME_SERIES, + queryCapabilities: TIME_SERIES_CAPABILITIES, startMs: START_MS, endMs: START_MS + HOUR_MS, }); diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/__tests__/persesQueryAdapters.test.ts b/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/__tests__/persesQueryAdapters.test.ts index 0afab16165d..b7e9e57c09a 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/__tests__/persesQueryAdapters.test.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/__tests__/persesQueryAdapters.test.ts @@ -7,7 +7,12 @@ import type { Query } from 'types/api/queryBuilder/queryBuilderData'; import { EQueryType } from 'types/common/dashboard'; import { DataSource } from 'types/common/queryBuilder'; -import { envelopesToQuery, fromPerses, toPerses } from '../persesQueryAdapters'; +import { + envelopesToQuery, + fromPerses, + panelTypeToRequestType, + toPerses, +} from '../persesQueryAdapters'; /** A bare perses query (single plugin, not wrapped in a CompositeQuery). */ function bareQuery( @@ -21,6 +26,23 @@ 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); diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/buildQueryRangeRequest.ts b/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/buildQueryRangeRequest.ts index bc4194484fa..8d9219251bf 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/buildQueryRangeRequest.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/buildQueryRangeRequest.ts @@ -14,9 +14,9 @@ import { Querybuildertypesv5QueryEnvelopeBuilderDTOType, Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType, Querybuildertypesv5QueryEnvelopePromQLDTOType, - Querybuildertypesv5RequestTypeDTO, } from 'api/generated/services/sigNoz.schemas'; -import { PANEL_TYPES } from 'constants/queryBuilder'; + +import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities'; // 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,31 +29,6 @@ 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 @@ -239,7 +214,13 @@ function withPagination( export interface BuildQueryRangeRequestArgs { queries: DashboardtypesQueryDTO[]; - panelType: PANEL_TYPES; + /** + * 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; /** Epoch milliseconds. */ startMs: number; /** Epoch milliseconds. */ @@ -258,7 +239,12 @@ export interface BuildQueryRangeRequestArgs { */ export function buildQueryRangeRequest({ queries, - panelType, + queryCapabilities: { + requestType, + formatTableResultForUI, + bucketedStepInterval, + orderTiebreaker, + }, startMs, endMs, fillGaps = false, @@ -266,10 +252,10 @@ export function buildQueryRangeRequest({ variables = {}, }: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO { let envelopes = toQueryEnvelopes(queries); - if (panelType === PANEL_TYPES.BAR) { + if (bucketedStepInterval) { envelopes = withBarStepInterval(envelopes, startMs, endMs); } - if (panelType === PANEL_TYPES.LIST) { + if (orderTiebreaker) { envelopes = withListOrderTiebreaker(envelopes); } if (pagination) { @@ -280,10 +266,10 @@ export function buildQueryRangeRequest({ schemaVersion: 'v1', start: startMs, end: endMs, - requestType: panelTypeToRequestType(panelType), + requestType, compositeQuery: { queries: envelopes }, formatOptions: { - formatTableResultForUI: panelType === PANEL_TYPES.TABLE, + formatTableResultForUI, fillGaps, }, variables, diff --git a/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters.ts b/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters.ts index 12ee5f905ce..d4e64155b27 100644 --- a/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters.ts +++ b/frontend/src/pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters.ts @@ -10,6 +10,7 @@ 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'; @@ -20,10 +21,7 @@ import type { QueryEnvelope } from 'types/api/v5/queryRange'; import { EQueryType } from 'types/common/dashboard'; import { DataSource } from 'types/common/queryBuilder'; -import { - panelTypeToRequestType, - toQueryEnvelopes, -} from './buildQueryRangeRequest'; +import { toQueryEnvelopes } from './buildQueryRangeRequest'; /** * Adapters between the V2 perses query shape and the V1 `Query` the shared query @@ -90,6 +88,33 @@ 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 diff --git a/frontend/src/pages/PublicDashboard/PublicDashboardView/PublicPanel/PublicPanel.tsx b/frontend/src/pages/PublicDashboard/PublicDashboardView/PublicPanel/PublicPanel.tsx index 0d42aa9a807..a81204f2816 100644 --- a/frontend/src/pages/PublicDashboard/PublicDashboardView/PublicPanel/PublicPanel.tsx +++ b/frontend/src/pages/PublicDashboard/PublicDashboardView/PublicPanel/PublicPanel.tsx @@ -40,6 +40,7 @@ function PublicPanel({ const { data, isFetching, isPreviousData, error, refetch } = usePublicPanelQuery({ panel, + queryCapabilities: panelDefinition.queryCapabilities, panelKey, publicDashboardId, startMs, diff --git a/frontend/src/pages/PublicDashboard/PublicDashboardView/hooks/__tests__/usePublicPanelQuery.test.tsx b/frontend/src/pages/PublicDashboard/PublicDashboardView/hooks/__tests__/usePublicPanelQuery.test.tsx index 65c5b2a3dee..558358a315d 100644 --- a/frontend/src/pages/PublicDashboard/PublicDashboardView/hooks/__tests__/usePublicPanelQuery.test.tsx +++ b/frontend/src/pages/PublicDashboard/PublicDashboardView/hooks/__tests__/usePublicPanelQuery.test.tsx @@ -1,6 +1,9 @@ import { renderHook, waitFor } from '@testing-library/react'; import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard'; -import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas'; +import { + type DashboardtypesPanelDTO, + Querybuildertypesv5RequestTypeDTO, +} from 'api/generated/services/sigNoz.schemas'; import { ReactNode } from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; @@ -42,6 +45,15 @@ 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, diff --git a/frontend/src/pages/PublicDashboard/PublicDashboardView/hooks/usePublicPanelQuery.ts b/frontend/src/pages/PublicDashboard/PublicDashboardView/hooks/usePublicPanelQuery.ts index 9488d7283e1..e0c3ba63e51 100644 --- a/frontend/src/pages/PublicDashboard/PublicDashboardView/hooks/usePublicPanelQuery.ts +++ b/frontend/src/pages/PublicDashboard/PublicDashboardView/hooks/usePublicPanelQuery.ts @@ -3,10 +3,9 @@ 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 { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind'; +import type { PanelQueryCapabilities } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities'; import { buildQueryRangeRequest, extractLegendMap, @@ -21,6 +20,8 @@ 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; @@ -52,15 +53,13 @@ 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; @@ -77,13 +76,13 @@ export function usePublicPanelQuery({ () => buildQueryRangeRequest({ queries, - panelType, + queryCapabilities, startMs, endMs, fillGaps, variables: {}, }), - [queries, panelType, startMs, endMs, fillGaps], + [queries, queryCapabilities, startMs, endMs, fillGaps], ); const legendMap = useMemo(() => extractLegendMap(queries), [queries]); From 0f36cb9334045da40684caaf52bd37225c59291d Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Fri, 4 Sep 2026 10:15:17 +0000 Subject: [PATCH 4/4] feat(subscription): add subscription endpoints with resource authz (#12767) #### Description - Adds a `subscription` domain: `POST`, `PUT`, and `GET /api/v1/subscriptions`, wired with `CheckResources` + `ResourceDef`s on the `subscription` metaresource (`create`, `list` + `update`, `read`). Community gets a noop implementation; enterprise talks to Zeus. - Migration `125_add_subscription_tuples` backfills the admin subscription tuples for existing organizations. - The legacy `/api/v1/checkout`, `/api/v1/billing`, and `/api/v1/portal` routes are untouched; they are deleted once the frontend has moved. #### Additional Information Part of SigNoz/platform-pod#3091. --- cmd/community/server.go | 5 + cmd/enterprise/server.go | 5 + docs/api/openapi.yml | 301 ++++++++++++++++++ ee/subscription/httpsubscription/provider.go | 95 ++++++ .../api/generated/services/sigNoz.schemas.ts | 171 ++++++++++ .../generated/services/subscriptions/index.ts | 280 ++++++++++++++++ pkg/apiserver/signozapiserver/provider.go | 10 + pkg/apiserver/signozapiserver/subscription.go | 89 ++++++ pkg/signoz/handler.go | 4 + pkg/signoz/handler_test.go | 2 +- pkg/signoz/openapi.go | 2 + pkg/signoz/provider.go | 2 + pkg/signoz/signoz.go | 6 +- .../125_add_subscription_tuples.go | 134 ++++++++ pkg/subscription/handler.go | 85 +++++ pkg/subscription/noopsubscription/provider.go | 28 ++ pkg/subscription/subscription.go | 30 ++ pkg/types/coretypes/registry_managed_role.go | 8 +- pkg/types/subscriptiontypes/subscription.go | 32 ++ pkg/types/subscriptiontypes/usage.go | 56 ++++ pkg/zeus/zeus.go | 3 - 21 files changed, 1338 insertions(+), 10 deletions(-) create mode 100644 ee/subscription/httpsubscription/provider.go create mode 100644 frontend/src/api/generated/services/subscriptions/index.ts create mode 100644 pkg/apiserver/signozapiserver/subscription.go create mode 100644 pkg/sqlmigration/125_add_subscription_tuples.go create mode 100644 pkg/subscription/handler.go create mode 100644 pkg/subscription/noopsubscription/provider.go create mode 100644 pkg/subscription/subscription.go create mode 100644 pkg/types/subscriptiontypes/subscription.go create mode 100644 pkg/types/subscriptiontypes/usage.go diff --git a/cmd/community/server.go b/cmd/community/server.go index 36a4b495bf6..c187c466d9b 100644 --- a/cmd/community/server.go +++ b/cmd/community/server.go @@ -44,6 +44,8 @@ import ( "github.com/SigNoz/signoz/pkg/ruler/signozruler" "github.com/SigNoz/signoz/pkg/signoz" "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/subscription" + "github.com/SigNoz/signoz/pkg/subscription/noopsubscription" "github.com/SigNoz/signoz/pkg/telemetrystore" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/types/dashboardtypes" @@ -87,6 +89,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e func(_ sqlstore.SQLStore, _ zeus.Zeus, _ organization.Getter, _ analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] { return nooplicensing.NewFactory() }, + func(_ zeus.Zeus, _ licensing.Licensing) subscription.Subscription { + return noopsubscription.New() + }, signoz.NewEmailingProviderFactories(), signoz.NewCacheProviderFactories(), signoz.NewWebProviderFactories(config.Global), diff --git a/cmd/enterprise/server.go b/cmd/enterprise/server.go index db79628b561..c0ec9787e7a 100644 --- a/cmd/enterprise/server.go +++ b/cmd/enterprise/server.go @@ -28,6 +28,7 @@ import ( eequerier "github.com/SigNoz/signoz/ee/querier" enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app" eerules "github.com/SigNoz/signoz/ee/query-service/rules" + "github.com/SigNoz/signoz/ee/subscription/httpsubscription" enterprisezeus "github.com/SigNoz/signoz/ee/zeus" "github.com/SigNoz/signoz/ee/zeus/httpzeus" "github.com/SigNoz/signoz/pkg/alertmanager" @@ -60,6 +61,7 @@ import ( "github.com/SigNoz/signoz/pkg/ruler/signozruler" "github.com/SigNoz/signoz/pkg/signoz" "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/subscription" "github.com/SigNoz/signoz/pkg/telemetrystore" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes" @@ -103,6 +105,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e func(sqlstore sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter, analytics analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] { return httplicensing.NewProviderFactory(sqlstore, zeus, orgGetter, analytics) }, + func(zeus zeus.Zeus, licensing licensing.Licensing) subscription.Subscription { + return httpsubscription.New(zeus, licensing) + }, signoz.NewEmailingProviderFactories(), signoz.NewCacheProviderFactories(), signoz.NewWebProviderFactories(config.Global), diff --git a/docs/api/openapi.yml b/docs/api/openapi.yml index 9c13b7e609a..029d4db99a4 100644 --- a/docs/api/openapi.yml +++ b/docs/api/openapi.yml @@ -9217,6 +9217,116 @@ components: required: - references type: object + SubscriptiontypesGettableSubscription: + properties: + redirectURL: + type: string + required: + - redirectURL + type: object + SubscriptiontypesGettableSubscriptionUsage: + properties: + billingPeriodEnd: + format: int64 + type: integer + billingPeriodStart: + format: int64 + type: integer + details: + $ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageDetails' + discount: + format: double + type: number + subscriptionStatus: + type: string + type: object + SubscriptiontypesPostableSubscription: + properties: + url: + type: string + required: + - url + type: object + SubscriptiontypesSubscriptionUsageBreakdown: + properties: + dayWiseBreakdown: + $ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageDayWiseBreakdown' + tiers: + items: + $ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageTier' + nullable: true + type: array + type: + type: string + unit: + type: string + type: object + SubscriptiontypesSubscriptionUsageDayWiseBreakdown: + properties: + breakdown: + items: + $ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageDayWiseData' + nullable: true + type: array + type: + type: string + type: object + SubscriptiontypesSubscriptionUsageDayWiseData: + properties: + count: + format: double + type: number + quantity: + format: double + type: number + size: + format: double + type: number + timestamp: + format: int64 + type: integer + total: + format: double + type: number + unitPrice: + format: double + type: number + type: object + SubscriptiontypesSubscriptionUsageDetails: + properties: + baseFee: + format: double + type: number + billTotal: + format: double + type: number + breakdown: + items: + $ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageBreakdown' + nullable: true + type: array + total: + format: double + type: number + type: object + SubscriptiontypesSubscriptionUsageTier: + properties: + quantity: + format: double + type: number + tierCost: + format: double + type: number + tierEnd: + format: int64 + type: integer + tierStart: + format: int64 + type: integer + unitPrice: + format: double + type: number + type: object TagtypesGettableTag: properties: key: @@ -14441,6 +14551,197 @@ paths: summary: Get stats tags: - stats + /api/v1/subscriptions: + get: + deprecated: false + description: This endpoint gets the organization's subscription along with its + usage and billing details. + operationId: GetSubscription + responses: + "200": + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/SubscriptiontypesGettableSubscriptionUsage' + status: + type: string + required: + - status + - data + type: object + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Forbidden + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Internal Server Error + security: + - api_key: + - subscription:read + - tokenizer: + - subscription:read + summary: Get the subscription. + tags: + - subscriptions + post: + deprecated: false + description: This endpoint creates a subscription for the organization. + operationId: CreateSubscription + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptiontypesPostableSubscription' + responses: + "201": + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/SubscriptiontypesGettableSubscription' + status: + type: string + required: + - status + - data + type: object + description: Created + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Forbidden + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Internal Server Error + security: + - api_key: + - subscription:create + - tokenizer: + - subscription:create + summary: Create a subscription. + tags: + - subscriptions + put: + deprecated: false + description: This endpoint updates the organization's subscription. + operationId: UpdateSubscription + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptiontypesPostableSubscription' + responses: + "200": + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/SubscriptiontypesGettableSubscription' + status: + type: string + required: + - status + - data + type: object + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Forbidden + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Internal Server Error + security: + - api_key: + - subscription:list + - subscription:update + - tokenizer: + - subscription:list + - subscription:update + summary: Update the subscription. + tags: + - subscriptions /api/v1/testChannel: post: deprecated: true diff --git a/ee/subscription/httpsubscription/provider.go b/ee/subscription/httpsubscription/provider.go new file mode 100644 index 00000000000..361c58a505e --- /dev/null +++ b/ee/subscription/httpsubscription/provider.go @@ -0,0 +1,95 @@ +package httpsubscription + +import ( + "context" + "encoding/json" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/licensing" + "github.com/SigNoz/signoz/pkg/subscription" + "github.com/SigNoz/signoz/pkg/types/subscriptiontypes" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/SigNoz/signoz/pkg/zeus" + "github.com/tidwall/gjson" +) + +const upstreamTimeout = 10 * time.Second + +type provider struct { + zeus zeus.Zeus + licensing licensing.Licensing +} + +func New(zeus zeus.Zeus, licensing licensing.Licensing) subscription.Subscription { + return &provider{ + zeus: zeus, + licensing: licensing, + } +} + +func (provider *provider) Create(ctx context.Context, organizationID valuer.UUID, postableSubscription *subscriptiontypes.PostableSubscription) (*subscriptiontypes.GettableSubscription, error) { + ctx, cancel := context.WithTimeout(ctx, upstreamTimeout) + defer cancel() + + license, err := provider.licensing.GetActive(ctx, organizationID) + if err != nil { + return nil, err + } + + body, err := json.Marshal(postableSubscription) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal subscription payload") + } + + response, err := provider.zeus.GetCheckoutURL(ctx, license.Key, body) + if err != nil { + if errors.Ast(err, errors.TypeAlreadyExists) { + return nil, errors.WithAdditionalf(err, "checkout has already been completed for this account. Please click 'Refresh Status' to sync your subscription") + } + return nil, err + } + + return &subscriptiontypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil +} + +func (provider *provider) Update(ctx context.Context, organizationID valuer.UUID, postableSubscription *subscriptiontypes.PostableSubscription) (*subscriptiontypes.GettableSubscription, error) { + ctx, cancel := context.WithTimeout(ctx, upstreamTimeout) + defer cancel() + + license, err := provider.licensing.GetActive(ctx, organizationID) + if err != nil { + return nil, err + } + + body, err := json.Marshal(postableSubscription) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal subscription payload") + } + + response, err := provider.zeus.GetPortalURL(ctx, license.Key, body) + if err != nil { + return nil, err + } + + return &subscriptiontypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil +} + +func (provider *provider) Get(ctx context.Context, organizationID valuer.UUID) (*subscriptiontypes.GettableSubscriptionUsage, error) { + license, err := provider.licensing.GetActive(ctx, organizationID) + if err != nil { + return nil, err + } + + data, err := provider.zeus.GetMeters(ctx, license.Key) + if err != nil { + return nil, err + } + + usage, err := subscriptiontypes.NewGettableSubscriptionUsage(data) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, zeus.ErrCodeResponseMalformed, "failed to unmarshal subscription usage") + } + + return usage, nil +} diff --git a/frontend/src/api/generated/services/sigNoz.schemas.ts b/frontend/src/api/generated/services/sigNoz.schemas.ts index e059724ebca..973c64d2bd4 100644 --- a/frontend/src/api/generated/services/sigNoz.schemas.ts +++ b/frontend/src/api/generated/services/sigNoz.schemas.ts @@ -10522,6 +10522,153 @@ export interface SpantypesUpdatableSpanMapperGroupDTO { name?: string | null; } +export interface SubscriptiontypesGettableSubscriptionDTO { + /** + * @type string + */ + redirectURL: string; +} + +export interface SubscriptiontypesSubscriptionUsageDayWiseDataDTO { + /** + * @type number + * @format double + */ + count?: number; + /** + * @type number + * @format double + */ + quantity?: number; + /** + * @type number + * @format double + */ + size?: number; + /** + * @type integer + * @format int64 + */ + timestamp?: number; + /** + * @type number + * @format double + */ + total?: number; + /** + * @type number + * @format double + */ + unitPrice?: number; +} + +export interface SubscriptiontypesSubscriptionUsageDayWiseBreakdownDTO { + /** + * @type array,null + */ + breakdown?: SubscriptiontypesSubscriptionUsageDayWiseDataDTO[] | null; + /** + * @type string + */ + type?: string; +} + +export interface SubscriptiontypesSubscriptionUsageTierDTO { + /** + * @type number + * @format double + */ + quantity?: number; + /** + * @type number + * @format double + */ + tierCost?: number; + /** + * @type integer + * @format int64 + */ + tierEnd?: number; + /** + * @type integer + * @format int64 + */ + tierStart?: number; + /** + * @type number + * @format double + */ + unitPrice?: number; +} + +export interface SubscriptiontypesSubscriptionUsageBreakdownDTO { + dayWiseBreakdown?: SubscriptiontypesSubscriptionUsageDayWiseBreakdownDTO; + /** + * @type array,null + */ + tiers?: SubscriptiontypesSubscriptionUsageTierDTO[] | null; + /** + * @type string + */ + type?: string; + /** + * @type string + */ + unit?: string; +} + +export interface SubscriptiontypesSubscriptionUsageDetailsDTO { + /** + * @type number + * @format double + */ + baseFee?: number; + /** + * @type number + * @format double + */ + billTotal?: number; + /** + * @type array,null + */ + breakdown?: SubscriptiontypesSubscriptionUsageBreakdownDTO[] | null; + /** + * @type number + * @format double + */ + total?: number; +} + +export interface SubscriptiontypesGettableSubscriptionUsageDTO { + /** + * @type integer + * @format int64 + */ + billingPeriodEnd?: number; + /** + * @type integer + * @format int64 + */ + billingPeriodStart?: number; + details?: SubscriptiontypesSubscriptionUsageDetailsDTO; + /** + * @type number + * @format double + */ + discount?: number; + /** + * @type string + */ + subscriptionStatus?: string; +} + +export interface SubscriptiontypesPostableSubscriptionDTO { + /** + * @type string + */ + url: string; +} + export type TelemetrytypesGettableFieldKeysDTOKeysAnyOf = { [key: string]: TelemetrytypesTelemetryFieldKeyDTO[]; }; @@ -11740,6 +11887,30 @@ export type GetStats200 = { status: string; }; +export type GetSubscription200 = { + data: SubscriptiontypesGettableSubscriptionUsageDTO; + /** + * @type string + */ + status: string; +}; + +export type CreateSubscription201 = { + data: SubscriptiontypesGettableSubscriptionDTO; + /** + * @type string + */ + status: string; +}; + +export type UpdateSubscription200 = { + data: SubscriptiontypesGettableSubscriptionDTO; + /** + * @type string + */ + status: string; +}; + export type GetTraceAggregationsPathParameters = { traceID: string; }; diff --git a/frontend/src/api/generated/services/subscriptions/index.ts b/frontend/src/api/generated/services/subscriptions/index.ts new file mode 100644 index 00000000000..de143254130 --- /dev/null +++ b/frontend/src/api/generated/services/subscriptions/index.ts @@ -0,0 +1,280 @@ +/** + * ! Do not edit manually + * * The file has been auto-generated using Orval for SigNoz + * * regenerate with 'pnpm generate:api' + * SigNoz + */ +import { useMutation, useQuery } from 'react-query'; +import type { + InvalidateOptions, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, +} from 'react-query'; + +import type { + CreateSubscription201, + GetSubscription200, + RenderErrorResponseDTO, + SubscriptiontypesPostableSubscriptionDTO, + UpdateSubscription200, +} from '../sigNoz.schemas'; + +import { GeneratedAPIInstance } from '../../../generatedAPIInstance'; +import type { ErrorType, BodyType } from '../../../generatedAPIInstance'; + +/** + * This endpoint gets the organization's subscription along with its usage and billing details. + * @summary Get the subscription. + */ +export const getSubscription = (signal?: AbortSignal) => { + return GeneratedAPIInstance({ + url: `/api/v1/subscriptions`, + method: 'GET', + signal, + }); +}; + +export const getGetSubscriptionQueryKey = () => { + return [`/api/v1/subscriptions`] as const; +}; + +export const getGetSubscriptionQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; +}) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetSubscriptionQueryKey(); + + const queryFn: QueryFunction>> = ({ + signal, + }) => getSubscription(signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetSubscriptionQueryResult = NonNullable< + Awaited> +>; +export type GetSubscriptionQueryError = ErrorType; + +/** + * @summary Get the subscription. + */ + +export function useGetSubscription< + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; +}): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetSubscriptionQueryOptions(options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Get the subscription. + */ +export const invalidateGetSubscription = async ( + queryClient: QueryClient, + options?: InvalidateOptions, +): Promise => { + await queryClient.invalidateQueries( + { queryKey: getGetSubscriptionQueryKey() }, + options, + ); + + return queryClient; +}; + +/** + * This endpoint creates a subscription for the organization. + * @summary Create a subscription. + */ +export const createSubscription = ( + subscriptiontypesPostableSubscriptionDTO?: BodyType, + signal?: AbortSignal, +) => { + return GeneratedAPIInstance({ + url: `/api/v1/subscriptions`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: subscriptiontypesPostableSubscriptionDTO, + signal, + }); +}; + +export const getCreateSubscriptionMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data?: BodyType }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { data?: BodyType }, + TContext +> => { + const mutationKey = ['createSubscription']; + const { mutation: mutationOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { data?: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return createSubscription(data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type CreateSubscriptionMutationResult = NonNullable< + Awaited> +>; +export type CreateSubscriptionMutationBody = + | BodyType + | undefined; +export type CreateSubscriptionMutationError = ErrorType; + +/** + * @summary Create a subscription. + */ +export const useCreateSubscription = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data?: BodyType }, + TContext + >; +}): UseMutationResult< + Awaited>, + TError, + { data?: BodyType }, + TContext +> => { + return useMutation(getCreateSubscriptionMutationOptions(options)); +}; +/** + * This endpoint updates the organization's subscription. + * @summary Update the subscription. + */ +export const updateSubscription = ( + subscriptiontypesPostableSubscriptionDTO?: BodyType, + signal?: AbortSignal, +) => { + return GeneratedAPIInstance({ + url: `/api/v1/subscriptions`, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + data: subscriptiontypesPostableSubscriptionDTO, + signal, + }); +}; + +export const getUpdateSubscriptionMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data?: BodyType }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { data?: BodyType }, + TContext +> => { + const mutationKey = ['updateSubscription']; + const { mutation: mutationOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { data?: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return updateSubscription(data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UpdateSubscriptionMutationResult = NonNullable< + Awaited> +>; +export type UpdateSubscriptionMutationBody = + | BodyType + | undefined; +export type UpdateSubscriptionMutationError = ErrorType; + +/** + * @summary Update the subscription. + */ +export const useUpdateSubscription = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data?: BodyType }, + TContext + >; +}): UseMutationResult< + Awaited>, + TError, + { data?: BodyType }, + TContext +> => { + return useMutation(getUpdateSubscriptionMutationOptions(options)); +}; diff --git a/pkg/apiserver/signozapiserver/provider.go b/pkg/apiserver/signozapiserver/provider.go index 4842ee910a2..cc110f30cfa 100644 --- a/pkg/apiserver/signozapiserver/provider.go +++ b/pkg/apiserver/signozapiserver/provider.go @@ -38,6 +38,7 @@ import ( "github.com/SigNoz/signoz/pkg/querier" "github.com/SigNoz/signoz/pkg/ruler" "github.com/SigNoz/signoz/pkg/statsreporter" + "github.com/SigNoz/signoz/pkg/subscription" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/zeus" @@ -72,6 +73,7 @@ type provider struct { rawDataExportHandler rawdataexport.Handler zeusHandler zeus.Handler licensingHandler licensing.Handler + subscriptionHandler subscription.Handler querierHandler querier.Handler serviceAccountHandler serviceaccount.Handler serviceAccountGetter serviceaccount.Getter @@ -115,6 +117,7 @@ func NewFactory( rawDataExportHandler rawdataexport.Handler, zeusHandler zeus.Handler, licensingHandler licensing.Handler, + subscriptionHandler subscription.Handler, querierHandler querier.Handler, serviceAccountHandler serviceaccount.Handler, serviceAccountGetter serviceaccount.Getter, @@ -161,6 +164,7 @@ func NewFactory( rawDataExportHandler, zeusHandler, licensingHandler, + subscriptionHandler, querierHandler, serviceAccountHandler, serviceAccountGetter, @@ -209,6 +213,7 @@ func newProvider( rawDataExportHandler rawdataexport.Handler, zeusHandler zeus.Handler, licensingHandler licensing.Handler, + subscriptionHandler subscription.Handler, querierHandler querier.Handler, serviceAccountHandler serviceaccount.Handler, serviceAccountGetter serviceaccount.Getter, @@ -256,6 +261,7 @@ func newProvider( rawDataExportHandler: rawDataExportHandler, zeusHandler: zeusHandler, licensingHandler: licensingHandler, + subscriptionHandler: subscriptionHandler, querierHandler: querierHandler, serviceAccountHandler: serviceAccountHandler, serviceAccountGetter: serviceAccountGetter, @@ -368,6 +374,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error { return err } + if err := provider.addSubscriptionRoutes(router); err != nil { + return err + } + if err := provider.addQuerierRoutes(router); err != nil { return err } diff --git a/pkg/apiserver/signozapiserver/subscription.go b/pkg/apiserver/signozapiserver/subscription.go new file mode 100644 index 00000000000..6b1fd22ae59 --- /dev/null +++ b/pkg/apiserver/signozapiserver/subscription.go @@ -0,0 +1,89 @@ +package signozapiserver + +import ( + "net/http" + + "github.com/SigNoz/signoz/pkg/http/handler" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/coretypes" + "github.com/SigNoz/signoz/pkg/types/subscriptiontypes" + "github.com/gorilla/mux" +) + +func (provider *provider) addSubscriptionRoutes(router *mux.Router) error { + if err := router.Handle("/api/v1/subscriptions", handler.New(provider.authzMiddleware.CheckResources(provider.subscriptionHandler.Create, authtypes.SigNozAdminRoleName), handler.OpenAPIDef{ + ID: "CreateSubscription", + Tags: []string{"subscriptions"}, + Summary: "Create a subscription.", + Description: "This endpoint creates a subscription for the organization.", + Request: new(subscriptiontypes.PostableSubscription), + RequestContentType: "application/json", + Response: new(subscriptiontypes.GettableSubscription), + ResponseContentType: "application/json", + SuccessStatusCode: http.StatusCreated, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSubscription.Scope(coretypes.VerbCreate)}), + }, handler.WithResourceDefs(handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceSubscription, + Verb: coretypes.VerbCreate, + Category: coretypes.ActionCategoryConfigurationChange, + Selector: coretypes.WildcardSelector, + }))).Methods(http.MethodPost).GetError(); err != nil { + return err + } + + if err := router.Handle("/api/v1/subscriptions", handler.New(provider.authzMiddleware.CheckResources(provider.subscriptionHandler.Update, authtypes.SigNozAdminRoleName), handler.OpenAPIDef{ + ID: "UpdateSubscription", + Tags: []string{"subscriptions"}, + Summary: "Update the subscription.", + Description: "This endpoint updates the organization's subscription.", + Request: new(subscriptiontypes.PostableSubscription), + RequestContentType: "application/json", + Response: new(subscriptiontypes.GettableSubscription), + ResponseContentType: "application/json", + SuccessStatusCode: http.StatusOK, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSubscription.Scope(coretypes.VerbList), coretypes.ResourceMetaResourceSubscription.Scope(coretypes.VerbUpdate)}), + }, handler.WithResourceDefs( + handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceSubscription, + Verb: coretypes.VerbList, + Category: coretypes.ActionCategoryDataAccess, + Selector: coretypes.WildcardSelector, + }, + handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceSubscription, + Verb: coretypes.VerbUpdate, + Category: coretypes.ActionCategoryConfigurationChange, + Selector: coretypes.WildcardSelector, + }, + ))).Methods(http.MethodPut).GetError(); err != nil { + return err + } + + if err := router.Handle("/api/v1/subscriptions", handler.New(provider.authzMiddleware.CheckResources(provider.subscriptionHandler.Get, authtypes.SigNozAdminRoleName), handler.OpenAPIDef{ + ID: "GetSubscription", + Tags: []string{"subscriptions"}, + Summary: "Get the subscription.", + Description: "This endpoint gets the organization's subscription along with its usage and billing details.", + Request: nil, + RequestContentType: "", + Response: new(subscriptiontypes.GettableSubscriptionUsage), + ResponseContentType: "application/json", + SuccessStatusCode: http.StatusOK, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSubscription.Scope(coretypes.VerbRead)}), + }, handler.WithResourceDefs(handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceSubscription, + Verb: coretypes.VerbRead, + Category: coretypes.ActionCategoryDataAccess, + Selector: coretypes.WildcardSelector, + }))).Methods(http.MethodGet).GetError(); err != nil { + return err + } + + return nil +} diff --git a/pkg/signoz/handler.go b/pkg/signoz/handler.go index 3dca50d9b06..f89d3df2249 100644 --- a/pkg/signoz/handler.go +++ b/pkg/signoz/handler.go @@ -55,6 +55,7 @@ import ( "github.com/SigNoz/signoz/pkg/ruler" "github.com/SigNoz/signoz/pkg/ruler/signozruler" "github.com/SigNoz/signoz/pkg/statsreporter" + "github.com/SigNoz/signoz/pkg/subscription" "github.com/SigNoz/signoz/pkg/types/telemetrytypes" "github.com/SigNoz/signoz/pkg/zeus" ) @@ -79,6 +80,7 @@ type Handlers struct { AuthzHandler authz.Handler ZeusHandler zeus.Handler LicensingHandler licensing.Handler + SubscriptionHandler subscription.Handler QuerierHandler querier.Handler ServiceAccountHandler serviceaccount.Handler RegistryHandler factory.Handler @@ -105,6 +107,7 @@ func NewHandlers( telemetryMetadataStore telemetrytypes.MetadataStore, authz authz.AuthZ, zeusService zeus.Zeus, + subscriptionService subscription.Subscription, registryHandler factory.Handler, alertmanagerService alertmanager.Alertmanager, prometheusService prometheus.Prometheus, @@ -131,6 +134,7 @@ func NewHandlers( AuthzHandler: signozauthzapi.NewHandler(authz), ZeusHandler: zeus.NewHandler(zeusService, licensingService), LicensingHandler: licensing.NewHandler(licensingService), + SubscriptionHandler: subscription.NewHandler(subscriptionService), QuerierHandler: querierHandler, ServiceAccountHandler: implserviceaccount.NewHandler(modules.ServiceAccount, modules.ServiceAccountGetter), RegistryHandler: registryHandler, diff --git a/pkg/signoz/handler_test.go b/pkg/signoz/handler_test.go index 9b53f3dedd5..afea165f967 100644 --- a/pkg/signoz/handler_test.go +++ b/pkg/signoz/handler_test.go @@ -65,7 +65,7 @@ func TestNewHandlers(t *testing.T) { querierHandler := querier.NewHandler(providerSettings, nil, nil) registryHandler := factory.NewHandler(nil) - handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil, nil, nil) + handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil, nil, nil) reflectVal := reflect.ValueOf(handlers) for i := 0; i < reflectVal.NumField(); i++ { f := reflectVal.Field(i) diff --git a/pkg/signoz/openapi.go b/pkg/signoz/openapi.go index 8fe8aaf0c0e..c00b9011b78 100644 --- a/pkg/signoz/openapi.go +++ b/pkg/signoz/openapi.go @@ -43,6 +43,7 @@ import ( "github.com/SigNoz/signoz/pkg/querier" "github.com/SigNoz/signoz/pkg/ruler" "github.com/SigNoz/signoz/pkg/statsreporter" + "github.com/SigNoz/signoz/pkg/subscription" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/zeus" "github.com/swaggest/jsonschema-go" @@ -85,6 +86,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta struct{ rawdataexport.Handler }{}, struct{ zeus.Handler }{}, struct{ licensing.Handler }{}, + struct{ subscription.Handler }{}, struct{ querier.Handler }{}, struct{ serviceaccount.Handler }{}, struct{ serviceaccount.Getter }{}, diff --git a/pkg/signoz/provider.go b/pkg/signoz/provider.go index 74fe2b5eb04..fd302cf4bd6 100644 --- a/pkg/signoz/provider.go +++ b/pkg/signoz/provider.go @@ -252,6 +252,7 @@ func NewSQLMigrationProviderFactories( sqlmigration.NewMigrateQuickFiltersFactory(sqlstore), sqlmigration.NewAddQuickFilterTuplesFactory(sqlstore), sqlmigration.NewAddIngestionTuplesFactory(sqlstore), + sqlmigration.NewAddSubscriptionTuplesFactory(sqlstore), ) } @@ -344,6 +345,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au handlers.RawDataExport, handlers.ZeusHandler, handlers.LicensingHandler, + handlers.SubscriptionHandler, handlers.QuerierHandler, handlers.ServiceAccountHandler, modules.ServiceAccountGetter, diff --git a/pkg/signoz/signoz.go b/pkg/signoz/signoz.go index 3fab0be1a93..0ce5f0dbdb0 100644 --- a/pkg/signoz/signoz.go +++ b/pkg/signoz/signoz.go @@ -56,6 +56,7 @@ import ( "github.com/SigNoz/signoz/pkg/statementbuilder/metricsstatementbuilder" "github.com/SigNoz/signoz/pkg/statementbuilder/tracesstatementbuilder" "github.com/SigNoz/signoz/pkg/statsreporter" + "github.com/SigNoz/signoz/pkg/subscription" "github.com/SigNoz/signoz/pkg/telemetrymetadata" "github.com/SigNoz/signoz/pkg/telemetrystore" pkgtokenizer "github.com/SigNoz/signoz/pkg/tokenizer" @@ -168,6 +169,7 @@ func New( zeusProviderFactory factory.ProviderFactory[zeus.Zeus, zeus.Config], licenseConfig licensing.Config, licenseProviderFactory func(sqlstore.SQLStore, zeus.Zeus, organization.Getter, analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config], + subscriptionCallback func(zeus.Zeus, licensing.Licensing) subscription.Subscription, emailingProviderFactories factory.NamedMap[factory.ProviderFactory[emailing.Emailing, emailing.Config]], cacheProviderFactories factory.NamedMap[factory.ProviderFactory[cache.Cache, cache.Config]], webProviderFactories factory.NamedMap[factory.ProviderFactory[web.Web, web.Config]], @@ -624,7 +626,9 @@ func New( // Initialize all handlers for the modules registryHandler := factory.NewHandler(registry) - handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, registryHandler, alertmanager, prometheus, rulerInstance, statsAggregator) + subscriptionService := subscriptionCallback(zeus, licensing) + + handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, subscriptionService, registryHandler, alertmanager, prometheus, rulerInstance, statsAggregator) // Initialize the API server (after registry so it can access service health) apiserverInstance, err := factory.NewProviderFromNamedMap( diff --git a/pkg/sqlmigration/125_add_subscription_tuples.go b/pkg/sqlmigration/125_add_subscription_tuples.go new file mode 100644 index 00000000000..5c63b409bac --- /dev/null +++ b/pkg/sqlmigration/125_add_subscription_tuples.go @@ -0,0 +1,134 @@ +package sqlmigration + +import ( + "context" + "database/sql" + "time" + + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/oklog/ulid/v2" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect" + "github.com/uptrace/bun/migrate" +) + +type addSubscriptionTuples struct { + sqlstore sqlstore.SQLStore +} + +func NewAddSubscriptionTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] { + return factory.NewProviderFactory(factory.MustNewName("add_subscription_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) { + return &addSubscriptionTuples{sqlstore: sqlstore}, nil + }) +} + +func (migration *addSubscriptionTuples) Register(migrations *migrate.Migrations) error { + return migrations.Register(migration.Up, migration.Down) +} + +func (migration *addSubscriptionTuples) Up(ctx context.Context, db *bun.DB) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var storeID string + err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID) + if err != nil { + return err + } + + var orgIDs []string + err = tx.NewSelect(). + Table("organizations"). + Column("id"). + Scan(ctx, &orgIDs) + if err != nil && err != sql.ErrNoRows { + return err + } + + isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG + + tuples := []migrationTuple{ + {authtypes.SigNozAdminRoleName, "metaresource", "subscription", "create"}, + {authtypes.SigNozAdminRoleName, "metaresource", "subscription", "read"}, + {authtypes.SigNozAdminRoleName, "metaresource", "subscription", "update"}, + {authtypes.SigNozAdminRoleName, "metaresource", "subscription", "delete"}, + {authtypes.SigNozAdminRoleName, "metaresource", "subscription", "list"}, + } + + for _, orgID := range orgIDs { + for _, tuple := range tuples { + entropy := ulid.DefaultEntropy() + now := time.Now().UTC() + tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String() + + objectID := "organization/" + orgID + "/" + tuple.objectName + "/*" + roleSubject := "organization/" + orgID + "/role/" + tuple.roleName + + if isPG { + user := "role:" + roleSubject + "#assignee" + result, err := tx.ExecContext(ctx, ` + INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now, + ) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + continue + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, ulid, object_type) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now, + ) + if err != nil { + return err + } + } else { + result, err := tx.ExecContext(ctx, ` + INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now, + ) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + continue + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, ulid, object_type) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now, + ) + if err != nil { + return err + } + } + } + } + + return tx.Commit() +} + +func (migration *addSubscriptionTuples) Down(context.Context, *bun.DB) error { + return nil +} diff --git a/pkg/subscription/handler.go b/pkg/subscription/handler.go new file mode 100644 index 00000000000..536e2597703 --- /dev/null +++ b/pkg/subscription/handler.go @@ -0,0 +1,85 @@ +package subscription + +import ( + "net/http" + + "github.com/SigNoz/signoz/pkg/http/binding" + "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/subscriptiontypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type handler struct { + subscription Subscription +} + +func NewHandler(subscription Subscription) Handler { + return &handler{subscription: subscription} +} + +func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + req := new(subscriptiontypes.PostableSubscription) + if err := binding.JSON.BindBody(r.Body, req); err != nil { + render.Error(rw, err) + return + } + + gettableSubscription, err := handler.subscription.Create(ctx, valuer.MustNewUUID(claims.OrgID), req) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusCreated, gettableSubscription) +} + +func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + req := new(subscriptiontypes.PostableSubscription) + if err := binding.JSON.BindBody(r.Body, req); err != nil { + render.Error(rw, err) + return + } + + gettableSubscription, err := handler.subscription.Update(ctx, valuer.MustNewUUID(claims.OrgID), req) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusOK, gettableSubscription) +} + +func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + usage, err := handler.subscription.Get(ctx, valuer.MustNewUUID(claims.OrgID)) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusOK, usage) +} diff --git a/pkg/subscription/noopsubscription/provider.go b/pkg/subscription/noopsubscription/provider.go new file mode 100644 index 00000000000..1cc8ea7b8c0 --- /dev/null +++ b/pkg/subscription/noopsubscription/provider.go @@ -0,0 +1,28 @@ +package noopsubscription + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/subscription" + "github.com/SigNoz/signoz/pkg/types/subscriptiontypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type provider struct{} + +func New() subscription.Subscription { + return &provider{} +} + +func (provider *provider) Create(context.Context, valuer.UUID, *subscriptiontypes.PostableSubscription) (*subscriptiontypes.GettableSubscription, error) { + return nil, errors.New(errors.TypeUnsupported, subscription.ErrCodeUnsupported, "creating a subscription is not supported") +} + +func (provider *provider) Update(context.Context, valuer.UUID, *subscriptiontypes.PostableSubscription) (*subscriptiontypes.GettableSubscription, error) { + return nil, errors.New(errors.TypeUnsupported, subscription.ErrCodeUnsupported, "updating a subscription is not supported") +} + +func (provider *provider) Get(context.Context, valuer.UUID) (*subscriptiontypes.GettableSubscriptionUsage, error) { + return nil, errors.New(errors.TypeUnsupported, subscription.ErrCodeUnsupported, "fetching the subscription is not supported") +} diff --git a/pkg/subscription/subscription.go b/pkg/subscription/subscription.go new file mode 100644 index 00000000000..754962fb015 --- /dev/null +++ b/pkg/subscription/subscription.go @@ -0,0 +1,30 @@ +package subscription + +import ( + "context" + "net/http" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/types/subscriptiontypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +var ( + ErrCodeUnsupported = errors.MustNewCode("subscription_unsupported") +) + +type Subscription interface { + Create(ctx context.Context, organizationID valuer.UUID, postableSubscription *subscriptiontypes.PostableSubscription) (*subscriptiontypes.GettableSubscription, error) + + Update(ctx context.Context, organizationID valuer.UUID, postableSubscription *subscriptiontypes.PostableSubscription) (*subscriptiontypes.GettableSubscription, error) + + Get(ctx context.Context, organizationID valuer.UUID) (*subscriptiontypes.GettableSubscriptionUsage, error) +} + +type Handler interface { + Create(http.ResponseWriter, *http.Request) + + Update(http.ResponseWriter, *http.Request) + + Get(http.ResponseWriter, *http.Request) +} diff --git a/pkg/types/coretypes/registry_managed_role.go b/pkg/types/coretypes/registry_managed_role.go index d1410759508..d1cb203db33 100644 --- a/pkg/types/coretypes/registry_managed_role.go +++ b/pkg/types/coretypes/registry_managed_role.go @@ -74,11 +74,9 @@ var ManagedRoleToTransactions = map[string][]Transaction{ {Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)}, {Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)}, // subscription — admin only. - // Uniform LCRUD shape; actual ee routes are POST /api/v1/checkout - // (create), POST /api/v1/portal (update — opens Stripe portal), GET - // /api/v1/billing (read — current billing state). delete and list are - // placeholders for shape parity; cancellation flows through Stripe's - // portal, no in-process route serves them. + // create = POST /api/v1/subscriptions, list + update = PUT /api/v1/subscriptions, + // read = GET /api/v1/subscriptions. delete is a placeholder for shape parity; + // no in-process route serves it. {Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSubscription}, WildCardSelectorString)}, {Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSubscription}, WildCardSelectorString)}, {Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSubscription}, WildCardSelectorString)}, diff --git a/pkg/types/subscriptiontypes/subscription.go b/pkg/types/subscriptiontypes/subscription.go new file mode 100644 index 00000000000..af22bece3bd --- /dev/null +++ b/pkg/types/subscriptiontypes/subscription.go @@ -0,0 +1,32 @@ +package subscriptiontypes + +import ( + "encoding/json" + + "github.com/SigNoz/signoz/pkg/errors" +) + +type PostableSubscription struct { + SuccessURL string `json:"url" required:"true"` +} + +func (postableSubscription *PostableSubscription) UnmarshalJSON(data []byte) error { + var raw struct { + SuccessURL string `json:"url"` + } + + if err := json.Unmarshal(data, &raw); err != nil { + return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to unmarshal payload") + } + + if raw.SuccessURL == "" { + return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "success url cannot be empty") + } + + postableSubscription.SuccessURL = raw.SuccessURL + return nil +} + +type GettableSubscription struct { + RedirectURL string `json:"redirectURL" required:"true"` +} diff --git a/pkg/types/subscriptiontypes/usage.go b/pkg/types/subscriptiontypes/usage.go new file mode 100644 index 00000000000..d180ca1dca5 --- /dev/null +++ b/pkg/types/subscriptiontypes/usage.go @@ -0,0 +1,56 @@ +package subscriptiontypes + +import "encoding/json" + +type GettableSubscriptionUsage struct { + BillingPeriodStart int64 `json:"billingPeriodStart"` + BillingPeriodEnd int64 `json:"billingPeriodEnd"` + Details SubscriptionUsageDetails `json:"details"` + Discount float64 `json:"discount"` + SubscriptionStatus string `json:"subscriptionStatus"` +} + +type SubscriptionUsageDetails struct { + Total float64 `json:"total"` + Breakdown []SubscriptionUsageBreakdown `json:"breakdown"` + BaseFee float64 `json:"baseFee"` + BillTotal float64 `json:"billTotal"` +} + +type SubscriptionUsageBreakdown struct { + Type string `json:"type"` + Unit string `json:"unit"` + Tiers []SubscriptionUsageTier `json:"tiers"` + DayWiseBreakdown SubscriptionUsageDayWiseBreakdown `json:"dayWiseBreakdown"` +} + +type SubscriptionUsageTier struct { + UnitPrice float64 `json:"unitPrice"` + Quantity float64 `json:"quantity"` + TierStart int64 `json:"tierStart"` + TierEnd int64 `json:"tierEnd"` + TierCost float64 `json:"tierCost"` +} + +type SubscriptionUsageDayWiseBreakdown struct { + Type string `json:"type"` + Breakdown []SubscriptionUsageDayWiseData `json:"breakdown"` +} + +type SubscriptionUsageDayWiseData struct { + Timestamp int64 `json:"timestamp"` + Count float64 `json:"count"` + Size float64 `json:"size"` + UnitPrice float64 `json:"unitPrice"` + Quantity float64 `json:"quantity"` + Total float64 `json:"total"` +} + +func NewGettableSubscriptionUsage(data []byte) (*GettableSubscriptionUsage, error) { + usage := new(GettableSubscriptionUsage) + if err := json.Unmarshal(data, usage); err != nil { + return nil, err + } + + return usage, nil +} diff --git a/pkg/zeus/zeus.go b/pkg/zeus/zeus.go index 37078859326..db4dede963c 100644 --- a/pkg/zeus/zeus.go +++ b/pkg/zeus/zeus.go @@ -53,12 +53,9 @@ type Zeus interface { } type Handler interface { - // API level handler for PutProfile PutProfile(http.ResponseWriter, *http.Request) - // API level handler for getting hosts a slim wrapper around GetDeployment GetHosts(http.ResponseWriter, *http.Request) - // API level handler for PutHost PutHost(http.ResponseWriter, *http.Request) }