mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-06 14:40:38 +01:00
Compare commits
7 Commits
issue-4960
...
e2e/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
053d306e99 | ||
|
|
6693bb7a06 | ||
|
|
8f4c00849c | ||
|
|
77390d5a5e | ||
|
|
cc94b87357 | ||
|
|
adb60c4683 | ||
|
|
7d8f896a5f |
@@ -155,8 +155,8 @@ querier:
|
||||
cache_ttl: 168h
|
||||
# The interval for recent data that should not be cached.
|
||||
flux_interval: 5m
|
||||
# The maximum number of queries a single query range request runs at once.
|
||||
max_concurrent_queries: 8
|
||||
# The maximum number of concurrent queries for missing ranges.
|
||||
max_concurrent_queries: 4
|
||||
# When filtering logs by trace_id, clamp the query window to the trace time
|
||||
# range with padding to include slightly delayed log exports. Logs only; set
|
||||
# to 0 to disable.
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
export const ExploreHeaderToolTip = {
|
||||
url: 'https://signoz.io/docs/querying/overview/?utm_source=product&utm_medium=new-query-builder',
|
||||
url: 'https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=new-query-builder',
|
||||
text: 'More details on how to use query builder',
|
||||
};
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
@@ -254,7 +254,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
|
||||
@@ -52,7 +52,7 @@ const ADD_ONS = [
|
||||
key: ADD_ONS_KEYS.GROUP_BY,
|
||||
description:
|
||||
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
|
||||
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
|
||||
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/#grouping',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
@@ -61,7 +61,7 @@ const ADD_ONS = [
|
||||
description:
|
||||
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having',
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
@@ -70,7 +70,7 @@ const ADD_ONS = [
|
||||
description:
|
||||
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#sorting--limiting',
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
@@ -79,7 +79,7 @@ const ADD_ONS = [
|
||||
description:
|
||||
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#how-limit-works-for-time-series',
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
@@ -88,7 +88,7 @@ const ADD_ONS = [
|
||||
description:
|
||||
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/aggregation-grouping/#legend-formatting',
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#legend-formatting',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -99,7 +99,7 @@ const REDUCE_TO = {
|
||||
description:
|
||||
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations',
|
||||
};
|
||||
|
||||
const hasValue = (value: unknown): boolean =>
|
||||
@@ -349,7 +349,7 @@ function QueryAddOns({
|
||||
<TooltipContent
|
||||
label="Group By"
|
||||
description="Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments."
|
||||
docLink="https://signoz.io/docs/querying/aggregation-grouping/#grouping"
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#grouping"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
@@ -385,7 +385,7 @@ function QueryAddOns({
|
||||
<TooltipContent
|
||||
label="Having"
|
||||
description="Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500"
|
||||
docLink="https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having"
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
@@ -434,7 +434,7 @@ function QueryAddOns({
|
||||
<TooltipContent
|
||||
label="Order By"
|
||||
description="Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers."
|
||||
docLink="https://signoz.io/docs/querying/result-manipulation/#sorting--limiting"
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
@@ -473,7 +473,7 @@ function QueryAddOns({
|
||||
<TooltipContent
|
||||
label="Reduce to"
|
||||
description="Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value."
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation"
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
|
||||
@@ -65,7 +65,7 @@ function QueryAggregationOptions({
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
|
||||
@@ -676,7 +676,7 @@ function QueryAggregationSelect({
|
||||
</span>
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/querying/aggregation-grouping/#core-aggregation-functions-logs--traces"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#core-aggregation-functions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
|
||||
@@ -44,7 +44,7 @@ function TraceOperatorSection({
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
Add Trace Matching
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/querying/multi-query-analysis/#trace-matching"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-trace-operators"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
@@ -106,7 +106,7 @@ export default function QueryFooter({
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
Add New Formula
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/querying/multi-query-analysis/#advanced-comparisons"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-advanced-comparisons"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const apDexToolTipText =
|
||||
"Apdex is a way to measure your users' satisfaction with the response time of your web service. It's represented as a score from 0-1.";
|
||||
export const apDexToolTipUrl =
|
||||
'https://signoz.io/docs/alerts-management/apdex-alerts/?utm_source=product&utm_medium=frontend&utm_campaign=apdex';
|
||||
'https://signoz.io/docs/userguide/metrics/#apdex?utm_source=product&utm_medium=frontend&utm_campaign=apdex';
|
||||
export const apDexToolTipUrlText = 'Learn more about Apdex.';
|
||||
|
||||
@@ -68,7 +68,7 @@ function AlertChannels(): JSX.Element {
|
||||
<RightActionContainer>
|
||||
<TextToolTip
|
||||
text={t('tooltip_notification_channels')}
|
||||
url="https://signoz.io/docs/setup-alerts-notification/"
|
||||
url="https://signoz.io/docs/userguide/alerts-management/#setting-notification-channel"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
|
||||
@@ -29,7 +29,8 @@ function SelectAlertType({ onSelect }: SelectAlertTypeProps): JSX.Element {
|
||||
let url = '';
|
||||
switch (option) {
|
||||
case AlertTypes.ANOMALY_BASED_ALERT:
|
||||
url = 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/';
|
||||
url =
|
||||
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples';
|
||||
break;
|
||||
case AlertTypes.METRICS_BASED_ALERT:
|
||||
url =
|
||||
|
||||
@@ -31,7 +31,8 @@ export const ALERT_TYPE_URL_MAP: Record<
|
||||
'https://signoz.io/docs/alerts-management/exceptions-based-alerts/?utm_source=product&utm_medium=alert-creation-page',
|
||||
},
|
||||
[AlertTypes.ANOMALY_BASED_ALERT]: {
|
||||
selection: 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/',
|
||||
selection:
|
||||
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples',
|
||||
creation:
|
||||
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-creation-page',
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -717,13 +717,13 @@ function ExplorerOptions({
|
||||
|
||||
const infoIconLink = useMemo(() => {
|
||||
if (isLogsExplorer) {
|
||||
return 'https://signoz.io/docs/userguide/logs_query_builder/?utm_source=product&utm_medium=logs-explorer-toolbar';
|
||||
return 'https://signoz.io/docs/product-features/logs-explorer/?utm_source=product&utm_medium=logs-explorer-toolbar';
|
||||
}
|
||||
// TODO: Add metrics explorer info icon link
|
||||
if (isMetricsExplorer) {
|
||||
return '';
|
||||
}
|
||||
return 'https://signoz.io/docs/userguide/traces/?utm_source=product&utm_medium=trace-explorer-toolbar';
|
||||
return 'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=trace-explorer-toolbar';
|
||||
}, [isLogsExplorer, isMetricsExplorer]);
|
||||
|
||||
const getQueryName = (query: Query): string => {
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function SavedViews({
|
||||
});
|
||||
|
||||
window.open(
|
||||
'https://signoz.io/docs/metrics-management/metrics-explorer/#saved-views-in-metrics-explorer',
|
||||
'https://signoz.io/docs/product-features/saved-view/',
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
|
||||
@@ -29,12 +29,12 @@ export const checkListStepToPreferenceKeyMap = {
|
||||
|
||||
export const DOCS_LINKS = {
|
||||
ADD_DATA_SOURCE: 'https://signoz.io/docs/instrumentation/overview/',
|
||||
SEND_LOGS: 'https://signoz.io/docs/userguide/logs_query_builder/',
|
||||
SEND_LOGS: 'https://signoz.io/docs/userguide/logs/',
|
||||
SEND_TRACES: 'https://signoz.io/docs/userguide/traces/',
|
||||
SEND_METRICS: 'https://signoz.io/docs/metrics-management/metrics-explorer/',
|
||||
SETUP_ALERTS: 'https://signoz.io/docs/alerts/',
|
||||
SETUP_ALERTS: 'https://signoz.io/docs/userguide/alerts-management/',
|
||||
SETUP_SAVED_VIEWS:
|
||||
'https://signoz.io/docs/metrics-management/metrics-explorer/#saved-views-in-metrics-explorer',
|
||||
'https://signoz.io/docs/product-features/saved-view/#step-2-save-your-view',
|
||||
SETUP_DASHBOARDS: 'https://signoz.io/docs/userguide/manage-dashboards/',
|
||||
};
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export function K8sEmptyState({
|
||||
<span className={styles.message}>
|
||||
Please refer to{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/infrastructure-monitoring/hostmetrics/"
|
||||
href="https://signoz.io/docs/userguide/hostmetrics/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -611,7 +611,7 @@ describe('K8sBaseList', () => {
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/',
|
||||
'https://signoz.io/docs/userguide/hostmetrics/',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
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';
|
||||
@@ -112,74 +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 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);
|
||||
@@ -213,58 +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}
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
|
||||
import DockerIcon from 'assets/CustomIcons/DockerIcon';
|
||||
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
|
||||
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
|
||||
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
|
||||
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
|
||||
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
|
||||
import NginxIcon from 'assets/CustomIcons/NginxIcon';
|
||||
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
|
||||
import RedisIcon from 'assets/CustomIcons/RedisIcon';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
ConciergeBell,
|
||||
DraftingCompass,
|
||||
Drill,
|
||||
Plus,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { DashboardTemplate } from 'types/api/dashboard/getAll';
|
||||
|
||||
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
|
||||
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
|
||||
|
||||
import { filterTemplates } from '../utils';
|
||||
|
||||
import './DashboardTemplatesModal.styles.scss';
|
||||
|
||||
const templatesList: DashboardTemplate[] = [
|
||||
{
|
||||
name: 'Blank dashboard',
|
||||
icon: <Drill />,
|
||||
id: 'blank',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Alert Manager',
|
||||
icon: <ConciergeBell />,
|
||||
id: 'alertManager',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Apache',
|
||||
icon: <ApacheIcon />,
|
||||
id: 'apache',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Docker',
|
||||
icon: <DockerIcon />,
|
||||
id: 'docker',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Elasticsearch',
|
||||
icon: <ElasticSearchIcon />,
|
||||
id: 'elasticSearch',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MongoDB',
|
||||
icon: <MongoDBIcon />,
|
||||
id: 'mongoDB',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Heroku',
|
||||
icon: <HerokuIcon />,
|
||||
id: 'heroku',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Nginx',
|
||||
icon: <NginxIcon />,
|
||||
id: 'nginx',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Kubernetes',
|
||||
icon: <KubernetesIcon />,
|
||||
id: 'kubernetes',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MySQL',
|
||||
icon: <MySQLIcon />,
|
||||
id: 'mySQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'PostgreSQL',
|
||||
icon: <PostgreSQLIcon />,
|
||||
id: 'postgreSQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Redis',
|
||||
icon: <RedisIcon />,
|
||||
id: 'redis',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: redisTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'AWS',
|
||||
icon: <DraftingCompass size={14} />,
|
||||
id: 'aws',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
];
|
||||
|
||||
interface DashboardTemplatesContentProps {
|
||||
onCreateNewDashboard: () => void;
|
||||
/** When provided, renders the modal-style header with a close affordance. Omitted for inline use. */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
// The template gallery (search + list + preview + create), extracted from the
|
||||
// modal so it can be embedded inline (e.g. the V2 new-dashboard modal's template
|
||||
// tab) as well as inside DashboardTemplatesModal. Styles live under the global
|
||||
// `.new-dashboard-templates-modal` scope, so inline callers wrap it in that class.
|
||||
export default function DashboardTemplatesContent({
|
||||
onCreateNewDashboard,
|
||||
onCancel,
|
||||
}: DashboardTemplatesContentProps): JSX.Element {
|
||||
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
|
||||
templatesList[0],
|
||||
);
|
||||
|
||||
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
|
||||
|
||||
const handleDashboardTemplateSearch = (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const searchText = event.target.value;
|
||||
const filteredTemplates = filterTemplates(searchText, templatesList);
|
||||
setDashboardTemplates(filteredTemplates);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="new-dashboard-templates-content-container">
|
||||
{onCancel && (
|
||||
<div className="new-dashboard-templates-content-header">
|
||||
<Typography.Text>New Dashboard</Typography.Text>
|
||||
|
||||
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="new-dashboard-templates-content">
|
||||
<div className="new-dashboard-templates-list">
|
||||
<Input
|
||||
className="new-dashboard-templates-search"
|
||||
placeholder="🔍 Search..."
|
||||
onChange={handleDashboardTemplateSearch}
|
||||
/>
|
||||
|
||||
<div className="templates-list">
|
||||
{dashboardTemplates.map((template) => (
|
||||
<div
|
||||
className={cx(
|
||||
'template-list-item',
|
||||
selectedDashboardTemplate.id === template.id ? 'active' : '',
|
||||
)}
|
||||
key={template.name}
|
||||
onClick={() => setSelectedDashboardTemplate(template)}
|
||||
>
|
||||
<div className="template-icon">{template.icon}</div>
|
||||
<div className="template-name">{template.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="new-dashboard-template-preview">
|
||||
<div className="template-preview-header">
|
||||
<div className="template-preview-title">
|
||||
<div className="template-preview-icon">
|
||||
{selectedDashboardTemplate.icon}
|
||||
</div>
|
||||
|
||||
<div className="template-info">
|
||||
<div className="template-name">{selectedDashboardTemplate.name}</div>
|
||||
|
||||
<div className="template-description">
|
||||
{selectedDashboardTemplate.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="create-dashboard-btn">
|
||||
<Button
|
||||
type="primary"
|
||||
className="periscope-btn primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={onCreateNewDashboard}
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="template-preview-image">
|
||||
<img
|
||||
src={selectedDashboardTemplate.previewImage}
|
||||
alt={`${selectedDashboardTemplate.name}-preview`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,129 @@
|
||||
import { Modal } from 'antd';
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
|
||||
import DockerIcon from 'assets/CustomIcons/DockerIcon';
|
||||
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
|
||||
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
|
||||
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
|
||||
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
|
||||
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
|
||||
import NginxIcon from 'assets/CustomIcons/NginxIcon';
|
||||
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
|
||||
import RedisIcon from 'assets/CustomIcons/RedisIcon';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
ConciergeBell,
|
||||
DraftingCompass,
|
||||
Drill,
|
||||
Plus,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { DashboardTemplate } from 'types/api/dashboard/getAll';
|
||||
|
||||
import DashboardTemplatesContent from './DashboardTemplatesContent';
|
||||
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
|
||||
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
|
||||
|
||||
import { filterTemplates } from '../utils';
|
||||
|
||||
import './DashboardTemplatesModal.styles.scss';
|
||||
|
||||
const templatesList: DashboardTemplate[] = [
|
||||
{
|
||||
name: 'Blank dashboard',
|
||||
icon: <Drill />,
|
||||
id: 'blank',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Alert Manager',
|
||||
icon: <ConciergeBell />,
|
||||
id: 'alertManager',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Apache',
|
||||
icon: <ApacheIcon />,
|
||||
id: 'apache',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Docker',
|
||||
icon: <DockerIcon />,
|
||||
id: 'docker',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Elasticsearch',
|
||||
icon: <ElasticSearchIcon />,
|
||||
id: 'elasticSearch',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MongoDB',
|
||||
icon: <MongoDBIcon />,
|
||||
id: 'mongoDB',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Heroku',
|
||||
icon: <HerokuIcon />,
|
||||
id: 'heroku',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Nginx',
|
||||
icon: <NginxIcon />,
|
||||
id: 'nginx',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Kubernetes',
|
||||
icon: <KubernetesIcon />,
|
||||
id: 'kubernetes',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MySQL',
|
||||
icon: <MySQLIcon />,
|
||||
id: 'mySQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'PostgreSQL',
|
||||
icon: <PostgreSQLIcon />,
|
||||
id: 'postgreSQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Redis',
|
||||
icon: <RedisIcon />,
|
||||
id: 'redis',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: redisTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'AWS',
|
||||
icon: <DraftingCompass size={14} />,
|
||||
id: 'aws',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
];
|
||||
|
||||
interface DashboardTemplatesModalProps {
|
||||
showNewDashboardTemplatesModal: boolean;
|
||||
onCreateNewDashboard: () => void;
|
||||
@@ -15,6 +135,20 @@ export default function DashboardTemplatesModal({
|
||||
onCreateNewDashboard,
|
||||
onCancel,
|
||||
}: DashboardTemplatesModalProps): JSX.Element {
|
||||
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
|
||||
templatesList[0],
|
||||
);
|
||||
|
||||
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
|
||||
|
||||
const handleDashboardTemplateSearch = (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const searchText = event.target.value;
|
||||
const filteredTemplates = filterTemplates(searchText, templatesList);
|
||||
setDashboardTemplates(filteredTemplates);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
wrapClassName="new-dashboard-templates-modal"
|
||||
@@ -25,10 +159,75 @@ export default function DashboardTemplatesModal({
|
||||
destroyOnClose
|
||||
width="60vw"
|
||||
>
|
||||
<DashboardTemplatesContent
|
||||
onCreateNewDashboard={onCreateNewDashboard}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
<div className="new-dashboard-templates-content-container">
|
||||
<div className="new-dashboard-templates-content-header">
|
||||
<Typography.Text>New Dashboard</Typography.Text>
|
||||
|
||||
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
|
||||
</div>
|
||||
|
||||
<div className="new-dashboard-templates-content">
|
||||
<div className="new-dashboard-templates-list">
|
||||
<Input
|
||||
className="new-dashboard-templates-search"
|
||||
placeholder="🔍 Search..."
|
||||
onChange={handleDashboardTemplateSearch}
|
||||
/>
|
||||
|
||||
<div className="templates-list">
|
||||
{dashboardTemplates.map((template) => (
|
||||
<div
|
||||
className={cx(
|
||||
'template-list-item',
|
||||
selectedDashboardTemplate.id === template.id ? 'active' : '',
|
||||
)}
|
||||
key={template.name}
|
||||
onClick={() => setSelectedDashboardTemplate(template)}
|
||||
>
|
||||
<div className="template-icon">{template.icon}</div>
|
||||
<div className="template-name">{template.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="new-dashboard-template-preview">
|
||||
<div className="template-preview-header">
|
||||
<div className="template-preview-title">
|
||||
<div className="template-preview-icon">
|
||||
{selectedDashboardTemplate.icon}
|
||||
</div>
|
||||
|
||||
<div className="template-info">
|
||||
<div className="template-name">{selectedDashboardTemplate.name}</div>
|
||||
|
||||
<div className="template-description">
|
||||
{selectedDashboardTemplate.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="create-dashboard-btn">
|
||||
<Button
|
||||
type="primary"
|
||||
className="periscope-btn primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={onCreateNewDashboard}
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="template-preview-image">
|
||||
<img
|
||||
src={selectedDashboardTemplate.previewImage}
|
||||
alt={`${selectedDashboardTemplate.name}-preview`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ function BreakDown(): JSX.Element {
|
||||
Meter metrics data is aggregated over 1 hour period. Please select time
|
||||
range accordingly.
|
||||
<a
|
||||
href="https://signoz.io/docs/cost-meter/overview/#get-started"
|
||||
href="https://signoz.io/docs/cost-meter/overview/#accessing-cost-meter"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
|
||||
@@ -197,7 +197,7 @@ function TopOperationsTable({
|
||||
|
||||
const entryPointSpanInfo = {
|
||||
text: 'Shows the spans where requests enter new services for the first time',
|
||||
url: 'https://signoz.io/docs/apm-and-distributed-tracing/application-details/',
|
||||
url: 'https://signoz.io/docs/traces-management/guides/entry-point-spans-service-overview/',
|
||||
urlText: 'Learn more about Entrypoint Spans.',
|
||||
};
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ function ConfigureGoogleAuthAuthnProvider({
|
||||
Enter OAuth 2.0 credentials obtained from the Google API Console below.
|
||||
Read the{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
|
||||
href="https://signoz.io/docs/userguide/sso-authentication"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -38,7 +38,7 @@ function ConfigureOIDCAuthnProvider({
|
||||
Configure OpenID Connect Single Sign-On with your Identity Provider. Read
|
||||
the{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
|
||||
href="https://signoz.io/docs/userguide/sso-authentication"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -37,7 +37,7 @@ function ConfigureSAMLAuthnProvider({
|
||||
<p className="authn-provider__description">
|
||||
Configure SAML 2.0 Single Sign-On with your Identity Provider. Read the{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
|
||||
href="https://signoz.io/docs/userguide/sso-authentication"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -216,7 +216,7 @@ export default function QueryFunctions({
|
||||
Add new function
|
||||
<Typography.Link
|
||||
style={{ textDecoration: 'underline' }}
|
||||
href="https://signoz.io/docs/querying/functions-extended-analysis/?utm_source=product&utm_medium=query-builder"
|
||||
href="https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=query-builder#functions-for-extended-data-analysis"
|
||||
target="_blank"
|
||||
>
|
||||
{' '}
|
||||
|
||||
@@ -100,7 +100,7 @@ function Version(): JSX.Element {
|
||||
{!isError && !isLatestVersion && (
|
||||
<div className="version-page-upgrade-container">
|
||||
<Button
|
||||
href="https://signoz.io/docs/opentelemetry-collection-agents/docker/overview/"
|
||||
href="https://signoz.io/docs/operate/docker-standalone/#upgrade"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
className="periscope-btn primary"
|
||||
|
||||
@@ -32,34 +32,6 @@
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.publicLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lockButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lockButton:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { KeyboardEvent } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Globe,
|
||||
LockKeyhole,
|
||||
LockKeyholeOpen,
|
||||
SolidInfoCircle,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Check, Globe, LockKeyhole, SolidInfoCircle, X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
@@ -14,7 +7,6 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
import { useVisibleTagCount } from './useVisibleTagCount';
|
||||
@@ -26,13 +18,7 @@ interface DashboardInfoProps {
|
||||
tags: string[];
|
||||
description: string;
|
||||
isPublicDashboard: boolean;
|
||||
/** Absolute URL of the public dashboard page; opened when the globe is clicked. */
|
||||
publicUrl: string;
|
||||
isDashboardLocked: boolean;
|
||||
/** Whether to render the lock toggle at all (hidden for never-locked dashboards). */
|
||||
showLockToggle: boolean;
|
||||
/** When provided, the lock icon toggles lock/unlock (author/admin only). */
|
||||
onToggleLock?: () => void;
|
||||
isEditing: boolean;
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
@@ -47,10 +33,7 @@ function DashboardInfo({
|
||||
tags,
|
||||
description,
|
||||
isPublicDashboard,
|
||||
publicUrl,
|
||||
isDashboardLocked,
|
||||
showLockToggle,
|
||||
onToggleLock,
|
||||
isEditing,
|
||||
draft,
|
||||
onDraftChange,
|
||||
@@ -68,17 +51,6 @@ function DashboardInfo({
|
||||
const visibleTags = needsOverflow ? tags.slice(0, visibleCount) : tags;
|
||||
const remainingTags = needsOverflow ? tags.slice(visibleCount) : [];
|
||||
|
||||
let lockTooltip: string;
|
||||
if (onToggleLock) {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'Locked — click to unlock'
|
||||
: 'Unlocked — click to lock';
|
||||
} else {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'This dashboard is locked'
|
||||
: 'This dashboard is unlocked';
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
@@ -129,7 +101,7 @@ function DashboardInfo({
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipSimple title={title} disableHoverableContent>
|
||||
<TooltipSimple title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
@@ -143,7 +115,7 @@ function DashboardInfo({
|
||||
)}
|
||||
|
||||
{hasDescription && (
|
||||
<TooltipSimple title={description} disableHoverableContent>
|
||||
<TooltipSimple title={description}>
|
||||
<SolidInfoCircle
|
||||
className={styles.descriptionIcon}
|
||||
size={14}
|
||||
@@ -153,44 +125,14 @@ function DashboardInfo({
|
||||
)}
|
||||
|
||||
{isPublicDashboard && (
|
||||
<TooltipSimple
|
||||
title="This dashboard is publicly accessible. Click to open the public page."
|
||||
disableHoverableContent
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.publicLink}
|
||||
aria-label="Open public dashboard"
|
||||
testId="dashboard-public-link"
|
||||
onClick={(): void => openInNewTab(publicUrl)}
|
||||
>
|
||||
<Globe size={14} />
|
||||
</Button>
|
||||
<TooltipSimple title="This dashboard is publicly accessible">
|
||||
<Globe size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{showLockToggle && (
|
||||
<TooltipSimple title={lockTooltip} disableHoverableContent>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.lockButton}
|
||||
aria-label={isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard'}
|
||||
testId="dashboard-lock"
|
||||
disabled={!onToggleLock}
|
||||
onClick={onToggleLock}
|
||||
>
|
||||
{isDashboardLocked ? (
|
||||
<LockKeyhole size={14} />
|
||||
) : (
|
||||
<LockKeyholeOpen size={14} />
|
||||
)}
|
||||
</Button>
|
||||
{isDashboardLocked && (
|
||||
<TooltipSimple title="This dashboard is locked">
|
||||
<LockKeyhole size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
@@ -203,14 +145,14 @@ function DashboardInfo({
|
||||
data-testid="dashboard-tags"
|
||||
>
|
||||
{visibleTags.map((tag) => (
|
||||
<Badge key={tag} color="sienna" variant="outline">
|
||||
<Badge key={tag} color="warning" variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{remainingTags.length > 0 && (
|
||||
<TooltipSimple title={remainingTags.join(', ')}>
|
||||
<Badge
|
||||
color="sienna"
|
||||
color="warning"
|
||||
variant="outline"
|
||||
data-testid="dashboard-tags-overflow"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -15,12 +15,9 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { useCreatePanel } from '../hooks/useCreatePanel';
|
||||
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
|
||||
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
@@ -39,15 +36,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const { dashboard, handle, refetch } = props;
|
||||
|
||||
const id = dashboard.id;
|
||||
|
||||
// Session-local lock state: the toggle appears once locked and persists for the page.
|
||||
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
|
||||
const [showLockToggle, setShowLockToggle] = useState(!!dashboard.locked);
|
||||
useEffect(() => {
|
||||
setIsDashboardLocked(!!dashboard.locked);
|
||||
setShowLockToggle(!!dashboard.locked);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard.id]);
|
||||
const isDashboardLocked = !!dashboard.locked;
|
||||
|
||||
const title = dashboard.spec.display.name;
|
||||
const description = dashboard.spec.display.description ?? '';
|
||||
@@ -69,36 +58,20 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
|
||||
// Author/admin can lock-unlock (mirrors the Actions menu gate); integration-owned
|
||||
// dashboards are never toggleable.
|
||||
const canToggleLock =
|
||||
(isAuthor || user.role === USER_ROLES.ADMIN) &&
|
||||
dashboard.createdBy !== 'integration';
|
||||
|
||||
// Public-sharing meta (deduped react-query read); drives the header globe.
|
||||
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
|
||||
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
|
||||
|
||||
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const next = !isDashboardLocked;
|
||||
setIsDashboardLocked(next);
|
||||
if (next) {
|
||||
setShowLockToggle(true);
|
||||
}
|
||||
try {
|
||||
if (next) {
|
||||
await lockDashboardV2({ id });
|
||||
toast.success('Dashboard locked');
|
||||
} else {
|
||||
if (isDashboardLocked) {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
} else {
|
||||
await lockDashboardV2({ id });
|
||||
toast.success('Dashboard locked');
|
||||
}
|
||||
refetch();
|
||||
} catch (error) {
|
||||
setIsDashboardLocked(!next);
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [id, isDashboardLocked, refetch, showErrorModal]);
|
||||
@@ -146,11 +119,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={isPublic}
|
||||
publicUrl={publicUrl}
|
||||
isPublicDashboard={false}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={canToggleLock ? handleLockDashboardToggle : undefined}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
type DashboardtypesThresholdWithLabelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
import type {
|
||||
AnyThreshold,
|
||||
ThresholdVariant,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
@@ -77,7 +77,7 @@ function ThresholdsSection({
|
||||
yAxisUnit,
|
||||
tableColumns = [],
|
||||
}: ThresholdsSectionProps): JSX.Element {
|
||||
const variant = controls?.variant ?? ThresholdVariant.LABEL;
|
||||
const variant = controls?.variant ?? 'label';
|
||||
const thresholds = value ?? [];
|
||||
// Which row is being edited, and whether it was just added (so Discard removes it).
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
|
||||
@@ -4,10 +4,7 @@ import {
|
||||
type DashboardtypesComparisonThresholdDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
ThresholdVariant,
|
||||
type AnyThreshold,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import UnifiedThresholdsSection from '../ThresholdsSection';
|
||||
@@ -24,7 +21,7 @@ function ComparisonThresholdsSection(props: {
|
||||
value={props.value}
|
||||
onChange={props.onChange as (next: AnyThreshold[]) => void}
|
||||
yAxisUnit={props.yAxisUnit}
|
||||
controls={{ variant: ThresholdVariant.COMPARISON }}
|
||||
controls={{ variant: 'comparison' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { SolidAlertTriangle, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { ConfirmDialog } from '@signozhq/ui/dialog';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useConfirmableAction } from 'hooks/useConfirmableAction';
|
||||
@@ -61,42 +61,24 @@ function Header({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogWrapper
|
||||
<ConfirmDialog
|
||||
open={discard.open}
|
||||
onOpenChange={(next: boolean): void => {
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
discard.cancel();
|
||||
}
|
||||
}}
|
||||
title="Discard changes?"
|
||||
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
|
||||
testId="panel-editor-v2-discard-modal"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
data-testid="panel-editor-v2-discard-confirm"
|
||||
loading={discard.isPending}
|
||||
onClick={discard.confirm}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
data-testid="panel-editor-v2-discard-cancel"
|
||||
onClick={discard.cancel}
|
||||
>
|
||||
Keep editing
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
confirmText="Discard"
|
||||
confirmColor="destructive"
|
||||
cancelText="Keep editing"
|
||||
onConfirm={discard.confirm}
|
||||
onCancel={discard.cancel}
|
||||
data-testid="panel-editor-v2-discard-modal"
|
||||
>
|
||||
<Typography>Your unsaved edits to this panel will be lost.</Typography>
|
||||
</DialogWrapper>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -25,8 +23,6 @@ interface PreviewPaneProps {
|
||||
data: PanelQueryData;
|
||||
/** Any fetch in flight — drives the header spinner and the body's loading state. */
|
||||
isFetching: boolean;
|
||||
/** Showing a prior page's data while the next loads; forwarded so the list shows skeleton rows. */
|
||||
isPreviousData?: boolean;
|
||||
error: Error | null;
|
||||
/** Re-run the query (drives PanelBody's error-state retry). */
|
||||
refetch: () => void;
|
||||
@@ -34,14 +30,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,15 +43,10 @@ function PreviewPane({
|
||||
panelDefinition,
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
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 +58,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}
|
||||
@@ -110,15 +87,12 @@ function PreviewPane({
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
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,5 +1,7 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
@@ -26,21 +28,20 @@ function specWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
// Thin wrapper — only prove delegation; seeding rules are covered in buildPluginSpec.test.ts.
|
||||
describe('getSwitchedPluginSpec', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDefaultColumnsForSignal.mockReturnValue([]);
|
||||
});
|
||||
|
||||
it("resolves the target kind's sections and carries the old spec through them", () => {
|
||||
it('carries only unit + decimalPrecision when the new kind has a formatting section', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
],
|
||||
sections: [{ kind: 'formatting', controls: { unit: true, decimals: true } }],
|
||||
});
|
||||
const old = specWith({
|
||||
formatting: { unit: 'ms', decimalPrecision: 2, columnUnits: { A: 'bytes' } },
|
||||
axes: { logScale: true },
|
||||
});
|
||||
const old = specWith({ formatting: { unit: 'ms', decimalPrecision: 2 } });
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
old,
|
||||
@@ -48,12 +49,25 @@ describe('getSwitchedPluginSpec', () => {
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(mockGetPanelDefinition).toHaveBeenCalledWith('signoz/TimeSeriesPanel');
|
||||
expect(result.legend?.position).toBe('bottom');
|
||||
expect(result.formatting).toStrictEqual({ unit: 'ms', decimalPrecision: 2 });
|
||||
// Type-specific config from the old kind is dropped.
|
||||
expect((result as { axes?: unknown }).axes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forwards the signal to seed List columns', () => {
|
||||
it('does not carry formatting when the new kind has no formatting section', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
|
||||
const old = specWith({ formatting: { unit: 'ms' } });
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
old,
|
||||
'signoz/ListPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(result.formatting).toBeUndefined();
|
||||
});
|
||||
|
||||
it('seeds List columns from the signal when switching into a List', () => {
|
||||
const columns = [{ name: 'body' }];
|
||||
mockDefaultColumnsForSignal.mockReturnValue(columns);
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
|
||||
@@ -69,4 +83,155 @@ describe('getSwitchedPluginSpec', () => {
|
||||
);
|
||||
expect(result.selectFields).toBe(columns);
|
||||
});
|
||||
|
||||
it('includes the kind section defaults (e.g. legend position)', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [{ kind: 'legend', controls: { position: true } }],
|
||||
});
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
specWith({}),
|
||||
'signoz/PieChartPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(result.legend?.position).toBe('bottom');
|
||||
});
|
||||
|
||||
describe('thresholds', () => {
|
||||
it('does not carry thresholds when the new kind has no thresholds section', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
|
||||
const old = specWith({
|
||||
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
|
||||
});
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
old,
|
||||
'signoz/ListPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(result.thresholds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('carries thresholds verbatim within the label variant (color/value/unit/label)', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
|
||||
});
|
||||
const old = specWith({
|
||||
thresholds: [{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' }],
|
||||
});
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
old,
|
||||
'signoz/BarChartPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(result.thresholds).toStrictEqual([
|
||||
{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('remaps label thresholds into the comparison variant, defaulting operator + format', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [{ kind: 'thresholds', controls: { variant: 'comparison' } }],
|
||||
});
|
||||
const old = specWith({
|
||||
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
|
||||
});
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
old,
|
||||
'signoz/NumberPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
// The label is dropped; operator/format are seeded so the threshold can match.
|
||||
expect(result.thresholds).toStrictEqual([
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('remaps comparison thresholds into the table variant, keeping operator/format and seeding a column', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [{ kind: 'thresholds', controls: { variant: 'table' } }],
|
||||
});
|
||||
const old = specWith({
|
||||
thresholds: [
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
old,
|
||||
'signoz/TablePanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(result.thresholds).toStrictEqual([
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
columnName: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the table-only columnName when remapping into the label variant', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
|
||||
});
|
||||
const old = specWith({
|
||||
thresholds: [
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
columnName: 'p99',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
old,
|
||||
'signoz/TimeSeriesPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(result.thresholds).toStrictEqual([{ value: 80, color: '#F1575F' }]);
|
||||
});
|
||||
|
||||
it('defaults the variant to label when the thresholds section omits controls', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [{ kind: 'thresholds', controls: {} }],
|
||||
});
|
||||
const old = specWith({
|
||||
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
|
||||
});
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
old,
|
||||
'signoz/TimeSeriesPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(result.thresholds).toStrictEqual([
|
||||
{ value: 80, color: '#F1575F', label: 'warn' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,149 @@
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
type TelemetrytypesSignalDTO,
|
||||
type TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
buildPluginSpec,
|
||||
type SeededPluginSpec,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
type AnyThreshold,
|
||||
type PanelFormattingSlice,
|
||||
type SectionConfig,
|
||||
SectionKind,
|
||||
type ThresholdVariant,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import {
|
||||
buildDefaultPluginSpec,
|
||||
type DefaultPluginSpec,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/buildDefaultPluginSpec';
|
||||
|
||||
export type SwitchedPluginSpec = SeededPluginSpec;
|
||||
import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
|
||||
|
||||
/**
|
||||
* Plugin spec for a first-visit switch to `newKind`: the kind's defaults plus the cross-kind
|
||||
* config each section carries from `oldSpec`. Revisiting a kind restores its stash instead.
|
||||
* Plugin spec produced on a first-time switch to a new kind. A partial cross-section
|
||||
* of the per-kind spec union; the caller assigns it to `plugin.spec` (typed `unknown`)
|
||||
* at the boundary.
|
||||
*/
|
||||
export interface SwitchedPluginSpec extends DefaultPluginSpec {
|
||||
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
thresholds?: AnyThreshold[];
|
||||
}
|
||||
|
||||
/** Every field any threshold variant can hold; switching reads across shapes to remap. */
|
||||
interface AnyThresholdFields {
|
||||
color: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
operator?: DashboardtypesComparisonOperatorDTO;
|
||||
format?: DashboardtypesThresholdFormatDTO;
|
||||
columnName?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** The threshold variant a kind edits, or `undefined` when it has no Thresholds section. */
|
||||
function getThresholdVariant(
|
||||
sections: SectionConfig[],
|
||||
): ThresholdVariant | undefined {
|
||||
const section = sections.find(
|
||||
(s): s is Extract<SectionConfig, { kind: SectionKind.Thresholds }> =>
|
||||
s.kind === SectionKind.Thresholds,
|
||||
);
|
||||
return section ? (section.controls.variant ?? 'label') : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaps a threshold to the target kind's variant: keeps the shared core (color, value,
|
||||
* unit) plus any cross-variant fields, and seeds the rest with the variant's defaults so
|
||||
* the carried threshold stays functional (a comparison/table threshold needs an operator
|
||||
* to match, a table threshold a column).
|
||||
*/
|
||||
function toThresholdVariant(
|
||||
source: AnyThresholdFields,
|
||||
variant: ThresholdVariant,
|
||||
): AnyThreshold {
|
||||
const core = {
|
||||
color: source.color,
|
||||
value: source.value,
|
||||
...(source.unit !== undefined && { unit: source.unit }),
|
||||
};
|
||||
if (variant === 'comparison') {
|
||||
return {
|
||||
...core,
|
||||
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
|
||||
format: source.format ?? DashboardtypesThresholdFormatDTO.text,
|
||||
};
|
||||
}
|
||||
if (variant === 'table') {
|
||||
return {
|
||||
...core,
|
||||
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
|
||||
format: source.format ?? DashboardtypesThresholdFormatDTO.background,
|
||||
columnName: source.columnName ?? '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...core,
|
||||
...(source.label !== undefined && { label: source.label }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the plugin spec for a first-visit switch to `newKind`: the kind's declared
|
||||
* section defaults (so the config pane opens populated, matching new-panel seeding) plus
|
||||
* the cross-kind config worth keeping — unit + decimal precision, and thresholds when the
|
||||
* new kind supports them (remapped to its variant). Switching into a List seeds the
|
||||
* current signal's default columns so the columns control isn't empty.
|
||||
*
|
||||
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
|
||||
*/
|
||||
export function getSwitchedPluginSpec(
|
||||
oldSpec: DashboardtypesPanelSpecDTO,
|
||||
newKind: PanelKind,
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): SwitchedPluginSpec {
|
||||
return buildPluginSpec(getPanelDefinition(newKind).sections, {
|
||||
oldSpec,
|
||||
signal,
|
||||
});
|
||||
const sections = getPanelDefinition(newKind).sections;
|
||||
const result: SwitchedPluginSpec = buildDefaultPluginSpec(sections);
|
||||
|
||||
if (sections.some((section) => section.kind === SectionKind.Formatting)) {
|
||||
const oldFormatting = (
|
||||
oldSpec.plugin.spec as {
|
||||
formatting?: PanelFormattingSlice;
|
||||
}
|
||||
).formatting;
|
||||
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
|
||||
...(oldFormatting?.unit !== undefined && { unit: oldFormatting.unit }),
|
||||
...(oldFormatting?.decimalPrecision !== undefined && {
|
||||
decimalPrecision: oldFormatting.decimalPrecision,
|
||||
}),
|
||||
};
|
||||
if (Object.keys(carried).length > 0) {
|
||||
result.formatting = carried;
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.some((section) => section.kind === SectionKind.Columns)) {
|
||||
const columns = defaultColumnsForSignal(signal);
|
||||
if (columns.length > 0) {
|
||||
result.selectFields = columns;
|
||||
}
|
||||
}
|
||||
|
||||
const thresholdVariant = getThresholdVariant(sections);
|
||||
if (thresholdVariant) {
|
||||
const oldThresholds = (
|
||||
oldSpec.plugin.spec as {
|
||||
thresholds?: AnyThreshold[] | null;
|
||||
}
|
||||
).thresholds;
|
||||
if (oldThresholds && oldThresholds.length > 0) {
|
||||
result.thresholds = oldThresholds.map((threshold) =>
|
||||
toThresholdVariant(threshold as AnyThresholdFields, thresholdVariant),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ const LIST_QUERIES = [{ id: 'list-q' }] as unknown as NonNullable<
|
||||
const TRANSFORMED = {
|
||||
id: 'transformed',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ orderBy: [] }] },
|
||||
} as unknown as Query;
|
||||
const CONVERTED = [{ id: 'converted' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
@@ -132,39 +131,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
expect(next.plugin.kind).toBe('signoz/ListPanel');
|
||||
expect(next.plugin.spec).toBe(SWITCHED_SPEC);
|
||||
expect(next.queries).toBe(CONVERTED);
|
||||
const redirected = state.redirectWithQueryBuilderData.mock
|
||||
.calls[0][0] as Query;
|
||||
expect(redirected.builder.queryData[0].orderBy).toStrictEqual([
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('seeds timestamp-desc Order By on every query when switching to a List panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ orderBy: [] }, { orderBy: undefined }] },
|
||||
} as unknown as Query);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
const [persisted] = mockToPerses.mock.calls[0] as [Query];
|
||||
persisted.builder.queryData.forEach((qd) => {
|
||||
expect(qd.orderBy).toStrictEqual([
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
]);
|
||||
});
|
||||
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(TRANSFORMED);
|
||||
});
|
||||
|
||||
it('coerces the query type when the new kind disallows it (promql → List)', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
type PartialPanelTypes,
|
||||
} from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type {
|
||||
OrderByPayload,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { resolveQueryType } from '../../Panels/capabilities';
|
||||
import {
|
||||
@@ -28,25 +25,6 @@ import {
|
||||
type SwitchedPluginSpec,
|
||||
} from '../getSwitchedPluginSpec';
|
||||
|
||||
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
|
||||
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
];
|
||||
|
||||
function withDefaultListOrder(query: Query): Query {
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((qd) =>
|
||||
qd.orderBy && qd.orderBy.length > 0
|
||||
? qd
|
||||
: { ...qd, orderBy: DEFAULT_LIST_ORDER_BY },
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** What a kind looks like when you leave it; restored verbatim if you return. */
|
||||
interface KindState {
|
||||
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
|
||||
@@ -138,21 +116,16 @@ export function usePanelTypeSwitch({
|
||||
{ ...query, queryType },
|
||||
panelTypeRef.current,
|
||||
);
|
||||
// Match a fresh list panel's default order so the builder's Order By isn't empty.
|
||||
const nextQuery =
|
||||
newPanelType === PANEL_TYPES.LIST
|
||||
? withDefaultListOrder(transformed)
|
||||
: transformed;
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
|
||||
setSpec(
|
||||
buildSpec(
|
||||
getSwitchedPluginSpec(currentSpec, newKind, signal),
|
||||
toPerses(nextQuery, newPanelType),
|
||||
toPerses(transformed, newPanelType),
|
||||
),
|
||||
);
|
||||
redirectWithQueryBuilderData(nextQuery);
|
||||
redirectWithQueryBuilderData(transformed);
|
||||
},
|
||||
[setSpec, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
@@ -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,37 @@ 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, 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 +156,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.
|
||||
@@ -225,7 +235,6 @@ function PanelEditorContainer({
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
@@ -236,7 +245,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,8 +1,4 @@
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
type SectionConfig,
|
||||
} from '../../types/sections';
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
|
||||
// Bar stacking lives in `visualization.stackedBarChart`, so it's a `visualization`
|
||||
// control, not `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
|
||||
@@ -14,9 +10,6 @@ export const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true } },
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.LABEL },
|
||||
},
|
||||
{ kind: SectionKind.Thresholds, controls: { variant: 'label' } },
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Select, Skeleton, Table } from 'antd';
|
||||
import { Select, Table } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ChevronLeft, ChevronRight } from '@signozhq/icons';
|
||||
@@ -36,7 +36,6 @@ function ListPanelRenderer({
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
pagination,
|
||||
isPreviousData = false,
|
||||
}: PanelRendererProps<'signoz/ListPanel'>): JSX.Element {
|
||||
// Pin the header while the body scrolls (shared with the Table kind).
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -116,25 +115,6 @@ function ListPanelRenderer({
|
||||
// Show the footer whenever the panel pages server-side, so the page-size picker stays reachable (V1 parity).
|
||||
const showPager = !!pagination;
|
||||
|
||||
// While the next page loads, swap the stale rows (held by keepPreviousData) for skeleton bars,
|
||||
// keeping the header + pager. Row count mirrors the page being left.
|
||||
const skeletonRowCount = dataSource.length || pagination?.pageSize || 10;
|
||||
const skeletonColumns = useMemo(
|
||||
() =>
|
||||
resizableColumns.map((col) => ({
|
||||
...col,
|
||||
render: (): JSX.Element => <Skeleton.Input active block size="small" />,
|
||||
})),
|
||||
[resizableColumns],
|
||||
);
|
||||
const skeletonRows = useMemo(
|
||||
() =>
|
||||
Array.from({ length: skeletonRowCount }, (_, index) => ({
|
||||
key: `skeleton-${index}`,
|
||||
})) as unknown as typeof filteredDataSource,
|
||||
[skeletonRowCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -154,13 +134,13 @@ function ListPanelRenderer({
|
||||
<Table
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
columns={isPreviousData ? skeletonColumns : resizableColumns}
|
||||
columns={resizableColumns}
|
||||
components={components}
|
||||
dataSource={isPreviousData ? skeletonRows : filteredDataSource}
|
||||
dataSource={filteredDataSource}
|
||||
pagination={false}
|
||||
// Vertical scroll only; `x: 'max-content'` forced a content-width min that pushed columns off-screen.
|
||||
scroll={{ y: scrollY }}
|
||||
onRow={isPreviousData ? undefined : onRow}
|
||||
onRow={onRow}
|
||||
/>
|
||||
</div>
|
||||
{showPager && pagination && (
|
||||
|
||||
@@ -162,18 +162,4 @@ describe('ListPanelRenderer', () => {
|
||||
|
||||
expect(queryByTestId('list-panel-pager')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('swaps rows for skeletons while the next page loads (isPreviousData), keeping header + pager', () => {
|
||||
const { getByText, queryByText, getByTestId, container } = renderPanel({
|
||||
data: dataWith([{ data: { body: 'stale row' } }]),
|
||||
pagination: makePagination({ canNext: true }),
|
||||
isPreviousData: true,
|
||||
});
|
||||
|
||||
expect(getByText('body')).toBeInTheDocument();
|
||||
expect(getByTestId('list-panel-pager')).toBeInTheDocument();
|
||||
// Stale page content is replaced by skeleton bars.
|
||||
expect(queryByText('stale row')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
type SectionConfig,
|
||||
} from '../../types/sections';
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
@@ -10,9 +6,6 @@ export const sections: SectionConfig[] = [
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.COMPARISON },
|
||||
},
|
||||
{ kind: SectionKind.Thresholds, controls: { variant: 'comparison' } },
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
type SectionConfig,
|
||||
} from '../../types/sections';
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
|
||||
// A table panel renders one scalar result (the V5 backend joins every query into a
|
||||
// single column set). It exposes the per-panel time scope, formatting (decimals +
|
||||
@@ -16,9 +12,6 @@ export const sections: SectionConfig[] = [
|
||||
kind: SectionKind.Formatting,
|
||||
controls: { decimals: true, columnUnits: true },
|
||||
},
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.TABLE },
|
||||
},
|
||||
{ kind: SectionKind.Thresholds, controls: { variant: 'table' } },
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
type SectionConfig,
|
||||
} from '../../types/sections';
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
@@ -22,9 +18,6 @@ export const sections: SectionConfig[] = [
|
||||
spanGaps: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.LABEL },
|
||||
},
|
||||
{ kind: SectionKind.Thresholds, controls: { variant: 'label' } },
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { UTC_TIMEZONE } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import { buildTimeSeriesConfig } from '../buildConfig';
|
||||
|
||||
const series: PanelSeries[] = [
|
||||
{
|
||||
queryName: 'A',
|
||||
legend: 'A',
|
||||
labels: {},
|
||||
values: [
|
||||
{ timestamp: 1000, value: 1 },
|
||||
{ timestamp: 61000, value: 2 },
|
||||
],
|
||||
kind: 'series',
|
||||
aggregation: { index: 0, alias: 'A' },
|
||||
},
|
||||
];
|
||||
|
||||
/** Resolved per-series `spanGaps` values from a built TimeSeries config. */
|
||||
function spanGapsFor(
|
||||
spanGaps: unknown,
|
||||
stepIntervals?: Record<string, number>,
|
||||
): (boolean | number | undefined)[] {
|
||||
const spec = {
|
||||
chartAppearance: spanGaps ? { spanGaps } : {},
|
||||
} as unknown as DashboardtypesTimeSeriesPanelSpecDTO;
|
||||
return buildTimeSeriesConfig({
|
||||
panelId: 'p1',
|
||||
spec,
|
||||
builderQueries: [],
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode: false,
|
||||
timezone: UTC_TIMEZONE,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
})
|
||||
.getSeriesSpanGapsOptions()
|
||||
.map((o) => o.spanGaps);
|
||||
}
|
||||
|
||||
describe('buildTimeSeriesConfig spanGaps', () => {
|
||||
it('floors a numeric threshold below the step interval at the step interval', () => {
|
||||
expect(
|
||||
spanGapsFor({ fillOnlyBelow: true, fillLessThan: '10s' }, { A: 60 }),
|
||||
).toStrictEqual([60]);
|
||||
});
|
||||
|
||||
it('keeps a numeric threshold larger than the step interval', () => {
|
||||
expect(
|
||||
spanGapsFor({ fillOnlyBelow: true, fillLessThan: '300s' }, { A: 60 }),
|
||||
).toStrictEqual([300]);
|
||||
});
|
||||
|
||||
it('uses the smallest step interval across queries as the floor', () => {
|
||||
expect(
|
||||
spanGapsFor({ fillOnlyBelow: true, fillLessThan: '10s' }, { A: 60, B: 30 }),
|
||||
).toStrictEqual([30]);
|
||||
});
|
||||
|
||||
it('leaves boolean span-all (true) untouched', () => {
|
||||
expect(spanGapsFor(undefined, { A: 60 })).toStrictEqual([true]);
|
||||
// fillOnlyBelow false → resolveSpanGaps returns true → not clamped.
|
||||
expect(
|
||||
spanGapsFor({ fillOnlyBelow: false, fillLessThan: '10s' }, { A: 60 }),
|
||||
).toStrictEqual([true]);
|
||||
});
|
||||
|
||||
it('passes a numeric threshold through when no step intervals are known', () => {
|
||||
expect(
|
||||
spanGapsFor({ fillOnlyBelow: true, fillLessThan: '10s' }),
|
||||
).toStrictEqual([10]);
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,7 @@ import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/service
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import {
|
||||
buildBaseConfig,
|
||||
minStepInterval,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import {
|
||||
FILL_MODE_MAP,
|
||||
LINE_INTERPOLATION_MAP,
|
||||
@@ -83,14 +80,7 @@ export function buildTimeSeriesConfig({
|
||||
onClick,
|
||||
});
|
||||
|
||||
addSeries({
|
||||
builder,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
});
|
||||
addSeries({ builder, spec, builderQueries, series, isDarkMode });
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -100,8 +90,6 @@ interface AddSeriesArgs {
|
||||
spec: DashboardtypesTimeSeriesPanelSpecDTO;
|
||||
builderQueries: BuilderQuery[];
|
||||
series: PanelSeries[];
|
||||
/** Per-query step intervals (seconds); floor for a numeric spanGaps threshold. */
|
||||
stepIntervals?: Record<string, number>;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
@@ -114,23 +102,15 @@ function addSeries({
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
}: AddSeriesArgs): void {
|
||||
const chartAppearance = spec.chartAppearance;
|
||||
// `customColors` is nullable on the spec; coerce so `addSeries` always gets
|
||||
// a defined record (it dereferences keys without a guard).
|
||||
const colorMapping = spec.legend?.customColors ?? {};
|
||||
const resolvedSpanGaps = chartAppearance?.spanGaps
|
||||
? resolveSpanGaps(chartAppearance.spanGaps)
|
||||
const spanGaps = chartAppearance?.spanGaps
|
||||
? resolveSpanGaps(chartAppearance?.spanGaps)
|
||||
: true;
|
||||
// A numeric spanGaps is a max-gap threshold (seconds); floor it at the step interval so a
|
||||
// sub-step value doesn't break the line at every normal point. Boolean `true` passes through.
|
||||
const minStep = stepIntervals ? minStepInterval(stepIntervals) : undefined;
|
||||
const spanGaps =
|
||||
typeof resolvedSpanGaps === 'number' && minStep !== undefined
|
||||
? Math.max(minStep, resolvedSpanGaps)
|
||||
: resolvedSpanGaps;
|
||||
|
||||
const lineStyle = chartAppearance?.lineStyle
|
||||
? LINE_STYLE_MAP[chartAppearance.lineStyle]
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -34,8 +34,6 @@ export interface BaseRendererProps {
|
||||
/** Raw V5 fetch result — response + the request that produced it. */
|
||||
data: PanelQueryData;
|
||||
isFetching: boolean;
|
||||
/** Showing a prior page's data while the next loads; list renderers swap in skeleton rows. */
|
||||
isPreviousData?: boolean;
|
||||
error: Error | null;
|
||||
/** Re-run the panel query; wired to the no-data Retry affordance. Optional so standalone call sites (e.g. the editor preview) can omit it. */
|
||||
refetch?: () => void;
|
||||
|
||||
@@ -58,11 +58,7 @@ export enum SectionKind {
|
||||
* - `comparison` — value crosses an operator → recolor (Number)
|
||||
* - `table` — per-column comparison (Table)
|
||||
*/
|
||||
export enum ThresholdVariant {
|
||||
LABEL = 'label',
|
||||
COMPARISON = 'comparison',
|
||||
TABLE = 'table',
|
||||
}
|
||||
export type ThresholdVariant = 'label' | 'comparison' | 'table';
|
||||
|
||||
/** Union of every threshold element shape stored under `plugin.spec.thresholds`. */
|
||||
export type AnyThreshold =
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { sections as barSections } from '../../kinds/BarChartPanel/sections';
|
||||
import { sections as histogramSections } from '../../kinds/HistogramPanel/sections';
|
||||
import { sections as listSections } from '../../kinds/ListPanel/sections';
|
||||
import { sections as timeSeriesSections } from '../../kinds/TimeSeriesPanel/sections';
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
import { buildDefaultPluginSpec } from '../buildDefaultPluginSpec';
|
||||
|
||||
describe('buildDefaultPluginSpec', () => {
|
||||
it('seeds the TimeSeries dropdowns/segmented controls with their renderer defaults', () => {
|
||||
expect(buildDefaultPluginSpec(timeSeriesSections)).toStrictEqual({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
},
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
chartAppearance: {
|
||||
lineStyle: DashboardtypesLineStyleDTO.solid,
|
||||
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
|
||||
fillMode: DashboardtypesFillModeDTO.none,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('omits chartAppearance for a kind that does not declare it (Bar)', () => {
|
||||
expect(buildDefaultPluginSpec(barSections)).toStrictEqual({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
},
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds only the legend for Histogram (no visualization section)', () => {
|
||||
expect(buildDefaultPluginSpec(histogramSections)).toStrictEqual({
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty spec for a kind with no seeded controls (List)', () => {
|
||||
expect(buildDefaultPluginSpec(listSections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('does not seed controls that already show a clear default', () => {
|
||||
// `axes` and `formatting` stay unset — their empty state is the chart default.
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Thresholds, controls: { variant: 'label' } },
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('only seeds the legend position when the kind exposes that control', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Legend, controls: { colors: true } },
|
||||
];
|
||||
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
@@ -13,14 +13,6 @@ describe('buildDefaultQueries', () => {
|
||||
expect(serialized.toLowerCase()).toContain('logs');
|
||||
});
|
||||
|
||||
it('seeds a List panel without a limit so it pages server-side by default', () => {
|
||||
const queries = buildDefaultQueries('signoz/ListPanel');
|
||||
|
||||
// A limit would make usePanelQuery treat the panel as a static, unpaged list.
|
||||
const spec = queries[0].spec.plugin.spec as { limit?: number };
|
||||
expect(spec.limit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('seeds no query for non-List kinds (they seed from the builder)', () => {
|
||||
expect(buildDefaultQueries('signoz/TimeSeriesPanel')).toStrictEqual([]);
|
||||
expect(buildDefaultQueries('signoz/NumberPanel')).toStrictEqual([]);
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { defaultColumnsForSignal } from '../../../PanelEditor/ListColumnsEditor/selectFields';
|
||||
import { sections as listSections } from '../../kinds/ListPanel/sections';
|
||||
import { sections as timeSeriesSections } from '../../kinds/TimeSeriesPanel/sections';
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
type SectionConfig,
|
||||
} from '../../types/sections';
|
||||
import { buildPluginSpec } from '../buildPluginSpec';
|
||||
|
||||
jest.mock('../../../PanelEditor/ListColumnsEditor/selectFields', () => ({
|
||||
defaultColumnsForSignal: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockDefaultColumnsForSignal =
|
||||
defaultColumnsForSignal as unknown as jest.Mock;
|
||||
|
||||
/** A panel spec carrying the plugin.spec a seed reads; the rest of the shape is irrelevant. */
|
||||
function oldSpecWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: pluginSpec },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDefaultColumnsForSignal.mockReturnValue([]);
|
||||
});
|
||||
|
||||
describe('buildPluginSpec', () => {
|
||||
describe('folding mechanism', () => {
|
||||
it('returns an empty spec for no sections', () => {
|
||||
expect(buildPluginSpec([])).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('seeds nothing for sections with no seed (Axes, Buckets, ContextLinks)', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Buckets, controls: { count: true, width: true } },
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('omits the key entirely when a seed returns undefined (never key: undefined)', () => {
|
||||
const result = buildPluginSpec([
|
||||
{ kind: SectionKind.Legend, controls: { colors: true } },
|
||||
]);
|
||||
|
||||
expect(result).toStrictEqual({});
|
||||
expect(result).not.toHaveProperty('legend');
|
||||
});
|
||||
|
||||
it('composes defaults and carried config from several sections in one pass', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: SectionKind.Legend, controls: { position: true } },
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
formatting: { unit: 'ms', decimalPrecision: 2 },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec })).toStrictEqual({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
},
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
formatting: { unit: 'ms', decimalPrecision: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('visualization / legend seeds', () => {
|
||||
it('seeds visualization global_time and legend bottom when those controls are on', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: SectionKind.Legend, controls: { position: true } },
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
},
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds neither when their defaulting controls are absent', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Visualization, controls: { switchPanelKind: true } },
|
||||
{ kind: SectionKind.Legend, controls: { colors: true } },
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('chartAppearance seed', () => {
|
||||
it('seeds only the declared defaulting controls', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.ChartAppearance,
|
||||
controls: { lineStyle: true, fillMode: true },
|
||||
},
|
||||
];
|
||||
expect(buildPluginSpec(sections).chartAppearance).toStrictEqual({
|
||||
lineStyle: DashboardtypesLineStyleDTO.solid,
|
||||
fillMode: DashboardtypesFillModeDTO.none,
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds nothing when only non-defaulting controls are declared (showPoints/spanGaps)', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.ChartAppearance,
|
||||
controls: { showPoints: true, spanGaps: true },
|
||||
},
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatting seed (carry, gated by controls)', () => {
|
||||
it('carries unit + decimalPrecision when the kind declares both', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
formatting: { unit: 'ms', decimalPrecision: 3 },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
|
||||
unit: 'ms',
|
||||
decimalPrecision: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops unit when the target kind does not declare it (TimeSeries → Table)', () => {
|
||||
// Table formatting has columnUnits + decimals only; carrying unit breaks the save.
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Formatting,
|
||||
controls: { decimals: true, columnUnits: true },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
formatting: { unit: 'ms', decimalPrecision: 2 },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
|
||||
decimalPrecision: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries a decimalPrecision of 0 (falsy but defined) and omits missing fields', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({ formatting: { decimalPrecision: 0 } });
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
|
||||
decimalPrecision: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds no formatting on a new panel or when nothing supported is present', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Formatting,
|
||||
controls: { decimals: true, columnUnits: true },
|
||||
},
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
expect(
|
||||
buildPluginSpec(sections, {
|
||||
oldSpec: oldSpecWith({ formatting: { unit: 'ms' } }),
|
||||
}),
|
||||
).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('columns seed', () => {
|
||||
it('seeds the signal default columns when a Columns section is present', () => {
|
||||
const columns = [{ name: 'timestamp' }, { name: 'body' }];
|
||||
mockDefaultColumnsForSignal.mockReturnValue(columns);
|
||||
|
||||
const result = buildPluginSpec([{ kind: SectionKind.Columns }], {
|
||||
signal: TelemetrytypesSignalDTO.traces,
|
||||
});
|
||||
|
||||
expect(mockDefaultColumnsForSignal).toHaveBeenCalledWith(
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
);
|
||||
expect(result.selectFields).toBe(columns);
|
||||
});
|
||||
|
||||
it('seeds nothing (and skips the lookup) when no signal is in context', () => {
|
||||
const result = buildPluginSpec([{ kind: SectionKind.Columns }]);
|
||||
|
||||
expect(mockDefaultColumnsForSignal).not.toHaveBeenCalled();
|
||||
expect(result).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('thresholds seed (variant remap)', () => {
|
||||
function switchThresholds(
|
||||
variant: ThresholdVariant | undefined,
|
||||
thresholds: unknown[],
|
||||
): unknown {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Thresholds, controls: { variant } },
|
||||
];
|
||||
return buildPluginSpec(sections, { oldSpec: oldSpecWith({ thresholds }) })
|
||||
.thresholds;
|
||||
}
|
||||
|
||||
it('keeps color/value/unit/label within the label variant (and defaults to label)', () => {
|
||||
expect(
|
||||
switchThresholds(undefined, [
|
||||
{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' },
|
||||
]),
|
||||
).toStrictEqual([
|
||||
{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('remaps label → comparison, seeding operator + format and dropping label', () => {
|
||||
expect(
|
||||
switchThresholds(ThresholdVariant.COMPARISON, [
|
||||
{ value: 80, color: '#F1575F', label: 'warn' },
|
||||
]),
|
||||
).toStrictEqual([
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves existing operator/format when remapping comparison → table', () => {
|
||||
expect(
|
||||
switchThresholds(ThresholdVariant.TABLE, [
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
]),
|
||||
).toStrictEqual([
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
columnName: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops table-only operator/format/columnName when remapping table → label', () => {
|
||||
expect(
|
||||
switchThresholds(ThresholdVariant.LABEL, [
|
||||
{
|
||||
value: 0,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
columnName: 'p99',
|
||||
},
|
||||
]),
|
||||
).toStrictEqual([{ value: 0, color: '#F1575F' }]);
|
||||
});
|
||||
|
||||
it('seeds nothing for an empty or absent threshold list', () => {
|
||||
expect(switchThresholds(ThresholdVariant.LABEL, [])).toBeUndefined();
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.LABEL },
|
||||
},
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// Integration against real kind configs — guards against a section-config regression.
|
||||
describe('per-kind defaults (real sections, no context)', () => {
|
||||
it('seeds the full TimeSeries default set', () => {
|
||||
expect(buildPluginSpec(timeSeriesSections)).toStrictEqual({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
},
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
chartAppearance: {
|
||||
lineStyle: DashboardtypesLineStyleDTO.solid,
|
||||
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
|
||||
fillMode: DashboardtypesFillModeDTO.none,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty spec for List (only switchPanelKind, nothing to seed)', () => {
|
||||
expect(buildPluginSpec(listSections)).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -178,11 +178,11 @@ export function mapThresholds(
|
||||
}
|
||||
|
||||
/**
|
||||
* Smallest per-query step interval (seconds), fed to uPlot so tick density matches the
|
||||
* Smallest per-query step interval, fed to uPlot so tick density matches the
|
||||
* densest query. Falls back to `undefined` (uPlot "auto") on an empty map, since
|
||||
* `Math.min` returns `Infinity` there and would corrupt scale math.
|
||||
*/
|
||||
export function minStepInterval(
|
||||
function minStepInterval(
|
||||
stepIntervals: Record<string, number>,
|
||||
): number | undefined {
|
||||
const values = Object.values(stepIntervals);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
SectionKind,
|
||||
type SectionConfig,
|
||||
type SectionSpecMap,
|
||||
} from '../types/sections';
|
||||
|
||||
/**
|
||||
* Seeded plugin-spec slices, typed as canonical section slices so each value is
|
||||
* checked against its DTO. A partial cross-section, not any single kind's spec,
|
||||
* so the union cast stays localized to `createDefaultPanel`.
|
||||
*/
|
||||
export interface DefaultPluginSpec {
|
||||
visualization?: SectionSpecMap[SectionKind.Visualization];
|
||||
legend?: SectionSpecMap[SectionKind.Legend];
|
||||
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds per-kind config defaults derived from the kind's declared `sections` so the
|
||||
* config pane opens populated. Values equal the renderer fallbacks (display only).
|
||||
* Controls whose empty state already IS the default are left unset.
|
||||
*/
|
||||
export function buildDefaultPluginSpec(
|
||||
sections: SectionConfig[],
|
||||
): DefaultPluginSpec {
|
||||
const spec: DefaultPluginSpec = {};
|
||||
|
||||
sections.forEach((section) => {
|
||||
switch (section.kind) {
|
||||
case SectionKind.Visualization:
|
||||
if (section.controls.timePreference) {
|
||||
spec.visualization = {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case SectionKind.Legend:
|
||||
if (section.controls.position) {
|
||||
spec.legend = { position: DashboardtypesLegendPositionDTO.bottom };
|
||||
}
|
||||
break;
|
||||
case SectionKind.ChartAppearance: {
|
||||
const chartAppearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
|
||||
if (section.controls.lineStyle) {
|
||||
chartAppearance.lineStyle = DashboardtypesLineStyleDTO.solid;
|
||||
}
|
||||
if (section.controls.lineInterpolation) {
|
||||
chartAppearance.lineInterpolation =
|
||||
DashboardtypesLineInterpolationDTO.spline;
|
||||
}
|
||||
if (section.controls.fillMode) {
|
||||
chartAppearance.fillMode = DashboardtypesFillModeDTO.none;
|
||||
}
|
||||
if (Object.keys(chartAppearance).length > 0) {
|
||||
spec.chartAppearance = chartAppearance;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return spec;
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
type TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { defaultColumnsForSignal } from '../../PanelEditor/ListColumnsEditor/selectFields';
|
||||
import {
|
||||
type AnyThreshold,
|
||||
type PanelFormattingSlice,
|
||||
type SectionConfig,
|
||||
type SectionControls,
|
||||
SectionKind,
|
||||
type SectionSpecMap,
|
||||
ThresholdVariant,
|
||||
} from '../types/sections';
|
||||
|
||||
/** Cross-section of the per-kind spec union; assigned to `plugin.spec` (unknown) at the boundary. */
|
||||
export interface SeededPluginSpec {
|
||||
visualization?: SectionSpecMap[SectionKind.Visualization];
|
||||
legend?: SectionSpecMap[SectionKind.Legend];
|
||||
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
|
||||
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
|
||||
selectFields?: SectionSpecMap[SectionKind.Columns];
|
||||
thresholds?: AnyThreshold[];
|
||||
}
|
||||
|
||||
export interface SeedContext {
|
||||
/** Present only on a kind switch — the spec being switched away from, to carry config across. */
|
||||
oldSpec?: DashboardtypesPanelSpecDTO;
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}
|
||||
|
||||
interface AnyThresholdFields {
|
||||
color: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
operator?: DashboardtypesComparisonOperatorDTO;
|
||||
format?: DashboardtypesThresholdFormatDTO;
|
||||
columnName?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Remaps a threshold to the target variant, seeding the fields that variant needs to stay functional. */
|
||||
function toThresholdVariant(
|
||||
source: AnyThresholdFields,
|
||||
variant: ThresholdVariant,
|
||||
): AnyThreshold {
|
||||
const core = {
|
||||
color: source.color,
|
||||
value: source.value,
|
||||
...(source.unit !== undefined && { unit: source.unit }),
|
||||
};
|
||||
if (variant === ThresholdVariant.COMPARISON) {
|
||||
return {
|
||||
...core,
|
||||
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
|
||||
format: source.format ?? DashboardtypesThresholdFormatDTO.text,
|
||||
};
|
||||
}
|
||||
if (variant === ThresholdVariant.TABLE) {
|
||||
return {
|
||||
...core,
|
||||
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
|
||||
format: source.format ?? DashboardtypesThresholdFormatDTO.background,
|
||||
columnName: source.columnName ?? '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...core,
|
||||
...(source.label !== undefined && { label: source.label }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How one section derives its plugin-spec slice on create/switch — the single place a section
|
||||
* declares this. Sections absent from `SECTION_SEEDS` seed nothing.
|
||||
*/
|
||||
interface SectionSeed {
|
||||
specKey: keyof SeededPluginSpec;
|
||||
seed: (controls: unknown, ctx: SeedContext) => unknown;
|
||||
}
|
||||
|
||||
const SECTION_SEEDS: Partial<Record<SectionKind, SectionSeed>> = {
|
||||
[SectionKind.Visualization]: {
|
||||
specKey: 'visualization',
|
||||
seed: (controls): SectionSpecMap[SectionKind.Visualization] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Visualization];
|
||||
return c.timePreference
|
||||
? { timePreference: DashboardtypesTimePreferenceDTO.global_time }
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.Legend]: {
|
||||
specKey: 'legend',
|
||||
seed: (controls): SectionSpecMap[SectionKind.Legend] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Legend];
|
||||
return c.position
|
||||
? { position: DashboardtypesLegendPositionDTO.bottom }
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.ChartAppearance]: {
|
||||
specKey: 'chartAppearance',
|
||||
seed: (controls): SectionSpecMap[SectionKind.ChartAppearance] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.ChartAppearance];
|
||||
const appearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
|
||||
if (c.lineStyle) {
|
||||
appearance.lineStyle = DashboardtypesLineStyleDTO.solid;
|
||||
}
|
||||
if (c.lineInterpolation) {
|
||||
appearance.lineInterpolation = DashboardtypesLineInterpolationDTO.spline;
|
||||
}
|
||||
if (c.fillMode) {
|
||||
appearance.fillMode = DashboardtypesFillModeDTO.none;
|
||||
}
|
||||
return Object.keys(appearance).length > 0 ? appearance : undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.Formatting]: {
|
||||
specKey: 'formatting',
|
||||
seed: (
|
||||
controls,
|
||||
{ oldSpec },
|
||||
): Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Formatting];
|
||||
const old = (oldSpec?.plugin.spec as { formatting?: PanelFormattingSlice })
|
||||
?.formatting;
|
||||
// Carry a field only when the target kind declares it (e.g. Table has no `unit`),
|
||||
// else the save API rejects the spec.
|
||||
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
|
||||
...(c.unit && old?.unit !== undefined && { unit: old.unit }),
|
||||
...(c.decimals &&
|
||||
old?.decimalPrecision !== undefined && {
|
||||
decimalPrecision: old.decimalPrecision,
|
||||
}),
|
||||
};
|
||||
return Object.keys(carried).length > 0 ? carried : undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.Columns]: {
|
||||
specKey: 'selectFields',
|
||||
seed: (
|
||||
_controls,
|
||||
{ signal },
|
||||
): SectionSpecMap[SectionKind.Columns] | undefined => {
|
||||
if (!signal) {
|
||||
return undefined;
|
||||
}
|
||||
const columns = defaultColumnsForSignal(signal);
|
||||
return columns.length > 0 ? columns : undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.Thresholds]: {
|
||||
specKey: 'thresholds',
|
||||
seed: (controls, { oldSpec }): AnyThreshold[] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Thresholds];
|
||||
const variant = c.variant ?? ThresholdVariant.LABEL;
|
||||
const old = (oldSpec?.plugin.spec as { thresholds?: AnyThreshold[] | null })
|
||||
?.thresholds;
|
||||
if (!old || old.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return old.map((threshold) =>
|
||||
toThresholdVariant(threshold as AnyThresholdFields, variant),
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a kind's plugin spec from its declared `sections`: no context → per-kind defaults
|
||||
* (new panel); `{ oldSpec, signal }` → defaults plus the config each target section carries.
|
||||
*/
|
||||
export function buildPluginSpec(
|
||||
sections: SectionConfig[],
|
||||
ctx: SeedContext = {},
|
||||
): SeededPluginSpec {
|
||||
const spec: SeededPluginSpec = {};
|
||||
|
||||
sections.forEach((section) => {
|
||||
const entry = SECTION_SEEDS[section.kind];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
const controls = 'controls' in section ? section.controls : undefined;
|
||||
const value = entry.seed(controls, ctx);
|
||||
if (value !== undefined) {
|
||||
// specKey ↔ value correlation can't be proven across the lookup; one localized cast.
|
||||
(spec as Record<string, unknown>)[entry.specKey] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return spec;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -59,13 +59,12 @@ function Panel({
|
||||
const searchable = !!panelDefinition?.actions.search;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { data, isFetching, isPreviousData, error, refetch, pagination } =
|
||||
usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
// Lazy: fetch only once on screen (undefined → visible) and a renderer exists.
|
||||
enabled: !!panelDefinition && isVisible !== false,
|
||||
});
|
||||
const { data, isFetching, error, refetch, pagination } = usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
// Lazy: fetch only once on screen (undefined → visible) and a renderer exists.
|
||||
enabled: !!panelDefinition && isVisible !== false,
|
||||
});
|
||||
|
||||
const { onDragSelect, dashboardPreference } = usePanelInteractions();
|
||||
|
||||
@@ -93,7 +92,6 @@ function Panel({
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,7 +6,6 @@ import PanelMessage from 'pages/DashboardPageV2/DashboardContainer/Panels/compon
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { hasRunnableQueries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
|
||||
import { getResponseType } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
import type {
|
||||
PanelPagination,
|
||||
PanelQueryData,
|
||||
@@ -22,8 +21,6 @@ interface PanelBodyProps {
|
||||
panelId: string;
|
||||
data: PanelQueryData;
|
||||
isFetching: boolean;
|
||||
/** Showing a prior page's data while the next loads; forwarded so list renderers can show skeletons. */
|
||||
isPreviousData?: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
@@ -35,8 +32,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +44,6 @@ function PanelBody({
|
||||
panelId,
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
refetch,
|
||||
onDragSelect,
|
||||
@@ -57,13 +51,10 @@ 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.
|
||||
const hasData =
|
||||
!!data.response &&
|
||||
getResponseType(data.response) === data.requestPayload?.requestType;
|
||||
// react-query keeps the previous response during refetches, so its presence is
|
||||
// the "have something to show" signal — only fail hard when there's nothing.
|
||||
const hasData = !!data.response;
|
||||
const queries = panel.spec.queries;
|
||||
|
||||
// Not-configured panel: no runnable query, so nothing to error/load on.
|
||||
@@ -98,9 +89,7 @@ function PanelBody({
|
||||
);
|
||||
}
|
||||
|
||||
// Full-panel loader only on first fetch; a refetch over existing data keeps the renderer
|
||||
// mounted (e.g. list page change) with the header carrying the in-flight indicator.
|
||||
if (isFetching && !hasData) {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className={styles.body} data-testid="panel-loading">
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
@@ -115,7 +104,6 @@ function PanelBody({
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
@@ -124,7 +112,6 @@ function PanelBody({
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchTerm}
|
||||
pagination={pagination}
|
||||
onCloseStandaloneView={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user