Compare commits

..

7 Commits

Author SHA1 Message Date
Abhi Kumar
053d306e99 chore: pr review fixes 2026-07-05 01:58:57 +05:30
Abhi Kumar
6693bb7a06 chore: removed duplicate tests 2026-07-05 01:58:57 +05:30
Abhi Kumar
8f4c00849c chore: fixed lint issue 2026-07-05 01:58:57 +05:30
Abhi Kumar
77390d5a5e chore: cleaned up duplicate tests 2026-07-05 01:58:57 +05:30
Abhi Kumar
cc94b87357 chore: ran oxfmt 2026-07-05 01:58:57 +05:30
Abhi Kumar
adb60c4683 chore: added more tests 2026-07-05 01:58:57 +05:30
Abhi Kumar
7d8f896a5f test: added e2e tests for dashboard create flow 2026-07-05 01:58:56 +05:30
183 changed files with 2120 additions and 8023 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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 {

View File

@@ -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"
}
}
]
}

View File

@@ -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' });
}
});
},
};
},
};

View File

@@ -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,
},
};

View File

@@ -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)

View File

@@ -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;
};

View File

@@ -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;
/**

View File

@@ -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',
};

View File

@@ -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' }}

View File

@@ -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"

View File

@@ -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' }}

View File

@@ -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' }}

View File

@@ -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' }}
>

View File

@@ -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.';

View File

@@ -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

View File

@@ -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 =

View File

@@ -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',
},

View File

@@ -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">

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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">

View File

@@ -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 => {

View File

@@ -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',
);

View File

@@ -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/',
};

View File

@@ -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"
>

View File

@@ -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/',
);
});
});

View File

@@ -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);
}
}

View File

@@ -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>
)}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -164,7 +164,7 @@ function BreakDown(): JSX.Element {
Meter metrics data is aggregated over 1 hour period. Please select time
range accordingly.&nbsp;
<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' }}

View File

@@ -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.',
};

View File

@@ -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"
>

View File

@@ -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"
>

View File

@@ -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"
>

View File

@@ -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"
>
{' '}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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"
>

View File

@@ -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}

View File

@@ -2,7 +2,6 @@
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 12px;
padding-top: 2px;
color: var(--l3-foreground);
}

View File

@@ -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}>

View File

@@ -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()}
/>,
);

View File

@@ -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],
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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}}');
});
});

View File

@@ -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' },
]);
});
});

View File

@@ -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');
});
});
});

View File

@@ -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;
}

View File

@@ -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],
);
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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' }}
/>
);
}

View File

@@ -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}
/>

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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' },
]);
});
});
});

View File

@@ -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;
}

View File

@@ -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)', () => {

View File

@@ -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,
};
}

View File

@@ -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],
);

View File

@@ -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}

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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 },
];

View File

@@ -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 && (

View File

@@ -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();
});
});

View File

@@ -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 },
];

View File

@@ -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 },
];

View File

@@ -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}

View File

@@ -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 },
];

View File

@@ -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]);
});
});

View File

@@ -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]

View File

@@ -7,7 +7,3 @@
height: 100%;
position: relative;
}
.chartManagerContainer {
padding: 36px 0;
}

View File

@@ -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;
}

View File

@@ -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],
),
);

View File

@@ -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;

View File

@@ -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 =

View File

@@ -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({});
});
});

View File

@@ -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([]);

View File

@@ -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({});
});
});
});

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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,
};
}

View File

@@ -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}

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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