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 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/.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/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/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/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/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/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/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/__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/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/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]); 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; 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) }