mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-06 14:40:38 +01:00
Compare commits
3 Commits
feat/quick
...
issue-5535
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6bb7569af | ||
|
|
5d431f9f6f | ||
|
|
1f0113645e |
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -56,6 +56,8 @@ jobs:
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
- clickhousecluster
|
||||
- metricreduction
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
|
||||
@@ -2917,13 +2917,6 @@ components:
|
||||
publicDashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
|
||||
type: object
|
||||
DashboardtypesGettablePublicDashboardDataV2:
|
||||
properties:
|
||||
dashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
publicDashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
|
||||
type: object
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -5231,16 +5224,6 @@ components:
|
||||
- offset
|
||||
- limit
|
||||
type: object
|
||||
LlmpricingruletypesGettableUnmappedModels:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesUnmappedModel'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
LlmpricingruletypesLLMPricingCacheCosts:
|
||||
properties:
|
||||
mode:
|
||||
@@ -5330,19 +5313,6 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
LlmpricingruletypesUnmappedModel:
|
||||
properties:
|
||||
modelName:
|
||||
type: string
|
||||
provider:
|
||||
type: string
|
||||
spanCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- modelName
|
||||
- spanCount
|
||||
type: object
|
||||
LlmpricingruletypesUpdatableLLMPricingRule:
|
||||
properties:
|
||||
enabled:
|
||||
@@ -11220,60 +11190,6 @@ paths:
|
||||
summary: Get a pricing rule
|
||||
tags:
|
||||
- llmpricingrules
|
||||
/api/v1/llm_pricing_rules/unmapped_models:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns models seen in the last hour of trace data (gen_ai.request.model)
|
||||
that no pricing rule pattern matches, so the user can add them to an existing
|
||||
rule or create a new one.
|
||||
operationId: ListUnmappedLLMModels
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesGettableUnmappedModels'
|
||||
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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List unmapped models
|
||||
tags:
|
||||
- llmpricingrules
|
||||
/api/v1/logs/promote_paths:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -17525,138 +17441,6 @@ paths:
|
||||
summary: Update my organization
|
||||
tags:
|
||||
- orgs
|
||||
/api/v2/public/dashboards/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns the sanitized v2-shape dashboard data for
|
||||
public access. Each panel query is reduced to a safe field subset, so filters
|
||||
and raw query strings are not exposed.
|
||||
operationId: GetPublicDashboardDataV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDashboardDataV2'
|
||||
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:
|
||||
- anonymous:
|
||||
- public-dashboard:read
|
||||
summary: Get public dashboard data (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/public/dashboards/{id}/panels/{key}/query_range:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns query range results for a panel of a v2-shape
|
||||
public dashboard. The panel is addressed by its key in spec.panels.
|
||||
operationId: GetPublicDashboardPanelQueryRangeV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: key
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeResponse'
|
||||
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:
|
||||
- anonymous:
|
||||
- public-dashboard:read
|
||||
summary: Get query range result (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/readyz:
|
||||
get:
|
||||
operationId: Readyz
|
||||
|
||||
@@ -29,7 +29,6 @@ type module struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
querier querier.Querier
|
||||
licensing licensing.Licensing
|
||||
tagModule tag.Module
|
||||
}
|
||||
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
@@ -42,7 +41,6 @@ func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, an
|
||||
settings: scopedProviderSettings,
|
||||
querier: querier,
|
||||
licensing: licensing,
|
||||
tagModule: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,55 +132,6 @@ func (module *module) GetPublicWidgetQueryRange(ctx context.Context, id valuer.U
|
||||
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
|
||||
}
|
||||
|
||||
func (module *module) GetDashboardByPublicIDV2(ctx context.Context, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := module.tagModule.ListForResource(ctx, storableDashboard.OrgID, coretypes.KindDashboard, storableDashboard.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storableDashboard.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
func (module *module) GetPublicWidgetQueryRangeV2(ctx context.Context, id valuer.UUID, panelKey, startTimeRaw, endTimeRaw string) (*querybuildertypesv5.QueryRangeResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "dashboard",
|
||||
instrumentationtypes.CodeFunctionName: "GetPublicWidgetQueryRangeV2",
|
||||
})
|
||||
|
||||
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// tags are not needed for query range.
|
||||
dashboard, err := storableDashboard.ToDashboardV2(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publicDashboard, err := module.GetPublic(ctx, dashboard.OrgID, dashboard.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startTime, endTime, err := publicDashboard.ResolveTimeRange(startTimeRaw, endTimeRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query, err := dashboard.GetPanelQuery(startTime, endTime, panelKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
|
||||
}
|
||||
|
||||
func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publicDashboard *dashboardtypes.PublicDashboard) error {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
|
||||
@@ -293,8 +293,6 @@
|
||||
// Forces subpath imports (@signozhq/ui/<component>) instead of the eagerly-loaded barrel
|
||||
"signoz/no-css-module-bracket-access": "warn",
|
||||
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
|
||||
"signoz/no-dashboard-fetch-outside-root": "error",
|
||||
// Forces useDashboardFetchRequired() outside the root V2 pages (allowlisted in overrides below)
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
@@ -560,18 +558,6 @@
|
||||
"rules": {
|
||||
"signoz/no-zustand-getstate-in-hooks": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
// Root V2 pages own the dashboard fetch lifecycle; useDashboardFetchRequired wraps it.
|
||||
// Everywhere else must use useDashboardFetchRequired().
|
||||
"files": [
|
||||
"src/pages/DashboardPageV2/DashboardPageV2.tsx",
|
||||
"src/pages/DashboardPageV2/PanelEditorPage/PanelEditorPage.tsx",
|
||||
"src/pages/DashboardPageV2/DashboardContainer/hooks/useDashboardFetchRequired.ts"
|
||||
],
|
||||
"rules": {
|
||||
"signoz/no-dashboard-fetch-outside-root": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Rule: no-dashboard-fetch-outside-root
|
||||
*
|
||||
* `useDashboardFetch` owns the V2 dashboard fetch and exposes its loading/error/refetch
|
||||
* lifecycle. That lifecycle is a root-page concern: DashboardPageV2 and PanelEditorPage
|
||||
* gate their subtree on a resolved dashboard before mounting anything below them.
|
||||
*
|
||||
* Every other component therefore renders inside an already-loaded subtree and must use
|
||||
* `useDashboardFetchRequired`, which reuses the same react-query cache entry but guarantees
|
||||
* a non-undefined dashboard (throwing otherwise) — so consumers don't re-handle states the
|
||||
* root page already owns.
|
||||
*
|
||||
* This rule flags `useDashboardFetch` imports. It is disabled via an override in
|
||||
* .oxlintrc.json for the two root pages and for useDashboardFetchRequired.ts (which wraps it).
|
||||
*/
|
||||
|
||||
const FETCH_HOOK = 'useDashboardFetch';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow useDashboardFetch outside the root dashboard pages; use useDashboardFetchRequired instead',
|
||||
category: 'Dashboard V2',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
useRequired:
|
||||
'Use useDashboardFetchRequired() instead of useDashboardFetch — the latter is reserved for the root pages (DashboardPageV2, PanelEditorPage) that own the fetch lifecycle. Components below them render inside a loaded subtree and should assume a guaranteed dashboard.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
node.specifiers.forEach((specifier) => {
|
||||
if (
|
||||
specifier.type === 'ImportSpecifier' &&
|
||||
specifier.imported.name === FETCH_HOOK
|
||||
) {
|
||||
context.report({ node: specifier, messageId: 'useRequired' });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -12,7 +12,6 @@ import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
|
||||
import noAntdComponents from './rules/no-antd-components.mjs';
|
||||
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
|
||||
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
|
||||
import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -26,6 +25,5 @@ export default {
|
||||
'no-antd-components': noAntdComponents,
|
||||
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
|
||||
'no-css-module-bracket-access': noCssModuleBracketAccess,
|
||||
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -38,10 +38,6 @@ import type {
|
||||
GetPublicDashboard200,
|
||||
GetPublicDashboardData200,
|
||||
GetPublicDashboardDataPathParameters,
|
||||
GetPublicDashboardDataV2200,
|
||||
GetPublicDashboardDataV2PathParameters,
|
||||
GetPublicDashboardPanelQueryRangeV2200,
|
||||
GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
@@ -1803,217 +1799,6 @@ export const useLockDashboardV2 = <
|
||||
> => {
|
||||
return useMutation(getLockDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
|
||||
* @summary Get public dashboard data (v2)
|
||||
*/
|
||||
export const getPublicDashboardDataV2 = (
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardDataV2200>({
|
||||
url: `/api/v2/public/dashboards/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardDataV2QueryKey = ({
|
||||
id,
|
||||
}: GetPublicDashboardDataV2PathParameters) => {
|
||||
return [`/api/v2/public/dashboards/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardDataV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetPublicDashboardDataV2QueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>
|
||||
> = ({ signal }) => getPublicDashboardDataV2({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetPublicDashboardDataV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>
|
||||
>;
|
||||
export type GetPublicDashboardDataV2QueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get public dashboard data (v2)
|
||||
*/
|
||||
|
||||
export function useGetPublicDashboardDataV2<
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardDataV2QueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get public dashboard data (v2)
|
||||
*/
|
||||
export const invalidateGetPublicDashboardDataV2 = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardDataV2QueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.
|
||||
* @summary Get query range result (v2)
|
||||
*/
|
||||
export const getPublicDashboardPanelQueryRangeV2 = (
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardPanelQueryRangeV2200>({
|
||||
url: `/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = ({
|
||||
id,
|
||||
key,
|
||||
}: GetPublicDashboardPanelQueryRangeV2PathParameters) => {
|
||||
return [`/api/v2/public/dashboards/${id}/panels/${key}/query_range`] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
|
||||
> = ({ signal }) => getPublicDashboardPanelQueryRangeV2({ id, key }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!(id && key),
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetPublicDashboardPanelQueryRangeV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
|
||||
>;
|
||||
export type GetPublicDashboardPanelQueryRangeV2QueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get query range result (v2)
|
||||
*/
|
||||
|
||||
export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardPanelQueryRangeV2QueryOptions(
|
||||
{ id, key },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get query range result (v2)
|
||||
*/
|
||||
export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
|
||||
queryClient: QueryClient,
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.
|
||||
* @summary List dashboards for the current user (v2)
|
||||
|
||||
@@ -23,7 +23,6 @@ import type {
|
||||
GetLLMPricingRulePathParameters,
|
||||
ListLLMPricingRules200,
|
||||
ListLLMPricingRulesParams,
|
||||
ListUnmappedLLMModels200,
|
||||
LlmpricingruletypesUpdatableLLMPricingRulesDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -394,87 +393,3 @@ export const invalidateGetLLMPricingRule = async (
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns models seen in the last hour of trace data (gen_ai.request.model) that no pricing rule pattern matches, so the user can add them to an existing rule or create a new one.
|
||||
* @summary List unmapped models
|
||||
*/
|
||||
export const listUnmappedLLMModels = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListUnmappedLLMModels200>({
|
||||
url: `/api/v1/llm_pricing_rules/unmapped_models`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListUnmappedLLMModelsQueryKey = () => {
|
||||
return [`/api/v1/llm_pricing_rules/unmapped_models`] as const;
|
||||
};
|
||||
|
||||
export const getListUnmappedLLMModelsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListUnmappedLLMModelsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>
|
||||
> = ({ signal }) => listUnmappedLLMModels(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListUnmappedLLMModelsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>
|
||||
>;
|
||||
export type ListUnmappedLLMModelsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List unmapped models
|
||||
*/
|
||||
|
||||
export function useListUnmappedLLMModels<
|
||||
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListUnmappedLLMModelsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List unmapped models
|
||||
*/
|
||||
export const invalidateListUnmappedLLMModels = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListUnmappedLLMModelsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
@@ -4941,11 +4941,6 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGettablePublicDashboardDataV2DTO {
|
||||
dashboard?: DashboardtypesGettableDashboardV2DTO;
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPatchOpDTO {
|
||||
add = 'add',
|
||||
remove = 'remove',
|
||||
@@ -6823,29 +6818,6 @@ export interface LlmpricingruletypesGettablePricingRulesDTO {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LlmpricingruletypesUnmappedModelDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
modelName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
provider?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
spanCount: number;
|
||||
}
|
||||
|
||||
export interface LlmpricingruletypesGettableUnmappedModelsDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
items: LlmpricingruletypesUnmappedModelDTO[] | null;
|
||||
}
|
||||
|
||||
export interface LlmpricingruletypesUpdatableLLMPricingRuleDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -10196,14 +10168,6 @@ export type GetLLMPricingRule200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUnmappedLLMModels200 = {
|
||||
data: LlmpricingruletypesGettableUnmappedModelsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListPromotedAndIndexedPaths200 = {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -11197,29 +11161,6 @@ export type GetMyOrganization200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardDataV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetPublicDashboardDataV2200 = {
|
||||
data: DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardPanelQueryRangeV2PathParameters = {
|
||||
id: string;
|
||||
key: string;
|
||||
};
|
||||
export type GetPublicDashboardPanelQueryRangeV2200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Readyz200 = {
|
||||
data: FactoryResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { isKeyMatch } from './utils';
|
||||
import { CheckedState } from '../../types';
|
||||
|
||||
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
|
||||
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
|
||||
@@ -149,7 +148,6 @@ export function applyCheckboxToggle({
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
previousState,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
activeQueryIndex: number;
|
||||
@@ -159,7 +157,6 @@ export function applyCheckboxToggle({
|
||||
value: string;
|
||||
checked: boolean;
|
||||
isOnlyOrAllClicked: boolean;
|
||||
previousState?: CheckedState;
|
||||
}): Query {
|
||||
const activeItems =
|
||||
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
|
||||
@@ -219,108 +216,15 @@ export function applyCheckboxToggle({
|
||||
);
|
||||
if (currentFilter) {
|
||||
const runningOperator = currentFilter?.op;
|
||||
|
||||
// Indeterminate items follow existing operator (IN or NOT IN)
|
||||
// No filter for key defaults to IN (handled in else branch below)
|
||||
if (previousState === 'indeterminate') {
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
switch (runningOperator) {
|
||||
case 'in':
|
||||
if (checked) {
|
||||
// if it's an IN operator then if we are checking another value it get's added to the
|
||||
// filter clause. example - key IN [value1, currentSelectedValue]
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// if the current state wasn't an array we make it one and add our value
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (!checked) {
|
||||
// if we are removing some value when the running operator is IN we filter.
|
||||
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// if not an array remove the whole thing altogether!
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'nin':
|
||||
case 'not in': {
|
||||
// NOT IN means "exclude these values"
|
||||
// Check if value is currently in the exclusion list
|
||||
const isValueInFilter = isArray(currentFilter.value)
|
||||
? currentFilter.value.includes(value)
|
||||
: currentFilter.value === value;
|
||||
|
||||
// When clicking unchecked "Other" item, user wants to SELECT it
|
||||
// Replace NOT IN filter with IN [value]
|
||||
if (previousState === 'unchecked' && checked) {
|
||||
const newFilter: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
switch (runningOperator) {
|
||||
case 'in':
|
||||
if (checked) {
|
||||
// if it's an IN operator then if we are checking another value it get's added to the
|
||||
// filter clause. example - key IN [value1, currentSelectedValue]
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
@@ -328,32 +232,33 @@ export function applyCheckboxToggle({
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
} else {
|
||||
// if the current state wasn't an array we make it one and add our value
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (!checked) {
|
||||
// if we are removing some value when the running operator is IN we filter.
|
||||
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
} else if (!checked || !isValueInFilter) {
|
||||
// Add to NOT IN when:
|
||||
// - checked=false (user explicitly unchecked to exclude)
|
||||
// - checked=true but value not in filter (clicking "other" value to exclude)
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
@@ -362,101 +267,132 @@ export function applyCheckboxToggle({
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Remove from NOT IN when value IS in filter and checked=true
|
||||
// (user wants to include this value back)
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
// if not an array remove the whole thing altogether!
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'nin':
|
||||
case 'not in':
|
||||
// if the current running operator is NIN then when unchecking the value it gets
|
||||
// added to the clause like key NIN [value1 , currentUnselectedValue]
|
||||
if (!checked) {
|
||||
// in case of array add the currentUnselectedValue to the list.
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
} else {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value === value ? null : currentFilter.value,
|
||||
};
|
||||
if (newFilter.value === null && query.filter?.expression) {
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// in case of not an array make it one!
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (checked) {
|
||||
// opposite of above!
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value === value ? null : currentFilter.value,
|
||||
};
|
||||
if (newFilter.value === null && query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '=':
|
||||
if (checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (!checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case '!=':
|
||||
if (!checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getNotInOperator(source),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case '=':
|
||||
if (checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (!checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case '!=':
|
||||
if (!checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getNotInOperator(source),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// case - when there is no filter for the current key that means all are selected right now.
|
||||
// Indeterminate items use IN (select), others use NOT IN (exclude)
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op:
|
||||
previousState === 'indeterminate'
|
||||
? getOperatorValue(OPERATORS.IN)
|
||||
: getNotInOperator(source),
|
||||
op: getNotInOperator(source),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
applyCheckboxToggle,
|
||||
clearFilterFromQuery,
|
||||
} from './checkboxFilterQuery';
|
||||
import { CheckedState } from '../../types';
|
||||
|
||||
interface UseCheckboxFilterActionsProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
@@ -25,7 +24,6 @@ interface UseCheckboxFilterActionsReturn {
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
previousState?: CheckedState,
|
||||
) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
@@ -55,7 +53,6 @@ function useCheckboxFilterActions({
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
previousState?: CheckedState,
|
||||
): void => {
|
||||
dispatch(
|
||||
applyCheckboxToggle({
|
||||
@@ -67,7 +64,6 @@ function useCheckboxFilterActions({
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
previousState,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../types';
|
||||
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
setupServer,
|
||||
} from './CheckboxFilterV2.testUtils';
|
||||
|
||||
const USE_FIELD_APIS_AUTO_DERIVE = {
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: undefined,
|
||||
};
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
const captureExistingQuery = (): Promise<string | null> =>
|
||||
new Promise((resolve) => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
const existingQuery = req.url.searchParams.get('existingQuery');
|
||||
resolve(existingQuery);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: ['test'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('useFieldApis.existingQuery takes precedence', () => {
|
||||
it('uses useFieldApis.existingQuery when provided', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'custom.query = "value"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe('custom.query = "value"');
|
||||
});
|
||||
|
||||
it('returns undefined when useFieldApis.existingQuery is null', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: null,
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('V5 filter.expression preferred over V3 filters.items', () => {
|
||||
it('uses V5 filter.expression when both exist', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'from-v3-items',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'v5.expression = "preferred"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe('v5.expression = "preferred"');
|
||||
});
|
||||
|
||||
it('uses V5 filter.expression when no V3 items exist', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'only.v5 = "expression"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe('only.v5 = "expression"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('V3 filters.items fallback', () => {
|
||||
it('converts V3 filters.items to expression when no V5 expression exists', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe("service.name = 'api-service'");
|
||||
});
|
||||
|
||||
it('converts multiple V3 filters.items with AND operator', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api',
|
||||
},
|
||||
{
|
||||
key: { key: 'env', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'prod',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe("service.name = 'api' AND env = 'prod'");
|
||||
});
|
||||
|
||||
it('returns undefined when no filters exist', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,525 +0,0 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../types';
|
||||
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
getFilterFromCall,
|
||||
mockFieldsValuesAPI,
|
||||
renderWithFilter,
|
||||
setupServer,
|
||||
} from './CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - interactions', () => {
|
||||
describe('search functionality', () => {
|
||||
it('filters values based on search text', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
let searchTextReceived = '';
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
searchTextReceived = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
const values =
|
||||
searchTextReceived === ''
|
||||
? ['production', 'staging', 'development']
|
||||
: ['production'];
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: values,
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(searchTextReceived).toBe('prod');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-staging'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters values via search while preserving existingQuery context', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
let requestCount = 0;
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
requestCount += 1;
|
||||
const searchText = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
if (requestCount === 1) {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging', 'development'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: searchText === 'prod' ? ['production'] : [],
|
||||
stringValues: searchText === 'prod' ? ['production'] : ['staging'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
expect(screen.getByTestId('badge-related')).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-staging'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-production'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('header interactions', () => {
|
||||
it('collapses when header clicked on open filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
|
||||
await user.click(header);
|
||||
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-production'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands when header clicked on closed filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
|
||||
await user.click(header);
|
||||
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
});
|
||||
});
|
||||
|
||||
describe('show more functionality', () => {
|
||||
it('shows "Show More..." when more than 10 values', async () => {
|
||||
const values = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) => `value-${String(i).padStart(2, '0')}`,
|
||||
);
|
||||
mockFieldsValuesAPI({ stringValues: values });
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-00');
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-show-more')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-value-10'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads more values when "Show More..." clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const values = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) => `value-${String(i).padStart(2, '0')}`,
|
||||
);
|
||||
mockFieldsValuesAPI({ stringValues: values });
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-00');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-show-more'));
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-10');
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-value-14'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear functionality', () => {
|
||||
it('shows clear button when filter is open and has filter applied', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when no filter applied for attribute', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onFilterChange when clear clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('value row interactions', () => {
|
||||
it('calls onFilterChange when checkbox value clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
|
||||
await user.click(within(productionRow).getByText('production'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates IN filter when toggling indeterminate (related) item with no existing filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Start with no filter, toggle indeterminate A → should create IN
|
||||
renderWithFilter(onFilterChange);
|
||||
|
||||
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
|
||||
expect(rowA).toHaveAttribute('data-state', 'indeterminate');
|
||||
|
||||
await user.click(within(rowA).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('in');
|
||||
expect(filter?.value).toBe('valueA');
|
||||
});
|
||||
|
||||
it('converts NOT IN to IN when toggling unchecked (other) item', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Clicking unchecked "Other" item with NOT IN filter should convert to IN [B]
|
||||
renderWithFilter(onFilterChange, { op: 'not in', value: ['valueA'] });
|
||||
|
||||
const rowB = await screen.findByTestId('checkbox-value-row-valueB');
|
||||
expect(rowB).toHaveAttribute('data-state', 'unchecked');
|
||||
|
||||
await user.click(within(rowB).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('in');
|
||||
expect(filter?.value).toBe('valueB');
|
||||
});
|
||||
|
||||
it('accumulates both values in IN when toggling indeterminate (related) then unchecked (other)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Start with IN filter for valueA
|
||||
renderWithFilter(onFilterChange, { op: 'in', value: ['valueA'] });
|
||||
|
||||
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
|
||||
expect(rowA).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
const rowB = screen.getByTestId('checkbox-value-row-valueB');
|
||||
expect(rowB).toHaveAttribute('data-state', 'unchecked');
|
||||
|
||||
// Toggle B (unchecked -> should add to IN)
|
||||
await user.click(within(rowB).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('in');
|
||||
expect(filter?.value).toStrictEqual(['valueA', 'valueB']);
|
||||
});
|
||||
|
||||
it('adds to NOT IN when toggling indeterminate (related) with existing NOT IN filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Start with NOT IN filter for valueB
|
||||
renderWithFilter(onFilterChange, { op: 'not in', value: ['valueB'] });
|
||||
|
||||
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
|
||||
expect(rowA).toHaveAttribute('data-state', 'indeterminate');
|
||||
|
||||
// Toggle A (indeterminate -> should add to NOT IN)
|
||||
await user.click(within(rowA).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('not in');
|
||||
expect(filter?.value).toStrictEqual(['valueB', 'valueA']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom renderer', () => {
|
||||
it('uses customRendererForValue when provided', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
const customRenderer = (value: string): JSX.Element => (
|
||||
<span data-testid="custom-rendered">{`ENV: ${value}`}</span>
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, customRendererForValue: customRenderer }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('custom-rendered');
|
||||
expect(screen.getByText('ENV: production')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,485 +0,0 @@
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../types';
|
||||
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
mockFieldsValuesAPI,
|
||||
setupServer,
|
||||
} from './CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - item rules', () => {
|
||||
describe('no existing query', () => {
|
||||
it('all values show as checked with no badge when no query exists', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(within(productionRow).getByText('production')).toBeInTheDocument();
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
expect(screen.queryByTestId('badge-related')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('badge-other')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with existing query (related values)', () => {
|
||||
it('shows "Related" badge with indeterminate state for values in relatedValues', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging', 'development'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(within(productionRow).getByText('production')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId('badge-related')).toBeInTheDocument();
|
||||
expect(productionRow).toHaveAttribute('data-state', 'indeterminate');
|
||||
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
|
||||
it('shows no badge for values not in relatedValues (unchecked state)', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const stagingRow = await screen.findByTestId('checkbox-value-row-staging');
|
||||
expect(within(stagingRow).getByText('staging')).toBeInTheDocument();
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(stagingRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Related" badge with indeterminate when hasFilterForThisKey=true and isInRelatedValues=true (Rule 5)', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['production', 'staging'],
|
||||
stringValues: ['development'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'indeterminate');
|
||||
expect(within(stagingRow).getByTestId('badge-related')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selected values with IN operator', () => {
|
||||
it('shows checked state with no badge for IN-selected values', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(
|
||||
within(productionRow).queryByTestId(/^badge-/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selected values with NOT IN operator', () => {
|
||||
it('shows "Not in" badge with unchecked state for NOT_IN-selected values', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(screen.getByTestId('badge-not_in')).toBeInTheDocument();
|
||||
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(stagingRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ordering by orderIndex', () => {
|
||||
it('orders selected values (orderIndex 0) before related (orderIndex 1) before other (orderIndex 2)', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['related-value'],
|
||||
stringValues: ['other-value', 'selected-value'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-value'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-selected-value');
|
||||
|
||||
const allRows = screen.getAllByTestId(/^checkbox-value-row-/);
|
||||
const values = allRows.map((row) =>
|
||||
row.getAttribute('data-testid')?.replace('checkbox-value-row-', ''),
|
||||
);
|
||||
|
||||
expect(values[0]).toBe('selected-value');
|
||||
expect(values[1]).toBe('related-value');
|
||||
expect(values[2]).toBe('other-value');
|
||||
});
|
||||
|
||||
it('sorts alphabetically within same orderIndex', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['zebra', 'alpha', 'mike'],
|
||||
stringValues: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-alpha');
|
||||
|
||||
const allRows = screen.getAllByTestId(/^checkbox-value-row-/);
|
||||
const values = allRows.map((row) =>
|
||||
row.getAttribute('data-testid')?.replace('checkbox-value-row-', ''),
|
||||
);
|
||||
|
||||
expect(values).toStrictEqual(['alpha', 'mike', 'zebra']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed state scenarios', () => {
|
||||
it('handles mixed state: IN-selected + related + other in same list', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['related-env'],
|
||||
stringValues: ['other-env', 'selected-env'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const selectedRow = await screen.findByTestId(
|
||||
'checkbox-value-row-selected-env',
|
||||
);
|
||||
expect(selectedRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(within(selectedRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
|
||||
const relatedRow = screen.getByTestId('checkbox-value-row-related-env');
|
||||
expect(relatedRow).toHaveAttribute('data-state', 'indeterminate');
|
||||
expect(within(relatedRow).getByTestId('badge-related')).toBeInTheDocument();
|
||||
|
||||
const otherRow = screen.getByTestId('checkbox-value-row-other-env');
|
||||
expect(otherRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(otherRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles NOT_IN-selected alongside related values', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['related-env'],
|
||||
stringValues: ['other-env', 'excluded-env'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['excluded-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const excludedRow = await screen.findByTestId(
|
||||
'checkbox-value-row-excluded-env',
|
||||
);
|
||||
expect(excludedRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(excludedRow).getByTestId('badge-not_in')).toBeInTheDocument();
|
||||
|
||||
const relatedRow = screen.getByTestId('checkbox-value-row-related-env');
|
||||
expect(relatedRow).toHaveAttribute('data-state', 'indeterminate');
|
||||
expect(within(relatedRow).getByTestId('badge-related')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
.checkboxFilter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-6);
|
||||
gap: var(--spacing-6);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.search {
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-focus-border-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.searchSpinner {
|
||||
color: var(--l2-foreground);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.values {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.loadingMore {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.noData {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.showMore {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.showMoreText {
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.goToDocs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 44px;
|
||||
}
|
||||
|
||||
.goToDocsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
.goToDocsMessage {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.goToDocsButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
cursor: pointer;
|
||||
margin: 0 0 var(--spacing-2);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.goToDocsButtonText {
|
||||
color: var(--bg-robin-400);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../types';
|
||||
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
mockFieldsValuesAPI,
|
||||
mockFieldsValuesAPILoading,
|
||||
setupServer,
|
||||
} from './CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - states', () => {
|
||||
describe('loading states', () => {
|
||||
it('shows skeleton while loading initial data', async () => {
|
||||
mockFieldsValuesAPILoading();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-v2')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows skeleton when initially closed filter is opened for the first time', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFieldsValuesAPILoading();
|
||||
|
||||
const closedFilter = { ...DEFAULT_FILTER, defaultOpen: false };
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={closedFilter}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Filter starts closed - no skeleton, no content
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-empty'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Click header to open
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
await user.click(header);
|
||||
|
||||
// Should show skeleton while loading, NOT "No values found"
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-empty'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows search spinner when fetching after initial load', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
let requestCount = 0;
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: ['production', 'staging'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res(ctx.delay(10000));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-search-loading'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty states', () => {
|
||||
it('shows "No values found" when API returns empty arrays', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: [],
|
||||
stringValues: [],
|
||||
numberValues: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emptySection = await screen.findByTestId('checkbox-filter-empty');
|
||||
expect(emptySection).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('value rendering', () => {
|
||||
it('renders values from API response', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging', 'development'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-development'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders number values converted to strings', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
numberValues: [200, 404, 500],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row200 = await screen.findByTestId('checkbox-value-row-200');
|
||||
expect(within(row200).getByText('200')).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('checkbox-value-row-404')).getByText('404'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('checkbox-value-row-500')).getByText('500'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters null/undefined values from response', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['valid', null, '', undefined as unknown as string],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const validRow = await screen.findByTestId('checkbox-value-row-valid');
|
||||
expect(within(validRow).getByText('valid')).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId(/^checkbox-value-row-/)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { render, RenderResult } from 'tests/test-utils';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
FiltersType,
|
||||
IQuickFiltersConfig,
|
||||
QuickFilterCheckboxUseFieldApis,
|
||||
QuickFiltersSource,
|
||||
} from '../../../types';
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
|
||||
export const DEFAULT_FILTER: IQuickFiltersConfig = {
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Environment',
|
||||
attributeKey: {
|
||||
key: 'deployment.environment',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
dataSource: DataSource.TRACES,
|
||||
defaultOpen: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_USE_FIELD_APIS: QuickFilterCheckboxUseFieldApis = {
|
||||
startUnixMilli: 1700000000000,
|
||||
endUnixMilli: 1700003600000,
|
||||
existingQuery: null,
|
||||
};
|
||||
|
||||
export function mockFieldsValuesAPI(response: {
|
||||
relatedValues?: (string | null)[];
|
||||
stringValues?: (string | null)[];
|
||||
numberValues?: (number | null)[];
|
||||
}): void {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: response.relatedValues ?? [],
|
||||
stringValues: response.stringValues ?? [],
|
||||
numberValues: response.numberValues ?? [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function mockFieldsValuesAPILoading(): void {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
|
||||
res(ctx.delay(10000)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function setupServer(): void {
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
}
|
||||
|
||||
export interface FilterItemConfig {
|
||||
op: string;
|
||||
value: string | string[];
|
||||
}
|
||||
|
||||
export function renderWithFilter(
|
||||
onFilterChange: jest.Mock,
|
||||
filterItem?: FilterItemConfig,
|
||||
): RenderResult {
|
||||
const items: TagFilterItem[] = filterItem
|
||||
? [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: filterItem.op,
|
||||
value: filterItem.value,
|
||||
} as TagFilterItem,
|
||||
]
|
||||
: [];
|
||||
|
||||
return render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items, op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getFilterFromCall(
|
||||
onFilterChange: jest.Mock,
|
||||
callIndex = 0,
|
||||
): TagFilterItem | undefined {
|
||||
const query = onFilterChange.mock.calls[callIndex]?.[0] as Query | undefined;
|
||||
return query?.builder.queryData[0]?.filters?.items?.find(
|
||||
(item) => item.key?.key === 'deployment.environment',
|
||||
);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { LoaderCircle } from '@signozhq/icons';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFilterCheckboxUseFieldApis,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
|
||||
import useActiveQueryIndex from '../useActiveQueryIndex';
|
||||
import useCheckboxDisclosure from '../useCheckboxDisclosure';
|
||||
import useCheckboxFilterActions from '../useCheckboxFilterActions';
|
||||
import useCheckboxFilterState from '../useCheckboxFilterState';
|
||||
import { useFieldValues } from './useFieldValues';
|
||||
import { useExistingQuery } from './useExistingQuery';
|
||||
import { isKeyMatch } from '../utils';
|
||||
|
||||
import { CheckboxFilterV2Header } from './CheckboxFilterV2Header';
|
||||
import { CheckboxFilterV2ValueRow } from './CheckboxFilterV2ValueRow';
|
||||
import { useSectionedValues } from './useSectionedValues';
|
||||
|
||||
import styles from './CheckboxFilterV2.module.scss';
|
||||
|
||||
interface CheckboxFilterV2Props {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
useFieldApis: QuickFilterCheckboxUseFieldApis;
|
||||
}
|
||||
|
||||
export default function CheckboxFilterV2(
|
||||
props: CheckboxFilterV2Props,
|
||||
): JSX.Element {
|
||||
const { source, filter, onFilterChange, useFieldApis } = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
visibleItemsCount,
|
||||
onToggleOpen,
|
||||
onShowMore,
|
||||
} = useCheckboxDisclosure({ filter, activeQueryIndex });
|
||||
|
||||
// Auto-preserve open state when filter is present
|
||||
useEffect(() => {
|
||||
if (isSomeFilterPresentForCurrentAttribute && userToggleState === null) {
|
||||
setUserToggleState(true);
|
||||
}
|
||||
}, [isSomeFilterPresentForCurrentAttribute, userToggleState]);
|
||||
|
||||
const { existingQuery, hasExistingQuery } = useExistingQuery({
|
||||
useFieldApis,
|
||||
activeQueryIndex,
|
||||
});
|
||||
|
||||
const { relatedValues, allValues, isLoading, isFetching } = useFieldValues({
|
||||
filter,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace: useFieldApis.metricNamespace,
|
||||
startUnixMilli: useFieldApis.startUnixMilli,
|
||||
endUnixMilli: useFieldApis.endUnixMilli,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Track if initial load completed (don't show skeleton after first load)
|
||||
// Must track if loading ever started, otherwise hasLoadedOnce gets set
|
||||
// immediately on first render when query is disabled (isLoading=false)
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const wasLoading = useRef(false);
|
||||
if (isLoading) {
|
||||
wasLoading.current = true;
|
||||
}
|
||||
if (!isLoading && wasLoading.current && !hasLoadedOnce.current) {
|
||||
hasLoadedOnce.current = true;
|
||||
}
|
||||
|
||||
// Combine for state derivation
|
||||
const attributeValues = useMemo(() => {
|
||||
const combined = [...relatedValues, ...allValues];
|
||||
return [...new Set(combined)];
|
||||
}, [relatedValues, allValues]);
|
||||
|
||||
const { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
|
||||
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
|
||||
|
||||
const { onChange, onClear } = useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
});
|
||||
|
||||
const setSearchTextDebounced = useDebouncedFn((...args) => {
|
||||
setSearchText(args[0] as string);
|
||||
}, DEBOUNCE_DELAY);
|
||||
|
||||
const currentFilterOp = useMemo(() => {
|
||||
const filterSync = currentQuery?.builder.queryData?.[
|
||||
activeQueryIndex
|
||||
]?.filters?.items.find((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
return filterSync?.op;
|
||||
}, [
|
||||
currentQuery?.builder.queryData,
|
||||
activeQueryIndex,
|
||||
filter.attributeKey.key,
|
||||
]);
|
||||
|
||||
const isNotInOperator = NON_SELECTED_OPERATORS.includes(currentFilterOp || '');
|
||||
|
||||
const { sectionedItems, totalCount } = useSectionedValues({
|
||||
relatedValues,
|
||||
allValues,
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
searchText,
|
||||
visibleItemsCount,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.checkboxFilter} data-testid="checkbox-filter-v2">
|
||||
<CheckboxFilterV2Header
|
||||
title={filter.title}
|
||||
isOpen={isOpen}
|
||||
showClearAll={!!attributeValues.length}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onClear={onClear}
|
||||
isSomeFilterPresentForCurrentAttribute={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
}
|
||||
/>
|
||||
{isOpen && isLoading && !hasLoadedOnce.current && (
|
||||
<section>
|
||||
<Skeleton paragraph={{ rows: 4 }} />
|
||||
</section>
|
||||
)}
|
||||
{isOpen && (!isLoading || hasLoadedOnce.current) && (
|
||||
<>
|
||||
<section className={styles.search}>
|
||||
<Input
|
||||
placeholder="Filter values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
data-testid="checkbox-filter-search"
|
||||
suffix={
|
||||
isFetching ? (
|
||||
<LoaderCircle
|
||||
size={14}
|
||||
className={styles.searchSpinner}
|
||||
data-testid="checkbox-filter-search-loading"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{totalCount > 0 && (
|
||||
<section className={styles.values}>
|
||||
{sectionedItems.map(({ value, badge, checkedState }) => {
|
||||
const isChecked = checkedState === 'checked';
|
||||
|
||||
return (
|
||||
<CheckboxFilterV2ValueRow
|
||||
key={value}
|
||||
value={value}
|
||||
checkedState={checkedState}
|
||||
disabled={isFilterDisabled}
|
||||
title={filter.title}
|
||||
badge={badge}
|
||||
onlyButtonLabel={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
? isChecked && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'
|
||||
}
|
||||
customRendererForValue={filter.customRendererForValue}
|
||||
onCheckboxChange={(checked, previousState): void =>
|
||||
onChange(value, checked, false, previousState)
|
||||
}
|
||||
onOnlyOrAllClick={(): void => onChange(value, isChecked, true)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{totalCount === 0 && hasLoadedOnce.current && (
|
||||
<section className={styles.noData} data-testid="checkbox-filter-empty">
|
||||
<Typography.Text>No values found</Typography.Text>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{visibleItemsCount < totalCount && (
|
||||
<section className={styles.showMore}>
|
||||
<Typography.Text
|
||||
className={styles.showMoreText}
|
||||
onClick={onShowMore}
|
||||
data-testid="checkbox-filter-show-more"
|
||||
>
|
||||
Show More...
|
||||
</Typography.Text>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.leftAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.rightAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.clearAll {
|
||||
font-size: 12px;
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { CheckboxFilterV2Header } from './CheckboxFilterV2Header';
|
||||
|
||||
describe('CheckboxFilterV2Header', () => {
|
||||
const defaultProps = {
|
||||
title: 'Environment',
|
||||
isOpen: false,
|
||||
showClearAll: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
onToggleOpen: jest.fn(),
|
||||
onClear: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('collapsed state', () => {
|
||||
it('renders title', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
|
||||
|
||||
expect(screen.getByText('Environment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets data-state="closed" when collapsed', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
});
|
||||
|
||||
it('does not show clear button when collapsed', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen={false} showClearAll />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('expanded state', () => {
|
||||
it('sets data-state="open" when expanded', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
});
|
||||
|
||||
it('shows clear button when expanded + showClearAll=true', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll />);
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when showClearAll=false', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll={false} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when no filter present for attribute', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
showClearAll
|
||||
isSomeFilterPresentForCurrentAttribute={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions', () => {
|
||||
it('calls onToggleOpen on header click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-header'));
|
||||
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onToggleOpen on Enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
|
||||
);
|
||||
|
||||
screen.getByTestId('checkbox-filter-header').focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onToggleOpen on Space key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
|
||||
);
|
||||
|
||||
screen.getByTestId('checkbox-filter-header').focus();
|
||||
await user.keyboard(' ');
|
||||
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClear on clear button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen onClear={onClear} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clear button click does not trigger onToggleOpen', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
const onClear = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
onToggleOpen={onToggleOpen}
|
||||
onClear={onClear}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
|
||||
import styles from './CheckboxFilterV2Header.module.scss';
|
||||
|
||||
interface CheckboxFilterHeaderProps {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
showClearAll: boolean;
|
||||
onToggleOpen: () => void;
|
||||
onClear: () => void;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
}
|
||||
|
||||
export function CheckboxFilterV2Header({
|
||||
title,
|
||||
isOpen,
|
||||
showClearAll,
|
||||
onToggleOpen,
|
||||
onClear,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
}: CheckboxFilterHeaderProps): JSX.Element {
|
||||
return (
|
||||
<section
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={styles.header}
|
||||
onClick={onToggleOpen}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onToggleOpen();
|
||||
}
|
||||
}}
|
||||
data-testid="checkbox-filter-header"
|
||||
data-state={isOpen ? 'open' : 'closed'}
|
||||
>
|
||||
<section className={styles.leftAction}>
|
||||
{isOpen ? (
|
||||
<ChevronDown size={13} cursor="pointer" />
|
||||
) : (
|
||||
<ChevronRight size={13} cursor="pointer" />
|
||||
)}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.rightAction}>
|
||||
{isOpen && showClearAll && isSomeFilterPresentForCurrentAttribute && (
|
||||
<Typography.Text
|
||||
className={styles.clearAll}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClear();
|
||||
}}
|
||||
data-testid="checkbox-filter-clear-all"
|
||||
>
|
||||
Clear
|
||||
</Typography.Text>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
.valueRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.valueButton {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
width: calc(100% - 24px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.valueLabel {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: end;
|
||||
|
||||
// Stack badge / only / toggle in a single cell so the crossfade overlaps
|
||||
// instead of laying them side-by-side mid-transition.
|
||||
> * {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
display 0.16s allow-discrete;
|
||||
}
|
||||
|
||||
.onlyButton {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
display 0.16s allow-discrete;
|
||||
--button-height: 21px;
|
||||
--button-padding: var(--spacing-5);
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
display 0.16s allow-discrete;
|
||||
--button-height: 21px;
|
||||
--button-padding: var(--spacing-5);
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.isDisabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
.valueLabel {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.onlyButton {
|
||||
cursor: not-allowed;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
cursor: not-allowed;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.valueButton:hover {
|
||||
.onlyButton {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox:hover ~ .valueButton {
|
||||
.toggleButton {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.badge,
|
||||
.onlyButton,
|
||||
.toggleButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.indicatorFalse {
|
||||
width: 2px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: var(--danger-background);
|
||||
}
|
||||
|
||||
.indicatorTrue {
|
||||
width: 2px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-forest-500);
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { BadgeConfig } from './itemRules';
|
||||
import { CheckedState } from '../../../types';
|
||||
import { CheckboxFilterV2ValueRow } from './CheckboxFilterV2ValueRow';
|
||||
|
||||
describe('CheckboxFilterV2ValueRow', () => {
|
||||
const defaultProps = {
|
||||
value: 'production',
|
||||
checkedState: 'unchecked' as CheckedState,
|
||||
disabled: false,
|
||||
title: 'Environment',
|
||||
onlyButtonLabel: 'Only',
|
||||
onCheckboxChange: jest.fn(),
|
||||
onOnlyOrAllClick: jest.fn(),
|
||||
badge: null as BadgeConfig | null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('checked states', () => {
|
||||
it('sets data-state="unchecked" for unchecked state', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="unchecked" />,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId('checkbox-value-row-production');
|
||||
expect(row).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
|
||||
it('sets data-state="checked" for checked state', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="checked" />,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId('checkbox-value-row-production');
|
||||
expect(row).toHaveAttribute('data-state', 'checked');
|
||||
});
|
||||
|
||||
it('sets data-state="indeterminate" for indeterminate state', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="indeterminate" />,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId('checkbox-value-row-production');
|
||||
expect(row).toHaveAttribute('data-state', 'indeterminate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('badge variations', () => {
|
||||
it('renders no badge when badge=null', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} badge={null} />);
|
||||
|
||||
expect(screen.queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Not in" warning badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
badge={{ key: 'not_in', label: 'Not in', color: 'warning' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-not_in')).toBeInTheDocument();
|
||||
expect(screen.getByText('Not in')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Related" robin badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
badge={{ key: 'related', label: 'Related', color: 'robin' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-related')).toBeInTheDocument();
|
||||
expect(screen.getByText('Related')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Other" secondary badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
badge={{ key: 'other', label: 'Other', color: 'secondary' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-other')).toBeInTheDocument();
|
||||
expect(screen.getByText('Other')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('only/all button label', () => {
|
||||
it('shows "Only" label by default', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} onlyButtonLabel="Only" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Only')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "All" label when appropriate', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} onlyButtonLabel="All" />);
|
||||
|
||||
expect(screen.getByText('All')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled state', () => {
|
||||
it('sets data-disabled=true when disabled', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} disabled />);
|
||||
|
||||
const row = screen.getByTestId('checkbox-value-row-production');
|
||||
expect(row).toHaveAttribute('data-disabled', 'true');
|
||||
});
|
||||
|
||||
it('does not call onOnlyOrAllClick when disabled + clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
disabled
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('production'));
|
||||
|
||||
expect(onOnlyOrAllClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onOnlyOrAllClick on keydown when disabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
disabled
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByText('production').focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(onOnlyOrAllClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('special value indicators', () => {
|
||||
it('renders row for "true" value', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} value="true" />);
|
||||
|
||||
expect(screen.getByTestId('checkbox-value-row-true')).toBeInTheDocument();
|
||||
expect(screen.getByText('true')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders row for "false" value', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} value="false" />);
|
||||
|
||||
expect(screen.getByTestId('checkbox-value-row-false')).toBeInTheDocument();
|
||||
expect(screen.getByText('false')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders row for regular values', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} value="production" />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-production'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('production')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions', () => {
|
||||
it('renders checkbox with correct testId', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="unchecked" />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('checkbox-Environment-production'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onOnlyOrAllClick on value text click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('production'));
|
||||
|
||||
expect(onOnlyOrAllClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onOnlyOrAllClick on Enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const valueButton = screen
|
||||
.getByText('production')
|
||||
.closest('[role="button"]');
|
||||
await user.tab();
|
||||
await user.tab();
|
||||
if (valueButton && document.activeElement === valueButton) {
|
||||
await user.keyboard('{Enter}');
|
||||
}
|
||||
|
||||
expect(onOnlyOrAllClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onOnlyOrAllClick on Space key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const valueButton = screen
|
||||
.getByText('production')
|
||||
.closest('[role="button"]');
|
||||
await user.tab();
|
||||
await user.tab();
|
||||
if (valueButton && document.activeElement === valueButton) {
|
||||
await user.keyboard(' ');
|
||||
}
|
||||
|
||||
expect(onOnlyOrAllClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows Toggle button', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Toggle')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom renderer', () => {
|
||||
it('uses customRendererForValue when provided', () => {
|
||||
const customRenderer = (value: string): JSX.Element => (
|
||||
<span data-testid="custom-render">{`Custom: ${value}`}</span>
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
customRendererForValue={customRenderer}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-render')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom: production')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows default value text when no custom renderer', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('production')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('state combinations', () => {
|
||||
it('checked + not_in badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
checkedState="unchecked"
|
||||
badge={{ key: 'not_in', label: 'Not in', color: 'warning' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-not_in')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('indeterminate + related badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
checkedState="indeterminate"
|
||||
badge={{ key: 'related', label: 'Related', color: 'robin' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-related')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disabled + badge still shows badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
disabled
|
||||
badge={{ key: 'other', label: 'Other', color: 'secondary' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-other')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { BadgeConfig } from './itemRules';
|
||||
import { CheckedState } from '../../../types';
|
||||
import styles from './CheckboxFilterV2ValueRow.module.scss';
|
||||
|
||||
interface ValueRowProps {
|
||||
value: string;
|
||||
checkedState: CheckedState;
|
||||
disabled: boolean;
|
||||
title: string;
|
||||
onlyButtonLabel: string;
|
||||
customRendererForValue?: (value: string) => JSX.Element;
|
||||
onCheckboxChange: (checked: boolean, previousState: CheckedState) => void;
|
||||
onOnlyOrAllClick: () => void;
|
||||
badge: BadgeConfig | null;
|
||||
}
|
||||
|
||||
function toCheckboxValue(state: CheckedState): boolean | 'indeterminate' {
|
||||
if (state === 'indeterminate') {
|
||||
return 'indeterminate';
|
||||
}
|
||||
return state === 'checked';
|
||||
}
|
||||
|
||||
const INDICATOR_CLASS_MAP = {
|
||||
false: styles.indicatorFalse,
|
||||
true: styles.indicatorTrue,
|
||||
} as Record<string, string>;
|
||||
|
||||
export function CheckboxFilterV2ValueRow({
|
||||
value,
|
||||
checkedState,
|
||||
disabled,
|
||||
title,
|
||||
onlyButtonLabel,
|
||||
customRendererForValue,
|
||||
onCheckboxChange,
|
||||
onOnlyOrAllClick,
|
||||
badge,
|
||||
}: ValueRowProps): JSX.Element {
|
||||
const indicatorClass = INDICATOR_CLASS_MAP[value];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.valueRow}
|
||||
data-testid={`checkbox-value-row-${value}`}
|
||||
data-state={checkedState}
|
||||
data-disabled={disabled}
|
||||
>
|
||||
<div className={styles.checkbox}>
|
||||
<Checkbox
|
||||
onChange={(isChecked): void =>
|
||||
onCheckboxChange(isChecked === true, checkedState)
|
||||
}
|
||||
value={toCheckboxValue(checkedState)}
|
||||
disabled={disabled}
|
||||
color="primary"
|
||||
testId={`checkbox-${title}-${value}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={cx(styles.valueButton, disabled && styles.isDisabled)}
|
||||
onClick={(): void => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onOnlyOrAllClick();
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onOnlyOrAllClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
{indicatorClass && <div className={indicatorClass} />}
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text title={value} className={styles.valueLabel}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{badge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
color={badge.color}
|
||||
className={styles.badge}
|
||||
testId={`badge-${badge.key}`}
|
||||
>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="ghost" color="secondary" className={styles.onlyButton}>
|
||||
{onlyButtonLabel}
|
||||
</Button>
|
||||
<Button variant="ghost" color="secondary" className={styles.toggleButton}>
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { deriveItemConfig, ItemContext } from './itemRules';
|
||||
|
||||
describe('itemRules', () => {
|
||||
describe('deriveItemConfig', () => {
|
||||
it('no query at all → orderIndex 0, no badge', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.orderIndex).toBe(0);
|
||||
expect(result.badge).toBeNull();
|
||||
});
|
||||
|
||||
it('selected + IN operator → orderIndex 0, no badge', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: true,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.orderIndex).toBe(0);
|
||||
expect(result.badge).toBeNull();
|
||||
});
|
||||
|
||||
it('selected + NOT IN operator → orderIndex 0, not_in badge', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: true,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: true,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.orderIndex).toBe(0);
|
||||
expect(result.badge).toStrictEqual({
|
||||
key: 'not_in',
|
||||
label: 'Not in',
|
||||
color: 'warning',
|
||||
});
|
||||
});
|
||||
|
||||
it('has query, no filter for this key, in related → orderIndex 1, related badge', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.orderIndex).toBe(1);
|
||||
expect(result.badge).toStrictEqual({
|
||||
key: 'related',
|
||||
label: 'Related',
|
||||
color: 'robin',
|
||||
});
|
||||
});
|
||||
|
||||
it('has query, has filter for this key, in related → orderIndex 1, related badge', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.orderIndex).toBe(1);
|
||||
expect(result.badge).toStrictEqual({
|
||||
key: 'related',
|
||||
label: 'Related',
|
||||
color: 'robin',
|
||||
});
|
||||
});
|
||||
|
||||
it('has query, not in related → orderIndex 2, no badge', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.orderIndex).toBe(2);
|
||||
expect(result.badge).toBeNull();
|
||||
});
|
||||
|
||||
it('has query + filter for key, not selected, not in related → orderIndex 2, no badge', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.orderIndex).toBe(2);
|
||||
expect(result.badge).toBeNull();
|
||||
});
|
||||
|
||||
it('no query but has filter for key, not selected → fallback to checked (DEFAULT_CONFIG)', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.orderIndex).toBe(0);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import { CheckedState } from '../../../types';
|
||||
|
||||
export interface BadgeConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
color: 'robin' | 'warning' | 'secondary';
|
||||
}
|
||||
|
||||
export interface ItemConfig {
|
||||
orderIndex: number;
|
||||
badge: BadgeConfig | null;
|
||||
checkedState: CheckedState;
|
||||
}
|
||||
|
||||
export interface ItemContext {
|
||||
isSelectedOnFilter: boolean;
|
||||
isInRelatedValues: boolean;
|
||||
isNotInOperator: boolean;
|
||||
hasExistingQuery: boolean;
|
||||
hasFilterForThisKey: boolean;
|
||||
}
|
||||
|
||||
export interface DerivedItem extends ItemConfig {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ItemRule {
|
||||
condition: (ctx: ItemContext) => boolean;
|
||||
config: ItemConfig;
|
||||
}
|
||||
|
||||
const ITEM_RULES: ItemRule[] = [
|
||||
{
|
||||
condition: (ctx): boolean =>
|
||||
!ctx.hasExistingQuery && !ctx.hasFilterForThisKey,
|
||||
config: { orderIndex: 0, badge: null, checkedState: 'checked' },
|
||||
},
|
||||
{
|
||||
condition: (ctx): boolean => ctx.isSelectedOnFilter && ctx.isNotInOperator,
|
||||
config: {
|
||||
orderIndex: 0,
|
||||
badge: { key: 'not_in', label: 'Not in', color: 'warning' },
|
||||
checkedState: 'unchecked',
|
||||
},
|
||||
},
|
||||
{
|
||||
condition: (ctx): boolean => ctx.isSelectedOnFilter && !ctx.isNotInOperator,
|
||||
config: { orderIndex: 0, badge: null, checkedState: 'checked' },
|
||||
},
|
||||
{
|
||||
condition: (ctx): boolean =>
|
||||
ctx.hasExistingQuery && !ctx.hasFilterForThisKey && ctx.isInRelatedValues,
|
||||
config: {
|
||||
orderIndex: 1,
|
||||
badge: { key: 'related', label: 'Related', color: 'robin' },
|
||||
checkedState: 'indeterminate',
|
||||
},
|
||||
},
|
||||
{
|
||||
condition: (ctx): boolean =>
|
||||
ctx.hasExistingQuery && ctx.hasFilterForThisKey && ctx.isInRelatedValues,
|
||||
config: {
|
||||
orderIndex: 1,
|
||||
badge: { key: 'related', label: 'Related', color: 'robin' },
|
||||
checkedState: 'indeterminate',
|
||||
},
|
||||
},
|
||||
{
|
||||
condition: (ctx): boolean => ctx.hasExistingQuery,
|
||||
config: {
|
||||
orderIndex: 2,
|
||||
badge: null,
|
||||
checkedState: 'unchecked',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Fallback when no rule matches
|
||||
const DEFAULT_CONFIG: ItemConfig = {
|
||||
orderIndex: 0,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
};
|
||||
|
||||
export function deriveItemConfig(ctx: ItemContext): ItemConfig {
|
||||
for (const rule of ITEM_RULES) {
|
||||
if (rule.condition(ctx)) {
|
||||
return rule.config;
|
||||
}
|
||||
}
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
export function deriveItems(
|
||||
values: string[],
|
||||
relatedSet: Set<string>,
|
||||
selectedOnFilterSet: Set<string>,
|
||||
ctx: Omit<ItemContext, 'isSelectedOnFilter' | 'isInRelatedValues'>,
|
||||
): DerivedItem[] {
|
||||
return values.map((value) => {
|
||||
const itemCtx: ItemContext = {
|
||||
...ctx,
|
||||
isSelectedOnFilter: selectedOnFilterSet.has(value),
|
||||
isInRelatedValues: relatedSet.has(value),
|
||||
};
|
||||
const config = deriveItemConfig(itemCtx);
|
||||
return { value, ...config };
|
||||
});
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { QuickFilterCheckboxUseFieldApis } from 'components/QuickFilters/types';
|
||||
|
||||
interface UseExistingQueryParams {
|
||||
useFieldApis: QuickFilterCheckboxUseFieldApis;
|
||||
activeQueryIndex: number;
|
||||
}
|
||||
|
||||
interface UseExistingQueryResult {
|
||||
existingQuery: string | undefined;
|
||||
hasExistingQuery: boolean;
|
||||
}
|
||||
|
||||
export function useExistingQuery({
|
||||
useFieldApis,
|
||||
activeQueryIndex,
|
||||
}: UseExistingQueryParams): UseExistingQueryResult {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const existingQuery = useMemo(() => {
|
||||
if (useFieldApis.existingQuery === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (useFieldApis.existingQuery) {
|
||||
return useFieldApis.existingQuery;
|
||||
}
|
||||
|
||||
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
|
||||
|
||||
// Prefer V5 filter.expression
|
||||
if (queryData?.filter?.expression) {
|
||||
return queryData.filter.expression;
|
||||
}
|
||||
|
||||
// Fall back to V3 filters.items
|
||||
if (queryData?.filters?.items?.length) {
|
||||
return convertFiltersToExpression(queryData.filters).expression;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [
|
||||
useFieldApis.existingQuery,
|
||||
currentQuery.builder.queryData,
|
||||
activeQueryIndex,
|
||||
]);
|
||||
|
||||
// Check if ANY filters exist in query (V3 items or V5 expression)
|
||||
// This is separate from existingQuery because existingQuery can be explicitly
|
||||
// disabled (null) while filters still exist in the query for UI purposes
|
||||
const hasExistingQuery = useMemo(() => {
|
||||
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
|
||||
const hasV3Items = (queryData?.filters?.items?.length ?? 0) > 0;
|
||||
const hasV5Expression = !!queryData?.filter?.expression;
|
||||
return hasV3Items || hasV5Expression || !!existingQuery;
|
||||
}, [currentQuery.builder.queryData, activeQueryIndex, existingQuery]);
|
||||
|
||||
return { existingQuery, hasExistingQuery };
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetFieldsValues } from 'api/generated/services/fields';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
|
||||
interface UseFieldValuesProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
searchText: string;
|
||||
existingQuery?: string;
|
||||
metricNamespace?: string;
|
||||
startUnixMilli?: number;
|
||||
endUnixMilli?: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface UseFieldValuesReturn {
|
||||
relatedValues: string[];
|
||||
allValues: string[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
const DATA_SOURCE_TO_SIGNAL: Record<DataSource, TelemetrytypesSignalDTO> = {
|
||||
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
|
||||
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
|
||||
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
|
||||
};
|
||||
|
||||
export function useFieldValues({
|
||||
filter,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
enabled,
|
||||
}: UseFieldValuesProps): UseFieldValuesReturn {
|
||||
const { data, isLoading, isFetching } = useGetFieldsValues(
|
||||
{
|
||||
signal: filter.dataSource
|
||||
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
|
||||
: undefined,
|
||||
name: filter.attributeKey.key,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
startUnixMilli,
|
||||
// This field does not affect the backend but I wanted to keep it here
|
||||
// in case we add the support in the future
|
||||
endUnixMilli,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled,
|
||||
cacheTime: FIELD_API_CACHE_TIME,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const relatedValues: string[] = useMemo(() => {
|
||||
const values = data?.data?.values;
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
values.relatedValues?.filter(
|
||||
(value): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
) || []
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const allValues: string[] = useMemo(() => {
|
||||
const values = data?.data?.values;
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stringValues =
|
||||
values.stringValues?.filter(
|
||||
(value): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
) || [];
|
||||
const numberValues =
|
||||
values.numberValues
|
||||
?.filter((value): value is number => value !== null && value !== undefined)
|
||||
.map((value) => value.toString()) || [];
|
||||
|
||||
return [...stringValues, ...numberValues];
|
||||
}, [data]);
|
||||
|
||||
return { relatedValues, allValues, isLoading, isFetching };
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useSectionedValues } from './useSectionedValues';
|
||||
|
||||
describe('useSectionedValues', () => {
|
||||
const baseInput = {
|
||||
relatedValues: ['val1', 'val2'],
|
||||
allValues: ['val1', 'val2', 'val3'],
|
||||
currentFilterState: {},
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
searchText: '',
|
||||
visibleItemsCount: 10,
|
||||
};
|
||||
|
||||
it('no query at all → all items orderIndex 0, no badges', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: false,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.sectionedItems).toHaveLength(3);
|
||||
result.current.sectionedItems.forEach((item) => {
|
||||
expect(item.orderIndex).toBe(0);
|
||||
expect(item.badge).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('has query, no filter for key → related values get related badge', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const relatedItems = result.current.sectionedItems.filter(
|
||||
(item) => item.value === 'val1' || item.value === 'val2',
|
||||
);
|
||||
const otherItems = result.current.sectionedItems.filter(
|
||||
(item) => item.value === 'val3',
|
||||
);
|
||||
|
||||
// Related values should have related badge
|
||||
relatedItems.forEach((item) => {
|
||||
expect(item.orderIndex).toBe(1);
|
||||
expect(item.badge?.key).toBe('related');
|
||||
});
|
||||
|
||||
// Other values should have no badge
|
||||
otherItems.forEach((item) => {
|
||||
expect(item.orderIndex).toBe(2);
|
||||
expect(item.badge).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('has query + filter for key, selected value → selected at top, no badge', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { val1: true, val2: false, val3: false },
|
||||
}),
|
||||
);
|
||||
|
||||
const selectedItem = result.current.sectionedItems.find(
|
||||
(item) => item.value === 'val1',
|
||||
);
|
||||
|
||||
expect(selectedItem?.orderIndex).toBe(0);
|
||||
expect(selectedItem?.badge).toBeNull();
|
||||
});
|
||||
|
||||
it('has query + filter for key, NOT IN operator → not_in values get badge', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
isNotInOperator: true,
|
||||
currentFilterState: { val1: false, val2: true, val3: true },
|
||||
}),
|
||||
);
|
||||
|
||||
// val1 is unchecked + NOT IN = excluded
|
||||
const excludedItem = result.current.sectionedItems.find(
|
||||
(item) => item.value === 'val1',
|
||||
);
|
||||
|
||||
expect(excludedItem?.orderIndex).toBe(0);
|
||||
expect(excludedItem?.badge?.key).toBe('not_in');
|
||||
});
|
||||
|
||||
it('items with same orderIndex sorted alphabetically', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['zebra', 'apple', 'mango'],
|
||||
allValues: ['zebra', 'apple', 'mango'],
|
||||
hasExistingQuery: false,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// All items have orderIndex 0, should be sorted alphabetically
|
||||
const values = result.current.sectionedItems.map((item) => item.value);
|
||||
expect(values).toStrictEqual(['apple', 'mango', 'zebra']);
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { BadgeConfig, deriveItems } from './itemRules';
|
||||
import { CheckedState } from '../../../types';
|
||||
|
||||
interface SectionedValuesInput {
|
||||
relatedValues: string[];
|
||||
allValues: string[];
|
||||
currentFilterState: Record<string, boolean>;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
isNotInOperator: boolean;
|
||||
hasExistingQuery: boolean;
|
||||
searchText: string;
|
||||
visibleItemsCount: number;
|
||||
}
|
||||
|
||||
export interface SectionedItem {
|
||||
value: string;
|
||||
orderIndex: number;
|
||||
badge: BadgeConfig | null;
|
||||
checkedState: CheckedState;
|
||||
}
|
||||
|
||||
interface SectionedValuesOutput {
|
||||
sectionedItems: SectionedItem[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export function useSectionedValues({
|
||||
relatedValues,
|
||||
allValues,
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
searchText,
|
||||
visibleItemsCount,
|
||||
}: SectionedValuesInput): SectionedValuesOutput {
|
||||
const items = useMemo(() => {
|
||||
const allUniqueValues = Array.from(new Set([...relatedValues, ...allValues]));
|
||||
|
||||
// When searching, only use allValues (API filtered)
|
||||
const valuesToProcess = searchText ? allValues : allUniqueValues;
|
||||
|
||||
// Build selected set based on operator
|
||||
// Only populate when filter exists for this key
|
||||
const selectedSet = new Set<string>();
|
||||
if (isSomeFilterPresentForCurrentAttribute) {
|
||||
for (const [val, isChecked] of Object.entries(currentFilterState)) {
|
||||
if (isNotInOperator) {
|
||||
// NOT IN: unchecked = explicitly excluded
|
||||
if (!isChecked) {
|
||||
selectedSet.add(val);
|
||||
}
|
||||
} else {
|
||||
// IN: checked = explicitly selected
|
||||
if (isChecked) {
|
||||
selectedSet.add(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always include selected values at top - they may not be in API response
|
||||
// (e.g., NOT IN filter excludes them from results)
|
||||
const finalValues = [
|
||||
...new Set([...Array.from(selectedSet), ...valuesToProcess]),
|
||||
];
|
||||
|
||||
const relatedSet = new Set(relatedValues);
|
||||
|
||||
const derived = deriveItems(finalValues, relatedSet, selectedSet, {
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
hasFilterForThisKey: isSomeFilterPresentForCurrentAttribute,
|
||||
});
|
||||
|
||||
return derived.sort(
|
||||
(a, b) => a.orderIndex - b.orderIndex || a.value.localeCompare(b.value),
|
||||
);
|
||||
}, [
|
||||
relatedValues,
|
||||
allValues,
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
searchText,
|
||||
]);
|
||||
|
||||
const sectionedItems = useMemo(
|
||||
() => items.slice(0, visibleItemsCount),
|
||||
[items, visibleItemsCount],
|
||||
);
|
||||
|
||||
return { sectionedItems, totalCount: items.length };
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
|
||||
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
|
||||
import Duration from './FilterRenderers/Duration/Duration';
|
||||
import Slider from './FilterRenderers/Slider/Slider';
|
||||
import useFilterConfig from './hooks/useFilterConfig';
|
||||
@@ -52,7 +51,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
signal,
|
||||
showFilterCollapse = true,
|
||||
showQueryName = true,
|
||||
useFieldApis,
|
||||
} = props;
|
||||
const { user } = useAppContext();
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
@@ -299,45 +297,21 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
{filterConfig.map((filter) => {
|
||||
switch (filter.type) {
|
||||
case FiltersType.CHECKBOX:
|
||||
return useFieldApis ? (
|
||||
<CheckboxV2
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
useFieldApis={useFieldApis}
|
||||
/>
|
||||
) : (
|
||||
return (
|
||||
<Checkbox
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
);
|
||||
case FiltersType.DURATION:
|
||||
return (
|
||||
<Duration
|
||||
key={filter.attributeKey.key}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
);
|
||||
return <Duration filter={filter} onFilterChange={onFilterChange} />;
|
||||
case FiltersType.SLIDER:
|
||||
return <Slider key={filter.attributeKey.key} />;
|
||||
return <Slider />;
|
||||
// eslint-disable-next-line sonarjs/no-duplicated-branches
|
||||
default:
|
||||
return useFieldApis ? (
|
||||
<CheckboxV2
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
useFieldApis={useFieldApis}
|
||||
/>
|
||||
) : (
|
||||
return (
|
||||
<Checkbox
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
@@ -407,5 +381,4 @@ QuickFilters.defaultProps = {
|
||||
config: [],
|
||||
showFilterCollapse: true,
|
||||
showQueryName: true,
|
||||
useFieldApis: undefined,
|
||||
};
|
||||
|
||||
@@ -26,11 +26,6 @@ export enum SignalType {
|
||||
METER_EXPLORER = 'meter',
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing export from signozhq/ui/checkbox, TODO(H4ad): Add and remove this type definition
|
||||
*/
|
||||
export type CheckedState = 'checked' | 'unchecked' | 'indeterminate';
|
||||
|
||||
export interface IQuickFiltersConfig {
|
||||
type: FiltersType;
|
||||
title: string;
|
||||
@@ -51,7 +46,6 @@ export interface IQuickFiltersProps {
|
||||
className?: string;
|
||||
showFilterCollapse?: boolean;
|
||||
showQueryName?: boolean;
|
||||
useFieldApis?: QuickFilterCheckboxUseFieldApis;
|
||||
}
|
||||
|
||||
export enum QuickFiltersSource {
|
||||
@@ -62,19 +56,3 @@ export enum QuickFiltersSource {
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in: fetch values from the /v1/fields/values API instead of /v3/autocomplete/attribute_values
|
||||
*/
|
||||
export type QuickFilterCheckboxUseFieldApis = {
|
||||
startUnixMilli: number;
|
||||
endUnixMilli: number;
|
||||
/**
|
||||
* If you didn't specify a string, we automatically try to extract this from the currentQuery,
|
||||
* from the filter.expression or filter.items.
|
||||
*
|
||||
* Use null to ignore/disable this behavior.
|
||||
*/
|
||||
existingQuery?: string | null;
|
||||
metricNamespace?: string;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const DASHBOARD_CACHE_TIME = 30_000;
|
||||
// keep it low or zero, otherwise, when enabled auto-refresh, this causes OOM due to accumulated queries in cache
|
||||
export const DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED = 0;
|
||||
|
||||
export const FIELD_API_CACHE_TIME = 60_000;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
selectIsDashboardLocked,
|
||||
useDashboardStore,
|
||||
} from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import { getChartManagerColumns } from './getChartMangerColumns';
|
||||
import { ExtendedChartDataset, getDefaultTableDataSet } from './utils';
|
||||
@@ -44,6 +44,7 @@ export default function ChartManager({
|
||||
decimalPrecision = PrecisionOptionsEnum.TWO,
|
||||
onCancel,
|
||||
}: ChartManagerProps): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
const { legendItemsMap } = useLegendsSync({
|
||||
config,
|
||||
subscribeToFocusChange: false,
|
||||
@@ -135,9 +136,11 @@ export default function ChartManager({
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
toast.success('The updated graphs & legends are saved');
|
||||
notifications.success({
|
||||
message: 'The updated graphs & legends are saved',
|
||||
});
|
||||
onCancel?.();
|
||||
}, [syncSeriesVisibilityToLocalStorage, onCancel]);
|
||||
}, [syncSeriesVisibilityToLocalStorage, notifications, onCancel]);
|
||||
|
||||
return (
|
||||
<div className="chart-manager-container">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { render, screen } from 'tests/test-utils';
|
||||
import ChartManager from '../ChartManager';
|
||||
|
||||
const mockSyncSeriesVisibilityToLocalStorage = jest.fn();
|
||||
const mockToastSuccess = jest.fn();
|
||||
const mockNotificationsSuccess = jest.fn();
|
||||
|
||||
jest.mock('lib/uPlotV2/context/PlotContext', () => ({
|
||||
usePlotContext: (): {
|
||||
@@ -46,11 +46,12 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
}): boolean => s.dashboardData?.locked ?? false,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
|
||||
},
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): { notifications: { success: jest.Mock } } => ({
|
||||
notifications: {
|
||||
success: mockNotificationsSuccess,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('components/ResizeTable', () => {
|
||||
@@ -159,7 +160,7 @@ describe('ChartManager', () => {
|
||||
expect(screen.queryByTestId('row-2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls syncSeriesVisibilityToLocalStorage, toast.success, and onCancel when Save is clicked', async () => {
|
||||
it('calls syncSeriesVisibilityToLocalStorage, notifications.success, and onCancel when Save is clicked', async () => {
|
||||
render(
|
||||
<ChartManager
|
||||
config={createMockConfig() as UPlotConfigBuilder}
|
||||
@@ -171,9 +172,9 @@ describe('ChartManager', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
expect(mockSyncSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
'The updated graphs & legends are saved',
|
||||
);
|
||||
expect(mockNotificationsSuccess).toHaveBeenCalledWith({
|
||||
message: 'The updated graphs & legends are saved',
|
||||
});
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,14 +5,6 @@
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
// Stacked children (the FullView / standalone graph-manager) sit below the chart
|
||||
// in the same container; size the chart region to its content so they aren't
|
||||
// pushed out. Only this case opts out of filling the height — the dashboard grid,
|
||||
// alert preview, and other charts keep 100% so they fill their container.
|
||||
&--with-layout-children {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&--legend-right {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ export default function ChartLayout({
|
||||
className={cx('chart-layout', {
|
||||
'chart-layout--legend-right':
|
||||
legendConfig.position === LegendPosition.RIGHT,
|
||||
'chart-layout--with-layout-children': !!layoutChildren,
|
||||
})}
|
||||
>
|
||||
<div className="chart-layout__content">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -9,10 +9,7 @@ import { FeatureKeys } from 'constants/features';
|
||||
import K8sBaseDetails from 'container/InfraMonitoringK8s/Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
|
||||
import {
|
||||
InfraMonitoringEntity,
|
||||
METRIC_NAMESPACE_BY_ENTITY,
|
||||
} from 'container/InfraMonitoringK8s/constants';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
|
||||
import {
|
||||
useInfraMonitoringFiltersK8s,
|
||||
useInfraMonitoringPageListing,
|
||||
@@ -20,8 +17,6 @@ import {
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
@@ -62,17 +57,6 @@ function Hosts(): JSX.Element {
|
||||
entityVersion: '',
|
||||
});
|
||||
|
||||
const selectedTime = useGlobalTimeStore((state) => state.selectedTime);
|
||||
const getMinMaxTime = useGlobalTimeStore((state) => state.getMinMaxTime);
|
||||
const { startUnixMilli, endUnixMilli } = useMemo(() => {
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
return {
|
||||
startUnixMilli: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
endUnixMilli: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTime, getMinMaxTime]);
|
||||
|
||||
// Track previous urlFilters to only sync when the value actually changes
|
||||
// (not when handleChangeQueryData changes due to query updates)
|
||||
const prevUrlFiltersRef = useRef<string | null>(null);
|
||||
@@ -171,12 +155,6 @@ function Hosts(): JSX.Element {
|
||||
config={getHostsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleQuickFiltersChange}
|
||||
useFieldApis={{
|
||||
metricNamespace:
|
||||
METRIC_NAMESPACE_BY_ENTITY[InfraMonitoringEntity.HOSTS],
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -66,6 +66,20 @@
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0 !important;
|
||||
padding-block: 0 !important;
|
||||
|
||||
:global(.quick-filters .checkbox-filter) {
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.quick-filters) {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@@ -97,6 +111,22 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabelIcon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.listContainer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -130,106 +160,3 @@
|
||||
.listContainerFiltersVisible {
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.categorySelectorSection {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
margin-bottom: var(--spacing-4);
|
||||
|
||||
&[data-type='filter'] {
|
||||
padding: 0 var(--spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l3-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sectionLine {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.categoryCard {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--spacing-3);
|
||||
padding: var(--spacing-2);
|
||||
}
|
||||
|
||||
.categoryList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.categoryItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border: none;
|
||||
border-radius: var(--spacing-2);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
--typography-color: var(--l1-foreground);
|
||||
|
||||
&:hover:not(.categoryItemSelected) {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
&.categoryItemSelected {
|
||||
background: var(--accent-primary);
|
||||
color: var(--l1-background);
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-primary-hover, var(--accent-primary));
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quickFiltersSection {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Button, CollapseProps } from 'antd';
|
||||
import { Collapse, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import {
|
||||
QuickFilterCheckboxUseFieldApis,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
@@ -25,8 +23,6 @@ import {
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { FeatureKeys } from '../../constants/features';
|
||||
@@ -42,9 +38,7 @@ import {
|
||||
GetPodsQuickFiltersConfig,
|
||||
GetStatefulsetsQuickFiltersConfig,
|
||||
GetVolumesQuickFiltersConfig,
|
||||
InfraMonitoringEntity,
|
||||
K8sCategories,
|
||||
METRIC_NAMESPACE_BY_ENTITY,
|
||||
} from './constants';
|
||||
import K8sDaemonSetsList from './DaemonSets/K8sDaemonSetsList';
|
||||
import K8sDeploymentsList from './Deployments/K8sDeploymentsList';
|
||||
@@ -104,26 +98,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
|
||||
?.active || false;
|
||||
|
||||
const selectedTime = useGlobalTimeStore((state) => state.selectedTime);
|
||||
const getMinMaxTime = useGlobalTimeStore((state) => state.getMinMaxTime);
|
||||
const { startUnixMilli, endUnixMilli } = useMemo(() => {
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
return {
|
||||
startUnixMilli: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
endUnixMilli: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTime, getMinMaxTime]);
|
||||
|
||||
const getUseFieldApis = useCallback(
|
||||
(entity: InfraMonitoringEntity): QuickFilterCheckboxUseFieldApis => ({
|
||||
metricNamespace: METRIC_NAMESPACE_BY_ENTITY[entity],
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
}),
|
||||
[startUnixMilli, endUnixMilli],
|
||||
);
|
||||
|
||||
const handleFilterChange = (query: Query): void => {
|
||||
// update the current query with the new filters
|
||||
// in infra monitoring k8s, we are using only one query, hence updating the 0th index of queryData
|
||||
@@ -139,79 +113,192 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
const categories = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: K8sCategories.PODS,
|
||||
label: 'Pods',
|
||||
icon: <Container size={14} />,
|
||||
config: GetPodsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NODES,
|
||||
label: 'Nodes',
|
||||
icon: <Workflow size={14} />,
|
||||
config: GetNodesQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NAMESPACES,
|
||||
label: 'Namespaces',
|
||||
icon: <FilePenLine size={14} />,
|
||||
config: GetNamespaceQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.CLUSTERS,
|
||||
label: 'Clusters',
|
||||
icon: <Boxes size={14} />,
|
||||
config: GetClustersQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DEPLOYMENTS,
|
||||
label: 'Deployments',
|
||||
icon: <Computer size={14} />,
|
||||
config: GetDeploymentsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.JOBS,
|
||||
label: 'Jobs',
|
||||
icon: <Bolt size={14} />,
|
||||
config: GetJobsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DAEMONSETS,
|
||||
label: 'DaemonSets',
|
||||
icon: <Group size={14} />,
|
||||
config: GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.STATEFULSETS,
|
||||
label: 'StatefulSets',
|
||||
icon: <ArrowUpDown size={14} />,
|
||||
config: GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.VOLUMES,
|
||||
label: 'Volumes',
|
||||
icon: <HardDrive size={14} />,
|
||||
config: GetVolumesQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
],
|
||||
[dotMetricsEnabled],
|
||||
const renderCategoryLabel = (
|
||||
icon: JSX.Element,
|
||||
label: string,
|
||||
): JSX.Element => (
|
||||
<div className={styles.quickFiltersCategoryLabel}>
|
||||
<div className={styles.quickFiltersCategoryLabelContainer}>
|
||||
{icon}
|
||||
<Typography.Text>{label}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const selectedCategoryConfig = useMemo(
|
||||
() => categories.find((cat) => cat.key === selectedCategory)?.config,
|
||||
[categories, selectedCategory],
|
||||
);
|
||||
const items: CollapseProps['items'] = [
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Container size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Pods',
|
||||
),
|
||||
key: K8sCategories.PODS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetPodsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Workflow size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Nodes',
|
||||
),
|
||||
key: K8sCategories.NODES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetNodesQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<FilePenLine size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Namespaces',
|
||||
),
|
||||
key: K8sCategories.NAMESPACES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetNamespaceQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Boxes size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Clusters',
|
||||
),
|
||||
key: K8sCategories.CLUSTERS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetClustersQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Computer size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Deployments',
|
||||
),
|
||||
key: K8sCategories.DEPLOYMENTS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetDeploymentsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Bolt size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Jobs',
|
||||
),
|
||||
key: K8sCategories.JOBS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetJobsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Group size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'DaemonSets',
|
||||
),
|
||||
key: K8sCategories.DAEMONSETS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<ArrowUpDown size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'StatefulSets',
|
||||
),
|
||||
key: K8sCategories.STATEFULSETS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<HardDrive size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Volumes',
|
||||
),
|
||||
key: K8sCategories.VOLUMES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetVolumesQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
// TODO: Enable once we have implemented containers.
|
||||
// {
|
||||
// label: (
|
||||
// <div className="k8s-quick-filters-category-label">
|
||||
// <div className="k8s-quick-filters-category-label-container">
|
||||
// <PackageOpen
|
||||
// size={14}
|
||||
// className="k8s-quick-filters-category-label-icon"
|
||||
// />
|
||||
// <Typography.Text>Containers</Typography.Text>
|
||||
// </div>
|
||||
// </div>
|
||||
// ),
|
||||
// key: K8sCategories.CONTAINERS,
|
||||
// showArrow: false,
|
||||
// children: (
|
||||
// <QuickFilters
|
||||
// source={QuickFiltersSource.INFRA_MONITORING}
|
||||
// config={ContainersQuickFiltersConfig}
|
||||
// handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
// onFilterChange={handleFilterChange}
|
||||
// />
|
||||
// ),
|
||||
// },
|
||||
];
|
||||
|
||||
const selectedCategoryUseFieldApis = useMemo(
|
||||
() => getUseFieldApis(selectedCategory as InfraMonitoringEntity),
|
||||
[getUseFieldApis, selectedCategory],
|
||||
);
|
||||
|
||||
const handleCategorySelect = (key: string): void => {
|
||||
if (key !== selectedCategory) {
|
||||
setSelectedCategory(key);
|
||||
const handleCategoryChange = (key: string | string[]): void => {
|
||||
if (Array.isArray(key) && key.length > 0) {
|
||||
setSelectedCategory(key[0] as string);
|
||||
// Reset filters
|
||||
setUrlFilters(null);
|
||||
setOrderBy(null);
|
||||
@@ -245,59 +332,26 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.categorySelectorSection}>
|
||||
<div className={styles.sectionHeader} data-type="resource">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
Viewing · Resource
|
||||
</Typography.Text>
|
||||
<div className={styles.sectionLine} />
|
||||
<Tooltip title="Collapse Filters">
|
||||
<ArrowUpToLine
|
||||
style={{ transform: 'rotate(270deg)' }}
|
||||
onClick={handleFilterVisibilityChange}
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.categoryCard}>
|
||||
<div className={styles.categoryList}>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.key}
|
||||
type="button"
|
||||
className={`${styles.categoryItem} ${
|
||||
selectedCategory === category.key
|
||||
? styles.categoryItemSelected
|
||||
: ''
|
||||
}`}
|
||||
onClick={(): void => handleCategorySelect(category.key)}
|
||||
data-testid={`category-${category.key}`}
|
||||
>
|
||||
{category.icon}
|
||||
<Typography.Text>{category.label}</Typography.Text>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
|
||||
<div className={styles.quickFiltersSection}>
|
||||
<div className={styles.sectionHeader} data-type="filter">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
Filter by
|
||||
</Typography.Text>
|
||||
<div className={styles.sectionLine} />
|
||||
</div>
|
||||
{selectedCategoryConfig && (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={selectedCategoryConfig}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
useFieldApis={selectedCategoryUseFieldApis}
|
||||
<Tooltip title="Collapse Filters">
|
||||
<ArrowUpToLine
|
||||
style={{ transform: 'rotate(270deg)' }}
|
||||
onClick={handleFilterVisibilityChange}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Collapse
|
||||
onChange={handleCategoryChange}
|
||||
items={items}
|
||||
defaultActiveKey={[selectedCategory]}
|
||||
activeKey={[selectedCategory]}
|
||||
accordion
|
||||
bordered={false}
|
||||
ghost
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -21,21 +21,6 @@ export enum InfraMonitoringEntity {
|
||||
VOLUMES = 'volumes',
|
||||
}
|
||||
|
||||
export const METRIC_NAMESPACE_BY_ENTITY: Record<InfraMonitoringEntity, string> =
|
||||
{
|
||||
[InfraMonitoringEntity.HOSTS]: 'system.',
|
||||
[InfraMonitoringEntity.PODS]: 'k8s.pod.',
|
||||
[InfraMonitoringEntity.NODES]: 'k8s.node.',
|
||||
[InfraMonitoringEntity.NAMESPACES]: 'k8s.pod.',
|
||||
[InfraMonitoringEntity.CLUSTERS]: 'k8s.node.',
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: 'k8s.',
|
||||
[InfraMonitoringEntity.STATEFULSETS]: 'k8s.',
|
||||
[InfraMonitoringEntity.DAEMONSETS]: 'k8s.',
|
||||
[InfraMonitoringEntity.CONTAINERS]: 'k8s.pod.',
|
||||
[InfraMonitoringEntity.JOBS]: 'k8s.',
|
||||
[InfraMonitoringEntity.VOLUMES]: 'k8s.volume.',
|
||||
};
|
||||
|
||||
export enum VIEWS {
|
||||
METRICS = 'metrics',
|
||||
LOGS = 'logs',
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
|
||||
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import styles from './PanelTypeSwitcher.module.scss';
|
||||
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
panelKind: PanelKind;
|
||||
/** Active query type — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse disable it). */
|
||||
queryType: EQueryType;
|
||||
/** Active query type — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse disable it). Defaults to Query Builder. */
|
||||
queryType?: EQueryType;
|
||||
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
onChange: (kind: PanelKind) => void;
|
||||
@@ -30,7 +31,22 @@ function PanelTypeSwitcher({
|
||||
signal,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
const items = usePanelTypeSelectItems({ queryType, signal });
|
||||
const items = PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind: panelKind,
|
||||
queryType: queryType ?? EQueryType.QUERY_BUILDER,
|
||||
signal,
|
||||
label,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
|
||||
@@ -49,11 +49,7 @@ describe('PanelTypeSwitcher', () => {
|
||||
it('fires onChange with the chosen plugin kind', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
<PanelTypeSwitcher panelKind="signoz/TimeSeriesPanel" onChange={onChange} />,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
@@ -66,7 +62,6 @@ describe('PanelTypeSwitcher', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
@@ -82,7 +77,6 @@ describe('PanelTypeSwitcher', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
|
||||
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
|
||||
interface UsePanelTypeSelectItemsArgs {
|
||||
/** Active query type — a kind that can't be authored in it is disabled. */
|
||||
queryType: EQueryType;
|
||||
/** Current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
|
||||
* tooltip) when the active query type or signal is incompatible — resolved through
|
||||
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
|
||||
* modal's header so the two selectors apply the same rule and can't drift.
|
||||
*/
|
||||
export function usePanelTypeSelectItems({
|
||||
queryType,
|
||||
signal,
|
||||
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
|
||||
return useMemo(
|
||||
() =>
|
||||
PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind: panelKind,
|
||||
queryType,
|
||||
signal,
|
||||
label,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
};
|
||||
}),
|
||||
[queryType, signal],
|
||||
);
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { UrlParam, VariableItem } from './types';
|
||||
import {
|
||||
getUrlParams,
|
||||
insertVariableAtCursor,
|
||||
isValidContextLinkUrl,
|
||||
updateUrlWithParams,
|
||||
} from './utils';
|
||||
import VariablesPopover from './VariablesPopover';
|
||||
|
||||
import styles from './ContextLinksSection.module.scss';
|
||||
|
||||
interface ContextLinkDialogProps {
|
||||
open: boolean;
|
||||
/** The link being edited, or null when adding a new one. */
|
||||
initialLink: DashboardLinkDTO | null;
|
||||
variables: VariableItem[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (link: DashboardLinkDTO) => void;
|
||||
}
|
||||
|
||||
const URL_ERROR = 'URL must start with http(s)://, /, or {{variable}}/';
|
||||
|
||||
const cursorOf = (e: { target: EventTarget }): number =>
|
||||
(e.target as HTMLInputElement).selectionStart ?? 0;
|
||||
|
||||
/**
|
||||
* Modal editor for a single context link (V1 parity): label + URL with `{{variable}}`
|
||||
* autocomplete + validation, a key/value URL-parameters editor, and an "open in new tab"
|
||||
* toggle. The URL is the source of truth; parameter rows are its query string projected
|
||||
* out (blank-key rows live only in local state until they get a key). Save is gated on a
|
||||
* non-empty, well-formed URL. `renderVariables` is set so consumers interpolate the URL.
|
||||
*/
|
||||
function ContextLinkDialog({
|
||||
open,
|
||||
initialLink,
|
||||
variables,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: ContextLinkDialogProps): JSX.Element {
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrlState] = useState('');
|
||||
const [targetBlank, setTargetBlank] = useState(true);
|
||||
const [params, setParams] = useState<UrlParam[]>([]);
|
||||
|
||||
// Seed the draft each time the dialog opens for a (possibly different) link.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initialLink?.name ?? '');
|
||||
setUrlState(initialLink?.url ?? '');
|
||||
setTargetBlank(initialLink?.targetBlank ?? true);
|
||||
setParams(getUrlParams(initialLink?.url ?? ''));
|
||||
}
|
||||
}, [open, initialLink]);
|
||||
|
||||
const setUrl = (next: string): void => {
|
||||
setUrlState(next);
|
||||
setParams(getUrlParams(next));
|
||||
};
|
||||
|
||||
const applyParams = (next: UrlParam[]): void => {
|
||||
setParams(next);
|
||||
setUrlState(updateUrlWithParams(url, next));
|
||||
};
|
||||
|
||||
const patchParam = (index: number, patch: Partial<UrlParam>): void =>
|
||||
applyParams(params.map((p, i) => (i === index ? { ...p, ...patch } : p)));
|
||||
|
||||
const addParam = (): void => {
|
||||
const last = params[params.length - 1];
|
||||
if (!last || last.key.trim() || last.value.trim()) {
|
||||
setParams([...params, { key: '', value: '' }]);
|
||||
}
|
||||
};
|
||||
|
||||
const urlInvalid = !isValidContextLinkUrl(url);
|
||||
const canSave = !!url.trim() && !urlInvalid;
|
||||
|
||||
const handleSave = (): void => {
|
||||
if (canSave) {
|
||||
onSave({ name, url, targetBlank, renderVariables: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={initialLink ? 'Edit context link' : 'Add a context link'}
|
||||
width="wide"
|
||||
testId="context-link-dialog"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
data-testid="context-link-cancel"
|
||||
onClick={(): void => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={!canSave}
|
||||
data-testid="context-link-save"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formField}>
|
||||
<Typography.Text className={styles.formLabel}>Label</Typography.Text>
|
||||
<Input
|
||||
data-testid="context-link-label"
|
||||
placeholder="View trace details: {{_traceId}}"
|
||||
value={name}
|
||||
onChange={(e): void => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<Typography.Text className={styles.formLabel}>
|
||||
URL <span className={styles.required}>*</span>
|
||||
</Typography.Text>
|
||||
<VariablesPopover
|
||||
variables={variables}
|
||||
onVariableSelect={(token, cursor): void =>
|
||||
setUrl(insertVariableAtCursor(url, token, cursor))
|
||||
}
|
||||
>
|
||||
{({ setIsOpen, setCursorPosition }): JSX.Element => (
|
||||
<Input
|
||||
data-testid="context-link-url"
|
||||
placeholder="https://… or /path?var={{variable}}"
|
||||
value={url}
|
||||
status={urlInvalid ? 'error' : undefined}
|
||||
autoComplete="off"
|
||||
onChange={(e): void => {
|
||||
setCursorPosition(e.target.selectionStart ?? 0);
|
||||
setUrl(e.target.value);
|
||||
}}
|
||||
onFocus={(): void => setIsOpen(true)}
|
||||
onClick={(e): void => setCursorPosition(cursorOf(e))}
|
||||
onKeyUp={(e): void => setCursorPosition(cursorOf(e))}
|
||||
/>
|
||||
)}
|
||||
</VariablesPopover>
|
||||
{urlInvalid && (
|
||||
<Typography.Text
|
||||
className={styles.urlError}
|
||||
data-testid="context-link-url-error"
|
||||
>
|
||||
{URL_ERROR}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{params.length > 0 && (
|
||||
<div className={styles.formField}>
|
||||
<Typography.Text className={styles.formLabel}>
|
||||
URL parameters
|
||||
</Typography.Text>
|
||||
<div className={styles.params}>
|
||||
{params.map((param, index) => (
|
||||
<div
|
||||
className={styles.paramRow}
|
||||
// Parameter rows have no stable id; index is the row identity here.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
>
|
||||
<Input
|
||||
data-testid={`context-link-param-key-${index}`}
|
||||
placeholder="Key"
|
||||
value={param.key}
|
||||
onChange={(e): void => patchParam(index, { key: e.target.value })}
|
||||
/>
|
||||
<VariablesPopover
|
||||
variables={variables}
|
||||
onVariableSelect={(token, cursor): void =>
|
||||
patchParam(index, {
|
||||
value: insertVariableAtCursor(param.value, token, cursor),
|
||||
})
|
||||
}
|
||||
>
|
||||
{({ setIsOpen, setCursorPosition }): JSX.Element => (
|
||||
<Input
|
||||
data-testid={`context-link-param-value-${index}`}
|
||||
placeholder="Value"
|
||||
value={param.value}
|
||||
onChange={(e): void => {
|
||||
setCursorPosition(e.target.selectionStart ?? 0);
|
||||
patchParam(index, { value: e.target.value });
|
||||
}}
|
||||
onFocus={(): void => setIsOpen(true)}
|
||||
onClick={(e): void => setCursorPosition(cursorOf(e))}
|
||||
onKeyUp={(e): void => setCursorPosition(cursorOf(e))}
|
||||
/>
|
||||
)}
|
||||
</VariablesPopover>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
aria-label={`Remove parameter ${index + 1}`}
|
||||
data-testid={`context-link-param-remove-${index}`}
|
||||
onClick={(): void =>
|
||||
applyParams(params.filter((_, i) => i !== index))
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={12} />}
|
||||
data-testid="context-link-add-param"
|
||||
onClick={addParam}
|
||||
>
|
||||
Add URL parameter
|
||||
</Button>
|
||||
|
||||
<div className={styles.newTab}>
|
||||
<Switch
|
||||
testId="context-link-newtab"
|
||||
value={targetBlank}
|
||||
onChange={setTargetBlank}
|
||||
/>
|
||||
<Typography.Text className={styles.newTabLabel}>
|
||||
Open in new tab
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContextLinkDialog;
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Pencil, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './ContextLinksSection.module.scss';
|
||||
|
||||
interface ContextLinkListItemProps {
|
||||
link: DashboardLinkDTO;
|
||||
index: number;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
/** A saved context link in the section list: its label + URL, with edit / delete actions. */
|
||||
function ContextLinkListItem({
|
||||
link,
|
||||
index,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: ContextLinkListItemProps): JSX.Element {
|
||||
const label = link.name?.trim() || link.url || 'Untitled link';
|
||||
|
||||
return (
|
||||
<div className={styles.listItem} data-testid={`context-link-item-${index}`}>
|
||||
<div className={styles.listItemText}>
|
||||
<Typography.Text className={styles.listItemLabel}>{label}</Typography.Text>
|
||||
{!!link.url && (
|
||||
<Typography.Text className={styles.listItemUrl}>
|
||||
{link.url}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.listItemActions}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label={`Edit link ${index + 1}`}
|
||||
data-testid={`context-link-edit-${index}`}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
aria-label={`Remove link ${index + 1}`}
|
||||
data-testid={`context-link-remove-${index}`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContextLinkListItem;
|
||||
@@ -1,99 +1,32 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* --- Saved-link list item --- */
|
||||
.listItem {
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.listItemText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.listItemLabel,
|
||||
.listItemUrl {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.listItemLabel {
|
||||
font-size: 13px;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.listItemUrl {
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.listItemActions {
|
||||
.rowFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* --- Dialog form --- */
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.formField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
|
||||
.urlError {
|
||||
font-size: 11px;
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
|
||||
.params {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.paramRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.newTab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.newTabLabel {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
@@ -1,63 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ContextLinkDialog from './ContextLinkDialog';
|
||||
import ContextLinkListItem from './ContextLinkListItem';
|
||||
import { useContextLinkVariables } from './useContextLinkVariables';
|
||||
|
||||
import styles from './ContextLinksSection.module.scss';
|
||||
|
||||
/**
|
||||
* Edits the panel's context links (`spec.links`) as a list of saved links, each added or
|
||||
* edited through a modal ({@link ContextLinkDialog}) that carries V1's full authoring UX
|
||||
* (variable autocomplete, URL-parameters editor, validation).
|
||||
* Edits the panel's context links (`spec.links`): a list of label + URL rows with an
|
||||
* "open in new tab" toggle, plus add/remove. Atomic section — no per-kind sub-controls.
|
||||
* URLs may reference dashboard/query variables; that interpolation is resolved at render
|
||||
* time, so this editor just captures the raw strings.
|
||||
*/
|
||||
function ContextLinksSection({
|
||||
value,
|
||||
onChange,
|
||||
}: SectionEditorProps<SectionKind.ContextLinks>): JSX.Element {
|
||||
const links = value ?? [];
|
||||
const variables = useContextLinkVariables();
|
||||
|
||||
// `index === null` while adding a new link; a number while editing an existing one.
|
||||
const [dialog, setDialog] = useState<{ open: boolean; index: number | null }>({
|
||||
open: false,
|
||||
index: null,
|
||||
});
|
||||
const updateAt = (index: number, patch: Partial<DashboardLinkDTO>): void =>
|
||||
onChange(
|
||||
links.map((link, i) => (i === index ? { ...link, ...patch } : link)),
|
||||
);
|
||||
|
||||
const addLink = (): void =>
|
||||
onChange([...links, { name: '', url: '', targetBlank: true }]);
|
||||
|
||||
const removeAt = (index: number): void =>
|
||||
onChange(links.filter((_, i) => i !== index));
|
||||
|
||||
const handleSave = (link: DashboardLinkDTO): void => {
|
||||
onChange(
|
||||
dialog.index === null
|
||||
? [...links, link]
|
||||
: links.map((existing, i) => (i === dialog.index ? link : existing)),
|
||||
);
|
||||
setDialog((d) => ({ ...d, open: false }));
|
||||
};
|
||||
|
||||
const editingLink =
|
||||
dialog.index !== null ? (links[dialog.index] ?? null) : null;
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{links.map((link, index) => (
|
||||
<ContextLinkListItem
|
||||
// Links have no stable id on the wire; index is the row identity here.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
link={link}
|
||||
index={index}
|
||||
onEdit={(): void => setDialog({ open: true, index })}
|
||||
onRemove={(): void => removeAt(index)}
|
||||
/>
|
||||
// Links have no stable id on the wire; index is the row identity here.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div className={styles.row} key={index}>
|
||||
<Input
|
||||
data-testid={`context-link-label-${index}`}
|
||||
placeholder="Label"
|
||||
value={link.name ?? ''}
|
||||
onChange={(e): void => updateAt(index, { name: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
data-testid={`context-link-url-${index}`}
|
||||
placeholder="https://… or /path?var=$variable"
|
||||
value={link.url ?? ''}
|
||||
onChange={(e): void => updateAt(index, { url: e.target.value })}
|
||||
/>
|
||||
<div className={styles.rowFooter}>
|
||||
<div className={styles.newTab}>
|
||||
<Switch
|
||||
testId={`context-link-newtab-${index}`}
|
||||
value={link.targetBlank ?? false}
|
||||
onChange={(checked: boolean): void =>
|
||||
updateAt(index, { targetBlank: checked })
|
||||
}
|
||||
/>
|
||||
<Typography.Text className={styles.newTabLabel}>
|
||||
Open in new tab
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
aria-label={`Remove link ${index + 1}`}
|
||||
data-testid={`context-link-remove-${index}`}
|
||||
onClick={(): void => removeAt(index)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
@@ -66,18 +86,10 @@ function ContextLinksSection({
|
||||
color="secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
data-testid="panel-editor-v2-add-link"
|
||||
onClick={(): void => setDialog({ open: true, index: null })}
|
||||
onClick={addLink}
|
||||
>
|
||||
Add Context Link
|
||||
Add link
|
||||
</Button>
|
||||
|
||||
<ContextLinkDialog
|
||||
open={dialog.open}
|
||||
initialLink={editingLink}
|
||||
variables={variables}
|
||||
onOpenChange={(open): void => setDialog((d) => ({ ...d, open }))}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.anchor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
z-index: 1100;
|
||||
padding: 4px 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-width: var(--radix-popover-trigger-width);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 8px 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.item {
|
||||
--button-display: flex;
|
||||
--button-justify-content: flex-start;
|
||||
--button-height: auto;
|
||||
--button-padding: 8px 12px;
|
||||
--button-border-radius: 0;
|
||||
--button-font-size: 13px;
|
||||
--button-line-height: 1.4;
|
||||
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name,
|
||||
.source {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1 1 auto;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.source {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 12px;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
// Uses Popover (not DropdownMenu): DropdownMenuTrigger preventDefaults pointerdown,
|
||||
// which steals input focus and dismisses on every keystroke. PopoverAnchor is a passive
|
||||
// positioning element that leaves the wrapped input fully interactive.
|
||||
import { ReactNode, useRef, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { VariableItem } from './types';
|
||||
|
||||
import styles from './VariablesPopover.module.scss';
|
||||
|
||||
interface VariablesPopoverRenderProps {
|
||||
setIsOpen: (open: boolean) => void;
|
||||
setCursorPosition: (position: number | null) => void;
|
||||
}
|
||||
|
||||
interface VariablesPopoverProps {
|
||||
variables: VariableItem[];
|
||||
/** Called with the braces-wrapped token (e.g. `{{env}}`) and the tracked cursor offset. */
|
||||
onVariableSelect: (token: string, cursorPosition?: number) => void;
|
||||
children: (props: VariablesPopoverRenderProps) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete for context-link variables. Wraps an input (via the render-prop) and
|
||||
* shows the available `{{variables}}` grouped by source; picking one inserts it at the
|
||||
* tracked cursor. The consumer drives `setIsOpen` (on focus) and `setCursorPosition`.
|
||||
*/
|
||||
function VariablesPopover({
|
||||
variables,
|
||||
onVariableSelect,
|
||||
children,
|
||||
}: VariablesPopoverProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleOpenChange = (open: boolean): void => {
|
||||
// Accept "close" events (outside-click, Esc) but ignore opens — opening is driven
|
||||
// by the input's onFocus in the consumer.
|
||||
if (!open) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Keep the popover open while the pointer/focus is inside the anchored input.
|
||||
const keepOpenIfInsideAnchor = (event: {
|
||||
target: EventTarget | null;
|
||||
}): void => {
|
||||
const target = event.target as Node | null;
|
||||
if (
|
||||
target &&
|
||||
anchorRef.current?.contains(target) &&
|
||||
'preventDefault' in event
|
||||
) {
|
||||
(event as Event).preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange} modal={false}>
|
||||
<PopoverAnchor asChild>
|
||||
<div className={styles.anchor} ref={anchorRef}>
|
||||
{children({ setIsOpen, setCursorPosition })}
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
// Render inside the anchor (not a body portal): the editor lives in a modal
|
||||
// DialogWrapper, and radix's modal sets pointer-events:none on everything
|
||||
// portalled outside it, which would make the suggestions unclickable.
|
||||
withPortal={false}
|
||||
className={styles.content}
|
||||
onOpenAutoFocus={(e): void => e.preventDefault()}
|
||||
onCloseAutoFocus={(e): void => e.preventDefault()}
|
||||
onInteractOutside={keepOpenIfInsideAnchor}
|
||||
onFocusOutside={keepOpenIfInsideAnchor}
|
||||
>
|
||||
{variables.length === 0 ? (
|
||||
<div className={styles.empty}>No variables available</div>
|
||||
) : (
|
||||
variables.map((v) => (
|
||||
<Button
|
||||
key={`${v.source}-${v.name}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="md"
|
||||
className={styles.item}
|
||||
aria-label={`Insert {{${v.name}}}`}
|
||||
testId={`context-link-variable-${v.name}`}
|
||||
// Prevent the input from losing focus when clicking an item.
|
||||
onMouseDown={(e): void => e.preventDefault()}
|
||||
onClick={(): void => {
|
||||
onVariableSelect(`{{${v.name}}}`, cursorPosition ?? undefined);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className={styles.row}>
|
||||
<Typography.Text
|
||||
className={styles.name}
|
||||
>{`{{${v.name}}}`}</Typography.Text>
|
||||
<Typography.Text className={styles.source}>{v.source}</Typography.Text>
|
||||
</div>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesPopover;
|
||||
@@ -3,92 +3,47 @@ import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ContextLinksSection from '../ContextLinksSection';
|
||||
|
||||
// The variable-source hook reads the query-builder provider + store; stub it so the
|
||||
// editor can be exercised in isolation (its own suite covers the sourcing logic).
|
||||
jest.mock('../useContextLinkVariables', () => ({
|
||||
useContextLinkVariables: (): unknown => [
|
||||
{ name: 'timestamp_start', source: 'Global timestamp' },
|
||||
{ name: 'env', source: 'Dashboard variable' },
|
||||
],
|
||||
}));
|
||||
|
||||
const LINKS: DashboardLinkDTO[] = [
|
||||
{ name: 'Docs', url: 'https://signoz.io', targetBlank: true },
|
||||
];
|
||||
|
||||
const lastCall = (fn: jest.Mock): DashboardLinkDTO[] =>
|
||||
fn.mock.calls[fn.mock.calls.length - 1][0];
|
||||
|
||||
describe('ContextLinksSection', () => {
|
||||
it('renders only the add button when there are no links', () => {
|
||||
render(<ContextLinksSection value={undefined} onChange={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-add-link')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('context-link-item-0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('context-link-label-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders existing links as list items showing the label', () => {
|
||||
render(<ContextLinksSection value={LINKS} onChange={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('context-link-item-0')).toHaveTextContent('Docs');
|
||||
// The editor is a modal — no inline fields until it's opened.
|
||||
expect(screen.queryByTestId('context-link-url')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a link through the dialog (Save gated on a valid URL)', async () => {
|
||||
it('appends a blank link (open-in-new-tab on) when Add link is clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ContextLinksSection value={[]} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
|
||||
|
||||
const save = await screen.findByTestId('context-link-save');
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByTestId('context-link-url'), {
|
||||
target: { value: 'https://signoz.io' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('context-link-label'), {
|
||||
target: { value: 'Docs' },
|
||||
});
|
||||
expect(save).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(save);
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{
|
||||
name: 'Docs',
|
||||
url: 'https://signoz.io',
|
||||
targetBlank: true,
|
||||
renderVariables: true,
|
||||
},
|
||||
{ name: '', url: '', targetBlank: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('edits an existing link through the dialog', async () => {
|
||||
it('renders existing links and edits a label through onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ContextLinksSection value={LINKS} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('context-link-edit-0'));
|
||||
|
||||
const label = await screen.findByTestId('context-link-label');
|
||||
expect(label).toHaveValue('Docs');
|
||||
expect(screen.getByTestId('context-link-url')).toHaveValue(
|
||||
expect(screen.getByTestId('context-link-label-0')).toHaveValue('Docs');
|
||||
expect(screen.getByTestId('context-link-url-0')).toHaveValue(
|
||||
'https://signoz.io',
|
||||
);
|
||||
|
||||
fireEvent.change(label, { target: { value: 'Runbook' } });
|
||||
fireEvent.click(screen.getByTestId('context-link-save'));
|
||||
|
||||
fireEvent.change(screen.getByTestId('context-link-label-0'), {
|
||||
target: { value: 'Runbook' },
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{
|
||||
name: 'Runbook',
|
||||
url: 'https://signoz.io',
|
||||
targetBlank: true,
|
||||
renderVariables: true,
|
||||
},
|
||||
{ name: 'Runbook', url: 'https://signoz.io', targetBlank: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes a link from the list', () => {
|
||||
it('removes a link through onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ContextLinksSection value={LINKS} onChange={onChange} />);
|
||||
|
||||
@@ -96,52 +51,4 @@ describe('ContextLinksSection', () => {
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('shows a validation error only for a malformed URL', async () => {
|
||||
render(<ContextLinksSection value={[]} onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
|
||||
|
||||
const urlInput = await screen.findByTestId('context-link-url');
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: 'not-a-url' } });
|
||||
expect(screen.getByTestId('context-link-url-error')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('context-link-save')).toBeDisabled();
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: '/valid/path' } });
|
||||
expect(
|
||||
screen.queryByTestId('context-link-url-error'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a URL parameter and writes it into the URL query string', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ContextLinksSection value={[]} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
|
||||
|
||||
fireEvent.change(await screen.findByTestId('context-link-url'), {
|
||||
target: { value: '/logs' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('context-link-add-param'));
|
||||
fireEvent.change(screen.getByTestId('context-link-param-key-0'), {
|
||||
target: { value: 'env' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('context-link-save'));
|
||||
|
||||
expect(lastCall(onChange)).toStrictEqual([
|
||||
{ name: '', url: '/logs?env=', targetBlank: true, renderVariables: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('inserts a {{variable}} into the URL from the autocomplete popover', async () => {
|
||||
render(<ContextLinksSection value={[]} onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
|
||||
|
||||
const urlInput = await screen.findByTestId('context-link-url');
|
||||
fireEvent.focus(urlInput);
|
||||
fireEvent.click(
|
||||
await screen.findByTestId('context-link-variable-timestamp_start'),
|
||||
);
|
||||
|
||||
expect(urlInput).toHaveValue('{{timestamp_start}}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useContextLinkVariables } from '../useContextLinkVariables';
|
||||
|
||||
const mockUseGetDashboardV2 = jest.fn();
|
||||
const mockUseQueryBuilder = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { dashboardId: string }) => unknown,
|
||||
): unknown => selector({ dashboardId: 'dash-1' }),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => mockUseQueryBuilder(),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
|
||||
}));
|
||||
|
||||
// dtoToFormModel is exercised by its own suite; here we only need it to surface a name.
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters',
|
||||
() => ({
|
||||
dtoToFormModel: (dto: { name: string }): { name: string } => ({
|
||||
name: dto.name,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
describe('useContextLinkVariables', () => {
|
||||
beforeEach(() => {
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{ groupBy: [{ key: 'service.name' }, { key: 'env' }] },
|
||||
// Duplicate across queries — must dedupe.
|
||||
{ groupBy: [{ key: 'service.name' }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
mockUseGetDashboardV2.mockReturnValue({
|
||||
data: {
|
||||
data: { spec: { variables: [{ name: 'region' }, { name: 'tier' }] } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('orders globals, then _prefixed query fields (deduped), then dashboard vars', () => {
|
||||
const { result } = renderHook(() => useContextLinkVariables());
|
||||
|
||||
expect(result.current).toStrictEqual([
|
||||
{ name: 'timestamp_start', source: 'Global timestamp' },
|
||||
{ name: 'timestamp_end', source: 'Global timestamp' },
|
||||
{ name: '_service.name', source: 'Query variable' },
|
||||
{ name: '_env', source: 'Query variable' },
|
||||
{ name: 'region', source: 'Dashboard variable' },
|
||||
{ name: 'tier', source: 'Dashboard variable' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('still returns the global timestamps when there are no queries or variables', () => {
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { builder: { queryData: [] } },
|
||||
});
|
||||
// Loaded dashboard with no variables — useDashboardFetchRequired guarantees the
|
||||
// dashboard is present, so the empty case is an empty spec, not `undefined`.
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
|
||||
|
||||
const { result } = renderHook(() => useContextLinkVariables());
|
||||
|
||||
expect(result.current).toStrictEqual([
|
||||
{ name: 'timestamp_start', source: 'Global timestamp' },
|
||||
{ name: 'timestamp_end', source: 'Global timestamp' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import {
|
||||
getUrlParams,
|
||||
insertVariableAtCursor,
|
||||
isValidContextLinkUrl,
|
||||
updateUrlWithParams,
|
||||
} from '../utils';
|
||||
|
||||
describe('ContextLinksSection utils', () => {
|
||||
describe('isValidContextLinkUrl', () => {
|
||||
it.each([
|
||||
['', true],
|
||||
['https://signoz.io', true],
|
||||
['http://localhost:3301/trace', true],
|
||||
['/trace/{{_traceId}}', true],
|
||||
['{{host}}/trace', true],
|
||||
['trace/{{_traceId}}', false],
|
||||
['ftp://signoz.io', false],
|
||||
])('validates %p as %p', (url, expected) => {
|
||||
expect(isValidContextLinkUrl(url)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertVariableAtCursor', () => {
|
||||
it('inserts at the cursor position', () => {
|
||||
expect(insertVariableAtCursor('/trace/', '{{id}}', 7)).toBe('/trace/{{id}}');
|
||||
expect(insertVariableAtCursor('ab', '{{x}}', 1)).toBe('a{{x}}b');
|
||||
});
|
||||
|
||||
it('appends when no cursor position is given', () => {
|
||||
expect(insertVariableAtCursor('/trace/', '{{id}}')).toBe('/trace/{{id}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUrlParams', () => {
|
||||
it('returns [] when there is no query string', () => {
|
||||
expect(getUrlParams('/trace/{{id}}')).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('parses and decodes key/value pairs', () => {
|
||||
expect(getUrlParams('/logs?service={{svc}}&env=prod')).toStrictEqual([
|
||||
{ key: 'service', value: '{{svc}}' },
|
||||
{ key: 'env', value: 'prod' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('double-decodes over-encoded values', () => {
|
||||
// %257B%257B == encodeURIComponent('%7B%7B') == encodeURIComponent('{{')
|
||||
expect(getUrlParams('/logs?q=%257B%257Bx%257D%257D')).toStrictEqual([
|
||||
{ key: 'q', value: '{{x}}' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUrlWithParams', () => {
|
||||
it('rebuilds the query string and drops empty keys', () => {
|
||||
expect(
|
||||
updateUrlWithParams('/logs?old=1', [
|
||||
{ key: 'service', value: '{{svc}}' },
|
||||
{ key: '', value: 'ignored' },
|
||||
]),
|
||||
).toBe('/logs?service=%7B%7Bsvc%7D%7D');
|
||||
});
|
||||
|
||||
it('drops the query string entirely when no valid params remain', () => {
|
||||
expect(updateUrlWithParams('/logs?a=b', [])).toBe('/logs');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Where a context-link variable comes from — shown as the right-hand label in the popover.
|
||||
*
|
||||
* - `Global timestamp` — the dashboard's selected time range: `timestamp_start` /
|
||||
* `timestamp_end`. Always available, independent of the panel or its queries.
|
||||
* - `Query variable` — a `groupBy` field from the panel's own queries, `_`-prefixed
|
||||
* (e.g. `_service_name`). Resolved at click time from the data point the user clicked,
|
||||
* so it reflects the current query definition and changes as the queries are edited.
|
||||
* - `Dashboard variable` — a user-defined dashboard variable from Dashboard Settings
|
||||
* (e.g. `env`, `region`). Shared across every panel and driven by the dashboard's
|
||||
* variable selectors rather than by an individual query.
|
||||
*/
|
||||
export type VariableSource =
|
||||
| 'Global timestamp'
|
||||
| 'Query variable'
|
||||
| 'Dashboard variable';
|
||||
|
||||
/** One entry in the context-link variable autocomplete. */
|
||||
export interface VariableItem {
|
||||
/** Bare variable name without braces, e.g. `timestamp_start`, `_service_name`, `env`. */
|
||||
name: string;
|
||||
source: VariableSource;
|
||||
}
|
||||
|
||||
/** A single decoded key/value pair parsed from (or written back to) a URL query string. */
|
||||
export interface UrlParam {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { dtoToFormModel } from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters';
|
||||
import { useDashboardFetchRequired } from 'pages/DashboardPageV2/DashboardContainer/hooks/useDashboardFetchRequired';
|
||||
|
||||
import type { VariableItem } from './types';
|
||||
|
||||
// Global time-range variables, always available (V1 parity: `timestamp_start` / `_end`).
|
||||
const GLOBAL_TIMESTAMP_VARIABLES: VariableItem[] = [
|
||||
{ name: 'timestamp_start', source: 'Global timestamp' },
|
||||
{ name: 'timestamp_end', source: 'Global timestamp' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Variables offered by the context-link autocomplete, ordered as in V1: global
|
||||
* timestamps, then per-query `groupBy` fields (prefixed `_`), then dashboard variables.
|
||||
*
|
||||
* Self-contained — the editor renders inside the query-builder provider and the
|
||||
* DashboardContainer, so every source is read here rather than threaded through the
|
||||
* section registry. The dashboard fetch dedupes against the editor page's own query.
|
||||
*/
|
||||
export function useContextLinkVariables(): VariableItem[] {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const { variables: variableDtos } = useDashboardFetchRequired();
|
||||
|
||||
const dashboardVariableNames = useMemo(
|
||||
() =>
|
||||
variableDtos
|
||||
.map((dto) => dtoToFormModel(dto).name)
|
||||
.filter((name): name is string => !!name),
|
||||
[variableDtos],
|
||||
);
|
||||
|
||||
// `_`-prefixed to match V1 and avoid colliding with dashboard-variable names.
|
||||
const fieldVariableNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
(currentQuery?.builder?.queryData ?? []).forEach((query) => {
|
||||
(query.groupBy ?? []).forEach((field) => {
|
||||
if (field.key) {
|
||||
names.add(`_${field.key}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(names);
|
||||
}, [currentQuery?.builder?.queryData]);
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
...GLOBAL_TIMESTAMP_VARIABLES,
|
||||
...fieldVariableNames.map(
|
||||
(name): VariableItem => ({ name, source: 'Query variable' }),
|
||||
),
|
||||
...dashboardVariableNames.map(
|
||||
(name): VariableItem => ({ name, source: 'Dashboard variable' }),
|
||||
),
|
||||
],
|
||||
[fieldVariableNames, dashboardVariableNames],
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { UrlParam } from './types';
|
||||
|
||||
// A link URL must be absolute (http/https), root-relative (`/path`), or start with a
|
||||
// leading `{{var}}/` segment (a variable that resolves to a host/path at click-time).
|
||||
const CONTEXT_LINK_URL_PATTERN = /^(https?:\/\/|\/|{{.*}}\/)/;
|
||||
|
||||
/**
|
||||
* Whether the URL is well-formed for a context link. Empty is treated as valid so the
|
||||
* editor doesn't nag while a row is still being filled in — emptiness is a separate concern.
|
||||
*/
|
||||
export function isValidContextLinkUrl(url: string): boolean {
|
||||
if (!url) {
|
||||
return true;
|
||||
}
|
||||
return CONTEXT_LINK_URL_PATTERN.test(url);
|
||||
}
|
||||
|
||||
/** Inserts `variable` into `current` at `cursorPosition`, or appends when it's unknown. */
|
||||
export function insertVariableAtCursor(
|
||||
current: string,
|
||||
variable: string,
|
||||
cursorPosition?: number,
|
||||
): string {
|
||||
if (cursorPosition === undefined) {
|
||||
return current + variable;
|
||||
}
|
||||
return (
|
||||
current.slice(0, cursorPosition) + variable + current.slice(cursorPosition)
|
||||
);
|
||||
}
|
||||
|
||||
// Values may be double-encoded on the wire; decode a second time only when it changes
|
||||
// the string, so already-single-encoded values are left intact.
|
||||
function decodeForDisplay(value: string): string {
|
||||
const decoded = decodeURIComponent(value);
|
||||
try {
|
||||
const doubleDecoded = decodeURIComponent(decoded);
|
||||
return doubleDecoded !== decoded ? doubleDecoded : decoded;
|
||||
} catch {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses the `?a=b&c=d` query string of a URL into decoded key/value rows. */
|
||||
export function getUrlParams(url: string): UrlParam[] {
|
||||
const [, queryString] = url.split('?');
|
||||
if (!queryString) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const params: UrlParam[] = [];
|
||||
queryString.split('&').forEach((pair) => {
|
||||
const [key, value] = pair.split('=');
|
||||
if (key) {
|
||||
params.push({
|
||||
key: decodeURIComponent(key),
|
||||
value: decodeForDisplay(value || ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
/** Rewrites `url`'s query string from `params`, dropping rows with an empty key. */
|
||||
export function updateUrlWithParams(url: string, params: UrlParam[]): string {
|
||||
const [baseUrl] = url.split('?');
|
||||
const queryString = params
|
||||
.filter((param) => param.key.trim() !== '')
|
||||
.map(
|
||||
(param) =>
|
||||
`${encodeURIComponent(param.key.trim())}=${encodeURIComponent(
|
||||
param.value,
|
||||
)}`,
|
||||
)
|
||||
.join('&');
|
||||
|
||||
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
@@ -39,9 +38,7 @@ function VisualizationSection({
|
||||
{controls.switchPanelKind && panelKind && onChangePanelKind && (
|
||||
<PanelTypeSwitcher
|
||||
panelKind={panelKind}
|
||||
// queryType is optional on the kind-erased section context, but always
|
||||
// supplied in practice; default to Query Builder at this boundary.
|
||||
queryType={queryType ?? EQueryType.QUERY_BUILDER}
|
||||
queryType={queryType}
|
||||
signal={signal}
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
|
||||
@@ -164,7 +164,7 @@ function PanelEditorQueryBuilder({
|
||||
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
|
||||
<RunQueryBtn
|
||||
className="run-query-dashboard-btn"
|
||||
label="Run Query"
|
||||
label="Stage & Run Query"
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={onCancelQuery}
|
||||
|
||||
@@ -49,13 +49,6 @@
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
// Standalone View stacks the graph-manager below the chart inside the surface (it
|
||||
// must stay within the chart's PlotContext). Let it flow out of the surface so the
|
||||
// modal body scrolls as a whole, instead of clipping it or scrolling the panel.
|
||||
.surfaceStacked {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import type {
|
||||
PanelPagination,
|
||||
@@ -34,14 +32,6 @@ interface PreviewPaneProps {
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
|
||||
pagination?: PanelPagination;
|
||||
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
|
||||
panelMode?: PanelMode;
|
||||
/** Hide the preview's top row entirely (query-type badge + time picker) — the View modal has its own header. */
|
||||
hideHeader?: boolean;
|
||||
/** Dashboard-wide preferences (cursor sync, …) forwarded to the body; the modal isolates cursor-sync. */
|
||||
dashboardPreference?: DashboardPreference;
|
||||
/** Close the standalone View modal — forwarded to the time-series/bar graph manager. */
|
||||
onCloseStandaloneView?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,10 +50,6 @@ function PreviewPane({
|
||||
refetch,
|
||||
onDragSelect,
|
||||
pagination,
|
||||
panelMode = PanelMode.DASHBOARD_EDIT,
|
||||
hideHeader = false,
|
||||
dashboardPreference,
|
||||
onCloseStandaloneView,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
|
||||
const queryType = getPanelQueryType(panel);
|
||||
@@ -75,24 +61,18 @@ function PreviewPane({
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
{!hideHeader && (
|
||||
<div className={styles.header}>
|
||||
<PlotTag
|
||||
queryType={queryType}
|
||||
panelType={panelType}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<div className={styles.dateTimeSelector}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<div className={styles.header}>
|
||||
<PlotTag
|
||||
queryType={queryType}
|
||||
panelType={panelType}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<div className={styles.dateTimeSelector}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={cx(styles.surface, {
|
||||
[styles.surfaceStacked]: panelMode === PanelMode.STANDALONE_VIEW,
|
||||
})}
|
||||
>
|
||||
<div className={styles.surface}>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
@@ -114,11 +94,9 @@ function PreviewPane({
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={panelMode}
|
||||
dashboardPreference={dashboardPreference}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onCloseStandaloneView={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelEditorContainer from '../index';
|
||||
|
||||
/**
|
||||
* Characterization test for the editor's composition: which derived values and
|
||||
* options it forwards to the draft/query/query-sync/type-switch hooks and to its
|
||||
* children. The leaf hooks are mocked as arg-capturing spies so this pins the
|
||||
* wiring; it stays valid (and guards behavior) after that wiring is pulled into a
|
||||
* shared edit-session hook, since the mocks intercept the leaf hooks either way.
|
||||
*/
|
||||
|
||||
const mockSetSpec = jest.fn();
|
||||
const mockRefetch = jest.fn();
|
||||
const mockCancelQuery = jest.fn();
|
||||
const mockBuildSaveSpec = jest.fn((spec: unknown) => spec);
|
||||
const mockOnChangePanelKind = jest.fn();
|
||||
const mockSave = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const mockUseDraft = jest.fn();
|
||||
jest.mock('../hooks/usePanelEditorDraft', () => ({
|
||||
usePanelEditorDraft: (panel: unknown): unknown => mockUseDraft(panel),
|
||||
}));
|
||||
|
||||
const mockUseQuery = jest.fn();
|
||||
jest.mock('../../hooks/usePanelQuery', () => ({
|
||||
usePanelQuery: (args: unknown): unknown => mockUseQuery(args),
|
||||
}));
|
||||
|
||||
const mockUseQuerySync = jest.fn();
|
||||
jest.mock('../hooks/usePanelEditorQuerySync', () => ({
|
||||
usePanelEditorQuerySync: (args: unknown): unknown => mockUseQuerySync(args),
|
||||
}));
|
||||
|
||||
const mockUseTypeSwitch = jest.fn();
|
||||
jest.mock('../hooks/usePanelTypeSwitch', () => ({
|
||||
usePanelTypeSwitch: (args: unknown): unknown => mockUseTypeSwitch(args),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/usePanelEditorSave', () => ({
|
||||
usePanelEditorSave: (): unknown => ({ save: mockSave, isSaving: false }),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useSwitchColumnsOnSignalChange', () => ({
|
||||
useSwitchColumnsOnSignalChange: jest.fn(),
|
||||
}));
|
||||
jest.mock('../hooks/useSeedNewListColumns', () => ({
|
||||
useSeedNewListColumns: jest.fn(),
|
||||
}));
|
||||
jest.mock('../hooks/useLegendSeries', () => ({
|
||||
useLegendSeries: (): [] => [],
|
||||
}));
|
||||
jest.mock('../hooks/useTableColumns', () => ({
|
||||
useTableColumns: (): [] => [],
|
||||
}));
|
||||
jest.mock('../hooks/useMetricYAxisUnit', () => ({
|
||||
useMetricYAxisUnit: (): unknown => ({
|
||||
metricUnit: undefined,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({ currentQuery: { queryType: 'builder' } }),
|
||||
}));
|
||||
jest.mock(
|
||||
'../../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions',
|
||||
() => ({
|
||||
usePanelInteractions: (): unknown => ({
|
||||
onDragSelect: jest.fn(),
|
||||
dashboardPreference: {},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('@signozhq/ui/resizable', () => ({
|
||||
__esModule: true,
|
||||
ResizablePanelGroup: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => <div>{children}</div>,
|
||||
ResizablePanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
ResizableHandle: (): null => null,
|
||||
useDefaultLayout: (): unknown => ({
|
||||
defaultLayout: undefined,
|
||||
onLayoutChanged: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
// Children mocked to capture props (and expose a Save trigger / footer slot).
|
||||
const mockHeaderProps = jest.fn();
|
||||
jest.mock('../Header/Header', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { onSave: () => void }): JSX.Element => {
|
||||
mockHeaderProps(props);
|
||||
return (
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
const mockPreviewProps = jest.fn();
|
||||
jest.mock('../PreviewPane/PreviewPane', () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown): JSX.Element => {
|
||||
mockPreviewProps(props);
|
||||
return <div data-testid="preview" />;
|
||||
},
|
||||
}));
|
||||
const mockQbProps = jest.fn();
|
||||
jest.mock('../PanelEditorQueryBuilder/PanelEditorQueryBuilder', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { footer?: React.ReactNode }): JSX.Element => {
|
||||
mockQbProps(props);
|
||||
return <div data-testid="qb">{props.footer}</div>;
|
||||
},
|
||||
}));
|
||||
const mockConfigProps = jest.fn();
|
||||
jest.mock('../ConfigPane/ConfigPane', () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown): JSX.Element => {
|
||||
mockConfigProps(props);
|
||||
return <div data-testid="config" />;
|
||||
},
|
||||
}));
|
||||
jest.mock('../ListColumnsEditor/ListColumnsEditor', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="list-columns" />,
|
||||
}));
|
||||
|
||||
function makePanel(kind: string): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind, spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
dashboardId: 'dash-1',
|
||||
panelId: 'panel-1',
|
||||
onClose: jest.fn(),
|
||||
onSaved: jest.fn(),
|
||||
};
|
||||
|
||||
function setup(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
|
||||
): void {
|
||||
mockUseDraft.mockReturnValue({
|
||||
draft: panel,
|
||||
spec: panel.spec,
|
||||
setSpec: mockSetSpec,
|
||||
isSpecDirty: false,
|
||||
});
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: { response: undefined },
|
||||
isFetching: false,
|
||||
error: null,
|
||||
cancelQuery: mockCancelQuery,
|
||||
refetch: mockRefetch,
|
||||
pagination: undefined,
|
||||
});
|
||||
mockUseQuerySync.mockReturnValue({
|
||||
runQuery: jest.fn(),
|
||||
isQueryDirty: false,
|
||||
buildSaveSpec: mockBuildSaveSpec,
|
||||
});
|
||||
mockUseTypeSwitch.mockReturnValue({
|
||||
onChangePanelKind: mockOnChangePanelKind,
|
||||
});
|
||||
render(<PanelEditorContainer {...baseProps} panel={panel} {...overrides} />);
|
||||
}
|
||||
|
||||
describe('PanelEditorContainer composition', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders the editor shell with preview, query builder, and config pane', () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
setup(panel);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('preview')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('qb')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('config')).toBeInTheDocument();
|
||||
|
||||
expect(mockPreviewProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panel,
|
||||
panelDefinition: getPanelDefinition('signoz/TimeSeriesPanel'),
|
||||
}),
|
||||
);
|
||||
expect(mockQbProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
|
||||
);
|
||||
expect(mockConfigProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panel,
|
||||
spec: panel.spec,
|
||||
onChangePanelKind: mockOnChangePanelKind,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards the derived panel type + query-sync options to the leaf hooks', () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
setup(panel);
|
||||
|
||||
expect(mockUseQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ panel, panelId: 'panel-1', enabled: true }),
|
||||
);
|
||||
expect(mockUseQuerySync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec: mockSetSpec,
|
||||
refetch: mockRefetch,
|
||||
alwaysSerializeQuery: false,
|
||||
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
|
||||
}),
|
||||
);
|
||||
expect(mockUseTypeSwitch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
spec: panel.spec,
|
||||
setSpec: mockSetSpec,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks a new panel dirty and always serializes its query', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
|
||||
|
||||
expect(mockUseQuerySync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ alwaysSerializeQuery: true }),
|
||||
);
|
||||
expect(mockHeaderProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isDirty: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('bakes the live query into the spec on save, then notifies', async () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
setup(panel, { onSaved: baseProps.onSaved });
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-save'));
|
||||
|
||||
await waitFor(() => expect(baseProps.onSaved).toHaveBeenCalled());
|
||||
expect(mockBuildSaveSpec).toHaveBeenCalledWith(panel.spec);
|
||||
expect(mockSave).toHaveBeenCalledWith(panel.spec);
|
||||
});
|
||||
|
||||
it('renders the list-columns editor only for list panels', () => {
|
||||
setup(makePanel('signoz/ListPanel'));
|
||||
expect(screen.getByTestId('list-columns')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the list-columns editor for non-list panels', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
expect(screen.queryByTestId('list-columns')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
usePanelQuery,
|
||||
type PanelQueryTimeOverride,
|
||||
type UsePanelQueryResult,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
|
||||
import { usePanelEditorDraft } from './usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './usePanelEditorQuerySync';
|
||||
import { usePanelTypeSwitch } from './usePanelTypeSwitch';
|
||||
|
||||
interface UsePanelEditSessionArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Per-view time window (epoch ms); omit to follow the dashboard's global window. */
|
||||
time?: PanelQueryTimeOverride;
|
||||
/** Serialize the live builder query into the spec on save even if unchanged (new panels). */
|
||||
alwaysSerializeQuery?: boolean;
|
||||
/** Seed an empty builder with the kind's default signal (new panels) — off for drilldown. */
|
||||
seedQuerySignal?: boolean;
|
||||
}
|
||||
|
||||
export interface UsePanelEditSessionReturn {
|
||||
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
isSpecDirty: boolean;
|
||||
/** Restore the draft to the originally-loaded panel. */
|
||||
reset: () => void;
|
||||
/** Draft kind → V1 panel type (drives the query builder + preview). */
|
||||
panelType: PANEL_TYPES;
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
/** The kind's first supported signal — seeds new queries/columns. */
|
||||
defaultSignal: TelemetrytypesSignalDTO;
|
||||
/** Shared query result for the draft over the resolved time window. */
|
||||
query: UsePanelQueryResult;
|
||||
/** Stage & run the live builder query into the draft. */
|
||||
runQuery: () => void;
|
||||
isQueryDirty: boolean;
|
||||
/** Bake the live (possibly un-run) query into a spec — for save / editor handoff. */
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
/** Switch the draft's visualization kind in place (reversible per session). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel-editing pipeline shared by the full-page editor and the View modal's
|
||||
* drilldown editor: a local draft, its query result over the resolved time window,
|
||||
* the staged-query sync, and the visualization-kind switch. Each consumer layers its
|
||||
* own concerns on top (the editor adds save + list seeding; the modal adds per-view
|
||||
* time isolation + reset). Keeping the wiring here stops the two from drifting.
|
||||
*/
|
||||
export function usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
time,
|
||||
alwaysSerializeQuery = false,
|
||||
seedQuerySignal = false,
|
||||
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
|
||||
const { draft, spec, setSpec, isSpecDirty, reset } =
|
||||
usePanelEditorDraft(panel);
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
|
||||
const query = usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
time,
|
||||
enabled: !!panelDefinition,
|
||||
});
|
||||
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch: query.refetch,
|
||||
alwaysSerializeQuery,
|
||||
signal: seedQuerySignal ? defaultSignal : undefined,
|
||||
});
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draft.spec,
|
||||
panelType,
|
||||
setSpec,
|
||||
});
|
||||
|
||||
return {
|
||||
draft,
|
||||
spec,
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
reset,
|
||||
panelType,
|
||||
panelDefinition,
|
||||
defaultSignal,
|
||||
query,
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
buildSaveSpec,
|
||||
onChangePanelKind,
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
@@ -23,9 +29,12 @@ import layoutStorage from './layoutStorage';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { useLegendSeries } from './hooks/useLegendSeries';
|
||||
import { usePanelQuery } from '../hooks/usePanelQuery';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
|
||||
import { useMetricYAxisUnit } from './hooks/useMetricYAxisUnit';
|
||||
import { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useTableColumns } from './hooks/useTableColumns';
|
||||
@@ -61,36 +70,7 @@ function PanelEditorContainer({
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
|
||||
// panel always serializes its seed query and seeds the builder's default signal.
|
||||
const {
|
||||
draft,
|
||||
spec,
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
panelDefinition,
|
||||
defaultSignal,
|
||||
query,
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
buildSaveSpec,
|
||||
onChangePanelKind,
|
||||
} = usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
alwaysSerializeQuery: isNew,
|
||||
seedQuerySignal: true,
|
||||
});
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
cancelQuery,
|
||||
refetch,
|
||||
pagination,
|
||||
} = query;
|
||||
|
||||
const { draft, spec, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
|
||||
// Live query type (the selected tab) — the type switcher disables kinds that can't be
|
||||
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
|
||||
// query until staged, so the spec would lag the tab.
|
||||
@@ -114,7 +94,44 @@ function PanelEditorContainer({
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
// Panel kind → V1 panel type, which drives the query builder and preview.
|
||||
const fullKind = draft.spec.plugin.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
|
||||
PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
// One shared query result for the whole editor; the preview renders it.
|
||||
const panelDefinition = getPanelDefinition(draft.spec.plugin.kind);
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
cancelQuery,
|
||||
refetch,
|
||||
pagination,
|
||||
} = usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
enabled: !!panelDefinition,
|
||||
});
|
||||
|
||||
// A new panel's default signal (its kind's first supported) — seeds the query and columns.
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
// New panel's seed query is the builder default, not a real saved query —
|
||||
// always serialize it on save.
|
||||
alwaysSerializeQuery: isNew,
|
||||
signal: defaultSignal,
|
||||
});
|
||||
|
||||
// Switch the panel's visualization kind in place (reversible per session).
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
|
||||
|
||||
// At editor level, not the collapsible FormattingSection, so seeding runs while closed.
|
||||
const formattingUnit = (
|
||||
@@ -146,7 +163,7 @@ function PanelEditorContainer({
|
||||
// Spec and query dirtiness are tracked independently so query re-serialization
|
||||
// never false-dirties. A new panel is always savable (you're creating it).
|
||||
const isDirty = isNew || isSpecDirty || isQueryDirty;
|
||||
const isListPanel = panelKind === 'signoz/ListPanel';
|
||||
const isListPanel = fullKind === 'signoz/ListPanel';
|
||||
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
|
||||
// values; cast at this boundary (as ConfigPane does) so the columns editor's
|
||||
// field-key lookup is typed.
|
||||
@@ -236,7 +253,7 @@ function PanelEditorContainer({
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={panelKind}
|
||||
panelKind={fullKind}
|
||||
signal={listSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Router location state for opening the panel editor pre-loaded with edits instead of
|
||||
* the saved panel. The View modal sets this so "Switch to Edit Mode" carries its
|
||||
* drilldown-edited spec (queries/plugin) into the editor.
|
||||
*/
|
||||
export interface PanelEditorHandoffState {
|
||||
editSpec?: DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
@@ -39,7 +37,6 @@ function BarPanelRenderer({
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
onCloseStandaloneView,
|
||||
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -117,32 +114,6 @@ function BarPanelRenderer({
|
||||
return resolveLegendPosition(spec.legend?.position);
|
||||
}, [spec.legend?.position]);
|
||||
|
||||
// The standalone View modal shows V1's graph-manager legend below the chart:
|
||||
// Filter Series + per-series show/hide + Save. Series visibility auto-persists to
|
||||
// localStorage (STANDALONE_VIEW selection prefs), keyed by panelId.
|
||||
const layoutChildren = useMemo(
|
||||
() =>
|
||||
panelMode === PanelMode.STANDALONE_VIEW ? (
|
||||
<div className={PanelStyles.chartManagerContainer}>
|
||||
<ChartManager
|
||||
config={config}
|
||||
alignedData={chartData}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
onCancel={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
[
|
||||
panelMode,
|
||||
config,
|
||||
chartData,
|
||||
spec.formatting?.unit,
|
||||
decimalPrecision,
|
||||
onCloseStandaloneView,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
|
||||
@@ -176,7 +147,6 @@ function BarPanelRenderer({
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
layoutChildren={layoutChildren}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
@@ -39,7 +37,6 @@ function TimeSeriesPanelRenderer({
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
onCloseStandaloneView,
|
||||
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -118,32 +115,6 @@ function TimeSeriesPanelRenderer({
|
||||
return resolveLegendPosition(spec.legend?.position);
|
||||
}, [spec.legend?.position]);
|
||||
|
||||
// The standalone View modal shows V1's graph-manager legend below the chart:
|
||||
// Filter Series + per-series show/hide + Save. Series visibility auto-persists to
|
||||
// localStorage (STANDALONE_VIEW selection prefs), keyed by panelId.
|
||||
const layoutChildren = useMemo(
|
||||
() =>
|
||||
panelMode === PanelMode.STANDALONE_VIEW ? (
|
||||
<div className={PanelStyles.chartManagerContainer}>
|
||||
<ChartManager
|
||||
config={config}
|
||||
alignedData={chartData}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
onCancel={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
[
|
||||
panelMode,
|
||||
config,
|
||||
chartData,
|
||||
spec.formatting?.unit,
|
||||
decimalPrecision,
|
||||
onCloseStandaloneView,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
|
||||
@@ -177,7 +148,6 @@ function TimeSeriesPanelRenderer({
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
layoutChildren={layoutChildren}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
|
||||
@@ -7,7 +7,3 @@
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chartManagerContainer {
|
||||
padding: 36px 0;
|
||||
}
|
||||
|
||||
@@ -22,9 +22,6 @@ export type PanelClickEvent =
|
||||
|
||||
type DragSelect = (start: number, end: number) => void;
|
||||
|
||||
/** Close the standalone View modal — fired by the chart's graph-manager Save/Cancel. */
|
||||
type CloseStandaloneView = () => void;
|
||||
|
||||
/**
|
||||
* Per-kind interaction props — each kind exposes only the gestures it supports.
|
||||
* Keyed by `PanelKind`; `PanelRendererProps<K>` indexes this, so a missing kind
|
||||
@@ -34,12 +31,10 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
'signoz/TimeSeriesPanel': {
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'signoz/BarChartPanel': {
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
|
||||
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
|
||||
@@ -55,5 +50,4 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
export interface AnyPanelInteractionProps {
|
||||
onClick?: (event: PanelClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
}
|
||||
|
||||
@@ -17,15 +17,3 @@ export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
|
||||
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
|
||||
'signoz/ListPanel': PANEL_TYPES.LIST,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverse of {@link PANEL_KIND_TO_PANEL_TYPE} — the mapping is a bijection, so every
|
||||
* panel kind round-trips. Partial because `PANEL_TYPES` also has types with no V2 kind
|
||||
* (e.g. trace/empty); a lookup on those returns `undefined`.
|
||||
*/
|
||||
export const PANEL_TYPE_TO_PANEL_KIND: Partial<Record<PANEL_TYPES, PanelKind>> =
|
||||
Object.fromEntries(
|
||||
(Object.entries(PANEL_KIND_TO_PANEL_TYPE) as [PanelKind, PANEL_TYPES][]).map(
|
||||
([kind, type]) => [type, kind],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getSwitchedPluginSpec } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/getSwitchedPluginSpec';
|
||||
import { PANEL_TYPE_TO_PANEL_KIND } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { toPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { buildViewPanelSpec } from '../buildViewPanelSpec';
|
||||
|
||||
// The query conversion + kind-switch spec builder are tested in their own suites; here we
|
||||
// isolate buildViewPanelSpec's branching (same kind vs. kind switch).
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
|
||||
() => ({ toPerses: jest.fn(() => [{ kind: 'mock-query' }]) }),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelEditor/getSwitchedPluginSpec',
|
||||
() => ({ getSwitchedPluginSpec: jest.fn(() => ({ switched: true })) }),
|
||||
);
|
||||
|
||||
const query = {} as Query;
|
||||
|
||||
function specOfKind(kind: string): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
plugin: { kind, spec: { formatting: {} } },
|
||||
queries: [],
|
||||
display: { name: 'panel' },
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
describe('PANEL_TYPE_TO_PANEL_KIND', () => {
|
||||
it('is the inverse of the kind→type map', () => {
|
||||
expect(PANEL_TYPE_TO_PANEL_KIND[PANEL_TYPES.VALUE]).toBe(
|
||||
'signoz/NumberPanel',
|
||||
);
|
||||
expect(PANEL_TYPE_TO_PANEL_KIND[PANEL_TYPES.TABLE]).toBe('signoz/TablePanel');
|
||||
expect(PANEL_TYPE_TO_PANEL_KIND[PANEL_TYPES.TIME_SERIES]).toBe(
|
||||
'signoz/TimeSeriesPanel',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildViewPanelSpec', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('keeps the kind and only swaps the queries when the target type matches', () => {
|
||||
const spec = specOfKind('signoz/TimeSeriesPanel');
|
||||
const result = buildViewPanelSpec({
|
||||
spec,
|
||||
query,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
expect(result.plugin.kind).toBe('signoz/TimeSeriesPanel');
|
||||
expect(result.plugin.spec).toBe(spec.plugin.spec);
|
||||
expect(result.queries).toStrictEqual([{ kind: 'mock-query' }]);
|
||||
expect(toPerses).toHaveBeenCalledWith(query, PANEL_TYPES.TIME_SERIES);
|
||||
expect(getSwitchedPluginSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('switches the kind (Value → Table) and rebuilds the plugin spec', () => {
|
||||
const result = buildViewPanelSpec({
|
||||
spec: specOfKind('signoz/NumberPanel'),
|
||||
query,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
});
|
||||
|
||||
expect(result.plugin.kind).toBe('signoz/TablePanel');
|
||||
expect(result.plugin.spec).toStrictEqual({ switched: true });
|
||||
expect(getSwitchedPluginSpec).toHaveBeenCalled();
|
||||
expect(toPerses).toHaveBeenCalledWith(query, PANEL_TYPES.TABLE);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesPanelPluginDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getSwitchedPluginSpec } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/getSwitchedPluginSpec';
|
||||
import {
|
||||
PANEL_TYPE_TO_PANEL_KIND,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { toPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getBuilderQueries } from '../getBuilderQueries';
|
||||
|
||||
/**
|
||||
* Bakes a V1 query + target panel type into a View-modal spec (drilldown seed + URL re-hydration).
|
||||
* When `panelType` maps to a different kind than the panel's (e.g. a breakout turns Value → Table),
|
||||
* the kind is switched via the editor's `getSwitchedPluginSpec` so it opens with populated config.
|
||||
*/
|
||||
export function buildViewPanelSpec({
|
||||
spec,
|
||||
query,
|
||||
panelType,
|
||||
}: {
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
query: Query;
|
||||
panelType: PANEL_TYPES;
|
||||
}): DashboardtypesPanelSpecDTO {
|
||||
const queries = toPerses(query, panelType);
|
||||
const currentKind = spec.plugin.kind as PanelKind;
|
||||
const newKind = PANEL_TYPE_TO_PANEL_KIND[panelType] ?? currentKind;
|
||||
|
||||
if (newKind === currentKind) {
|
||||
return { ...spec, queries };
|
||||
}
|
||||
|
||||
// The plugin cast mirrors the editor's type-switch — a dynamically chosen kind can't be
|
||||
// correlated with its spec statically.
|
||||
const signal = getBuilderQueries(spec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
return {
|
||||
...spec,
|
||||
plugin: {
|
||||
...spec.plugin,
|
||||
kind: newKind,
|
||||
spec: getSwitchedPluginSpec(spec, newKind, signal),
|
||||
} as DashboardtypesPanelPluginDTO,
|
||||
queries,
|
||||
};
|
||||
}
|
||||
@@ -14,19 +14,6 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
const mockOpenView = jest.fn();
|
||||
jest.mock('../../hooks/useViewPanel', () => ({
|
||||
useViewPanel: (): {
|
||||
openView: jest.Mock;
|
||||
closeView: jest.Mock;
|
||||
expandedPanelId: string | null;
|
||||
} => ({
|
||||
openView: mockOpenView,
|
||||
closeView: jest.fn(),
|
||||
expandedPanelId: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockMovePanel = jest.fn();
|
||||
jest.mock('../../hooks/useMovePanelToSection', () => ({
|
||||
useMovePanelToSection: (): jest.Mock => mockMovePanel,
|
||||
@@ -277,13 +264,18 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('view opens the View modal for the panel', () => {
|
||||
it('not-yet-implemented actions (view) fire the placeholder alert with the feature name', () => {
|
||||
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
|
||||
const view = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'view-panel',
|
||||
);
|
||||
(view as { onClick: () => void }).onClick();
|
||||
expect(mockOpenView).toHaveBeenCalledWith('panel-1');
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledTimes(1);
|
||||
expect(alertSpy).toHaveBeenCalledWith('View option clicked');
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('create-alert seeds an alert from this panel', () => {
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
type MovePanelArgs,
|
||||
useMovePanelToSection,
|
||||
} from '../hooks/useMovePanelToSection';
|
||||
import { useViewPanel } from '../hooks/useViewPanel';
|
||||
import { PANEL_ACTION_META } from './panelActionMeta';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
@@ -147,7 +146,6 @@ export function usePanelActionItems({
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { openView } = useViewPanel();
|
||||
|
||||
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
|
||||
// supplies data (`sections`), so no callbacks are threaded through it.
|
||||
@@ -180,7 +178,7 @@ export function usePanelActionItems({
|
||||
key: 'view-panel',
|
||||
label: 'View',
|
||||
icon: <Fullscreen size={14} />,
|
||||
onClick: (): void => openView(panelId),
|
||||
onClick: (): void => notImplementedYet('View'),
|
||||
});
|
||||
}
|
||||
if (isEditable && canEditWidget && panelCapabilities.edit) {
|
||||
@@ -265,7 +263,6 @@ export function usePanelActionItems({
|
||||
panelActions,
|
||||
sections,
|
||||
panelId,
|
||||
openView,
|
||||
openPanelEditor,
|
||||
createAlert,
|
||||
movePanel,
|
||||
|
||||
@@ -35,8 +35,6 @@ interface PanelBodyProps {
|
||||
searchTerm?: string;
|
||||
/** Server-side paging handles — only consumed by raw/list renderers. */
|
||||
pagination?: PanelPagination;
|
||||
/** Close the standalone View modal — only consumed by the time-series/bar graph manager. */
|
||||
onCloseStandaloneView?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,7 +55,6 @@ function PanelBody({
|
||||
panelMode = PanelMode.DASHBOARD_VIEW,
|
||||
searchTerm,
|
||||
pagination,
|
||||
onCloseStandaloneView,
|
||||
}: PanelBodyProps): JSX.Element {
|
||||
// A retained response (keepPreviousData) counts as data only if its type matches the current
|
||||
// request — else a prior panel kind's response (time_series → raw) flashes NoData on switch.
|
||||
@@ -124,7 +121,6 @@ function PanelBody({
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchTerm}
|
||||
pagination={pagination}
|
||||
onCloseStandaloneView={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
// Expanded state: a compact input that fits the header row.
|
||||
.input {
|
||||
width: min(100%, 320px);
|
||||
height: 24px;
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.clear {
|
||||
--button-height: 18px;
|
||||
--button-width: 18px;
|
||||
--button-padding: 0;
|
||||
}
|
||||
|
||||
.searchTrigger {
|
||||
--button-width: 24px;
|
||||
--button-height: 24px;
|
||||
--button-padding: 4px;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ function PanelHeaderSearch({
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => setExpanded(true)}
|
||||
className={styles.searchTrigger}
|
||||
data-testid="panel-header-search-trigger"
|
||||
aria-label="Search"
|
||||
>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.modal {
|
||||
:global(.ant-modal-body) {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate a long panel name so the header stays on one line; the wrapping tooltip
|
||||
// then surfaces the full name on hover.
|
||||
.title {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
// Tall, fixed-height column so the renderer's resize observer measures real
|
||||
// dimensions — the chart self-sizes to fill whatever space it's given.
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 78vh;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.queryBuilder {
|
||||
flex: 0 0 auto;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.toolbarTime {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.panelTypeSelector {
|
||||
width: 240px;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Modal } from 'antd';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ViewPanelModalContent from './ViewPanelModalContent';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface ViewPanelModalProps {
|
||||
/**
|
||||
* The expanded panel and its id. Absent while the modal is closed — a single
|
||||
* host instance lives at the layout level and only carries a panel when open.
|
||||
*/
|
||||
panel?: DashboardtypesPanelDTO;
|
||||
panelId?: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ViewPanelModal({
|
||||
panel,
|
||||
panelId,
|
||||
open,
|
||||
onClose,
|
||||
}: ViewPanelModalProps): JSX.Element {
|
||||
const name = panel?.spec.display.name ?? '';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
centered
|
||||
width="85%"
|
||||
destroyOnClose
|
||||
className={styles.modal}
|
||||
title={
|
||||
<TooltipSimple title={name} arrow>
|
||||
<Typography.Text className={styles.title}>
|
||||
{name} - (View mode)
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
}
|
||||
>
|
||||
{open && panel && panelId && (
|
||||
<ViewPanelModalContent panel={panel} panelId={panelId} onClose={onClose} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelModal;
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import PanelEditorQueryBuilder from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
|
||||
import { usePanelInteractions } from '../hooks/usePanelInteractions';
|
||||
import ViewPanelModalHeader from './ViewPanelModalHeader';
|
||||
import { useViewPanelMode } from './useViewPanelMode';
|
||||
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface ViewPanelModalContentProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Close the modal — wired to the graph manager's Save/Cancel. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of the View modal: a compact drilldown editor. It renders an editable draft of
|
||||
* the panel (preview) over a per-view time window plus the shared query builder, so the
|
||||
* user can tweak + Stage & Run without touching the dashboard. Edits are temporary.
|
||||
*/
|
||||
function ViewPanelModalContent({
|
||||
panel,
|
||||
panelId,
|
||||
onClose,
|
||||
}: ViewPanelModalContentProps): JSX.Element | null {
|
||||
const {
|
||||
timeOverride,
|
||||
selectedInterval,
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
} = useViewPanelTimeWindow();
|
||||
|
||||
const {
|
||||
draft,
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
resetQuery,
|
||||
buildSaveSpec,
|
||||
} = useViewPanelMode({ panel, panelId, time: timeOverride });
|
||||
const { data, isFetching, error, refetch, cancelQuery, pagination } = query;
|
||||
|
||||
// Drag-to-zoom stays inside the modal; opt the chart out of the dashboard's
|
||||
// cursor-sync group so a drag here can't replay onto the grid panels.
|
||||
const { dashboardPreference } = usePanelInteractions();
|
||||
const isolatedPreference = useMemo<DashboardPreference>(
|
||||
() => ({ ...dashboardPreference, syncMode: DashboardCursorSync.None }),
|
||||
[dashboardPreference],
|
||||
);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
|
||||
// The View action only appears for registered kinds, so this is defensive.
|
||||
if (!panelDefinition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.content} data-testid="view-panel-modal-content">
|
||||
<ViewPanelModalHeader
|
||||
selectedInterval={selectedInterval}
|
||||
startMs={timeOverride.startMs}
|
||||
endMs={timeOverride.endMs}
|
||||
onTimeChange={onTimeChange}
|
||||
isFetching={isFetching}
|
||||
onRefresh={(): void => {
|
||||
// Relative windows re-anchor to now (new key → refetch); a fixed
|
||||
// custom window just re-runs the same query.
|
||||
if (selectedInterval === 'custom') {
|
||||
refetch();
|
||||
} else {
|
||||
refreshWindow();
|
||||
}
|
||||
}}
|
||||
onSwitchToEdit={(): void =>
|
||||
// Carry the drilldown edits so the editor opens on them, not the saved panel.
|
||||
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) })
|
||||
}
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
queryType={queryType}
|
||||
signal={signal}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
onResetQuery={resetQuery}
|
||||
/>
|
||||
<div className={styles.queryBuilder}>
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
signal={signal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
pagination={pagination}
|
||||
panelMode={PanelMode.STANDALONE_VIEW}
|
||||
dashboardPreference={isolatedPreference}
|
||||
onCloseStandaloneView={onClose}
|
||||
hideHeader
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelModalContent;
|
||||
@@ -1,123 +0,0 @@
|
||||
import { PenLine, RotateCw } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { usePanelTypeSelectItems } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems';
|
||||
import ConfigSelect from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/controls/ConfigSelect/ConfigSelect';
|
||||
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface ViewPanelModalHeaderProps {
|
||||
selectedInterval: Time | CustomTimeType;
|
||||
/** Current window bounds (epoch ms) — seed the picker's modal display. */
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
onTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
range?: [number, number],
|
||||
) => void;
|
||||
/** Any query in flight — spins the refresh icon and disables it. */
|
||||
isFetching: boolean;
|
||||
onRefresh: () => void;
|
||||
onSwitchToEdit: () => void;
|
||||
/** Draft's current kind (selected value of the panel-type selector). */
|
||||
panelKind: PanelKind;
|
||||
/**
|
||||
* The active query-builder tab (Query Builder / PromQL / ClickHouse). The type
|
||||
* selector greys out kinds that can't be authored in it — e.g. List is
|
||||
* Query-Builder-only, so PromQL/ClickHouse disable it.
|
||||
*/
|
||||
queryType: EQueryType;
|
||||
/** Current builder datasource — greys out kinds that don't support it (e.g. List needs logs/traces, not metrics). */
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/** Restore the saved query + kind (drilldown reset). */
|
||||
onResetQuery: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar for the View modal: reset the drilldown, open the full editor, switch the
|
||||
* visualization kind, pick a per-view time window (isolated from the dashboard), and
|
||||
* refresh. Mirrors V1's FullView header controls.
|
||||
*/
|
||||
function ViewPanelModalHeader({
|
||||
selectedInterval,
|
||||
startMs,
|
||||
endMs,
|
||||
onTimeChange,
|
||||
isFetching,
|
||||
onRefresh,
|
||||
onSwitchToEdit,
|
||||
panelKind,
|
||||
queryType,
|
||||
signal,
|
||||
onChangePanelKind,
|
||||
onResetQuery,
|
||||
}: ViewPanelModalHeaderProps): JSX.Element {
|
||||
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
|
||||
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
|
||||
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
|
||||
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.panelTypeSelector}>
|
||||
<ConfigSelect<PanelKind>
|
||||
testId="view-panel-type-selector"
|
||||
value={panelKind}
|
||||
items={panelTypeItems}
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
onClick={onSwitchToEdit}
|
||||
data-testid="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
onClick={onResetQuery}
|
||||
data-testid="view-panel-reset-query"
|
||||
>
|
||||
Reset Query
|
||||
</Button>
|
||||
<div className={styles.toolbarTime}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={false}
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection
|
||||
disableUrlSync
|
||||
onTimeChange={onTimeChange}
|
||||
modalSelectedInterval={selectedInterval as Time}
|
||||
modalInitialStartTime={startMs}
|
||||
modalInitialEndTime={endMs}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onRefresh}
|
||||
disabled={isFetching}
|
||||
aria-label="Refresh"
|
||||
data-testid="view-panel-refresh"
|
||||
>
|
||||
<RotateCw className={cx({ 'animate-spin': isFetching })} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelModalHeader;
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { usePanelEditSession } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/hooks/usePanelEditSession';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { buildViewPanelSpec } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
|
||||
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import {
|
||||
type PanelQueryTimeOverride,
|
||||
type UsePanelQueryResult,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
interface UseViewPanelModeArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Per-view time window (epoch ms); isolates the preview from the dashboard. */
|
||||
time: PanelQueryTimeOverride;
|
||||
}
|
||||
|
||||
export interface UseViewPanelModeReturn {
|
||||
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
/** Resolved renderer for the draft's current kind (registry always resolves a kind). */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
/**
|
||||
* Builder datasource driving the query builder and the panel-type selector's
|
||||
* disabled rule. Resolved from the query, falling back to the kind's default
|
||||
* signal, so it's always defined.
|
||||
*/
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
/** Active query type (selected builder tab) — drives the panel-type selector's disabled rule. */
|
||||
queryType: EQueryType;
|
||||
/** Query result for the draft over the per-view window. */
|
||||
query: UsePanelQueryResult;
|
||||
/** Stage & run the live builder query into the draft (drilldown; not persisted). */
|
||||
runQuery: () => void;
|
||||
/** Switch the draft's visualization kind (temporary; reversible per session). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/** Restore the query the view opened with, discarding in-modal edits. */
|
||||
resetQuery: () => void;
|
||||
/** Bake the live (possibly un-run) query into a spec — used to hand edits to the full editor. */
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* The View modal's compact drilldown editor on the shared `usePanelEditSession`. Edits are
|
||||
* temporary — they live in the builder/URL + draft, never the dashboard (V1 parity).
|
||||
*/
|
||||
export function useViewPanelMode({
|
||||
panel,
|
||||
panelId,
|
||||
time,
|
||||
}: UseViewPanelModeArgs): UseViewPanelModeReturn {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
// Seed the draft from the URL (`compositeQuery` + `graphType`) when present, else the saved
|
||||
// panel — mount-only, so a refresh re-seeds from the URL and in-modal edits survive (V1 parity).
|
||||
const urlQuery = useGetCompositeQueryParam();
|
||||
const urlGraphType = useUrlQuery().get(
|
||||
QueryParams.graphType,
|
||||
) as PANEL_TYPES | null;
|
||||
const initialPanel = useMemo<DashboardtypesPanelDTO>(
|
||||
() =>
|
||||
urlQuery
|
||||
? {
|
||||
...panel,
|
||||
spec: buildViewPanelSpec({
|
||||
spec: panel.spec,
|
||||
query: urlQuery,
|
||||
panelType:
|
||||
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
|
||||
}),
|
||||
}
|
||||
: panel,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
draft,
|
||||
panelDefinition,
|
||||
defaultSignal,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
buildSaveSpec,
|
||||
reset,
|
||||
} = usePanelEditSession({ panel: initialPanel, panelId, time });
|
||||
|
||||
// The query the view opened with, captured once — the Reset target.
|
||||
const savedQuery = useMemo(
|
||||
() =>
|
||||
fromPerses(
|
||||
panel.spec.queries,
|
||||
PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
|
||||
[],
|
||||
);
|
||||
|
||||
const resetQuery = useCallback((): void => {
|
||||
reset();
|
||||
redirectWithQueryBuilderData(savedQuery);
|
||||
}, [reset, redirectWithQueryBuilderData, savedQuery]);
|
||||
|
||||
// Current builder datasource — resolved the same way as the full editor's
|
||||
// ConfigPane so the two selectors stay in sync, then defaulted to the kind's first
|
||||
// signal (PromQL/ClickHouse carry none) so the query builder always has one.
|
||||
const signal =
|
||||
resolveSignal(draft.spec.queries, defaultSignal) ?? defaultSignal;
|
||||
|
||||
return {
|
||||
draft,
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType: currentQuery.queryType,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
resetQuery,
|
||||
buildSaveSpec,
|
||||
};
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import type { PanelQueryTimeOverride } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
const NS_PER_MS = 1e6;
|
||||
|
||||
export interface ViewPanelTimeWindow {
|
||||
/** Absolute window (epoch ms) to pass to usePanelQuery as a time override. */
|
||||
timeOverride: PanelQueryTimeOverride;
|
||||
/** Interval shown in the picker — a relative `Time` or `'custom'`. */
|
||||
selectedInterval: Time | CustomTimeType;
|
||||
/** Apply a selection from DateTimeSelectionV2 (modal mode). */
|
||||
onTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
range?: [number, number],
|
||||
) => void;
|
||||
/** Re-anchor a relative window to "now" (manual refresh); no-op for custom. */
|
||||
refreshWindow: () => void;
|
||||
/** Drag-to-zoom on a time chart → set a custom window locally (not the dashboard's). */
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-view time window for the panel View modal, isolated from the dashboard's
|
||||
* global time (V1 parity: the modal's time selector doesn't move the grid). Seeded
|
||||
* once from the current global window, then owned locally. Relative intervals
|
||||
* resolve to an absolute ms window via the same `GetMinMax` the app-wide picker uses.
|
||||
*/
|
||||
export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
|
||||
const { selectedTime, minTime, maxTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
const [selectedInterval, setSelectedInterval] = useState<
|
||||
Time | CustomTimeType
|
||||
>(selectedTime as Time);
|
||||
const [timeOverride, setTimeOverride] = useState<PanelQueryTimeOverride>(
|
||||
() => ({
|
||||
startMs: Math.floor(minTime / NS_PER_MS),
|
||||
endMs: Math.floor(maxTime / NS_PER_MS),
|
||||
}),
|
||||
);
|
||||
|
||||
const onTimeChange = useCallback(
|
||||
(interval: Time | CustomTimeType, range?: [number, number]): void => {
|
||||
setSelectedInterval(interval);
|
||||
// Absolute range comes through directly (already epoch ms).
|
||||
if (interval === 'custom' && range) {
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(range[0]),
|
||||
endMs: Math.floor(range[1]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// GetMinMax returns nanoseconds — convert to the ms window we work in.
|
||||
const { minTime: startNs, maxTime: endNs } = GetMinMax(interval);
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(startNs / NS_PER_MS),
|
||||
endMs: Math.floor(endNs / NS_PER_MS),
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshWindow = useCallback((): void => {
|
||||
// A custom window is fixed; only relative intervals re-anchor to now.
|
||||
if (selectedInterval === 'custom') {
|
||||
return;
|
||||
}
|
||||
const { minTime: startNs, maxTime: endNs } = GetMinMax(selectedInterval);
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(startNs / NS_PER_MS),
|
||||
endMs: Math.floor(endNs / NS_PER_MS),
|
||||
});
|
||||
}, [selectedInterval]);
|
||||
|
||||
const onDragSelect = useCallback((start: number, end: number): void => {
|
||||
// Drag values are already epoch ms (same as the global custom range).
|
||||
const startMs = Math.floor(start);
|
||||
const endMs = Math.floor(end);
|
||||
// Ignore a click / zero-width or inverted selection.
|
||||
if (startMs >= endMs) {
|
||||
return;
|
||||
}
|
||||
setSelectedInterval('custom');
|
||||
setTimeOverride({ startMs, endMs });
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
timeOverride,
|
||||
selectedInterval,
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
}),
|
||||
[timeOverride, selectedInterval, onTimeChange, refreshWindow, onDragSelect],
|
||||
);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
|
||||
|
||||
// The preview reuses the edit page's PreviewPane (chart + header + heavy render
|
||||
// path); stub it (capturing props) so this suite asserts the modal shell + what it
|
||||
// threads down, not the preview internals (PreviewPane/PanelHeader own those).
|
||||
const mockPreviewPaneRender = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane',
|
||||
() =>
|
||||
function MockPreviewPane(props: Record<string, unknown>): ReactElement {
|
||||
mockPreviewPaneRender(props);
|
||||
return <div data-testid="preview-pane" />;
|
||||
},
|
||||
);
|
||||
|
||||
// Isolate from the draft/query-builder plumbing (its own suite covers it).
|
||||
jest.mock('../ViewPanelModal/useViewPanelMode', () => ({
|
||||
useViewPanelMode: (args: {
|
||||
panel: { spec: { plugin: { kind: string } } };
|
||||
}): unknown => {
|
||||
const { kind } = args.panel.spec.plugin;
|
||||
return {
|
||||
draft: args.panel,
|
||||
panelDefinition: {
|
||||
kind,
|
||||
actions: { search: kind === 'signoz/ListPanel' },
|
||||
Renderer: (): null => null,
|
||||
},
|
||||
query: {
|
||||
data: { response: undefined, requestPayload: undefined, legendMap: {} },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
cancelQuery: jest.fn(),
|
||||
pagination: undefined,
|
||||
},
|
||||
runQuery: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
resetQuery: jest.fn(),
|
||||
signal: 'logs',
|
||||
buildSaveSpec: (spec: unknown): unknown => spec,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// The View modal reuses the edit page's query builder, which reads the global
|
||||
// QueryBuilder context and pulls in the ClickHouse/PromQL editors; stub it here.
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder',
|
||||
() =>
|
||||
function MockPanelEditorQueryBuilder(): ReactElement {
|
||||
return <div data-testid="panel-editor-v2-query-builder" />;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('../hooks/usePanelInteractions', () => ({
|
||||
usePanelInteractions: (): unknown => ({
|
||||
onDragSelect: jest.fn(),
|
||||
dashboardPreference: { syncMode: 0 },
|
||||
}),
|
||||
}));
|
||||
|
||||
// The header mounts DateTimeSelectionV2 (redux + router + heavy deps); stub it so
|
||||
// this suite asserts the modal body, not the toolbar internals.
|
||||
jest.mock(
|
||||
'../ViewPanelModal/ViewPanelModalHeader',
|
||||
() =>
|
||||
function MockViewPanelModalHeader(): ReactElement {
|
||||
return <div data-testid="view-panel-header" />;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('../ViewPanelModal/useViewPanelTimeWindow', () => ({
|
||||
useViewPanelTimeWindow: (): unknown => ({
|
||||
timeOverride: { startMs: 0, endMs: 0 },
|
||||
selectedInterval: '5m',
|
||||
onTimeChange: jest.fn(),
|
||||
refreshWindow: jest.fn(),
|
||||
onDragSelect: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockOpenEditor = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
|
||||
() => ({
|
||||
useOpenPanelEditor: (): jest.Mock => mockOpenEditor,
|
||||
}),
|
||||
);
|
||||
|
||||
const renderWithProvider = (ui: ReactElement): ReturnType<typeof render> =>
|
||||
render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
|
||||
function makePanel(kind: string, name = 'My panel'): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name },
|
||||
plugin: { kind, spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('ViewPanelModal', () => {
|
||||
it('renders nothing until opened', () => {
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel')}
|
||||
panelId="p1"
|
||||
open={false}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('view-panel-modal-content'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the header, query builder, and preview when open', () => {
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel', 'CPU usage')}
|
||||
panelId="p1"
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('view-panel-modal-content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('view-panel-header')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-query-builder'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('preview-pane')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invokes onClose when the modal is dismissed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = jest.fn();
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel')}
|
||||
panelId="p1"
|
||||
open
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByLabelText('Close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Charts share one global cursor-sync key and uPlot replays drag across the
|
||||
// group; the modal must opt out so a drag here can't move the dashboard's time.
|
||||
it('opts the chart out of the dashboard cursor-sync group', () => {
|
||||
mockPreviewPaneRender.mockClear();
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel')}
|
||||
panelId="p1"
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const props = mockPreviewPaneRender.mock.calls.at(-1)?.[0] as {
|
||||
dashboardPreference?: { syncMode?: unknown };
|
||||
};
|
||||
expect(props.dashboardPreference?.syncMode).toBe(DashboardCursorSync.None);
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
|
||||
import { useViewPanelTimeWindow } from '../ViewPanelModal/useViewPanelTimeWindow';
|
||||
|
||||
const NS_PER_MS = 1e6;
|
||||
|
||||
// Global time is stored in nanoseconds; the hook must surface milliseconds.
|
||||
const mockState = {
|
||||
globalTime: {
|
||||
selectedTime: '6h',
|
||||
minTime: 6_000_000 * NS_PER_MS,
|
||||
maxTime: 7_000_000 * NS_PER_MS,
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown): unknown =>
|
||||
selector(mockState),
|
||||
}));
|
||||
|
||||
jest.mock('lib/getMinMax', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetMinMax = GetMinMax as unknown as jest.Mock;
|
||||
|
||||
describe('useViewPanelTimeWindow', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('seeds the window from global time, converting ns → ms', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: mockState.globalTime.minTime / NS_PER_MS,
|
||||
endMs: mockState.globalTime.maxTime / NS_PER_MS,
|
||||
});
|
||||
expect(result.current.selectedInterval).toBe('6h');
|
||||
});
|
||||
|
||||
it('converts GetMinMax (ns) to ms on a relative selection', () => {
|
||||
mockGetMinMax.mockReturnValue({
|
||||
minTime: 1_700_000_000_000 * NS_PER_MS,
|
||||
maxTime: 1_700_000_300_000 * NS_PER_MS,
|
||||
});
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
act(() => result.current.onTimeChange('5m'));
|
||||
|
||||
expect(result.current.selectedInterval).toBe('5m');
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: 1_700_000_000_000,
|
||||
endMs: 1_700_000_300_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses an absolute custom range as-is (already ms)', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
act(() => result.current.onTimeChange('custom', [111, 222]));
|
||||
|
||||
expect(mockGetMinMax).not.toHaveBeenCalled();
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: 111,
|
||||
endMs: 222,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets a custom window from a drag selection (modal-local, ms)', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
act(() => result.current.onDragSelect(1000, 5000));
|
||||
|
||||
expect(result.current.selectedInterval).toBe('custom');
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: 1000,
|
||||
endMs: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a zero-width or inverted drag selection', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
const initial = result.current.timeOverride;
|
||||
|
||||
act(() => result.current.onDragSelect(5000, 5000));
|
||||
act(() => result.current.onDragSelect(9000, 1000));
|
||||
|
||||
expect(result.current.timeOverride).toStrictEqual(initial);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
export interface UseViewPanelApi {
|
||||
/** Panel id currently expanded in the View modal; null when none is open. */
|
||||
expandedPanelId: string | null;
|
||||
/** Open the View modal on the saved panel (clears any leftover in-modal query/kind). */
|
||||
openView: (panelId: string) => void;
|
||||
/** Close the View modal by clearing the URL param. */
|
||||
closeView: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the panel View modal off the `expandedWidgetId` URL param (V1 parity):
|
||||
* the open state is shareable, survives refresh, and the browser back-button
|
||||
* closes it. Reuses V1's param key so a deep-linked V1 URL maps cleanly.
|
||||
*/
|
||||
export function useViewPanel(): UseViewPanelApi {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const expandedPanelId = urlQuery.get(QueryParams.expandedWidgetId);
|
||||
|
||||
const openView = useCallback(
|
||||
(panelId: string): void => {
|
||||
// Copy before mutating: useUrlQuery returns a memoized instance.
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.set(QueryParams.expandedWidgetId, panelId);
|
||||
// Drop any leftover in-modal query/kind so a plain View opens on the saved
|
||||
// panel, not a stale URL query the modal would otherwise hydrate from.
|
||||
next.delete(QueryParams.compositeQuery);
|
||||
next.delete(QueryParams.graphType);
|
||||
safeNavigate(`${pathname}?${next.toString()}`);
|
||||
},
|
||||
[pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
const closeView = useCallback((): void => {
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.delete(QueryParams.expandedWidgetId);
|
||||
// Drop the drilldown editor's URL state so it doesn't leak to the dashboard
|
||||
// (the in-modal query builder writes compositeQuery, V1 parity).
|
||||
next.delete(QueryParams.compositeQuery);
|
||||
next.delete(QueryParams.graphType);
|
||||
const search = next.toString();
|
||||
safeNavigate(search ? `${pathname}?${search}` : pathname);
|
||||
}, [pathname, safeNavigate, urlQuery]);
|
||||
|
||||
return { expandedPanelId, openView, closeView };
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import type {
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { layoutsToSections } from '../utils';
|
||||
import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
|
||||
import { useViewPanel } from './Panel/hooks/useViewPanel';
|
||||
import ViewPanelModal from './Panel/ViewPanelModal/ViewPanelModal';
|
||||
import Section from './Section/Section/Section';
|
||||
import SectionList from './Section/SectionList';
|
||||
import styles from './PanelsAndSectionsLayout.module.scss';
|
||||
@@ -28,12 +26,6 @@ function PanelsAndSectionsLayout({
|
||||
}: PanelsAndSectionsLayoutProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
|
||||
// Single View-modal host for the whole dashboard, driven by the URL
|
||||
// (`expandedWidgetId`). One mounted modal beats one-per-panel: no N location
|
||||
// subscriptions, and the expanded panel is looked up by id from the map.
|
||||
const { expandedPanelId, closeView } = useViewPanel();
|
||||
const expandedPanel = expandedPanelId ? panels[expandedPanelId] : undefined;
|
||||
|
||||
const sections = useMemo(
|
||||
() => layoutsToSections(layouts, panels),
|
||||
[layouts, panels],
|
||||
@@ -64,17 +56,7 @@ function PanelsAndSectionsLayout({
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
{renderContent()}
|
||||
<ViewPanelModal
|
||||
open={!!expandedPanel}
|
||||
panel={expandedPanel}
|
||||
panelId={expandedPanelId ?? undefined}
|
||||
onClose={closeView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return <div className={styles.body}>{renderContent()}</div>;
|
||||
}
|
||||
|
||||
export default PanelsAndSectionsLayout;
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useDashboardFetchRequired } from '../useDashboardFetchRequired';
|
||||
|
||||
const mockUseGetDashboardV2 = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { dashboardId: string }) => unknown,
|
||||
): unknown => selector({ dashboardId: 'dash-1' }),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
|
||||
}));
|
||||
|
||||
describe('useDashboardFetchRequired', () => {
|
||||
it('returns the loaded dashboard and its variables', () => {
|
||||
const dashboard = {
|
||||
spec: { variables: [{ name: 'region' }, { name: 'tier' }] },
|
||||
};
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: { data: dashboard } });
|
||||
|
||||
const { result } = renderHook(() => useDashboardFetchRequired());
|
||||
|
||||
expect(result.current.dashboard).toBe(dashboard);
|
||||
expect(result.current.variables).toStrictEqual([
|
||||
{ name: 'region' },
|
||||
{ name: 'tier' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes variables to an empty array when the spec has none', () => {
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
|
||||
|
||||
const { result } = renderHook(() => useDashboardFetchRequired());
|
||||
|
||||
expect(result.current.variables).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('throws when used outside a loaded dashboard subtree', () => {
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: undefined });
|
||||
|
||||
// Suppress React's expected render-error logging for this case.
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
expect(() => renderHook(() => useDashboardFetchRequired())).toThrow(
|
||||
/outside a loaded dashboard subtree/,
|
||||
);
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import {
|
||||
getDashboardV2,
|
||||
useGetDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { GetDashboardV2QueryError } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// Instantiation expression pins `TData`/`TError` to the hook's defaults; a plain
|
||||
// `ReturnType<typeof useGetDashboardV2>` collapses the generics to `unknown`.
|
||||
type GetDashboardV2Query = ReturnType<
|
||||
typeof useGetDashboardV2<
|
||||
Awaited<ReturnType<typeof getDashboardV2>>,
|
||||
GetDashboardV2QueryError
|
||||
>
|
||||
>;
|
||||
|
||||
export interface UseDashboardFetchResult {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
|
||||
isLoading: GetDashboardV2Query['isLoading'];
|
||||
isError: GetDashboardV2Query['isError'];
|
||||
error: GetDashboardV2Query['error'];
|
||||
refetch: GetDashboardV2Query['refetch'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical fetch-only loader for a V2 dashboard by id — shared so the page, panel
|
||||
* editor, and config sections resolve to one react-query cache entry. Exposes just the
|
||||
* raw `dashboard` plus lifecycle; spec derivation (e.g. `variables`) lives in
|
||||
* `useDashboardFetchRequired`, where the dashboard is guaranteed present.
|
||||
*/
|
||||
export function useDashboardFetch(
|
||||
dashboardId: string,
|
||||
): UseDashboardFetchResult {
|
||||
const { data, isLoading, isError, error, refetch } = useGetDashboardV2({
|
||||
id: dashboardId,
|
||||
});
|
||||
const dashboard = data?.data;
|
||||
|
||||
return { dashboard, isLoading, isError, error, refetch };
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
|
||||
import { useDashboardFetch } from './useDashboardFetch';
|
||||
import type { UseDashboardFetchResult } from './useDashboardFetch';
|
||||
|
||||
export interface UseDashboardFetchRequiredResult {
|
||||
/** Guaranteed present — the hook throws otherwise. */
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
/** Always an array, so callers skip `?? []` guards. */
|
||||
variables: DashboardtypesVariableDTO[];
|
||||
refetch: UseDashboardFetchResult['refetch'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard accessor for components rendered inside a loaded dashboard subtree. The
|
||||
* root pages own the fetch and gate on a resolved dashboard before mounting their
|
||||
* subtree, so this reuses the shared react-query cache entry and throws if the
|
||||
* dashboard is missing — turning a silent `undefined` into a loud programmer error. It
|
||||
* also derives spec collections (`variables`) since the dashboard is guaranteed here.
|
||||
*
|
||||
* Prefer this over `useDashboardFetch` outside the two root pages; the
|
||||
* `signoz/no-dashboard-fetch-outside-root` lint rule enforces the split.
|
||||
*/
|
||||
export function useDashboardFetchRequired(): UseDashboardFetchRequiredResult {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const { dashboard, refetch } = useDashboardFetch(dashboardId);
|
||||
|
||||
const variables = useMemo(
|
||||
() => dashboard?.spec?.variables ?? [],
|
||||
[dashboard?.spec?.variables],
|
||||
);
|
||||
|
||||
if (!dashboard) {
|
||||
throw new Error(
|
||||
'useDashboardFetchRequired was used outside a loaded dashboard subtree — the ' +
|
||||
'dashboard is undefined. Only call it below a root page that gates rendering ' +
|
||||
'on useDashboardFetch (DashboardPageV2 / PanelEditorPage).',
|
||||
);
|
||||
}
|
||||
|
||||
return { dashboard, variables, refetch };
|
||||
}
|
||||
@@ -3,28 +3,21 @@ import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
/**
|
||||
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
|
||||
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
|
||||
* caller can open the editor with just the panel id. The optional `handoffState` is passed as
|
||||
* router location state — the View modal uses it to hand its drilldown-edited spec off to the
|
||||
* editor (view → edit) so the editor opens on those edits rather than the saved panel.
|
||||
* caller can open the editor with just the panel id.
|
||||
*/
|
||||
export function useOpenPanelEditor(): (
|
||||
panelId: string,
|
||||
handoffState?: PanelEditorHandoffState,
|
||||
) => void {
|
||||
export function useOpenPanelEditor(): (panelId: string) => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
return useCallback(
|
||||
(panelId: string, handoffState?: PanelEditorHandoffState): void => {
|
||||
(panelId: string): void => {
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
|
||||
handoffState ? { state: handoffState } : undefined,
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId],
|
||||
|
||||
@@ -22,13 +22,9 @@ function DashboardContainer({
|
||||
dashboard,
|
||||
refetch,
|
||||
}: DashboardContainerProps): JSX.Element {
|
||||
const spec = dashboard.spec;
|
||||
const image = dashboard.image || Base64Icons[0];
|
||||
const name = spec.display.name;
|
||||
|
||||
useEffect(() => {
|
||||
document.title = name;
|
||||
}, [name]);
|
||||
document.title = dashboard.name;
|
||||
}, [dashboard.name]);
|
||||
|
||||
const fullScreenHandle = useFullScreenHandle();
|
||||
|
||||
@@ -59,6 +55,10 @@ function DashboardContainer({
|
||||
// the store, so each panel's query substitutes the bar's selected values.
|
||||
useResolvedVariables(dashboard);
|
||||
|
||||
const spec = dashboard.spec;
|
||||
const image = dashboard.image || Base64Icons[0];
|
||||
const name = spec.display.name;
|
||||
|
||||
return (
|
||||
<FullScreen handle={fullScreenHandle}>
|
||||
<div className={styles.container}>
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import Spinner from 'components/Spinner';
|
||||
|
||||
import DashboardContainer from './DashboardContainer';
|
||||
import { useDashboardFetch } from './DashboardContainer/hooks/useDashboardFetch';
|
||||
import styles from './DashboardPageV2.module.scss';
|
||||
|
||||
function DashboardPageV2(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
const { dashboard, isLoading, isError, error, refetch } =
|
||||
useDashboardFetch(dashboardId);
|
||||
const { data, isLoading, isError, error, refetch } = useGetDashboardV2({
|
||||
id: dashboardId,
|
||||
});
|
||||
|
||||
const dashboard = data?.data;
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
|
||||
@@ -6,17 +6,16 @@ import {
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import { useDashboardFetch } from '../DashboardContainer/hooks/useDashboardFetch';
|
||||
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
|
||||
import type { PanelEditorHandoffState } from '../DashboardContainer/PanelEditor/panelEditorHandoff';
|
||||
import {
|
||||
parseNewPanelKind,
|
||||
parseNewPanelLayoutIndex,
|
||||
@@ -33,34 +32,29 @@ function PanelEditorPage(): JSX.Element {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
}>();
|
||||
const { search, state } = useLocation();
|
||||
const { search } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
// Edits handed off from the View modal's drilldown — open the editor on these
|
||||
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
|
||||
const handoffSpec = (state as PanelEditorHandoffState | null)?.editSpec;
|
||||
|
||||
const { dashboard, isLoading, isError, error } =
|
||||
useDashboardFetch(dashboardId);
|
||||
const { data, isLoading, isError, error } = useGetDashboardV2({
|
||||
id: dashboardId,
|
||||
});
|
||||
const dashboard = data?.data;
|
||||
|
||||
// A `panel/new?panelKind=…` route means "create": seed a default panel of that
|
||||
// kind rather than looking one up. Persisted (with a real id) only on save.
|
||||
const newKind = parseNewPanelKind(panelId, search);
|
||||
const existingPanel = dashboard?.spec.panels[panelId];
|
||||
const panel = useMemo(() => {
|
||||
if (newKind) {
|
||||
return createDefaultPanel(
|
||||
newKind,
|
||||
buildPluginSpec(getPanelDefinition(newKind).sections),
|
||||
buildDefaultQueries(newKind),
|
||||
);
|
||||
}
|
||||
if (!existingPanel) {
|
||||
return undefined;
|
||||
}
|
||||
// Open on the modal's drilldown edits when handed off; else the saved panel.
|
||||
return handoffSpec ? { ...existingPanel, spec: handoffSpec } : existingPanel;
|
||||
}, [newKind, existingPanel, handoffSpec]);
|
||||
const panel = useMemo(
|
||||
() =>
|
||||
newKind
|
||||
? createDefaultPanel(
|
||||
newKind,
|
||||
buildPluginSpec(getPanelDefinition(newKind).sections),
|
||||
buildDefaultQueries(newKind),
|
||||
)
|
||||
: existingPanel,
|
||||
[newKind, existingPanel],
|
||||
);
|
||||
|
||||
// Target section for a newly-created panel (set by the "Add panel" trigger).
|
||||
const layoutIndex = parseNewPanelLayoutIndex(search);
|
||||
|
||||
@@ -421,61 +421,5 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/public/dashboards/{id}", handler.New(provider.authzMiddleware.CheckWithoutClaims(
|
||||
provider.dashboardHandler.GetPublicDataV2,
|
||||
authtypes.Relation{Verb: coretypes.VerbRead},
|
||||
coretypes.ResourceMetaResourcePublicDashboard,
|
||||
func(req *http.Request, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
return nil, valuer.UUID{}, err
|
||||
}
|
||||
|
||||
return provider.dashboardModule.GetPublicDashboardSelectorsAndOrg(req.Context(), id, orgs)
|
||||
}, []string{}), handler.OpenAPIDef{
|
||||
ID: "GetPublicDashboardDataV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get public dashboard data (v2)",
|
||||
Description: "This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettablePublicDashboardDataV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newAnonymousSecuritySchemes([]string{coretypes.ResourceMetaResourcePublicDashboard.Scope(coretypes.VerbRead)}),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/public/dashboards/{id}/panels/{key}/query_range", handler.New(provider.authzMiddleware.CheckWithoutClaims(
|
||||
provider.dashboardHandler.GetPublicWidgetQueryRangeV2,
|
||||
authtypes.Relation{Verb: coretypes.VerbRead},
|
||||
coretypes.ResourceMetaResourcePublicDashboard,
|
||||
func(req *http.Request, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
return nil, valuer.UUID{}, err
|
||||
}
|
||||
|
||||
return provider.dashboardModule.GetPublicDashboardSelectorsAndOrg(req.Context(), id, orgs)
|
||||
}, []string{}), handler.OpenAPIDef{
|
||||
ID: "GetPublicDashboardPanelQueryRangeV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get query range result (v2)",
|
||||
Description: "This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(querybuildertypesv5.QueryRangeResponse),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newAnonymousSecuritySchemes([]string{coretypes.ResourceMetaResourcePublicDashboard.Scope(coretypes.VerbRead)}),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -49,26 +49,6 @@ func (provider *provider) addLLMPricingRuleRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/llm_pricing_rules/unmapped_models", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.llmPricingRuleHandler.ListUnmappedModels),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListUnmappedLLMModels",
|
||||
Tags: []string{"llmpricingrules"},
|
||||
Summary: "List unmapped models",
|
||||
Description: "Returns models seen in the last hour of trace data (gen_ai.request.model) that no pricing rule pattern matches, so the user can add them to an existing rule or create a new one.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(llmpricingruletypes.GettableUnmappedModels),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/llm_pricing_rules/{id}", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.llmPricingRuleHandler.Get),
|
||||
handler.OpenAPIDef{
|
||||
|
||||
@@ -81,12 +81,6 @@ type Module interface {
|
||||
|
||||
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
|
||||
|
||||
// get the v2 dashboard data by public dashboard id
|
||||
GetDashboardByPublicIDV2(context.Context, valuer.UUID) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
// gets the query results by panel key and public shared id for a v2 dashboard
|
||||
GetPublicWidgetQueryRangeV2(ctx context.Context, id valuer.UUID, panelKey, startTimeRaw, endTimeRaw string) (*querybuildertypesv5.QueryRangeResponse, error)
|
||||
|
||||
CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error)
|
||||
|
||||
ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error)
|
||||
@@ -107,10 +101,6 @@ type Handler interface {
|
||||
|
||||
GetPublicWidgetQueryRange(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetPublicDataV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetPublicWidgetQueryRangeV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdatePublic(http.ResponseWriter, *http.Request)
|
||||
|
||||
DeletePublic(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -254,14 +254,6 @@ func (module *module) GetPublicWidgetQueryRange(context.Context, valuer.UUID, ui
|
||||
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (module *module) GetDashboardByPublicIDV2(_ context.Context, _ valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (module *module) GetPublicWidgetQueryRangeV2(context.Context, valuer.UUID, string, string, string) (*qbtypes.QueryRangeResponse, error) {
|
||||
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (module *module) GetPublicDashboardSelectorsAndOrg(_ context.Context, _ valuer.UUID, _ []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
|
||||
return nil, valuer.UUID{}, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
@@ -376,53 +376,3 @@ func (handler *handler) DeleteV2(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetPublicDataV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.GetDashboardByPublicIDV2(ctx, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
publicDashboard, err := handler.module.GetPublic(ctx, dashboard.OrgID, dashboard.ID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboardtypes.NewPublicDashboardDataFromDashboardV2(dashboard, publicDashboard))
|
||||
}
|
||||
|
||||
func (handler *handler) GetPublicWidgetQueryRangeV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
panelKey, ok := mux.Vars(r)["key"]
|
||||
if !ok || panelKey == "" {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, dashboardtypes.ErrCodePublicDashboardInvalidInput, "panel key is missing from the path"))
|
||||
return
|
||||
}
|
||||
|
||||
queryRangeResults, err := handler.module.GetPublicWidgetQueryRangeV2(ctx, id, panelKey, r.URL.Query().Get("startTime"), r.URL.Query().Get("endTime"))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, queryRangeResults)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func (h *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *handler) CreateOrUpdate(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
@@ -118,28 +118,6 @@ func (h *handler) CreateOrUpdate(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// ListUnmappedModels handles GET /api/v1/llm_pricing_rules/unmapped_models.
|
||||
func (h *handler) ListUnmappedModels(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
models, err := h.module.ListUnmappedModels(ctx, orgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, llmpricingruletypes.NewGettableUnmappedModels(models))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/llm_pricing_rules/{id}.
|
||||
func (h *handler) Delete(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
|
||||
@@ -3,32 +3,24 @@ package impllmpricingrule
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// unmappedModelsLookback is the trace data window scanned to discover models in use.
|
||||
const unmappedModelsLookback = time.Hour
|
||||
|
||||
type module struct {
|
||||
store llmpricingruletypes.Store
|
||||
querier querier.Querier
|
||||
flagger flagger.Flagger
|
||||
}
|
||||
|
||||
func NewModule(store llmpricingruletypes.Store, flagger flagger.Flagger, querier querier.Querier) llmpricingrule.Module {
|
||||
func NewModule(store llmpricingruletypes.Store, flagger flagger.Flagger) llmpricingrule.Module {
|
||||
return &module{store: store, flagger: flagger}
|
||||
}
|
||||
|
||||
@@ -40,28 +32,6 @@ func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID
|
||||
return module.store.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
// ListUnmappedModels discovers the models present in the last hour of trace data
|
||||
// (gen_ai.request.model) and returns the ones that no pricing rule pattern matches.
|
||||
func (module *module) ListUnmappedModels(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.UnmappedModel, error) {
|
||||
models, err := module.discoverModels(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules, err := module.listAllRules(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
unmapped := make([]*llmpricingruletypes.UnmappedModel, 0, len(models))
|
||||
for _, m := range models {
|
||||
if !llmpricingruletypes.ModelMatchesAnyRule(m.ModelName, rules) {
|
||||
unmapped = append(unmapped, m)
|
||||
}
|
||||
}
|
||||
return unmapped, nil
|
||||
}
|
||||
|
||||
// CreateOrUpdate applies a batch of pricing rule changes:
|
||||
// - ID set → match by id, overwrite fields.
|
||||
// - SourceID set → match by source_id; if found overwrite, else insert.
|
||||
@@ -151,7 +121,7 @@ func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []
|
||||
}
|
||||
|
||||
func (module *module) getEnabledRules(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.LLMPricingRule, error) {
|
||||
rules, err := module.listAllRules(ctx, orgID)
|
||||
rules, _, err := module.List(ctx, orgID, 0, 10000, "", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -165,25 +135,6 @@ func (module *module) getEnabledRules(ctx context.Context, orgID valuer.UUID) ([
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
// listAllRules pages through every pricing rule for the org, since rule matching
|
||||
// needs the full set and the count is otherwise unbounded.
|
||||
func (module *module) listAllRules(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.LLMPricingRule, error) {
|
||||
const pageSize = 1000
|
||||
|
||||
all := make([]*llmpricingruletypes.LLMPricingRule, 0)
|
||||
for offset := 0; ; offset += pageSize {
|
||||
page, total, err := module.store.List(ctx, orgID, offset, pageSize, "", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, page...)
|
||||
if len(page) == 0 || len(all) >= total {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// findExisting returns the row matching the updatable's ID or SourceID.
|
||||
// Returns a TypeNotFound error when neither matches; the caller treats that
|
||||
// as "insert new".
|
||||
@@ -197,91 +148,3 @@ func (module *module) findExisting(ctx context.Context, orgID valuer.UUID, u *ll
|
||||
return nil, errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "rule has neither id nor sourceId")
|
||||
}
|
||||
}
|
||||
|
||||
// discoverModels runs a QBv5 traces aggregation grouped by gen_ai.request.model
|
||||
// over the lookback window and returns each distinct model with its span count.
|
||||
func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.UnmappedModel, error) {
|
||||
now := time.Now()
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(now.Add(-unmappedModelsLookback).UnixMilli()),
|
||||
End: uint64(now.UnixMilli()),
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", llmpricingruletypes.GenAIRequestModel)},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "count()", Alias: "spanCount"},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: llmpricingruletypes.GenAIRequestModel,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: llmpricingruletypes.GenAIProviderName,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
},
|
||||
Limit: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := module.querier.QueryRange(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp == nil || len(resp.Data.Results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
sd, ok := resp.Data.Results[0].(*qbtypes.ScalarData)
|
||||
if !ok || sd == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
modelIdx, providerIdx, countIdx := -1, -1, -1
|
||||
for i, c := range sd.Columns {
|
||||
switch c.Type {
|
||||
case qbtypes.ColumnTypeGroup:
|
||||
switch c.Name {
|
||||
case llmpricingruletypes.GenAIRequestModel:
|
||||
modelIdx = i
|
||||
case llmpricingruletypes.GenAIProviderName:
|
||||
providerIdx = i
|
||||
}
|
||||
case qbtypes.ColumnTypeAggregation:
|
||||
countIdx = i
|
||||
}
|
||||
}
|
||||
if modelIdx == -1 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
models := make([]*llmpricingruletypes.UnmappedModel, 0, len(sd.Data))
|
||||
for _, row := range sd.Data {
|
||||
name, _ := row[modelIdx].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
provider := ""
|
||||
if providerIdx != -1 {
|
||||
provider, _ = row[providerIdx].(string)
|
||||
}
|
||||
var spanCount uint64
|
||||
if countIdx >= 0 && countIdx < len(row) {
|
||||
spanCount, _ = row[countIdx].(uint64)
|
||||
}
|
||||
models = append(models, &llmpricingruletypes.UnmappedModel{ModelName: name, Provider: provider, SpanCount: spanCount})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user