Compare commits

..

9 Commits

Author SHA1 Message Date
nityanandagohain
d1682f2ab6 feat: span mapper test endpoint 2026-06-19 18:31:31 +05:30
Vinicius Lourenço
b8567664da refactor(quick-filters): split checkbox into multiple files (#11768)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* refactor(quick-filters): extract checkbox value fetching into hook

* refactor(quick-filters): extract checkbox query algebra into pure module

* refactor(quick-filters): extract checkbox glue hooks

* refactor(quick-filters): split checkbox renderers into components
2026-06-18 15:20:16 +00:00
Vinicius Lourenço
643aac4424 fix(alerts): missing fields when duplicating via edit alert (#11767) 2026-06-18 13:30:08 +00:00
Srikanth Chekuri
2cf7ef93ea chore: send warning instead of error for unseen metrics and missing (… (#11754)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: send warning instead of error for unseen metrics and missing (metric, key)

* chore: update integration test

* chore: fix integration test

* chore: fix test

* chore: add unit test for missing key
2026-06-18 10:42:09 +00:00
Nikhil Mantri
dba827ee33 feat(infra-monitoring): namespace+cluster group by for PVC monitoring, cluster group by for namespace monitoring (#11739)
* chore: deployments -> add default namespace group by

* chore: added integration tests for statefulsets

* chore: namespace group by for jobs

* chore: namespace group by for daemonsets

* chore: added group by clustername for all workloads and integration tests for the same

* chore: fix py fmt for integration tests

* chore: added group by namespace, cluster for pvcs

* chore: added cluster name default group by for namespaces monitoring
2026-06-18 09:14:42 +00:00
Ashwin Bhatkal
467a556062 feat(dashboard-v2): redesign public dashboard publish drawer (#11748)
* feat(dashboard-v2): redesign public dashboard publish drawer

Rework the Publish tab to the status-strip design (Claude Design handoff):
- a status strip with a lock/globe medallion, plain-language line and a
  Private/Public badge
- a public-link field shown in both states — a dashed placeholder while
  private, the live URL with copy / open actions once published
- an "Enable time range" switch + default-range select, and a quiet inline
  variables caveat
- actions grouped in a footer (Publish / Unpublish + Update)

Split each piece into its own folder with a co-located *.module.scss, drop the
dead time-range constants in favour of the shared RelativeDurationOptions, and
render the range dropdown without a portal (z-index + trigger width) so it shows
correctly inside the settings drawer.

* feat(dashboard-v2): fetch public dashboard meta once, globally

Move the public-sharing GET out of the publish drawer: a shared
usePublicDashboardMeta hook (keyed by dashboard id, license-gated, kept warm via
staleTime) owns the request, the toolbar mounts it with the dashboard to drive the
public-access badge, and the drawer's usePublicDashboard reads the same cache
instead of issuing its own call. Mutations invalidate the key so all consumers
refresh together.

Also rename the variables Callout to Hint, and drop redundant font-family: Inter /
font-weight: 400 from the publish-drawer styles (Inter is the inherited default).
2026-06-18 07:10:26 +00:00
primus-bot[bot]
a8f6b8187e chore(release): bump to v0.129.0 (#11773)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-06-18 07:02:37 +00:00
Swapnil Nakade
03796f012f chore: bumping agent version to v0.0.13 (#11757)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-06-17 12:05:28 +00:00
Abhi kumar
a06900bbff chore: added fix for infinite query call on services page (#11755) 2026-06-17 11:17:17 +00:00
138 changed files with 2860 additions and 5340 deletions

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.128.0
image: signoz/signoz:v0.129.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.128.0
image: signoz/signoz:v0.129.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.128.0}
image: signoz/signoz:${VERSION:-v0.129.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.128.0}
image: signoz/signoz:${VERSION:-v0.129.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -6992,6 +6992,16 @@ components:
required:
- items
type: object
SpantypesGettableSpanMapperTest:
properties:
spans:
items:
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
nullable: true
type: array
required:
- spans
type: object
SpantypesGettableTraceAggregations:
properties:
aggregations:
@@ -7079,6 +7089,39 @@ components:
- name
- condition
type: object
SpantypesPostableSpanMapperTest:
properties:
groups:
items:
$ref: '#/components/schemas/SpantypesPostableSpanMapperTestGroup'
nullable: true
type: array
spans:
items:
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
nullable: true
type: array
required:
- spans
- groups
type: object
SpantypesPostableSpanMapperTestGroup:
properties:
condition:
$ref: '#/components/schemas/SpantypesSpanMapperGroupCondition'
enabled:
type: boolean
mappers:
items:
$ref: '#/components/schemas/SpantypesPostableSpanMapper'
nullable: true
type: array
name:
type: string
required:
- name
- condition
type: object
SpantypesPostableTraceAggregations:
properties:
aggregations:
@@ -7240,6 +7283,17 @@ components:
- operation
- priority
type: object
SpantypesSpanMapperTestSpan:
properties:
attributes:
additionalProperties: {}
nullable: true
type: object
resource:
additionalProperties: {}
nullable: true
type: object
type: object
SpantypesUpdatableSpanMapper:
properties:
config:
@@ -12797,6 +12851,69 @@ paths:
summary: Update a span mapper
tags:
- spanmapper
/api/v1/span_mapper_groups/test:
post:
deprecated: false
description: Tests how span mappers would transform sample spans
operationId: TestSpanMappers
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesPostableSpanMapperTest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableSpanMapperTest'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Test span mappers against sample spans
tags:
- spanmapper
/api/v1/stats:
get:
deprecated: false

View File

@@ -29,18 +29,6 @@ if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function (): void {};
}
// jsdom doesn't implement the Pointer Capture API, which Radix UI primitives
// (e.g. @signozhq/ui Select) call when opening. Stub them so those components
// can be exercised in tests.
if (!HTMLElement.prototype.hasPointerCapture) {
HTMLElement.prototype.hasPointerCapture = function (): boolean {
return false;
};
}
if (!HTMLElement.prototype.releasePointerCapture) {
HTMLElement.prototype.releasePointerCapture = function (): void {};
}
if (typeof window.IntersectionObserver === 'undefined') {
class IntersectionObserverMock {
observe(): void {}

View File

@@ -122,13 +122,6 @@ export const DashboardWidget = Loadable(
import(/* webpackChunkName: "DashboardWidgetPage" */ 'pages/DashboardWidget'),
);
export const DashboardPanelEditorPage = Loadable(
() =>
import(
/* webpackChunkName: "DashboardPanelEditorPage" */ 'pages/DashboardPageV2/PanelEditorPage/PanelEditorPage'
),
);
export const EditRulesPage = Loadable(
() => import(/* webpackChunkName: "Alerts Edit Page" */ 'pages/EditRules'),
);

View File

@@ -11,7 +11,6 @@ import {
CreateAlertChannelAlerts,
CreateNewAlerts,
DashboardPage,
DashboardPanelEditorPage,
DashboardsListPage,
DashboardWidget,
EditRulesPage,
@@ -197,13 +196,6 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'DASHBOARD_WIDGET',
},
{
path: ROUTES.DASHBOARD_PANEL_EDITOR,
exact: true,
component: DashboardPanelEditorPage,
isPrivate: true,
key: 'DASHBOARD_PANEL_EDITOR',
},
{
path: ROUTES.EDIT_ALERTS,
exact: true,

View File

@@ -8135,6 +8135,44 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
items: SpantypesSpanMapperGroupDTO[];
}
export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOAttributes =
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOResource =
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
export interface SpantypesSpanMapperTestSpanDTO {
/**
* @type object,null
*/
attributes?: SpantypesSpanMapperTestSpanDTOAttributes;
/**
* @type object,null
*/
resource?: SpantypesSpanMapperTestSpanDTOResource;
}
export interface SpantypesGettableSpanMapperTestDTO {
/**
* @type array,null
*/
spans: SpantypesSpanMapperTestSpanDTO[] | null;
}
export enum SpantypesSpanAggregationTypeDTO {
span_count = 'span_count',
execution_time_percentage = 'execution_time_percentage',
@@ -8430,6 +8468,33 @@ export interface SpantypesPostableSpanMapperGroupDTO {
name: string;
}
export interface SpantypesPostableSpanMapperTestGroupDTO {
condition: SpantypesSpanMapperGroupConditionDTO | null;
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type array,null
*/
mappers?: SpantypesPostableSpanMapperDTO[] | null;
/**
* @type string
*/
name: string;
}
export interface SpantypesPostableSpanMapperTestDTO {
/**
* @type array,null
*/
groups: SpantypesPostableSpanMapperTestGroupDTO[] | null;
/**
* @type array,null
*/
spans: SpantypesSpanMapperTestSpanDTO[] | null;
}
export interface SpantypesSpanAggregationDTO {
aggregation: SpantypesSpanAggregationTypeDTO;
field: TelemetrytypesTelemetryFieldKeyDTO;
@@ -9798,6 +9863,14 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type TestSpanMappers200 = {
data: SpantypesGettableSpanMapperTestDTO;
/**
* @type string
*/
status: string;
};
export type GetStats200Data = { [key: string]: unknown };
export type GetStats200 = {

View File

@@ -30,8 +30,10 @@ import type {
RenderErrorResponseDTO,
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesPostableSpanMapperTestDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
TestSpanMappers200,
UpdateSpanMapperGroupPathParameters,
UpdateSpanMapperPathParameters,
} from '../sigNoz.schemas';
@@ -780,3 +782,86 @@ export const useUpdateSpanMapper = <
> => {
return useMutation(getUpdateSpanMapperMutationOptions(options));
};
/**
* Tests how span mappers would transform sample spans
* @summary Test span mappers against sample spans
*/
export const testSpanMappers = (
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<TestSpanMappers200>({
url: `/api/v1/span_mapper_groups/test`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableSpanMapperTestDTO,
signal,
});
};
export const getTestSpanMappersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
const mutationKey = ['testSpanMappers'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof testSpanMappers>>,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
> = (props) => {
const { data } = props ?? {};
return testSpanMappers(data);
};
return { mutationFn, ...mutationOptions };
};
export type TestSpanMappersMutationResult = NonNullable<
Awaited<ReturnType<typeof testSpanMappers>>
>;
export type TestSpanMappersMutationBody =
| BodyType<SpantypesPostableSpanMapperTestDTO>
| undefined;
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Test span mappers against sample spans
*/
export const useTestSpanMappers = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
return useMutation(getTestSpanMappersMutationOptions(options));
};

View File

@@ -1,67 +1,29 @@
/* eslint-disable sonarjs/no-identical-functions */
import { Fragment, useMemo, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button, Skeleton } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { OPERATORS } from 'constants/antlrQueryConstants';
import {
DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { cloneDeep, isArray, isFunction } from 'lodash-es';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import CheckboxFilterHeader from './CheckboxFilterHeader';
import CheckboxValueRow from './CheckboxValueRow';
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
import { isKeyMatch } from './utils';
import useActiveQueryIndex from './useActiveQueryIndex';
import useCheckboxDisclosure from './useCheckboxDisclosure';
import useCheckboxFilterActions from './useCheckboxFilterActions';
import useCheckboxFilterState from './useCheckboxFilterState';
import useCheckboxFilterValues from './useCheckboxFilterValues';
import './Checkbox.styles.scss';
const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
const SOURCES_WITH_EMPTY_STATE_ENABLED = [QuickFiltersSource.LOGS_EXPLORER];
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
/**
* Returns the correct NOT_IN operator value based on source.
* InfraMonitoring backend expects 'nin', others expect 'not in'.
*/
function getNotInOperator(source: QuickFiltersSource): string {
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
return 'nin';
}
return getOperatorValue('NOT_IN');
}
function setDefaultValues(
values: string[],
trueOrFalse: boolean,
): Record<string, boolean> {
const defaultState: Record<string, boolean> = {};
values.forEach((val) => {
defaultState[val] = trueOrFalse;
});
return defaultState;
}
interface ICheckboxProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
@@ -72,194 +34,39 @@ interface ICheckboxProps {
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
const { source, filter, onFilterChange } = props;
const [searchText, setSearchText] = useState<string>('');
// null = no user action, true = user opened, false = user closed
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(10);
const activeQueryIndex = useActiveQueryIndex(source);
const {
lastUsedQuery,
currentQuery,
redirectWithQueryBuilderData,
panelType,
} = useQueryBuilder();
// Determine if we're in ListView mode
const isListView = panelType === PANEL_TYPES.LIST;
// In ListView mode, use index 0 for most sources; for TRACES_EXPLORER, use lastUsedQuery
// Otherwise use lastUsedQuery for non-ListView modes
const activeQueryIndex = useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
// Check if this filter has active filters in the query
const isSomeFilterPresentForCurrentAttribute = useMemo(
() =>
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
),
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
);
// Derive isOpen from filter state + user action
const isOpen = useMemo(() => {
// If user explicitly toggled, respect that
if (userToggleState !== null) {
return userToggleState;
}
// Auto-open if this filter has active filters in the query
if (isSomeFilterPresentForCurrentAttribute) {
return true;
}
// Otherwise use default behavior (first 2 filters open)
return filter.defaultOpen;
}, [
userToggleState,
isOpen,
isSomeFilterPresentForCurrentAttribute,
filter.defaultOpen,
]);
visibleItemsCount,
onToggleOpen,
onShowMore,
} = useCheckboxDisclosure({ filter, activeQueryIndex });
const { data, isLoading } = useGetAggregateValues(
{
aggregateOperator: filter.aggregateOperator || 'noop',
dataSource: filter.dataSource || DataSource.LOGS,
aggregateAttribute: filter.aggregateAttribute || '',
attributeKey: filter.attributeKey.key,
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
tagType: filter.attributeKey.type || '',
searchText: searchText ?? '',
},
{
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
);
const { attributeValues, isLoading } = useCheckboxFilterValues({
filter,
source,
searchText,
isOpen,
});
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
useGetQueryKeyValueSuggestions({
key: filter.attributeKey.key,
signal: filter.dataSource || DataSource.LOGS,
signalSource: 'meter',
options: {
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
});
const { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
const attributeValues: string[] = useMemo(() => {
const dataType = filter.attributeKey.dataType || DataTypes.String;
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
// Process the response data
const responseData = keyValueSuggestions?.data as any;
const values = responseData.data?.values || {};
const stringValues = values.stringValues || [];
const numberValues = values.numberValues || [];
// Generate options from string values - explicitly handle empty strings
const stringOptions = stringValues
// Strict filtering for empty string - we'll handle it as a special case if needed
.filter(
(value: string | null | undefined): value is string =>
value !== null && value !== undefined && value !== '',
);
// Generate options from number values
const numberOptions = numberValues
.filter(
(value: number | null | undefined): value is number =>
value !== null && value !== undefined,
)
.map((value: number) => value.toString());
// Combine all options and make sure we don't have duplicate labels
return [...stringOptions, ...numberOptions];
}
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
return (data?.payload?.[key] || []).filter(
(val) => val !== undefined && val !== null,
);
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
const { onChange, onClear } = useCheckboxFilterActions({
filter,
source,
attributeValues,
activeQueryIndex,
onFilterChange,
});
const setSearchTextDebounced = useDebouncedFn((...args) => {
setSearchText(args[0] as string);
}, DEBOUNCE_DELAY);
// derive the state of each filter key here in the renderer itself and keep it in sync with current query
// also we need to keep a note of last focussed query.
// eslint-disable-next-line sonarjs/cognitive-complexity
const currentFilterState = useMemo(() => {
let filterState: Record<string, boolean> = setDefaultValues(
attributeValues,
false,
);
const filterSync = currentQuery?.builder.queryData?.[
activeQueryIndex
]?.filters?.items.find((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
);
if (filterSync) {
if (SELECTED_OPERATORS.includes(filterSync.op)) {
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = true;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = true;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = true;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = true;
}
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
filterState = setDefaultValues(attributeValues, true);
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = false;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = false;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = false;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = false;
}
}
} else {
filterState = setDefaultValues(attributeValues, true);
}
return filterState;
}, [
attributeValues,
currentQuery?.builder.queryData,
filter.attributeKey,
activeQueryIndex,
]);
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
const isFilterDisabled = useMemo(
() =>
(currentQuery?.builder?.queryData?.[
activeQueryIndex
]?.filters?.items?.filter((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)?.length || 0) > 1,
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
);
// variable to check if the current filter has multiple values to its name in the key op value section
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
// Sort checked items to the top, then unchecked items
const currentAttributeKeys = useMemo(() => {
const checkedValues = attributeValues.filter(
@@ -277,293 +84,6 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
[currentAttributeKeys, currentFilterState],
);
const handleClearFilterAttribute = (): void => {
const preparedQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filter: {
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
filter.attributeKey.key,
]),
},
filters: {
...item.filters,
items:
idx === activeQueryIndex
? item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
) || []
: [...(item.filters?.items || [])],
op: item.filters?.op || 'AND',
},
})),
},
};
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(preparedQuery);
} else {
redirectWithQueryBuilderData(preparedQuery);
}
};
const onChange = (
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): void => {
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
// if only or all are clicked we do not need to worry about anything just override whatever we have
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
if (isOnlyOrAllClicked && query?.filters?.items) {
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only';
query.filters.items = query.filters.items.filter(
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
if (isOnlyOrAll === 'Only') {
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.IN),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
} else if (query?.filters?.items) {
if (
query.filters?.items?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)
) {
// if there is already a running filter for the current attribute key then
// we split the cases by which particular operator is present right now!
const currentFilter = query.filters?.items?.find((q) =>
isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (currentFilter) {
const runningOperator = currentFilter?.op;
switch (runningOperator) {
case 'in':
if (checked) {
// if it's an IN operator then if we are checking another value it get's added to the
// filter clause. example - key IN [value1, currentSelectedValue]
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// if the current state wasn't an array we make it one and add our value
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (!checked) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
// if not an array remove the whole thing altogether!
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case 'nin':
case 'not in':
// if the current running operator is NIN then when unchecking the value it gets
// added to the clause like key NIN [value1 , currentUnselectedValue]
if (!checked) {
// in case of array add the currentUnselectedValue to the list.
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// in case of not an array make it one!
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (checked) {
// opposite of above!
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
const newFilter = {
...currentFilter,
value: currentFilter.value === value ? null : currentFilter.value,
};
if (newFilter.value === null && query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case '=':
if (checked) {
const newFilter = {
...currentFilter,
op: getOperatorValue(OPERATORS.IN),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (!checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
case '!=':
if (!checked) {
const newFilter = {
...currentFilter,
op: getNotInOperator(source),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
default:
break;
}
}
} else {
// case - when there is no filter for the current key that means all are selected right now.
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getNotInOperator(source),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
}
const finalQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
...currentQuery.builder.queryData.map((q, idx) => {
if (idx === activeQueryIndex) {
return query;
}
return q;
}),
],
},
};
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(finalQuery);
} else {
redirectWithQueryBuilderData(finalQuery);
}
};
const isEmptyStateWithDocsEnabled =
SOURCES_WITH_EMPTY_STATE_ENABLED.includes(source) &&
!searchText &&
@@ -571,48 +91,19 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
return (
<div className="checkbox-filter">
<section
className="filter-header-checkbox"
onClick={(): void => {
if (isOpen) {
setUserToggleState(false);
setVisibleItemsCount(10);
} else {
setUserToggleState(true);
}
}}
>
<section className="left-action">
{isOpen ? (
<ChevronDown size={13} cursor="pointer" />
) : (
<ChevronRight size={13} cursor="pointer" />
)}
<Typography.Text className="title">{filter.title}</Typography.Text>
<CheckboxFilterHeader
title={filter.title}
isOpen={isOpen}
showClearAll={!!attributeValues.length}
onToggleOpen={onToggleOpen}
onClear={onClear}
/>
{isOpen && isLoading && !attributeValues.length && (
<section className="loading">
<Skeleton paragraph={{ rows: 4 }} />
</section>
<section className="right-action">
{isOpen && !!attributeValues.length && (
<Typography.Text
className="clear-all"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
handleClearFilterAttribute();
}}
>
Clear All
</Typography.Text>
)}
</section>
</section>
{isOpen &&
(isLoading || isLoadingKeyValueSuggestions) &&
!attributeValues.length && (
<section className="loading">
<Skeleton paragraph={{ rows: 4 }} />
</section>
)}
{isOpen && !isLoading && !isLoadingKeyValueSuggestions && (
)}
{isOpen && !isLoading && (
<>
{!isEmptyStateWithDocsEnabled && (
<section className="search">
@@ -634,48 +125,24 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
data-testid="filter-separator"
/>
)}
<div className="value">
<Checkbox
onChange={(checked): void =>
onChange(value, checked === true, false)
}
value={currentFilterState[value]}
disabled={isFilterDisabled}
className="check-box"
/>
<div
className={cx(
'checkbox-value-section',
isFilterDisabled ? 'filter-disabled' : '',
)}
onClick={(): void => {
if (isFilterDisabled) {
return;
}
onChange(value, currentFilterState[value], true);
}}
>
<div className={`${filter.title} label-${value}`} />
{filter.customRendererForValue ? (
filter.customRendererForValue(value)
) : (
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
<CheckboxValueRow
value={value}
checked={currentFilterState[value]}
disabled={isFilterDisabled}
title={filter.title}
onlyButtonLabel={
isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'
}
customRendererForValue={filter.customRendererForValue}
onCheckboxChange={(checked): void => onChange(value, checked, false)}
onOnlyOrAllClick={(): void =>
onChange(value, currentFilterState[value], true)
}
/>
</Fragment>
))}
</section>
@@ -688,10 +155,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
)}
{visibleItemsCount < attributeValues?.length && (
<section className="show-more">
<Typography.Text
className="show-more-text"
onClick={(): void => setVisibleItemsCount((prev) => prev + 10)}
>
<Typography.Text className="show-more-text" onClick={onShowMore}>
Show More...
</Typography.Text>
</section>

View File

@@ -0,0 +1,47 @@
import { Typography } from '@signozhq/ui/typography';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
interface CheckboxFilterHeaderProps {
title: string;
isOpen: boolean;
showClearAll: boolean;
onToggleOpen: () => void;
onClear: () => void;
}
function CheckboxFilterHeader({
title,
isOpen,
showClearAll,
onToggleOpen,
onClear,
}: CheckboxFilterHeaderProps): JSX.Element {
return (
<section className="filter-header-checkbox" onClick={onToggleOpen}>
<section className="left-action">
{isOpen ? (
<ChevronDown size={13} cursor="pointer" />
) : (
<ChevronRight size={13} cursor="pointer" />
)}
<Typography.Text className="title">{title}</Typography.Text>
</section>
<section className="right-action">
{isOpen && showClearAll && (
<Typography.Text
className="clear-all"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
onClear();
}}
>
Clear All
</Typography.Text>
)}
</section>
</section>
);
}
export default CheckboxFilterHeader;

View File

@@ -0,0 +1,68 @@
import { Button } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
interface CheckboxValueRowProps {
value: string;
checked: boolean;
disabled: boolean;
title: string;
onlyButtonLabel: string;
customRendererForValue?: (value: string) => JSX.Element;
onCheckboxChange: (checked: boolean) => void;
onOnlyOrAllClick: () => void;
}
function CheckboxValueRow({
value,
checked,
disabled,
title,
onlyButtonLabel,
customRendererForValue,
onCheckboxChange,
onOnlyOrAllClick,
}: CheckboxValueRowProps): JSX.Element {
return (
<div className="value">
<Checkbox
onChange={(isChecked): void => onCheckboxChange(isChecked === true)}
value={checked}
disabled={disabled}
className="check-box"
/>
<div
className={cx('checkbox-value-section', disabled ? 'filter-disabled' : '')}
onClick={(): void => {
if (disabled) {
return;
}
onOnlyOrAllClick();
}}
>
<div className={`${title} label-${value}`} />
{customRendererForValue ? (
customRendererForValue(value)
) : (
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{onlyButtonLabel}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
);
}
CheckboxValueRow.defaultProps = {
customRendererForValue: undefined,
};
export default CheckboxValueRow;

View File

@@ -0,0 +1,417 @@
/* eslint-disable sonarjs/no-identical-functions */
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { cloneDeep, isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { isKeyMatch } from './utils';
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
/**
* Returns the correct NOT_IN operator value based on source.
* InfraMonitoring backend expects 'nin', others expect 'not in'.
*/
export function getNotInOperator(source: QuickFiltersSource): string {
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
return 'nin';
}
return getOperatorValue('NOT_IN');
}
function setDefaultValues(
values: string[],
trueOrFalse: boolean,
): Record<string, boolean> {
const defaultState: Record<string, boolean> = {};
values.forEach((val) => {
defaultState[val] = trueOrFalse;
});
return defaultState;
}
/**
* Derives the checked/unchecked state for each attribute value by reading the
* active filter clause for this attribute key out of the query.
*
* - No matching clause -> every value is checked (all selected).
* - IN / `=` clause -> only the listed values are checked.
* - NOT IN / `!=` clause -> every value is checked except the excluded ones.
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export function deriveCheckboxState({
attributeValues,
filterItems,
filterKey,
}: {
attributeValues: string[];
filterItems: TagFilterItem[] | undefined;
filterKey: string;
}): Record<string, boolean> {
let filterState: Record<string, boolean> = setDefaultValues(
attributeValues,
false,
);
const filterSync = filterItems?.find((item) =>
isKeyMatch(item.key?.key, filterKey),
);
if (filterSync) {
if (SELECTED_OPERATORS.includes(filterSync.op)) {
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = true;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = true;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = true;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = true;
}
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
filterState = setDefaultValues(attributeValues, true);
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = false;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = false;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = false;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = false;
}
}
} else {
filterState = setDefaultValues(attributeValues, true);
}
return filterState;
}
/**
* Returns a new query with every clause for this attribute key removed, both
* from the structured filter items and the raw filter expression.
*/
export function clearFilterFromQuery({
currentQuery,
filter,
activeQueryIndex,
}: {
currentQuery: Query;
filter: IQuickFiltersConfig;
activeQueryIndex: number;
}): Query {
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filter: {
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
filter.attributeKey.key,
]),
},
filters: {
...item.filters,
items:
idx === activeQueryIndex
? item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
) || []
: [...(item.filters?.items || [])],
op: item.filters?.op || 'AND',
},
})),
},
};
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function applyCheckboxToggle({
currentQuery,
activeQueryIndex,
filter,
source,
attributeValues,
value,
checked,
isOnlyOrAllClicked,
}: {
currentQuery: Query;
activeQueryIndex: number;
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
attributeValues: string[];
value: string;
checked: boolean;
isOnlyOrAllClicked: boolean;
}): Query {
const activeItems =
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
const isSomeFilterPresentForCurrentAttribute = !!activeItems?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
);
const currentFilterState = deriveCheckboxState({
attributeValues,
filterItems: activeItems,
filterKey: filter.attributeKey.key,
});
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
// if only or all are clicked we do not need to worry about anything just override whatever we have
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
if (isOnlyOrAllClicked && query?.filters?.items) {
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only';
query.filters.items = query.filters.items.filter(
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(query.filter.expression, [
filter.attributeKey.key,
]);
}
if (isOnlyOrAll === 'Only') {
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.IN),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
} else if (query?.filters?.items) {
if (
query.filters?.items?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)
) {
// if there is already a running filter for the current attribute key then
// we split the cases by which particular operator is present right now!
const currentFilter = query.filters?.items?.find((q) =>
isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (currentFilter) {
const runningOperator = currentFilter?.op;
switch (runningOperator) {
case 'in':
if (checked) {
// if it's an IN operator then if we are checking another value it get's added to the
// filter clause. example - key IN [value1, currentSelectedValue]
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// if the current state wasn't an array we make it one and add our value
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (!checked) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
// if not an array remove the whole thing altogether!
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case 'nin':
case 'not in':
// if the current running operator is NIN then when unchecking the value it gets
// added to the clause like key NIN [value1 , currentUnselectedValue]
if (!checked) {
// in case of array add the currentUnselectedValue to the list.
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// in case of not an array make it one!
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (checked) {
// opposite of above!
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
const newFilter = {
...currentFilter,
value: currentFilter.value === value ? null : currentFilter.value,
};
if (newFilter.value === null && query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case '=':
if (checked) {
const newFilter = {
...currentFilter,
op: getOperatorValue(OPERATORS.IN),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (!checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
case '!=':
if (!checked) {
const newFilter = {
...currentFilter,
op: getNotInOperator(source),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
default:
break;
}
}
} else {
// case - when there is no filter for the current key that means all are selected right now.
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getNotInOperator(source),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
}
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
...currentQuery.builder.queryData.map((q, idx) => {
if (idx === activeQueryIndex) {
return query;
}
return q;
}),
],
},
};
}

View File

@@ -0,0 +1,27 @@
import { useMemo } from 'react';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
/**
* Resolves which query-builder query index the checkbox filter reads from and
* writes to.
*
* In ListView most sources use index 0; TRACES_EXPLORER and every non-ListView
* mode track the last focused query.
*/
function useActiveQueryIndex(source: QuickFiltersSource): number {
const { lastUsedQuery, panelType } = useQueryBuilder();
const isListView = panelType === PANEL_TYPES.LIST;
return useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
}
export default useActiveQueryIndex;

View File

@@ -0,0 +1,90 @@
import { useMemo, useState } from 'react';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isKeyMatch } from './utils';
const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
interface UseCheckboxDisclosureProps {
filter: IQuickFiltersConfig;
activeQueryIndex: number;
}
interface UseCheckboxDisclosureReturn {
isOpen: boolean;
isSomeFilterPresentForCurrentAttribute: boolean;
visibleItemsCount: number;
onToggleOpen: () => void;
onShowMore: () => void;
}
/**
* Owns the open/collapsed state of a checkbox filter section and how many
* values are visible.
*
* Auto-opens when the query already has a clause for this attribute, otherwise
* falls back to `filter.defaultOpen`. An explicit user toggle always wins.
* Collapsing resets the visible count.
*/
function useCheckboxDisclosure({
filter,
activeQueryIndex,
}: UseCheckboxDisclosureProps): UseCheckboxDisclosureReturn {
const { currentQuery } = useQueryBuilder();
// null = no user action, true = user opened, false = user closed
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(
DEFAULT_VISIBLE_ITEMS_COUNT,
);
const isSomeFilterPresentForCurrentAttribute = useMemo(
() =>
!!currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
),
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
);
const isOpen = useMemo(() => {
// If user explicitly toggled, respect that
if (userToggleState !== null) {
return userToggleState;
}
// Auto-open if this filter has active filters in the query
if (isSomeFilterPresentForCurrentAttribute) {
return true;
}
// Otherwise use default behavior (first 2 filters open)
return filter.defaultOpen;
}, [
userToggleState,
isSomeFilterPresentForCurrentAttribute,
filter.defaultOpen,
]);
const onToggleOpen = (): void => {
if (isOpen) {
setUserToggleState(false);
setVisibleItemsCount(DEFAULT_VISIBLE_ITEMS_COUNT);
} else {
setUserToggleState(true);
}
};
const onShowMore = (): void => {
setVisibleItemsCount((prev) => prev + DEFAULT_VISIBLE_ITEMS_COUNT);
};
return {
isOpen,
isSomeFilterPresentForCurrentAttribute,
visibleItemsCount,
onToggleOpen,
onShowMore,
};
}
export default useCheckboxDisclosure;

View File

@@ -0,0 +1,78 @@
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
applyCheckboxToggle,
clearFilterFromQuery,
} from './checkboxFilterQuery';
interface UseCheckboxFilterActionsProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
attributeValues: string[];
activeQueryIndex: number;
onFilterChange?: ((query: Query) => void) | null;
}
interface UseCheckboxFilterActionsReturn {
onChange: (
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
) => void;
onClear: () => void;
}
/**
* Wires the pure checkbox query algebra to query-builder dispatch: the
* caller-provided `onFilterChange` when present, otherwise a URL redirect.
*/
function useCheckboxFilterActions({
filter,
source,
attributeValues,
activeQueryIndex,
onFilterChange,
}: UseCheckboxFilterActionsProps): UseCheckboxFilterActionsReturn {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const dispatch = (query: Query): void => {
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(query);
} else {
redirectWithQueryBuilderData(query);
}
};
const onChange = (
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
): void => {
dispatch(
applyCheckboxToggle({
currentQuery,
activeQueryIndex,
filter,
source,
attributeValues,
value,
checked,
isOnlyOrAllClicked,
}),
);
};
const onClear = (): void => {
dispatch(clearFilterFromQuery({ currentQuery, filter, activeQueryIndex }));
};
return { onChange, onClear };
}
export default useCheckboxFilterActions;

View File

@@ -0,0 +1,71 @@
import { useMemo } from 'react';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { deriveCheckboxState } from './checkboxFilterQuery';
import { isKeyMatch } from './utils';
interface UseCheckboxFilterStateProps {
filter: IQuickFiltersConfig;
attributeValues: string[];
activeQueryIndex: number;
}
interface UseCheckboxFilterStateReturn {
currentFilterState: Record<string, boolean>;
isFilterDisabled: boolean;
isMultipleValuesTrueForTheKey: boolean;
}
/**
* Reads the active query and derives the per-value checked state for this
* attribute, whether the filter is disabled (same key used more than once in
* the filter bar), and whether more than one value is currently selected.
*/
function useCheckboxFilterState({
filter,
attributeValues,
activeQueryIndex,
}: UseCheckboxFilterStateProps): UseCheckboxFilterStateReturn {
const { currentQuery } = useQueryBuilder();
// derive the state of each filter key here and keep it in sync with current query
const currentFilterState = useMemo(
() =>
deriveCheckboxState({
attributeValues,
filterItems:
currentQuery?.builder.queryData?.[activeQueryIndex]?.filters?.items,
filterKey: filter.attributeKey.key,
}),
[
attributeValues,
currentQuery?.builder.queryData,
filter.attributeKey,
activeQueryIndex,
],
);
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
const isFilterDisabled = useMemo(
() =>
(currentQuery?.builder?.queryData?.[
activeQueryIndex
]?.filters?.items?.filter((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)?.length || 0) > 1,
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
);
// whether the current filter has multiple values to its name in the key op value section
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
return {
currentFilterState,
isFilterDisabled,
isMultipleValuesTrueForTheKey,
};
}
export default useCheckboxFilterState;

View File

@@ -0,0 +1,99 @@
import { useMemo } from 'react';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
interface UseCheckboxFilterValuesProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
searchText: string;
isOpen: boolean;
}
interface UseCheckboxFilterValuesReturn {
attributeValues: string[];
isLoading: boolean;
}
function useCheckboxFilterValues({
filter,
source,
searchText,
isOpen,
}: UseCheckboxFilterValuesProps): UseCheckboxFilterValuesReturn {
const { data, isLoading } = useGetAggregateValues(
{
aggregateOperator: filter.aggregateOperator || 'noop',
dataSource: filter.dataSource || DataSource.LOGS,
aggregateAttribute: filter.aggregateAttribute || '',
attributeKey: filter.attributeKey.key,
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
tagType: filter.attributeKey.type || '',
searchText: searchText ?? '',
},
{
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
);
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
useGetQueryKeyValueSuggestions({
key: filter.attributeKey.key,
signal: filter.dataSource || DataSource.LOGS,
signalSource: 'meter',
options: {
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
});
const attributeValues: string[] = useMemo(() => {
const dataType = filter.attributeKey.dataType || DataTypes.String;
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
// Process the response data
const responseData = keyValueSuggestions?.data as any;
const values = responseData.data?.values || {};
const stringValues = values.stringValues || [];
const numberValues = values.numberValues || [];
// Generate options from string values - explicitly handle empty strings
const stringOptions = stringValues
// Strict filtering for empty string - we'll handle it as a special case if needed
.filter(
(value: string | null | undefined): value is string =>
value !== null && value !== undefined && value !== '',
);
// Generate options from number values
const numberOptions = numberValues
.filter(
(value: number | null | undefined): value is number =>
value !== null && value !== undefined,
)
.map((value: number) => value.toString());
// Combine all options and make sure we don't have duplicate labels
return [...stringOptions, ...numberOptions];
}
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
return (data?.payload?.[key] || []).filter(
(val) => val !== undefined && val !== null,
);
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
return {
attributeValues,
isLoading: isLoading || isLoadingKeyValueSuggestions,
};
}
export default useCheckboxFilterValues;

View File

@@ -24,7 +24,6 @@ const ROUTES = {
ALL_DASHBOARD: '/dashboard',
DASHBOARD: '/dashboard/:dashboardId',
DASHBOARD_WIDGET: '/dashboard/:dashboardId/:widgetId',
DASHBOARD_PANEL_EDITOR: '/dashboard/:dashboardId/panel/:panelId',
EDIT_ALERTS: '/alerts/edit',
LIST_ALL_ALERT: '/alerts',
ALERTS_NEW: '/alerts/new',

View File

@@ -408,9 +408,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const isPublicDashboard = pathname.startsWith('/public/dashboard/');
const isAIAssistantPage = pathname.startsWith('/ai-assistant/');
// The V2 panel editor is a chromeless full-page route (no side nav / top nav),
// like the onboarding and public-dashboard screens.
const isPanelEditorV2 = routeKey === 'DASHBOARD_PANEL_EDITOR';
const renderFullScreen =
pathname === ROUTES.GET_STARTED ||
@@ -421,8 +418,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
pathname === ROUTES.GET_STARTED_AWS_MONITORING ||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING ||
isPublicDashboard ||
isPanelEditorV2;
isPublicDashboard;
const [showTrialExpiryBanner, setShowTrialExpiryBanner] = useState(false);

View File

@@ -7,17 +7,15 @@
&--legend-right {
flex-direction: row;
.chart-layout__legend-wrapper {
padding-left: 0 !important;
}
}
&__legend-wrapper {
// The inline height is the legend rectangle from calculateChartDimensions;
// border-box keeps the padding inside it so the wrapper doesn't grow past
// that height and steal space from the chart. overflow:hidden clips to the
// rectangle so the virtualized legend scrolls within it.
box-sizing: border-box;
min-height: 0;
overflow: hidden;
padding-left: 12px;
padding-bottom: 12px;
overflow: auto;
}
}

View File

@@ -1,6 +1,5 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { Skeleton } from 'antd';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { FeatureKeys } from 'constants/features';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -124,24 +123,14 @@ function ServiceOverview({
/>
<Card data-testid="service_latency">
<GraphContainer>
{topLevelOperationsIsLoading && (
<Skeleton
style={{
height: '100%',
padding: '16px',
}}
/>
)}
{!topLevelOperationsIsLoading && (
<Graph
onDragSelect={onDragSelect}
widget={latencyWidget}
onClickHandler={handleGraphClick('Service')}
isQueryEnabled={isQueryEnabled}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
)}
<Graph
onDragSelect={onDragSelect}
widget={latencyWidget}
onClickHandler={handleGraphClick('Service')}
isQueryEnabled={isQueryEnabled}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>
</>

View File

@@ -1,4 +1,3 @@
import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import axios from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api';
@@ -29,24 +28,14 @@ function TopLevelOperation({
</Typography>
) : (
<GraphContainer>
{topLevelOperationsIsLoading && (
<Skeleton
style={{
height: '100%',
padding: '16px',
}}
/>
)}
{!topLevelOperationsIsLoading && (
<Graph
widget={widget}
onClickHandler={handleGraphClick(opName)}
onDragSelect={onDragSelect}
isQueryEnabled={!topLevelOperationsIsLoading}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
)}
<Graph
widget={widget}
onClickHandler={handleGraphClick(opName)}
onDragSelect={onDragSelect}
isQueryEnabled={!topLevelOperationsIsLoading}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
)}
</Card>

View File

@@ -1,61 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { useConfirmableAction } from '../useConfirmableAction';
describe('useConfirmableAction', () => {
it('starts closed and idle', () => {
const { result } = renderHook(() =>
useConfirmableAction(jest.fn().mockResolvedValue(undefined)),
);
expect(result.current.open).toBe(false);
expect(result.current.isPending).toBe(false);
});
it('request() opens the prompt without running the action', () => {
const action = jest.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useConfirmableAction(action));
act(() => result.current.request());
expect(result.current.open).toBe(true);
expect(action).not.toHaveBeenCalled();
});
it('confirm() runs the action and closes on success', async () => {
const action = jest.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useConfirmableAction(action));
act(() => result.current.request());
await act(async () => {
await result.current.confirm();
});
expect(action).toHaveBeenCalledTimes(1);
expect(result.current.open).toBe(false);
expect(result.current.isPending).toBe(false);
});
it('keeps the prompt open and resets pending when the action rejects', async () => {
const action = jest.fn().mockRejectedValue(new Error('boom'));
const { result } = renderHook(() => useConfirmableAction(action));
act(() => result.current.request());
await act(async () => {
await expect(result.current.confirm()).rejects.toThrow('boom');
});
expect(result.current.open).toBe(true);
expect(result.current.isPending).toBe(false);
});
it('cancel() closes the prompt without running the action', () => {
const action = jest.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useConfirmableAction(action));
act(() => result.current.request());
act(() => result.current.cancel());
expect(result.current.open).toBe(false);
expect(action).not.toHaveBeenCalled();
});
});

View File

@@ -1,45 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
export interface ConfirmableAction {
/** Whether the confirmation prompt is open. */
open: boolean;
/** The confirmed action is in flight. */
isPending: boolean;
/** Open the confirmation prompt (e.g. from a menu item / button). */
request: () => void;
/** Run the action, tracking the in-flight flag; closes the prompt on success. */
confirm: () => Promise<void>;
/** Dismiss the prompt without acting. */
cancel: () => void;
}
/**
* Generic two-step confirm flow for a (usually destructive) async action.
* `request()` opens the prompt, `confirm()` runs `action` while tracking an
* in-flight flag and closes on success, `cancel()` dismisses it. Owns only the
* confirm state machine — what renders the prompt (dialog, popover) is the
* caller's concern, so it stays reusable across confirm surfaces.
*/
export function useConfirmableAction(
action: () => Promise<void>,
): ConfirmableAction {
const [open, setOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
const request = useCallback((): void => setOpen(true), []);
const cancel = useCallback((): void => setOpen(false), []);
const confirm = useCallback(async (): Promise<void> => {
setIsPending(true);
try {
await action();
setOpen(false);
} finally {
setIsPending(false);
}
}, [action]);
return useMemo(
() => ({ open, isPending, request, confirm, cancel }),
[open, isPending, request, confirm, cancel],
);
}

View File

@@ -1,5 +1,3 @@
@use '../../../../styles/scrollbar' as *;
.legend-search-container {
flex-shrink: 0;
width: 100%;
@@ -17,10 +15,6 @@
gap: 12px;
height: 100%;
width: 100%;
// Allow the flex children to shrink below their content height so the
// virtualized grid scrolls within the capped legend height instead of
// overflowing the wrapper (default min-height:auto would block the shrink).
min-height: 0;
&:has(.legend-item-focused) .legend-item {
opacity: 0.3;
@@ -39,11 +33,6 @@
}
.legend-virtuoso-container {
// flex:1 + min-height:0 pins the scroller to the space left after the
// search box (RIGHT legend) and lets it scroll instead of growing to fit
// every row — without this the grid overflows a BOTTOM legend's fixed height.
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
@@ -78,7 +67,18 @@
}
}
@include custom-scrollbar;
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
border-radius: 0.5rem;
}
}
}
@@ -108,10 +108,6 @@
align-items: center;
gap: 6px;
padding: 4px 8px;
// Include padding within the width so a full-width row (legend-item-right) fits its
// column instead of overflowing by the 16px horizontal padding — there is no global
// border-box reset, so the default content-box would make it overflow.
box-sizing: border-box;
max-width: 100%;
overflow: hidden;
border-radius: 4px;

View File

@@ -87,7 +87,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
lineConfig.fill = `${finalFillColor}40`;
} else if (fillMode && fillMode !== FillMode.None) {
if (fillMode === FillMode.Solid) {
lineConfig.fill = `${finalFillColor}70`;
lineConfig.fill = finalFillColor;
} else if (fillMode === FillMode.Gradient) {
lineConfig.fill = (self: uPlot): CanvasGradient =>
generateGradientFill(self, finalFillColor, 'rgba(0, 0, 0, 0)');

View File

@@ -20,6 +20,7 @@ import APIError from 'types/api/error';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
import styles from './DashboardPageToolbar.module.scss';
@@ -52,6 +53,10 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
(s) => s.setIsPanelTypeSelectionModalOpen,
);
// Single global fetch of the public-sharing meta (the drawer reuses this cache);
// drives the public-access badge.
const { isPublic: isPublicDashboard } = usePublicDashboardMeta(id);
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
@@ -117,7 +122,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
image={image}
tags={tags}
description={description}
isPublicDashboard={false}
isPublicDashboard={isPublicDashboard}
isDashboardLocked={isDashboardLocked}
isEditing={isEditing}
draft={draft}

View File

@@ -1,106 +1,15 @@
// settings card wrapper — mirrors the V1 public dashboard treatment
.publicDashboardCard {
// Publish tab — "status strip" direction (Claude Design: Publish Drawer Final).
// Fills the drawer height so the actions anchor a footer instead of floating.
.publishTab {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
border-radius: 3px;
border: 1px solid var(--l2-border);
height: 100%;
min-height: 100%;
}
.statusTitle {
margin-bottom: 16px;
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.checkbox {
margin-bottom: 8px;
}
.timeRangeSelectGroup {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.timeRangeSelectLabel {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
.timeRangeSelect {
width: 200px;
}
.urlGroup {
display: flex;
flex-direction: column;
gap: 4px;
}
.urlLabel {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
.urlContainer {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.urlText {
.content {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
line-height: 32px;
}
.callout {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 12px 8px;
border-radius: 3px;
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
}
.calloutIcon {
flex-shrink: 0;
color: var(--text-robin-300);
}
.calloutText {
color: var(--text-robin-300);
font-family: Inter;
font-size: 11px;
font-weight: 400;
line-height: 16px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 32px;
flex-direction: column;
gap: 20px;
}

View File

@@ -0,0 +1,12 @@
.footer {
position: sticky;
z-index: 1;
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 10px;
padding-top: 14px;
border-top: 1px solid var(--l2-border);
}

View File

@@ -1,7 +1,7 @@
import { Globe, Trash } from '@signozhq/icons';
import { Globe, RefreshCw, Trash } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from './PublicDashboard.module.scss';
import styles from './PublicDashboardActions.module.scss';
interface PublicDashboardActionsProps {
isPublic: boolean;
@@ -25,7 +25,7 @@ function PublicDashboardActions({
onUnpublish,
}: PublicDashboardActionsProps): JSX.Element {
return (
<div className={styles.actions}>
<div className={styles.footer}>
{isPublic ? (
<>
<Button
@@ -33,22 +33,22 @@ function PublicDashboardActions({
color="destructive"
disabled={disabled}
loading={isUnpublishing}
prefix={<Trash size={14} />}
prefix={<Trash size={15} />}
testId="public-dashboard-unpublish"
onClick={onUnpublish}
>
Unpublish dashboard
Unpublish Dashboard
</Button>
<Button
variant="solid"
color="primary"
disabled={disabled}
loading={isUpdating}
prefix={<Globe size={14} />}
prefix={<RefreshCw size={15} />}
testId="public-dashboard-update"
onClick={onUpdate}
>
Update published dashboard
Update Dashboard
</Button>
</>
) : (
@@ -57,11 +57,11 @@ function PublicDashboardActions({
color="primary"
disabled={disabled}
loading={isPublishing}
prefix={<Globe size={14} />}
prefix={<Globe size={15} />}
testId="public-dashboard-publish"
onClick={onPublish}
>
Publish dashboard
Publish Dashboard
</Button>
)}
</div>

View File

@@ -1,17 +0,0 @@
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from './PublicDashboard.module.scss';
function PublicDashboardCallout(): JSX.Element {
return (
<div className={styles.callout}>
<Info size={12} className={styles.calloutIcon} />
<Typography.Text className={styles.calloutText}>
Dashboard variables won&apos;t work in public dashboards
</Typography.Text>
</div>
);
}
export default PublicDashboardCallout;

View File

@@ -0,0 +1,19 @@
.hint {
display: flex;
align-items: flex-start;
gap: 8px;
padding-top: 2px;
color: var(--l3-foreground);
}
.hintIcon {
flex: none;
margin-top: 1px;
color: var(--l3-foreground);
}
.hintText {
color: var(--l3-foreground);
font-size: 12px;
line-height: 1.5;
}

View File

@@ -0,0 +1,17 @@
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from './PublicDashboardHint.module.scss';
function PublicDashboardHint(): JSX.Element {
return (
<div className={styles.hint}>
<Info size={14} className={styles.hintIcon} />
<Typography.Text className={styles.hintText}>
Dashboard variables aren&apos;t supported on public links.
</Typography.Text>
</div>
);
}
export default PublicDashboardHint;

View File

@@ -0,0 +1,34 @@
.switchRow {
display: flex;
align-items: center;
}
.fieldGroup {
display: flex;
flex-direction: column;
gap: 8px;
// Render the (non-portaled) dropdown above the drawer.
[data-radix-popper-content-wrapper] {
z-index: 1100 !important;
}
// Radix sets --radix-select-trigger-width on the content element (the wrapper's
// child), so match it there to make the dropdown take the input's width.
// SelectSimple exposes no content className, hence the descendant selector.
[data-radix-popper-content-wrapper] > * {
width: var(--radix-select-trigger-width);
min-width: var(--radix-select-trigger-width);
}
}
.fieldLabel {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 500;
line-height: 1;
}
.timeRangeSelect {
width: 100%;
}

View File

@@ -1,9 +1,9 @@
import { Checkbox } from '@signozhq/ui/checkbox';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { RelativeDurationOptions } from 'container/TopNav/DateTimeSelectionV2/constants';
import { TIME_RANGE_PRESETS_OPTIONS } from './constants';
import styles from './PublicDashboard.module.scss';
import styles from './PublicDashboardSettingsForm.module.scss';
interface PublicDashboardSettingsFormProps {
timeRangeEnabled: boolean;
@@ -22,28 +22,29 @@ function PublicDashboardSettingsForm({
}: PublicDashboardSettingsFormProps): JSX.Element {
return (
<>
<Checkbox
id="public-dashboard-enable-time-range"
className={styles.checkbox}
testId="public-dashboard-time-range-toggle"
value={timeRangeEnabled}
disabled={disabled}
onChange={(checked): void => onTimeRangeEnabledChange(checked === true)}
>
Enable time range
</Checkbox>
<div className={styles.switchRow}>
<Switch
testId="public-dashboard-time-range-toggle"
value={timeRangeEnabled}
disabled={disabled}
onChange={onTimeRangeEnabledChange}
>
Enable time range
</Switch>
</div>
<div className={styles.timeRangeSelectGroup}>
<Typography.Text className={styles.timeRangeSelectLabel}>
<div className={styles.fieldGroup}>
<Typography.Text className={styles.fieldLabel}>
Default time range
</Typography.Text>
<SelectSimple
className={styles.timeRangeSelect}
testId="public-dashboard-default-time-range"
placeholder="Select default time range"
items={TIME_RANGE_PRESETS_OPTIONS}
items={RelativeDurationOptions}
value={defaultTimeRange}
disabled={disabled}
withPortal={false}
onChange={(value): void => onDefaultTimeRangeChange(value as string)}
/>
</div>

View File

@@ -1,21 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import styles from './PublicDashboard.module.scss';
interface PublicDashboardStatusProps {
isPublic: boolean;
}
function PublicDashboardStatus({
isPublic,
}: PublicDashboardStatusProps): JSX.Element {
return (
<Typography.Text className={styles.statusTitle}>
{isPublic
? 'This dashboard is publicly accessible. Anyone with the link can view it.'
: 'This dashboard is private. Publish it to make it accessible to anyone with the link.'}
</Typography.Text>
);
}
export default PublicDashboardStatus;

View File

@@ -0,0 +1,67 @@
.statusStrip {
display: flex;
align-items: center;
gap: 13px;
padding: 14px 16px;
border-radius: 8px;
border: 1px solid var(--l2-border);
background: var(--l1-background);
}
.statusStripLive {
border-color: var(--callout-primary-border);
background: var(--callout-primary-background);
}
.statusMedallion {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 38px;
height: 38px;
border-radius: 6px;
border: 1px solid var(--l2-border);
background: var(--l3-background);
color: var(--l2-foreground);
}
.statusMedallionLive {
border-color: var(--callout-primary-border);
background: var(--callout-primary-background);
color: var(--callout-primary-icon);
}
.statusBody {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.statusTitle {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 600;
line-height: 1.2;
}
.statusSubtitle {
margin-top: 2px;
color: var(--l3-foreground);
font-size: 13px;
line-height: 1.35;
}
.statusSubtitleLive {
color: var(--l2-foreground);
}
.statusBadgeDot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 6px;
background: currentColor;
}

View File

@@ -0,0 +1,50 @@
import { Globe, LockKeyhole } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './PublicDashboardStatus.module.scss';
interface PublicDashboardStatusProps {
isPublic: boolean;
}
function PublicDashboardStatus({
isPublic,
}: PublicDashboardStatusProps): JSX.Element {
return (
<div
className={cx(styles.statusStrip, { [styles.statusStripLive]: isPublic })}
>
<span
className={cx(styles.statusMedallion, {
[styles.statusMedallionLive]: isPublic,
})}
>
{isPublic ? <Globe size={18} /> : <LockKeyhole size={18} />}
</span>
<div className={styles.statusBody}>
<Typography.Text className={styles.statusTitle}>
{isPublic ? 'This dashboard is live' : 'This dashboard is private'}
</Typography.Text>
<Typography.Text
className={cx(styles.statusSubtitle, {
[styles.statusSubtitleLive]: isPublic,
})}
>
{isPublic
? 'Anyone with the link can view it — no account needed.'
: 'Publish it to share a read-only view with anyone who has the link.'}
</Typography.Text>
</div>
<Badge variant="outline" color={isPublic ? 'robin' : 'secondary'}>
<span className={styles.statusBadgeDot} />
{isPublic ? 'Public' : 'Private'}
</Badge>
</div>
);
}
export default PublicDashboardStatus;

View File

@@ -1,49 +0,0 @@
import { Copy, ExternalLink } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './PublicDashboard.module.scss';
interface PublicDashboardUrlProps {
url: string;
onCopy: () => void;
onOpen: () => void;
}
function PublicDashboardUrl({
url,
onCopy,
onOpen,
}: PublicDashboardUrlProps): JSX.Element {
return (
<div className={styles.urlGroup}>
<Typography.Text className={styles.urlLabel}>
Public dashboard URL
</Typography.Text>
<div className={styles.urlContainer}>
<Typography.Text className={styles.urlText}>{url}</Typography.Text>
<Button
variant="ghost"
size="icon"
aria-label="Copy public dashboard URL"
testId="public-dashboard-copy-url"
onClick={onCopy}
>
<Copy size={14} />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Open public dashboard in new tab"
testId="public-dashboard-open-url"
onClick={onOpen}
>
<ExternalLink size={14} />
</Button>
</div>
</div>
);
}
export default PublicDashboardUrl;

View File

@@ -0,0 +1,69 @@
.fieldGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.fieldLabel {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 500;
line-height: 1;
}
.linkPlaceholder {
display: flex;
align-items: center;
gap: 9px;
height: 40px;
padding: 0 12px;
border-radius: 6px;
border: 1px dashed var(--l2-border);
background: var(--l1-background);
color: var(--l3-foreground);
}
.linkPlaceholderIcon {
flex: none;
color: var(--l3-foreground);
}
.linkPlaceholderText {
color: var(--l3-foreground);
font-size: 13px;
line-height: 1;
}
.linkField {
display: flex;
align-items: center;
gap: 2px;
height: 40px;
padding: 0 5px 0 12px;
border-radius: 6px;
border: 1px solid var(--l2-border);
background: var(--l1-background);
&:hover {
border-color: var(--l3-border);
}
}
.linkUrl {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--l2-foreground);
font-family: var(--font-mono, 'Geist Mono'), monospace;
font-size: 13px;
line-height: 1;
}
.linkDivider {
width: 1px;
height: 20px;
margin: 0 4px;
background: var(--l2-border);
}

View File

@@ -0,0 +1,59 @@
import { Copy, ExternalLink, Link2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './PublicDashboardUrl.module.scss';
interface PublicDashboardUrlProps {
isPublic: boolean;
url: string;
onCopy: () => void;
onOpen: () => void;
}
function PublicDashboardUrl({
isPublic,
url,
onCopy,
onOpen,
}: PublicDashboardUrlProps): JSX.Element {
return (
<div className={styles.fieldGroup}>
<Typography.Text className={styles.fieldLabel}>Public link</Typography.Text>
{isPublic ? (
<div className={styles.linkField}>
<Typography.Text className={styles.linkUrl}>{url}</Typography.Text>
<span className={styles.linkDivider} />
<Button
variant="ghost"
size="icon"
aria-label="Copy link"
testId="public-dashboard-copy-url"
onClick={onCopy}
>
<Copy size={15} />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Open link"
testId="public-dashboard-open-url"
onClick={onOpen}
>
<ExternalLink size={15} />
</Button>
</div>
) : (
<div className={styles.linkPlaceholder}>
<Link2 size={15} className={styles.linkPlaceholderIcon} />
<Typography.Text className={styles.linkPlaceholderText}>
Your shareable link will appear here once published
</Typography.Text>
</div>
)}
</div>
);
}
export default PublicDashboardUrl;

View File

@@ -1,14 +0,0 @@
export interface TimeRangePresetOption {
label: string;
value: string;
}
// Default time-range presets offered for the public dashboard viewer.
export const TIME_RANGE_PRESETS_OPTIONS: TimeRangePresetOption[] = [
{ label: 'Last 5 minutes', value: '5m' },
{ label: 'Last 15 minutes', value: '15m' },
{ label: 'Last 30 minutes', value: '30m' },
{ label: 'Last 1 hour', value: '1h' },
{ label: 'Last 6 hours', value: '6h' },
{ label: 'Last 1 day', value: '24h' },
];

View File

@@ -1,10 +1,10 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import PublicDashboardActions from './PublicDashboardActions';
import PublicDashboardCallout from './PublicDashboardCallout';
import PublicDashboardSettingsForm from './PublicDashboardSettingsForm';
import PublicDashboardStatus from './PublicDashboardStatus';
import PublicDashboardUrl from './PublicDashboardUrl';
import PublicDashboardActions from './PublicDashboardActions/PublicDashboardActions';
import PublicDashboardHint from './PublicDashboardHint/PublicDashboardHint';
import PublicDashboardSettingsForm from './PublicDashboardSettingsForm/PublicDashboardSettingsForm';
import PublicDashboardStatus from './PublicDashboardStatus/PublicDashboardStatus';
import PublicDashboardUrl from './PublicDashboardUrl/PublicDashboardUrl';
import { usePublicDashboard } from './usePublicDashboard';
import styles from './PublicDashboard.module.scss';
@@ -37,22 +37,27 @@ function PublicDashboardSettings({
const controlsDisabled = isLoading || !isAdmin;
return (
<div className={styles.publicDashboardCard}>
<PublicDashboardStatus isPublic={isPublic} />
<div className={styles.publishTab}>
<div className={styles.content}>
<PublicDashboardStatus isPublic={isPublic} />
<PublicDashboardSettingsForm
timeRangeEnabled={timeRangeEnabled}
defaultTimeRange={defaultTimeRange}
disabled={controlsDisabled}
onTimeRangeEnabledChange={setTimeRangeEnabled}
onDefaultTimeRangeChange={setDefaultTimeRange}
/>
<PublicDashboardUrl
isPublic={isPublic}
url={publicUrl}
onCopy={onCopyUrl}
onOpen={onOpenUrl}
/>
{isPublic && (
<PublicDashboardUrl url={publicUrl} onCopy={onCopyUrl} onOpen={onOpenUrl} />
)}
<PublicDashboardSettingsForm
timeRangeEnabled={timeRangeEnabled}
defaultTimeRange={defaultTimeRange}
disabled={controlsDisabled}
onTimeRangeEnabledChange={setTimeRangeEnabled}
onDefaultTimeRangeChange={setDefaultTimeRange}
/>
</div>
<PublicDashboardCallout />
<PublicDashboardHint />
<PublicDashboardActions
isPublic={isPublic}

View File

@@ -6,7 +6,6 @@ import {
invalidateGetPublicDashboard,
useCreatePublicDashboard,
useDeletePublicDashboard,
useGetPublicDashboard,
useUpdatePublicDashboard,
} from 'api/generated/services/dashboard';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
@@ -17,6 +16,8 @@ import { USER_ROLES } from 'types/roles';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import { usePublicDashboardMeta } from './usePublicDashboardMeta';
export interface UsePublicDashboardReturn {
isPublic: boolean;
isAdmin: boolean;
@@ -54,22 +55,16 @@ export function usePublicDashboard(
const [defaultTimeRange, setDefaultTimeRange] =
useState<string>(DEFAULT_TIME_RANGE);
// Read the shared public-meta cache — the GET is owned globally (toolbar), so the
// drawer reuses it rather than issuing its own request.
const {
data,
publicMeta,
isPublic,
isLoading: isLoadingMeta,
isFetching,
error,
refetch,
} = useGetPublicDashboard(
{ id: dashboardId },
{ query: { enabled: !!dashboardId, retry: false } },
);
// react-query retains the last successful `data` even after a refetch errors, so
// after unpublishing (the refetch 404s) `data` still holds the old publicPath.
// Gate on `!error` so the UI flips back to the private state.
const publicMeta = error ? undefined : data?.data;
const isPublic = !!publicMeta?.publicPath;
} = usePublicDashboardMeta(dashboardId);
// Seed form state from the server config when published.
useEffect(() => {
@@ -103,7 +98,7 @@ export function usePublicDashboard(
(message: string): void => {
toast.success(message);
void invalidateGetPublicDashboard(queryClient, { id: dashboardId });
void refetch();
refetch();
},
[queryClient, dashboardId, refetch],
);

View File

@@ -0,0 +1,65 @@
import { useMemo } from 'react';
import { useGetPublicDashboard } from 'api/generated/services/dashboard';
import type { DashboardtypesGettablePublicDasbhboardDTO } from 'api/generated/services/sigNoz.schemas';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
export interface UsePublicDashboardMetaReturn {
publicMeta: DashboardtypesGettablePublicDasbhboardDTO | undefined;
isPublic: boolean;
isLoading: boolean;
isFetching: boolean;
error: unknown;
refetch: () => void;
}
// How long a fetched result stays fresh before a natural trigger may refresh it.
const PUBLIC_META_STALE_TIME = 5 * 60 * 1000;
/**
* Single source of truth for a dashboard's public-sharing meta. Keyed by dashboard
* id via the generated query, so the GET happens once globally (the toolbar mounts it
* with the dashboard) and every other caller — the publish settings drawer — reads the
* same cache instead of issuing its own request. A mutation that invalidates
* getGetPublicDashboardQueryKey refreshes all consumers at once.
*
* Only fetched on cloud / enterprise tenants, where public dashboards are available.
*/
export function usePublicDashboardMeta(
dashboardId: string,
): UsePublicDashboardMetaReturn {
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const enabled = !!dashboardId && (isCloudUser || isEnterpriseSelfHostedUser);
const { data, isLoading, isFetching, error, refetch } = useGetPublicDashboard(
{ id: dashboardId },
{
query: {
enabled,
retry: false,
// refetchOnMount: false stops opening the drawer / switching to the Publish
// tab from refiring the GET — it reuses the toolbar's cached result. A finite
// staleTime still lets it refresh naturally once the data ages, and mutations
// invalidate the key to refresh the published state immediately.
staleTime: PUBLIC_META_STALE_TIME,
refetchOnMount: false,
},
},
);
// react-query retains the last successful `data` after a refetch errors (e.g. the
// 404 once a dashboard is unpublished), so gate on the error to reflect the
// private state.
const publicMeta = error ? undefined : data?.data;
return useMemo(
() => ({
publicMeta,
isPublic: !!publicMeta?.publicPath,
isLoading,
isFetching,
error,
refetch,
}),
[publicMeta, isLoading, isFetching, error, refetch],
);
}

View File

@@ -1,22 +0,0 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
background-color: var(--l1-background-60);
border-bottom: 1px solid var(--l1-border);
}
.title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -1,84 +0,0 @@
import { useCallback, useState } from 'react';
import { SolidAlertTriangle, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import styles from './Header.module.scss';
interface HeaderProps {
isDirty: boolean;
isSaving: boolean;
onSave: () => void;
onClose: () => void;
}
function Header({
isDirty,
isSaving,
onSave,
onClose,
}: HeaderProps): JSX.Element {
const [isDiscardOpen, setIsDiscardOpen] = useState(false);
// Closing with unsaved edits prompts for confirmation; a pristine panel closes
// straight away.
const handleCloseClick = useCallback((): void => {
if (isDirty) {
setIsDiscardOpen(true);
} else {
onClose();
}
}, [isDirty, onClose]);
return (
<div className={styles.header}>
<div className={styles.title}>
<Button
variant="ghost"
color="secondary"
size="icon"
suffix={<X size={14} />}
data-testid="panel-editor-v2-close"
onClick={handleCloseClick}
/>
<Divider type="vertical" />
<Typography.Text>Configure panel</Typography.Text>
</div>
<div className={styles.actions}>
<Button
variant="solid"
color="primary"
data-testid="panel-editor-v2-save"
disabled={!isDirty || isSaving}
loading={isSaving}
onClick={onSave}
>
Save changes
</Button>
</div>
<ConfirmDialog
open={isDiscardOpen}
onOpenChange={(next): void => {
if (!next) {
setIsDiscardOpen(false);
}
}}
title="Discard changes?"
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
confirmText="Discard"
confirmColor="destructive"
cancelText="Keep editing"
onConfirm={onClose}
onCancel={(): void => setIsDiscardOpen(false)}
data-testid="panel-editor-v2-discard-modal"
>
<Typography>Your unsaved edits to this panel will be lost.</Typography>
</ConfirmDialog>
</div>
);
}
export default Header;

View File

@@ -1,28 +0,0 @@
// Full-page editor: fills the route's content area as a header-over-split
// column (the editor is its own page now, not a modal overlay).
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--l1-background);
}
.left {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.right {
display: flex;
}
.handle {
background: var(--l1-border);
&:hover {
background: var(--l2-border);
}
}

View File

@@ -1,45 +0,0 @@
@use '../../../../../styles/scrollbar' as *;
.container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: auto;
background-color: var(--l1-background);
@include custom-scrollbar;
}
.scrollArea {
padding: 12px;
}
.tabsContainer {
width: 100%;
:global(.ant-tabs-tab) {
background-color: var(--l2-background) !important;
border-color: var(--l2-border) !important;
}
:global(.ant-tabs-tab-active) {
background-color: var(--l1-background) !important;
}
:global(.ant-tabs-nav) {
&::before {
border-color: var(--l2-border);
}
}
}
.queryTypeTab {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.runQueryBtnContainer {
padding: 4px 0 8px 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 1rem;
}

View File

@@ -1,170 +0,0 @@
import {
type KeyboardEvent,
type ReactNode,
useCallback,
useMemo,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Atom, Terminal } from '@signozhq/icons';
import { Tabs } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import TextToolTip from 'components/TextToolTip';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ClickHouseQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse';
import PromQLQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL';
import { PANEL_TYPE_TO_QUERY_TYPES } from 'container/NewWidget/utils';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EQueryType } from 'types/common/dashboard';
import styles from './PanelEditorQueryBuilder.module.scss';
interface PanelEditorQueryBuilderProps {
panelType: PANEL_TYPES;
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
isLoadingQueries: boolean;
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
onStageRunQuery: () => void;
/** Abort the in-flight preview fetch (the button's cancel action). */
onCancelQuery: () => void;
/** Optional content pinned below the builder (e.g. the List columns editor). */
footer?: ReactNode;
}
/**
* Query builder for the V2 panel editor's left pane — the queryType tabs
* (Query Builder / ClickHouse / PromQL) over the shared `QueryBuilderV2` and the
* V1 ClickHouse/PromQL containers, plus the Stage & Run button. All of these
* read/write the global `QueryBuilderProvider`; `usePanelEditorQuerySync` owns
* seeding the provider from the panel and pushing Stage-&-Run results back into
* the editor draft, so this component is purely the builder UI.
*/
function PanelEditorQueryBuilder({
panelType,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
footer,
}: PanelEditorQueryBuilderProps): JSX.Element {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
const handleQueryCategoryChange = useCallback(
(queryType: string): void => {
redirectWithQueryBuilderData({
...currentQuery,
queryType: queryType as EQueryType,
});
},
[currentQuery, redirectWithQueryBuilderData],
);
// ⌘↵ / Ctrl+↵ stages and runs the query while a query-builder field is
// focused. The global keyboard-hotkeys provider deliberately ignores keydowns
// originating in inputs / the query editor, so this is handled locally. Uses
// the capture phase so it fires even for fields that stop the event from
// bubbling (e.g. the filter search, CodeMirror) — the container sees the
// keydown on the way down to the focused field.
const handleKeyDownCapture = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
onStageRunQuery();
}
},
[onStageRunQuery],
);
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => ({ stepInterval: { isHidden: false, isDisabled: false } }),
[],
);
const items = useMemo(() => {
const supportedQueryTypes = PANEL_TYPE_TO_QUERY_TYPES[panelType] || [];
const queryTypeComponents = {
[EQueryType.QUERY_BUILDER]: {
icon: <Atom size={14} />,
label: 'Query Builder',
component: (
<div className="query-builder-v2-container">
<QueryBuilderV2
panelType={panelType}
filterConfigs={filterConfigs}
showTraceOperator={panelType !== PANEL_TYPES.LIST}
version="v3"
isListViewPanel={panelType === PANEL_TYPES.LIST}
queryComponents={{}}
signalSourceChangeEnabled
savePreviousQuery
/>
</div>
),
},
[EQueryType.CLICKHOUSE]: {
icon: <Terminal size={14} />,
label: 'ClickHouse Query',
component: <ClickHouseQueryContainer />,
},
[EQueryType.PROM]: {
icon: (
<PromQLIcon
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
/>
),
label: 'PromQL',
component: <PromQLQueryContainer />,
},
};
return supportedQueryTypes.map((queryType) => ({
key: queryType,
label: (
<div className={styles.queryTypeTab}>
{queryTypeComponents[queryType].icon}
<Typography>{queryTypeComponents[queryType].label}</Typography>
</div>
),
children: queryTypeComponents[queryType].component,
}));
}, [panelType, filterConfigs, isDarkMode]);
return (
<div
className={styles.container}
data-testid="panel-editor-v2-query-builder"
onKeyDownCapture={handleKeyDownCapture}
role="presentation"
>
<div className={styles.scrollArea}>
<Tabs
type="card"
className={styles.tabsContainer}
activeKey={currentQuery.queryType}
onChange={handleQueryCategoryChange}
tabBarExtraContent={
<span className={styles.runQueryBtnContainer}>
<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="Stage & Run Query"
onStageRunQuery={onStageRunQuery}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={onCancelQuery}
/>
</span>
}
items={items}
/>
</div>
{footer}
</div>
);
}
export default PanelEditorQueryBuilder;

View File

@@ -1,59 +0,0 @@
.preview {
display: flex;
flex-direction: column;
height: 100%;
gap: 16px;
padding: 24px;
background-image: radial-gradient(var(--l2-border) 1px, transparent 0);
background-size: 20px 20px;
border-bottom: 1px solid var(--l1-border);
}
.header {
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
}
.queryType {
display: inline-flex;
padding: 4px 8px 4px 6px;
align-items: center;
gap: 6px;
border-radius: 4px;
background: var(--l3-background);
backdrop-filter: blur(6px);
width: fit-content;
}
.container {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
}
.surface {
flex: 1;
min-width: 0;
min-height: 0;
border: 1px solid var(--l2-border);
border-radius: 4px;
overflow: hidden;
display: flex;
background: var(--l2-background);
padding: 8px;
}
.state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--l2-forground);
font-size: 13px;
text-align: center;
}

View File

@@ -1,85 +0,0 @@
import { Spline } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type {
PanelPagination,
PanelQueryData,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { EQueryType } from 'types/common/dashboard';
import styles from './PreviewPane.module.scss';
interface PreviewPaneProps {
panelId: string;
panel: DashboardtypesPanelDTO;
/** Resolved definition for the panel kind; undefined when the kind is unsupported. */
panelDef: RenderablePanelDefinition | undefined;
data: PanelQueryData;
isLoading: boolean;
error: Error | null;
/** Re-run the query (drives PanelBody's error-state retry). */
refetch: () => void;
/** Drag-to-zoom on a time-axis chart → updates the (URL-synced) time window. */
onDragSelect: (start: number, end: number) => void;
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
pagination?: PanelPagination;
}
/**
* Live preview for the panel editor. Presentational: the draft panel renders
* through `PanelBody` — the very same body the dashboard grid uses — so the
* preview is the production render path (loading / error-retry / renderer), with
* `panelMode={DASHBOARD_EDIT}` the only difference. The query result is owned by
* the editor root (`usePanelQuery`) and passed in, shared with the config pane.
*/
function PreviewPane({
panelId,
panel,
panelDef,
data,
isLoading,
error,
refetch,
onDragSelect,
pagination,
}: PreviewPaneProps): JSX.Element {
return (
<div className={styles.preview}>
<div className={styles.header}>
<div className={styles.queryType}>
<Spline size={14} />
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
</div>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<div className={styles.container}>
<div className={styles.surface}>
{panelDef ? (
<PanelBody
panelDefinition={panelDef}
panel={panel}
panelId={panelId}
data={data}
isLoading={isLoading}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
panelMode={PanelMode.DASHBOARD_EDIT}
pagination={pagination}
/>
) : (
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
This panel type is not yet supported in V2.
</div>
)}
</div>
</div>
</div>
);
}
export default PreviewPane;

View File

@@ -1,93 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { usePanelEditorDraft } from '../usePanelEditorDraft';
function panel(name = 'CPU', description = 'usage'): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name, description },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('usePanelEditorDraft', () => {
it('exposes the panel spec and starts clean', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
expect(result.current.spec).toBe(result.current.draft.spec);
expect(result.current.spec.display?.name).toBe('CPU');
expect(result.current.isSpecDirty).toBe(false);
});
it('flags dirty and writes through on a display (title) edit via setSpec', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
act(() =>
result.current.setSpec({
...result.current.spec,
display: { ...result.current.spec.display, name: 'Memory' },
}),
);
expect(result.current.isSpecDirty).toBe(true);
expect(result.current.draft.spec?.display?.name).toBe('Memory');
});
it('flags dirty on a plugin-spec (non-display) edit', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
act(() =>
result.current.setSpec({
...result.current.spec,
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { formatting: { unit: 'bytes' } },
},
} as typeof result.current.spec),
);
expect(result.current.isSpecDirty).toBe(true);
expect(
(
result.current.draft.spec?.plugin?.spec as {
formatting?: { unit?: string };
}
)?.formatting?.unit,
).toBe('bytes');
});
it('does not flag spec-dirty when only spec.queries changes (owned by the builder)', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
act(() =>
result.current.setSpec({
...result.current.spec,
queries: [{ id: 'committed-by-builder' }],
} as unknown as typeof result.current.spec),
);
expect(result.current.isSpecDirty).toBe(false);
});
it('reset restores the spec and clears dirty after an edit', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
act(() =>
result.current.setSpec({
...result.current.spec,
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { formatting: { unit: 'ms' } },
},
} as typeof result.current.spec),
);
act(() => result.current.reset());
expect(result.current.isSpecDirty).toBe(false);
expect(result.current.spec.display?.name).toBe('CPU');
});
});

View File

@@ -1,331 +0,0 @@
import { renderHook } from '@testing-library/react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getIsQueryModified } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { fromPerses, toPerses } from '../../../queryV5/persesQueryAdapters';
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
useShareBuilderUrl: jest.fn(),
}));
jest.mock('container/NewWidget/utils', () => ({
getIsQueryModified: jest.fn(),
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
fromPerses: jest.fn(),
toPerses: jest.fn(),
}));
// commitQuery's no-op guard compares queries at the envelope level; with the
// adapters mocked, unwrap identity-style so the opaque fixtures stay distinct
// (CONVERTED vs SAVED) and the commit decisions are what's under test.
jest.mock('../../../queryV5/buildQueryRangeRequest', () => ({
toQueryEnvelopes: jest.fn((queries: unknown) => queries),
}));
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockUseShareBuilderUrl = useShareBuilderUrl as unknown as jest.Mock;
const mockGetIsQueryModified = getIsQueryModified as unknown as jest.Mock;
const mockFromPerses = fromPerses as unknown as jest.Mock;
const mockToPerses = toPerses as unknown as jest.Mock;
// Opaque fixtures — the adapters are mocked, so only identity matters here.
const SAVED_QUERIES = [{ id: 'saved' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const CONVERTED_QUERIES = [{ id: 'converted' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const SEED_V1 = { id: 'seed', queryType: 'builder' } as unknown as Query;
const STAGED_V1 = { id: 'staged', queryType: 'builder' } as unknown as Query;
function makeDraft(
queries = SAVED_QUERIES,
kind = 'signoz/TimeSeriesPanel',
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'Panel' },
plugin: { kind, spec: {} },
queries,
},
} as unknown as DashboardtypesPanelDTO;
}
function builderState(
overrides: Partial<{
currentQuery: Query;
stagedQuery: Query | null;
handleRunQuery: jest.Mock;
}> = {},
): {
currentQuery: Query;
stagedQuery: Query | null;
handleRunQuery: jest.Mock;
} {
return {
currentQuery: { id: 'current', queryType: 'builder' } as unknown as Query,
stagedQuery: STAGED_V1,
handleRunQuery: jest.fn(),
...overrides,
};
}
describe('usePanelEditorQuerySync', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFromPerses.mockReturnValue(SEED_V1);
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
mockGetIsQueryModified.mockReturnValue(false);
mockUseQueryBuilder.mockReturnValue(builderState());
});
function setup(
opts: {
draft?: DashboardtypesPanelDTO;
setSpec?: jest.Mock;
refetch?: jest.Mock;
} = {},
): {
result: {
current: {
runQuery: () => void;
isQueryDirty: boolean;
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
};
};
setSpec: jest.Mock;
refetch: jest.Mock;
rerender: () => void;
} {
const setSpec = opts.setSpec ?? jest.fn();
const refetch = opts.refetch ?? jest.fn();
const draft = opts.draft ?? makeDraft();
const { result, rerender } = renderHook(() =>
usePanelEditorQuerySync({
draft,
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
refetch,
}),
);
return { result, setSpec, refetch, rerender };
}
it('force-resets the builder to the saved queries on mount (discards stale URL)', () => {
setup();
expect(mockFromPerses).toHaveBeenCalledWith(
SAVED_QUERIES,
PANEL_TYPES.TIME_SERIES,
);
expect(mockUseShareBuilderUrl).toHaveBeenCalledWith({
defaultValue: SEED_V1,
forceReset: true,
});
});
it('does not touch the draft on mount for an unedited panel', () => {
const { setSpec, refetch } = setup();
// Mount runs the type-change effect once; an unedited query must no-op.
expect(setSpec).not.toHaveBeenCalled();
expect(refetch).not.toHaveBeenCalled();
});
it('compares the live query against the saved query (seed), not the staged query', () => {
const currentQuery = { id: 'current', queryType: 'builder' } as Query;
mockUseQueryBuilder.mockReturnValue(builderState({ currentQuery }));
const { result } = setup();
result.current.runQuery();
// Baseline is the saved seed — a stale staged/URL query must not be the
// reference, or a real datasource switch would read as "unchanged".
expect(mockGetIsQueryModified).toHaveBeenCalledWith(currentQuery, SEED_V1);
});
describe('runQuery', () => {
it('stages the query (handleRunQuery)', () => {
const handleRunQuery = jest.fn();
mockUseQueryBuilder.mockReturnValue(builderState({ handleRunQuery }));
const { result } = setup();
result.current.runQuery();
expect(handleRunQuery).toHaveBeenCalledTimes(1);
});
it('commits a modified query into the draft and does not force a refetch', () => {
mockGetIsQueryModified.mockReturnValue(true);
const { result, setSpec, refetch } = setup();
result.current.runQuery();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
expect(refetch).not.toHaveBeenCalled();
});
it('forces a refetch and leaves the draft alone when the query is unchanged', () => {
mockGetIsQueryModified.mockReturnValue(false);
const { result, setSpec, refetch } = setup();
result.current.runQuery();
expect(setSpec).not.toHaveBeenCalled();
expect(refetch).toHaveBeenCalledTimes(1);
});
it('commits a datasource switch even when the staged query is stale (no revert to saved)', () => {
// A stale staged query (e.g. URL-restored after refresh) must not be used
// as the baseline; the switch is detected against the saved seed and the
// live query is committed so the preview fetches it.
mockUseQueryBuilder.mockReturnValue(builderState({ stagedQuery: null }));
mockGetIsQueryModified.mockReturnValue(true);
const { result, setSpec, refetch } = setup();
result.current.runQuery();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
expect(refetch).not.toHaveBeenCalled();
});
});
describe('query-type switch', () => {
it('commits the active query when the query type changes', () => {
const state = builderState({
currentQuery: { id: 'a', queryType: 'builder' } as Query,
});
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Switch query type → the effect should commit.
state.currentQuery = { id: 'b', queryType: 'promql' } as Query;
rerender();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
});
it('does not commit when the active query type is unchanged', () => {
const state = builderState({
currentQuery: { id: 'a', queryType: 'builder' } as Query,
});
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Same query type, different object → effect must not re-fire.
state.currentQuery = { id: 'b', queryType: 'builder' } as Query;
rerender();
expect(setSpec).not.toHaveBeenCalled();
});
});
describe('datasource switch', () => {
const withSource = (id: string, dataSource: string): Query =>
({
id,
queryType: 'builder',
builder: { queryData: [{ dataSource }] },
}) as unknown as Query;
it('commits the active query when a query datasource changes', () => {
const state = builderState({ currentQuery: withSource('a', 'logs') });
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Switch datasource logs → traces → the effect should commit (→ refetch).
state.currentQuery = withSource('b', 'traces');
rerender();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
});
it('does not commit when the datasource is unchanged', () => {
const state = builderState({ currentQuery: withSource('a', 'logs') });
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Same datasource, different object → effect must not re-fire.
state.currentQuery = withSource('b', 'logs');
rerender();
expect(setSpec).not.toHaveBeenCalled();
});
});
describe('query dirty + save', () => {
it('compares the live query against the builder baseline (first staged query), not the raw seed', () => {
mockGetIsQueryModified.mockReturnValue(true);
const { result } = setup();
// Baseline is the builder's own normalized staged query — immune to the
// raw-seed vs builder-normalized serialization drift.
expect(mockGetIsQueryModified).toHaveBeenCalledWith(
expect.anything(),
STAGED_V1,
);
expect(result.current.isQueryDirty).toBe(true);
});
it('is not query-dirty when the live query matches the baseline', () => {
mockGetIsQueryModified.mockReturnValue(false);
const { result } = setup();
expect(result.current.isQueryDirty).toBe(false);
});
it('buildSaveSpec bakes the live query in when dirty', () => {
mockGetIsQueryModified.mockReturnValue(true);
const { result } = setup();
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
...spec,
queries: CONVERTED_QUERIES,
});
});
it('buildSaveSpec returns the spec untouched when the query is unchanged', () => {
mockGetIsQueryModified.mockReturnValue(false);
const { result } = setup();
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toBe(spec);
});
});
});

View File

@@ -1,82 +0,0 @@
import { renderHook } from '@testing-library/react';
import {
getGetDashboardV2QueryKey,
usePatchDashboardV2,
} from 'api/generated/services/dashboard';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { usePanelEditorSave } from '../usePanelEditorSave';
const mockInvalidateQueries = jest.fn();
jest.mock('react-query', () => ({
useQueryClient: (): { invalidateQueries: jest.Mock } => ({
invalidateQueries: mockInvalidateQueries,
}),
}));
jest.mock('api/generated/services/dashboard', () => ({
usePatchDashboardV2: jest.fn(),
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
}));
const mockUsePatch = usePatchDashboardV2 as unknown as jest.Mock;
const mockGetQueryKey = getGetDashboardV2QueryKey as unknown as jest.Mock;
describe('usePanelEditorSave', () => {
const mutateAsync = jest.fn().mockResolvedValue(undefined);
beforeEach(() => {
jest.clearAllMocks();
mockUsePatch.mockReturnValue({
mutateAsync,
isLoading: false,
error: null,
});
});
it('emits an add patch replacing the whole panel spec and invalidates the dashboard query', async () => {
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
);
const spec = {
display: { name: 'New title', description: 'desc' },
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { formatting: { unit: 'bytes' } },
},
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
await result.current.save(spec);
expect(mutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'dash-1' },
data: [
{
op: 'add',
path: '/spec/panels/panel-9/spec',
value: spec,
},
],
});
expect(mockGetQueryKey).toHaveBeenCalledWith({ id: 'dash-1' });
expect(mockInvalidateQueries).toHaveBeenCalledWith([
'/api/v2/dashboards/dash-1',
]);
});
it('surfaces the mutation loading state as isSaving', () => {
mockUsePatch.mockReturnValue({
mutateAsync,
isLoading: true,
error: null,
});
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
);
expect(result.current.isSaving).toBe(true);
});
});

View File

@@ -1,56 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { isEqual } from 'lodash-es';
import type { PanelEditorDraftApi } from '../types';
/**
* Owns the editable draft of a single panel. Seeded once from the loaded panel
* (`useState` initializer), then mutated locally until the user saves. Keeping
* the draft in the perses `DashboardtypesPanelDTO` shape lets the preview pane
* render it through the same renderer registry the dashboard uses, and lets the
* save hook patch it without any conversion.
*
* Everything the config pane edits — title/description, the per-kind plugin spec
* (formatting, axes, …), legend colors, context links — flows through the single
* `spec`/`setSpec` pair (the ConfigPane registry lens), so there is one editing path.
*/
export function usePanelEditorDraft(
initialPanel: DashboardtypesPanelDTO,
): PanelEditorDraftApi {
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
const setSpec = useCallback((next: DashboardtypesPanelSpecDTO): void => {
setDraft((prev) => ({ ...prev, spec: next }));
}, []);
const reset = useCallback((): void => {
setDraft(initialPanel);
}, [initialPanel]);
// Deep compare, ignoring `spec.queries`: the live query is owned by the shared
// query builder and re-serialized into the draft purely as a preview cache, so
// its representation drifts (builder-filled defaults, regenerated ids, wrapper
// kind) without a real edit. Query dirtiness is tracked separately against the
// builder; here we only flag divergence in display + plugin spec slices
// (formatting, axes, thresholds, links, list columns, …).
const isSpecDirty = useMemo(
() =>
!isEqual(
{ ...draft, spec: { ...draft.spec, queries: null } },
{ ...initialPanel, spec: { ...initialPanel.spec, queries: null } },
),
[draft, initialPanel],
);
return {
draft,
spec: draft.spec,
setSpec,
isSpecDirty,
reset,
};
}

View File

@@ -1,161 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getIsQueryModified } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { isEqual } from 'lodash-es';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
import { fromPerses, toPerses } from '../../queryV5/persesQueryAdapters';
interface UsePanelEditorQuerySyncArgs {
draft: DashboardtypesPanelDTO;
panelType: PANEL_TYPES;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Re-fetch the preview when the query is unchanged (Stage & Run on a no-op). */
refetch: () => void;
}
interface UsePanelEditorQuerySyncApi {
/** Run the current query (Stage & Run / ⌘↵). */
runQuery: () => void;
/**
* True when the live builder query differs from the saved query. Compared
* builder-normalized vs builder-normalized (against the builder's own baseline),
* so re-serialization noise never reads as an edit.
*/
isQueryDirty: boolean;
/**
* Bake the live query into a spec for saving — so unstaged edits still persist.
* Returns the spec untouched when the query is unchanged from saved.
*/
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
}
/**
* Bridges the shared query builder (global `QueryBuilderProvider`, URL-synced) and
* the V2 editor draft: seeds the builder from the saved panel, then commits the
* active query into `draft.spec.queries` (what the preview fetches) on a query-type
* or datasource switch and on Stage & Run.
*/
export function usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch,
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
// Saved queries, captured once: seed the builder and serve as the restore target.
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
const savedQueries = useMemo(() => draft.spec?.queries ?? [], []);
const seedQuery = useMemo(
() => fromPerses(savedQueries, panelType),
[savedQueries, panelType],
);
// Open the builder from the SAVED panel, discarding any stale URL query left by
// a prior edit/refresh — otherwise the QB shows the URL query while the preview
// keeps fetching the saved one, and the dirty baseline gets captured from the URL
// (so switching back to that datasource reads as "unchanged" and never commits).
// Force-reset on the first render only; after that the URL syncs normally.
const isInitialRenderRef = useRef(true);
useShareBuilderUrl({
defaultValue: seedQuery,
forceReset: isInitialRenderRef.current,
});
useEffect(() => {
isInitialRenderRef.current = false;
}, []);
// Commit the live query into the draft (what the preview fetches). The dirty
// check compares against the SAVED query (`seedQuery`), not the URL-synced
// staged query — a staged query can carry stale state across a refresh, which
// would make a real datasource switch read as "unchanged" and silently revert.
// Unchanged from saved → restore the saved queries (don't dirty the draft);
// changed → commit the live query. Returns whether the draft changed.
const commitQuery = useCallback(
(query: Query): boolean => {
const next = getIsQueryModified(query, seedQuery)
? toPerses(query, panelType)
: savedQueries;
// No-op guard at the V5 envelope level: an unchanged query re-serialized
// to a different but equivalent wrapper (a bare `signoz/BuilderQuery` vs a
// `signoz/CompositeQuery`) unwraps to the same envelopes — comparing the
// wrappers structurally would falsely dirty the draft on Stage & Run.
const current = draft.spec?.queries ?? [];
if (isEqual(toQueryEnvelopes(next), toQueryEnvelopes(current))) {
return false;
}
setSpec({ ...draft.spec, queries: next });
return true;
},
[seedQuery, panelType, savedQueries, draft.spec, setSpec],
);
// Latest query/commit, read by the structural-change effect without re-subscribing.
const commitRef = useRef(commitQuery);
commitRef.current = commitQuery;
const queryRef = useRef(currentQuery);
queryRef.current = currentQuery;
// Re-commit on a query-type or datasource switch so the preview refetches the
// structurally-changed query. Skip mount: the draft already holds the saved
// queries and the builder is being force-reset to them.
const dataSourceSignature = useMemo(
() =>
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
[currentQuery.builder],
);
const didMountRef = useRef(false);
useEffect(() => {
if (!didMountRef.current) {
didMountRef.current = true;
return;
}
commitRef.current(queryRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
}, [currentQuery.queryType, dataSourceSignature]);
// Stage & Run / ⌘↵: stage, commit, and re-fetch when unchanged so the same query
// can be re-run.
const runQuery = useCallback((): void => {
handleRunQuery();
if (!commitQuery(currentQuery)) {
refetch();
}
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
// Dirty baseline for the query: the builder's OWN normalized form of the saved
// query — the first non-null `stagedQuery` the builder produces after the mount
// force-reset. Capturing it (instead of the raw `seedQuery`) makes the dirty
// check builder-normalized vs builder-normalized, immune to the serialization
// drift that otherwise reads an untouched query as modified. Held in state (not
// a ref) so capturing it re-renders and `isQueryDirty` recomputes; captured once
// and never moved by Stage & Run, so it stays anchored to the saved query.
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
useEffect(() => {
if (queryBaseline === null && stagedQuery) {
setQueryBaseline(stagedQuery);
}
}, [queryBaseline, stagedQuery]);
const isQueryDirty =
queryBaseline !== null && getIsQueryModified(currentQuery, queryBaseline);
const buildSaveSpec = useCallback(
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>
isQueryDirty
? { ...spec, queries: toPerses(currentQuery, panelType) }
: spec,
[isQueryDirty, currentQuery, panelType],
);
return { runQuery, isQueryDirty, buildSaveSpec };
}

View File

@@ -1,61 +0,0 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import {
getGetDashboardV2QueryKey,
usePatchDashboardV2,
} from 'api/generated/services/dashboard';
import {
type DashboardtypesJSONPatchOperationDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesPatchOpDTO,
} from 'api/generated/services/sigNoz.schemas';
interface UsePanelEditorSaveArgs {
dashboardId: string;
panelId: string;
}
interface UsePanelEditorSaveApi {
save: (spec: DashboardtypesPanelSpecDTO) => Promise<void>;
isSaving: boolean;
error: Error | null;
}
/**
* Persists panel edits for the V2 editor via RFC-6902 JSON Patch.
*
* Replaces the whole panel spec in one `add` op against `/spec/panels/{panelId}/spec`
* with the editor's draft spec — so every edit the config pane makes (display,
* formatting/axes/legend/chart-appearance under `plugin.spec`, `legend.customColors`,
* context links) is persisted, not just the title/description. `add` doubles as
* create-or-replace, so panels that loaded without a sub-object are handled without a
* separate existence check. The draft carries `queries` unchanged until the V2 query
* builder lands, so replacing the whole spec is safe.
*/
export function usePanelEditorSave({
dashboardId,
panelId,
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
const queryClient = useQueryClient();
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
const save = useCallback(
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [
{
op: DashboardtypesPatchOpDTO.add,
path: `/spec/panels/${panelId}/spec`,
value: spec,
},
];
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
await queryClient.invalidateQueries(
getGetDashboardV2QueryKey({ id: dashboardId }),
);
},
[dashboardId, panelId, mutateAsync, queryClient],
);
return { save, isSaving: isLoading, error: (error as Error) ?? null };
}

View File

@@ -1,177 +0,0 @@
import { useCallback } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import Header from './Header/Header';
import layoutStorage from './layoutStorage';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from './PreviewPane/PreviewPane';
import { usePanelQuery } from '../hooks/usePanelQuery';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import styles from './PanelEditor.module.scss';
interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
onSaved: () => void;
}
/**
* V2 panel editor page body. Rendered by the `DASHBOARD_PANEL_EDITOR` route
* (`PanelEditorPage`) as a full page — a resizable split holds the live preview
* + query builder on the left and the configuration pane on the right. Owns the
* draft state and the save round-trip.
*/
function PanelEditorContainer({
dashboardId,
panelId,
panel,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
const { draft, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
});
const {
defaultLayout: mainDefaultLayout,
onLayoutChanged: onMainLayoutChanged,
} = useDefaultLayout({
id: 'panel-editor-v2-main',
storage: layoutStorage,
});
// Panel kind → V1 panel type (drives the query builder + 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 panelDef = getPanelDefinition(draft.spec?.plugin?.kind);
const {
data,
isLoading,
isFetching,
error,
cancelQuery,
refetch,
pagination,
} = usePanelQuery({
panel: draft,
panelId,
enabled: !!panelDef,
});
// Seed the shared query builder from the draft and expose the Stage-&-Run
// action (writes the query into the draft → preview re-fetches, or forces a
// re-fetch when unchanged).
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch,
});
// Dirty = an edited config slice (display/plugin spec) OR an edited query. The
// two are tracked independently so query re-serialization never false-dirties.
const isDirty = isSpecDirty || isQueryDirty;
// Drag-to-zoom on the preview chart updates the (URL-synced) time window,
// exactly as on the dashboard.
const { onDragSelect } = usePanelInteractions();
const onSave = useCallback(async (): Promise<void> => {
try {
// Bake the live query into the spec so unstaged edits are saved too.
await save(buildSaveSpec(draft.spec));
toast.success('Panel saved');
onSaved();
} catch {
toast.error('Failed to save panel');
}
}, [save, buildSaveSpec, draft.spec, onSaved]);
return (
<div className={styles.page} data-testid="panel-editor-v2">
<Header
isDirty={isDirty}
isSaving={isSaving}
onSave={onSave}
onClose={onClose}
/>
<ResizablePanelGroup
id="panel-editor-v2"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
<div className={styles.left}>
<ResizablePanelGroup
id="panel-editor-v2-main"
orientation="vertical"
defaultLayout={mainDefaultLayout}
onLayoutChanged={onMainLayoutChanged}
>
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
<PreviewPane
panelId={panelId}
panel={draft}
panelDef={panelDef}
data={data}
isLoading={isLoading}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<PanelEditorQueryBuilder
panelType={panelType}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel
minSize="20%"
maxSize="25%"
defaultSize="20%"
className={styles.right}
/>
</ResizablePanelGroup>
</div>
);
}
export default PanelEditorContainer;

View File

@@ -1,17 +0,0 @@
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
/**
* `Storage`-shaped adapter (just `getItem`/`setItem`, which is all
* `useDefaultLayout` consumes) backed by the scoped localStorage wrappers. The
* wrappers prefix keys with the URL base path, so the persisted resizable
* layout stays isolated per deployment instead of touching the raw global.
*/
const layoutStorage: Pick<Storage, 'getItem' | 'setItem'> = {
getItem: (key: string): string | null => getLocalStorageApi(key),
setItem: (key: string, value: string): void => {
setLocalStorageApi(key, value);
},
};
export default layoutStorage;

View File

@@ -1,30 +0,0 @@
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Local draft state for the panel being edited. The draft is kept as a perses
* `DashboardtypesPanelDTO` so the live preview (which feeds the panel renderer)
* and the save patch share a single shape — no intermediate translation.
*/
export interface PanelEditorDraftApi {
/** The current (possibly edited) panel. Always a defined object once seeded. */
draft: DashboardtypesPanelDTO;
/**
* The panel spec (`draft.spec`) — the single editing surface for the config pane.
* Title/description live at `spec.display`; the section registry reads its slices
* from here (plugin-level via `spec.plugin.spec.<key>`, panel-level via `spec.links`).
*/
spec: DashboardtypesPanelSpecDTO;
/** Replace the whole panel spec (the registry lens returns a new one per edit). */
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
/**
* True when the draft's display/plugin-spec slices diverge from the loaded
* panel. Excludes `spec.queries` — the query is owned by the shared builder and
* its dirtiness is tracked there (`usePanelEditorQuerySync.isQueryDirty`).
*/
isSpecDirty: boolean;
/** Restore the draft to the originally-loaded panel. */
reset: () => void;
}

View File

@@ -10,12 +10,4 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: {
view: true,
edit: true,
clone: true,
download: false,
createAlert: true,
},
headerControls: { search: false },
};

View File

@@ -1,13 +1,9 @@
import type { SectionConfig } from '../../types/sections';
// Bar stacking lives in `visualization.stackedBarChart` (a different spec key from the
// time-series `chartAppearance`), so it's a control on the `visualization` section, not
// `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true, stacking: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, logScale: true } },
{ kind: 'legend', controls: { position: true } },
{ kind: 'thresholds', controls: { variant: 'label' } },
{ kind: 'contextLinks' },
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { stacked: true } },
];

View File

@@ -10,12 +10,4 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: {
view: true,
edit: true,
clone: true,
download: false,
createAlert: true,
},
headerControls: { search: false },
};

View File

@@ -1,22 +1,6 @@
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{
kind: 'legend',
controls: { position: true },
// Merging all queries collapses the histogram to a single distribution with no
// legend — so hide the legend settings when that's on.
isHidden: (spec): boolean =>
Boolean(
(spec.plugin?.spec as DashboardtypesHistogramPanelSpecDTO | undefined)
?.histogramBuckets?.mergeAllActiveQueries,
),
},
{
kind: 'buckets',
controls: { count: true, width: true, mergeQueries: true },
},
{ kind: 'contextLinks' },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'buckets', controls: { count: true } },
];

View File

@@ -10,12 +10,4 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: {
view: true,
edit: true,
clone: true,
download: false,
createAlert: true,
},
headerControls: { search: false },
};

View File

@@ -1,12 +1,8 @@
import type { SectionConfig } from '../../types/sections';
// A number panel renders one scalar — no axes, legend, or stacking. Just value
// formatting, thresholds, and context links. Number's thresholds use the `comparison`
// variant (value crosses an operator → recolor the displayed number), distinct from the
// value+label `label` variant TimeSeries/Bar use.
// formatting and thresholds that recolor the value/background.
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'thresholds', controls: { variant: 'comparison' } },
{ kind: 'contextLinks' },
{ kind: 'thresholds', controls: { list: true } },
];

View File

@@ -1,7 +1,36 @@
import type { DashboardtypesComparisonThresholdDTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesComparisonOperatorDTO,
DashboardtypesComparisonThresholdDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PanelThreshold } from '../../types/threshold';
import { toPanelThreshold } from '../../utils/mapComparisonThreshold';
import type {
PanelThreshold,
ThresholdComparisonOperator,
ThresholdDisplayFormat,
} from '../../types/threshold';
// Perses comparison operators → the symbol operators V2 threshold evaluation
// uses.
const OPERATOR_MAP: Record<
DashboardtypesComparisonOperatorDTO,
ThresholdComparisonOperator
> = {
[DashboardtypesComparisonOperatorDTO.above]: '>',
[DashboardtypesComparisonOperatorDTO.below]: '<',
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '>=',
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '<=',
[DashboardtypesComparisonOperatorDTO.equal]: '=',
[DashboardtypesComparisonOperatorDTO.not_equal]: '!=',
};
const FORMAT_MAP: Record<
DashboardtypesThresholdFormatDTO,
ThresholdDisplayFormat
> = {
[DashboardtypesThresholdFormatDTO.text]: 'text',
[DashboardtypesThresholdFormatDTO.background]: 'background',
};
/**
* Maps the panel-spec threshold shape (`ComparisonThresholdDTO`) onto the
@@ -15,5 +44,11 @@ export function mapNumberThresholds(
return [];
}
return thresholds.map(toPanelThreshold);
return thresholds.map((threshold) => ({
color: threshold.color,
operator: threshold.operator ? OPERATOR_MAP[threshold.operator] : undefined,
value: threshold.value,
unit: threshold.unit,
format: threshold.format ? FORMAT_MAP[threshold.format] : undefined,
}));
}

View File

@@ -10,12 +10,4 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: {
view: true,
edit: true,
clone: true,
download: false,
createAlert: false,
},
headerControls: { search: false },
};

View File

@@ -1,10 +1,8 @@
import type { SectionConfig } from '../../types/sections';
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
// Legend `colors` is omitted: the pie legend is always interactive swatches.
// Pie has no axes, thresholds, or stacking — just value formatting and a
// legend. `mode` is omitted: the pie legend is always interactive swatches.
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'legend', controls: { position: true } },
{ kind: 'contextLinks' },
];

View File

@@ -10,12 +10,4 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: {
view: true,
edit: true,
clone: true,
download: false,
createAlert: true,
},
headerControls: { search: false },
};

View File

@@ -1,20 +1,15 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true, fillSpans: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, logScale: true } },
{ kind: 'legend', controls: { position: true, colors: true } },
{
kind: 'chartAppearance',
kind: 'formatting',
controls: {
lineStyle: true,
lineInterpolation: true,
fillMode: true,
showPoints: true,
spanGaps: true,
unit: true,
decimals: true,
},
},
{ kind: 'thresholds', controls: { variant: 'label' } },
{ kind: 'contextLinks' },
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { lineStyle: true, fillOpacity: true } },
];

View File

@@ -7,7 +7,7 @@ import type {
PanelRegistry,
RenderablePanelDefinition,
} from './types/panelDefinition';
import { PanelKind } from './types/panelKind';
import type { DashboardtypesPanelPluginKindDTO as PanelKind } from 'api/generated/services/sigNoz.schemas';
// Pure assembly: each kind owns its own PanelDefinition (see
// `kinds/<Kind>/definition.ts`). Registering a new panel = add its folder and a

View File

@@ -6,56 +6,12 @@ import type { AnyPanelInteractionProps } from './interactions';
import type { PanelKind } from './panelKind';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
/**
* Kind-level action capabilities: which panel actions THIS kind supports.
* Declared per-kind in `kinds/<Kind>/definition.ts` — the field is required,
* so registering a new kind forces an explicit decision for every action
* (mirroring how PanelInteractionMap forces per-kind interaction coverage).
*
* Chrome actions (move to section, clone, delete) are dashboard-layout
* concerns, available for every panel — including kinds V2 can't render —
* and are intentionally not declarable here.
*/
export interface PanelActionCapabilities {
/** Kind has a full-screen view — gates the "View" action. */
view: boolean;
/** Kind is editable in the V2 panel editor — gates the "Edit panel" action. */
edit: boolean;
/** Kind can be cloned — gates the "Clone" action. */
clone: boolean;
/**
* Kind's data can be exported as CSV — gates "Download as CSV". V1 parity:
* only table panels carry tabular data worth exporting.
*/
download: boolean;
/** Kind's query can seed a new alert — gates "Create Alerts". */
createAlert: boolean;
}
/**
* Kind-level header controls: chrome the panel header renders for THIS kind,
* beyond the universal title / status / actions. Declared per-kind so the
* header stays generic and never branches on kind. Required, mirroring
* `actions`, so registering a new kind forces an explicit decision for every
* control.
*/
export interface PanelHeaderControls {
/**
* Header carries a collapsible search box that filters the rendered rows
* client-side. V1 parity: only tabular panels expose it. The kind's renderer
* must consume `searchTerm` (see BaseRendererProps) to apply the filter.
*/
search: boolean;
}
export interface PanelDefinition<K extends PanelKind = PanelKind> {
kind: K;
displayName: string;
Renderer: ComponentType<PanelRendererProps<K>>;
sections: SectionConfig[];
supportedSignals: DataSource[];
actions: PanelActionCapabilities;
headerControls: PanelHeaderControls;
}
// Keyed registry that preserves the kind ↔ definition correlation: indexing

View File

@@ -4,10 +4,7 @@ import type {
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type {
PanelPagination,
PanelQueryData,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { PanelInteractionMap } from './interactions';
import type { PanelKind } from './panelKind';
@@ -67,17 +64,6 @@ export interface BaseRendererProps {
* resolving these; the renderer just consumes them.
*/
dashboardPreference?: DashboardPreference;
/**
* Free-text filter from the header search box, owned by the shell and
* applied client-side by the renderer. Only meaningful for kinds that
* declare `headerControls.search`; other renderers ignore it.
*/
searchTerm?: string;
/**
* Server-side paging handles, owned by `usePanelQuery`. Present only for
* raw/list panels; other renderers ignore it.
*/
pagination?: PanelPagination;
}
// Renderer props for a specific panel kind: the shared base plus that kind's

View File

@@ -1,25 +1,8 @@
import type {
DashboardLinkDTO,
DashboardtypesAxesDTO,
DashboardtypesBarChartVisualizationDTO,
DashboardtypesComparisonThresholdDTO,
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesPanelFormattingDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesTableFormattingDTO,
DashboardtypesTableThresholdDTO,
DashboardtypesThresholdWithLabelDTO,
DashboardtypesTimeSeriesChartAppearanceDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
BarChart,
Columns3,
Hash,
Layers,
LayoutDashboard,
Link,
ListEnd,
Palette,
Ruler,
SlidersHorizontal,
@@ -35,150 +18,38 @@ export interface SectionMetadata {
description?: string;
}
/**
* Which threshold editor a kind uses. All three variants persist to the same
* `plugin.spec.thresholds` key but with different element shapes, so one section
* (`thresholds`) drives all of them, discriminated by this variant:
* - `label` — value + color + label lines (TimeSeries / Bar)
* - `comparison` — value crosses an operator → recolor (Number)
* - `table` — per-column comparison (Table)
*/
export type ThresholdVariant = 'label' | 'comparison' | 'table';
/** Union of every threshold element shape stored under `plugin.spec.thresholds`. */
export type AnyThreshold =
| DashboardtypesThresholdWithLabelDTO
| DashboardtypesComparisonThresholdDTO
| DashboardtypesTableThresholdDTO;
/**
* The single source of truth for sections: each section ↔ exactly one slice of the
* panel spec it edits. The slice type is uniform across every panel kind that shows
* the section, so a section editor is written once and reused everywhere.
*
* Most slices live under the plugin spec (`spec.plugin.spec.<key>`); a few are
* panel-level (`contextLinks` → `spec.links`). The section registry's lens (see
* `ConfigPane/sectionRegistry`) abstracts over both, so this map stays purely about
* "what shape does this section edit".
*
* `SectionKind` is derived below as `ControlledSectionKind | AtomicSectionKind`; the
* `satisfies Record<SectionKind, …>` checks on `SectionControls` + `SECTION_METADATA`
* keep all three structures covering the exact same set of kinds.
*/
// Formatting slice spans the union of every kind's formatting DTO: a single `unit`
// + `decimalPrecision` (most kinds) plus Table's per-column `columnUnits`. The
// per-kind `controls` bag gates which fields each editor writes, so a kind never
// writes a field its real DTO lacks (cast localized in the registry).
export type PanelFormattingSlice = DashboardtypesPanelFormattingDTO &
Pick<DashboardtypesTableFormattingDTO, 'columnUnits'>;
export interface SectionSpecMap {
formatting: PanelFormattingSlice; // spec.plugin.spec.formatting
axes: DashboardtypesAxesDTO; // spec.plugin.spec.axes
legend: DashboardtypesLegendDTO; // spec.plugin.spec.legend
chartAppearance: DashboardtypesTimeSeriesChartAppearanceDTO; // spec.plugin.spec.chartAppearance
buckets: DashboardtypesHistogramBucketsDTO; // spec.plugin.spec.histogramBuckets
// spec.plugin.spec.visualization. Typed as the Bar shape because it's the widest
// superset (stackedBarChart + fillSpans + timePreference); other kinds' visualization
// DTOs are subsets. The per-kind `controls` bag gates which fields each editor writes,
// so a kind never writes a field its real DTO lacks (cast localized in the registry).
visualization: DashboardtypesBarChartVisualizationDTO;
// spec.plugin.spec.thresholds. One slice, three element shapes (see ThresholdVariant);
// the per-kind `variant` control picks the editor, so a kind only ever reads/writes the
// shape its spec actually stores.
thresholds: AnyThreshold[];
contextLinks: DashboardLinkDTO[]; // spec.links (PANEL-level)
columns: TelemetrytypesTelemetryFieldKeyDTO[]; // spec.plugin.spec.selectFields (List)
}
/**
* (1) CONTROLLED sections — those with multiple independently-pickable sub-features.
* The per-kind bag lets a kind expose a SUBSET of the section's controls (the V2
* analogue of V1's `allowSoftMinMax` / `allowLegendColors` flags). Every key here
* corresponds to a real, editable field on the section's spec slice.
*/
export interface SectionControls {
formatting: { unit?: boolean; decimals?: boolean; columnUnits?: boolean };
axes: { minMax?: boolean; logScale?: boolean }; // minMax → softMin/softMax
legend: { position?: boolean; colors?: boolean }; // colors → customColors
// Per-kind control toggles (type-only — runtime metadata is in SECTIONS).
// Section components type their controls prop via `SectionControls['axes']`.
export type SectionControls = {
formatting: { unit?: boolean; decimals?: boolean };
axes: { minMax?: boolean; unit?: boolean; logScale?: boolean };
legend: { position?: boolean; mode?: boolean };
thresholds: { list?: boolean };
chartAppearance: {
lineStyle?: boolean;
lineInterpolation?: boolean;
fillMode?: boolean;
showPoints?: boolean;
spanGaps?: boolean;
fillOpacity?: boolean;
stacked?: boolean;
};
buckets: { count?: boolean; width?: boolean; mergeQueries?: boolean };
// timePreference → per-panel time scope (all kinds); stacking → stackedBarChart (Bar);
// fillSpans → fill data gaps with 0 (TimeSeries). Each kind exposes only its subset.
visualization: {
timePreference?: boolean;
stacking?: boolean;
fillSpans?: boolean;
};
// Not a spec field but the editor discriminator: which threshold variant a kind edits
// (label / comparison / table). All three persist to plugin.spec.thresholds.
thresholds: { variant?: ThresholdVariant };
}
columnUnits: { perColumnUnit?: boolean };
buckets: { count?: boolean; min?: boolean; max?: boolean };
};
export type ControlledSectionKind = keyof SectionControls;
/**
* (2) ATOMIC sections — no sub-controls; a kind either shows them or not. Thresholds
* and Context Links are each just a list editor, so there is nothing to subset.
*/
export type AtomicSectionKind = 'contextLinks' | 'columns';
export type SectionKind = ControlledSectionKind | AtomicSectionKind;
/**
* What a panel kind declares in `kinds/<Kind>/sections.ts`. A controlled section is
* declared with its `controls` subset; an atomic section is declared bare (`{ kind }`).
*
* Whether a kind ALLOWS a section at all is governed entirely by whether it appears in
* the kind's `sections` array — e.g. Pie/Histogram omit `thresholds`, so it never shows.
*/
/**
* Optional predicate to hide a section based on the current panel spec — for
* cross-section rules (e.g. the Histogram legend is irrelevant once its queries are
* merged into one distribution). Returning true removes the section from the pane.
*/
export type SectionVisibilityPredicate = (
spec: DashboardtypesPanelSpecDTO,
) => boolean;
export type SectionConfig =
| {
[K in ControlledSectionKind]: {
kind: K;
controls: SectionControls[K];
isHidden?: SectionVisibilityPredicate;
};
}[ControlledSectionKind]
| { kind: AtomicSectionKind; isHidden?: SectionVisibilityPredicate };
// Runtime UI metadata per section (title + sidebar icon). Pure data — no component
// coupling. The editor component + spec lens live in the ConfigPane section registry.
export const SECTION_METADATA = {
// Source of truth for sections. Its keys define SectionKind; its values are the
// runtime UI metadata (consumed by PanelEditor in 1.8). Adding a new section =
// one entry here + one entry in SectionControls.
export const SECTIONS = {
formatting: { title: 'Formatting', icon: Hash },
axes: { title: 'Axes', icon: Ruler },
legend: { title: 'Legend', icon: Layers },
chartAppearance: { title: 'Chart appearance', icon: Palette },
visualization: { title: 'Visualization', icon: LayoutDashboard },
buckets: { title: 'Histogram / Buckets', icon: BarChart },
legend: { title: 'Legend', icon: ListEnd },
thresholds: { title: 'Thresholds', icon: SlidersHorizontal },
contextLinks: { title: 'Context Links', icon: Link },
columns: { title: 'Columns', icon: Columns3 },
} as const satisfies Record<SectionKind, SectionMetadata>;
chartAppearance: { title: 'Chart appearance', icon: Palette },
columnUnits: { title: 'Column units', icon: Columns3 },
buckets: { title: 'Buckets', icon: BarChart },
} as const satisfies Record<string, SectionMetadata>;
/**
* Props every section editor receives — exactly its slice type (`value`), an
* `onChange` to write the next slice, and (controlled sections only) the per-kind
* `controls` subset. Atomic editors omit `controls`.
*/
export type SectionEditorProps<K extends SectionKind> = {
value: SectionSpecMap[K] | undefined;
onChange: (next: SectionSpecMap[K]) => void;
} & (K extends ControlledSectionKind
? { controls: SectionControls[K] }
: unknown);
export type SectionKind = keyof typeof SECTIONS;
// Discriminated union derived from SectionControls — kept in lockstep automatically.
export type SectionConfig = {
[K in SectionKind]: { kind: K; controls: SectionControls[K] };
}[SectionKind];

View File

@@ -1,63 +0,0 @@
import {
DashboardtypesComparisonOperatorDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
PanelThreshold,
ThresholdComparisonOperator,
ThresholdDisplayFormat,
} from '../types/threshold';
// Perses comparison operators → the symbol operators V2 threshold evaluation uses.
const OPERATOR_MAP: Record<
DashboardtypesComparisonOperatorDTO,
ThresholdComparisonOperator
> = {
[DashboardtypesComparisonOperatorDTO.above]: '>',
[DashboardtypesComparisonOperatorDTO.below]: '<',
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '>=',
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '<=',
[DashboardtypesComparisonOperatorDTO.equal]: '=',
[DashboardtypesComparisonOperatorDTO.not_equal]: '!=',
};
const FORMAT_MAP: Record<
DashboardtypesThresholdFormatDTO,
ThresholdDisplayFormat
> = {
[DashboardtypesThresholdFormatDTO.text]: 'text',
[DashboardtypesThresholdFormatDTO.background]: 'background',
};
/**
* The comparison-shaped fields shared by every panel-spec threshold DTO that
* recolors on an operator crossing (`ComparisonThresholdDTO`,
* `TableThresholdDTO`). The container DTOs add their own keys (e.g. a table
* threshold's `columnName`) around this common core.
*/
export interface ComparisonThresholdShape {
color: string;
value: number;
operator?: DashboardtypesComparisonOperatorDTO;
unit?: string;
format?: DashboardtypesThresholdFormatDTO;
}
/**
* Maps a comparison-shaped panel-spec threshold onto the V2-native
* `PanelThreshold` consumed by threshold evaluation / rendering. The single
* place the Perses operator/format enums cross into the symbol model, shared by
* every kind that carries comparison thresholds (Number, Table, …).
*/
export function toPanelThreshold(
threshold: ComparisonThresholdShape,
): PanelThreshold {
return {
color: threshold.color,
operator: threshold.operator ? OPERATOR_MAP[threshold.operator] : undefined,
value: threshold.value,
unit: threshold.unit,
format: threshold.format ? FORMAT_MAP[threshold.format] : undefined,
};
}

View File

@@ -1,30 +1,26 @@
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import type {
DashboardtypesPanelDTO,
DashboardtypesTimePreferenceDTO,
DashboardtypesPanelPluginKindDTO as PanelKind,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { panelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import type { Warning } from 'types/api';
import type { DashboardtypesPanelPluginKindDTO as PanelKind } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSection } from '../../utils';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import PanelBody from './PanelBody/PanelBody';
import UnsupportedPanelBody from './PanelBody/UnsupportedPanelBody';
import PanelHeader from './PanelHeader/PanelHeader';
import styles from './Panel.module.scss';
/**
* Layout context for the panel actions menu — pure data, present only in
* editable mode. No callbacks: the menu resolves its own mutations from
* store-backed hooks (useDeletePanel / useMovePanelToSection), and edit is
* URL-driven (useOpenPanelEditor).
*/
/** Panel action context — present together only in editable sectioned mode. */
export interface PanelActionsConfig {
currentLayoutIndex: number;
sections: DashboardSection[];
onMovePanel: (args: MovePanelArgs) => void;
onDeletePanel: (args: DeletePanelArgs) => void;
}
interface PanelProps {
@@ -54,32 +50,15 @@ function Panel({
const kind = fullKind?.replace(/^signoz\//, '') ?? 'unknown';
const queryCount = panel.spec.queries?.length ?? 0;
// A per-panel relative time preference (anything other than global_time) is
// surfaced as a pill in the header. `visualization` is common to every
// plugin-spec variant — localized cast reads it without narrowing on kind.
const timePreference = (
panel.spec.plugin?.spec as
| { visualization?: { timePreference?: DashboardtypesTimePreferenceDTO } }
| undefined
)?.visualization?.timePreference;
const timeLabel = panelTimePreferenceLabel(timePreference);
const panelDefinition = getPanelDefinition(fullKind);
// Header search: only kinds that declare it (e.g. tables) render the box; the
// term is owned here and threaded to both the header (input) and the renderer
// (filter), the two being siblings under this orchestrator.
const searchable = !!panelDefinition?.headerControls.search;
const [searchTerm, setSearchTerm] = useState('');
const { data, isLoading, isFetching, error, refetch, pagination } =
usePanelQuery({
panel,
panelId,
// Lazy: only fetch once the section is on screen (undefined → treat as
// visible) and a renderer exists for the kind.
enabled: !!panelDefinition && isVisible !== false,
});
const { data, isLoading, isFetching, error, refetch } = usePanelQuery({
panel,
panelId,
// Lazy: only fetch once the section is on screen (undefined → treat as
// visible) and a renderer exists for the kind.
enabled: !!panelDefinition && isVisible !== false,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
@@ -102,15 +81,13 @@ function Panel({
<PanelHeader
title={headerTitle}
panelId={panelId}
panelKind={fullKind}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}
timeLabel={timeLabel}
// The V5 response `warning` is the same object the legacy chain
// surfaced as `Warning` — passed through untouched; the cast is the
// generated-DTO → hand-written-type boundary.
warning={data.response?.data?.warning as Warning | undefined}
panelActions={panelActions}
searchable={searchable}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
{panelDefinition ? (
<PanelBody
@@ -123,8 +100,6 @@ function Panel({
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
/>
) : (
// TODO: remove this after all panel kinds are supported

View File

@@ -1,70 +1,98 @@
import { EllipsisVertical } from '@signozhq/icons';
import { useMemo } from 'react';
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import type { PanelActionsConfig } from '../Panel';
import { usePanelActionItems } from './usePanelActionItems';
import type { DashboardSection } from '../../../utils';
import type { DeletePanelArgs } from '../hooks/useDeletePanel';
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
import styles from './PanelActionsMenu.module.scss';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
interface PanelActionsMenuProps {
panelId: string;
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`);*/
panelKind: PanelKind;
/** Layout context for move/delete — absent outside editable sectioned mode. */
panelActions?: PanelActionsConfig;
currentLayoutIndex: number;
sections: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
/**
* Purely presentational: the trigger button + dropdown, plus the delete
* confirmation dialog. Which items appear — and the delete-confirm state — is
* owned by `usePanelActionItems` (kind ∧ role ∧ context gating per action).
*/
function PanelActionsMenu({
panelId,
panelKind,
panelActions,
}: PanelActionsMenuProps): JSX.Element | null {
const { items, deleteConfirm } = usePanelActionItems({
panelId,
panelKind,
panelActions,
});
currentLayoutIndex,
sections,
onMovePanel,
onDeletePanel,
}: PanelActionsMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
if (items.length === 0) {
return null;
}
if (onMovePanel) {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
);
if (targets.length === 0) {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
disabled: true,
});
} else {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
onMovePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
});
}
}
if (onDeletePanel) {
if (result.length > 0) {
result.push({ type: 'divider' });
}
result.push({
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete panel',
onClick: (): void =>
onDeletePanel({ panelId, layoutIndex: currentLayoutIndex }),
});
}
return result;
}, [sections, currentLayoutIndex, panelId, onMovePanel, onDeletePanel]);
return (
<>
<DropdownMenuSimple menu={{ items }} align="end">
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.trigger}
aria-label="Panel actions"
data-testid={`panel-actions-${panelId}`}
// Stop pointer/mouse down from reaching the RGL drag handle this
// button lives inside, so opening the menu never starts a panel drag.
onPointerDown={(e): void => e.stopPropagation()}
onMouseDown={(e): void => e.stopPropagation()}
onClick={(e): void => e.stopPropagation()}
>
<EllipsisVertical size={14} />
</Button>
</DropdownMenuSimple>
<ConfirmDeleteDialog
open={deleteConfirm.open}
title="Delete panel?"
description="This panel will be removed from the dashboard. This action cannot be undone."
isLoading={deleteConfirm.isPending}
onConfirm={deleteConfirm.confirm}
onClose={deleteConfirm.cancel}
/>
</>
<DropdownMenuSimple menu={{ items }}>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.trigger}
aria-label="Panel actions"
data-testid={`panel-actions-${panelId}`}
// Stop pointer/mouse down from reaching the RGL drag handle this
// button lives inside, so opening the menu never starts a panel drag.
onPointerDown={(e): void => e.stopPropagation()}
onMouseDown={(e): void => e.stopPropagation()}
onClick={(e): void => e.stopPropagation()}
>
<EllipsisVertical size={14} />
</Button>
</DropdownMenuSimple>
);
}

View File

@@ -1,231 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import type { ROLES } from 'types/roles';
import type { DashboardSection } from '../../../../utils';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelActionItems } from '../usePanelActionItems';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
const mockOpenEditor = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
() => ({
useOpenPanelEditor: (): jest.Mock => mockOpenEditor,
}),
);
const mockMovePanel = jest.fn();
jest.mock('../../hooks/useMovePanelToSection', () => ({
useMovePanelToSection: (): jest.Mock => mockMovePanel,
}));
const mockDeletePanel = jest.fn();
jest.mock('../../hooks/useDeletePanel', () => ({
useDeletePanel: (): jest.Mock => mockDeletePanel,
}));
const mockClonePanel = jest.fn();
jest.mock('../../hooks/useClonePanel', () => ({
useClonePanel: (): jest.Mock => mockClonePanel,
}));
// Role is the only thing read off the app context; useComponentPermission runs
// for real so the tests exercise the actual role → permission mapping.
let mockRole: ROLES = 'ADMIN';
jest.mock('providers/App/App', () => ({
useAppContext: (): { user: { role: ROLES } } => ({
user: { role: mockRole },
}),
}));
function section(
layoutIndex: number,
title: string | undefined,
): DashboardSection {
return {
id: `section-${layoutIndex}`,
layoutIndex,
title,
items: [],
repeatVariable: undefined,
};
}
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
const baseArgs = {
panelId: 'panel-1',
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
};
function itemKeys(result: ReturnType<typeof usePanelActionItems>): unknown[] {
return result.items.map((item) =>
'key' in item && item.key !== undefined ? item.key : item.type,
);
}
describe('usePanelActionItems', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRole = 'ADMIN';
useDashboardStore.setState({ isEditable: true });
});
it('ADMIN on an editable dashboard with a known kind gets the full V1-parity set, divider-separated', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'edit-panel',
'clone-panel',
'divider',
'create-alert',
'divider',
'move',
'divider',
'delete-panel',
]);
// download stays hidden: no current kind declares the capability
// (V1 parity — CSV export was table-only).
});
it('AUTHOR loses edit and clone (edit_widget excludes AUTHOR) but keeps the rest', () => {
mockRole = 'AUTHOR';
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'divider',
'create-alert',
'divider',
'move',
'divider',
'delete-panel',
]);
});
it('VIEWER keeps only the role-ungated actions (view, create-alert)', () => {
mockRole = 'VIEWER';
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'divider',
'create-alert',
]);
});
it('unknown panel kind hides all kind-gated actions (incl. clone), keeping only move/delete', () => {
const { result } = renderHook(() =>
// A kind with no registered definition — exercises the "unsupported kind"
// branch. Clone is kind-gated (needs the kind to declare actions.clone),
// so it drops too; only the kind-agnostic layout actions remain.
usePanelActionItems({
...baseArgs,
panelKind: 'signoz/UnsupportedPanel' as PanelKind,
}),
);
expect(itemKeys(result.current)).toStrictEqual([
'move',
'divider',
'delete-panel',
]);
});
it('read-only dashboard keeps only View (V1 parity)', () => {
useDashboardStore.setState({ isEditable: false });
const { result } = renderHook(() =>
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
);
expect(itemKeys(result.current)).toStrictEqual(['view-panel']);
});
it('move is disabled when there is no other titled section to move to', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: {
currentLayoutIndex: 0,
sections: [section(0, 'Overview'), section(1, undefined)],
},
}),
);
const move = result.current.items.find((i) => 'key' in i && i.key === 'move');
expect(move).toMatchObject({ disabled: true });
});
it('edit opens the panel editor for this panel', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const edit = result.current.items.find(
(i) => 'key' in i && i.key === 'edit-panel',
);
(edit as { onClick: () => void }).onClick();
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1');
});
it('move targets call the mutation with from/to layout indexes', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const move = result.current.items.find(
(i) => 'key' in i && i.key === 'move',
) as {
children: { key: string; onClick: () => void }[];
};
expect(move.children).toHaveLength(1);
move.children[0].onClick();
expect(mockMovePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
fromLayoutIndex: 0,
toLayoutIndex: 1,
});
});
it('delete defers to a confirmation: the item opens the dialog, confirm runs the mutation', async () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const del = result.current.items.find(
(i) => 'key' in i && i.key === 'delete-panel',
);
// Clicking the menu item only opens the dialog — no mutation yet.
expect(result.current.deleteConfirm.open).toBe(false);
act(() => {
(del as { onClick: () => void }).onClick();
});
expect(result.current.deleteConfirm.open).toBe(true);
expect(mockDeletePanel).not.toHaveBeenCalled();
// Confirming runs the delete and closes the dialog.
await act(async () => {
await result.current.deleteConfirm.confirm();
});
expect(mockDeletePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
layoutIndex: 0,
});
expect(result.current.deleteConfirm.open).toBe(false);
});
it('clone calls the clone mutation with the panel and its layout index', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const clone = result.current.items.find(
(i) => 'key' in i && i.key === 'clone-panel',
);
(clone as { onClick: () => void }).onClick();
expect(mockClonePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
layoutIndex: 0,
});
});
it('not-yet-implemented actions (view/create-alert) fire the placeholder alert with the feature name', () => {
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
const { result } = renderHook(() => usePanelActionItems(baseArgs));
['view-panel', 'create-alert'].forEach((key) => {
const item = result.current.items.find((i) => 'key' in i && i.key === key);
(item as { onClick: () => void }).onClick();
});
expect(alertSpy).toHaveBeenCalledTimes(2);
expect(alertSpy).toHaveBeenCalledWith('View option clicked');
expect(alertSpy).toHaveBeenCalledWith('Create Alerts option clicked');
alertSpy.mockRestore();
});
});

View File

@@ -1,48 +0,0 @@
import type { PanelActionCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { ComponentTypes } from 'utils/permission';
/**
* Every action the panel menu can offer. `Record<PanelActionId, …>` below
* forces a meta entry per id — adding an action without declaring its gates is
* a compile error.
*/
export type PanelActionId =
| 'view'
| 'edit'
| 'clone'
| 'download'
| 'createAlert'
| 'move'
| 'delete';
export interface PanelActionMeta {
/**
* Role gate: componentPermission key checked against the current user.
* Absent = available to every role (V1 parity: view, download and
* create-alerts were never role-gated).
*/
permission?: ComponentTypes;
/**
* Kind gate: the PanelActionCapabilities flag this action requires.
* Chrome actions (move/clone/delete) are layout concerns available for
* every panel kind — including kinds V2 can't render — so they declare none.
*/
capability?: keyof PanelActionCapabilities;
}
/**
* Single source of truth for how each panel action is gated, mirroring V1's
* WidgetHeader rules. The third gate — context (dashboard editable, target
* sections available) — is runtime state resolved in `usePanelActionItems`,
* not declarable here.
*/
export const PANEL_ACTION_META: Record<PanelActionId, PanelActionMeta> = {
view: { capability: 'view' },
edit: { permission: 'edit_widget', capability: 'edit' },
clone: { permission: 'edit_widget' },
download: { capability: 'download' },
createAlert: { capability: 'createAlert' },
// Moving a panel between sections mutates the dashboard layout.
move: { permission: 'edit_dashboard' },
delete: { permission: 'delete_widget' },
};

View File

@@ -1,234 +0,0 @@
import { useCallback, useMemo } from 'react';
import {
Bell,
CloudDownload,
Copy,
FolderInput,
Fullscreen,
PenLine,
Trash2,
} from '@signozhq/icons';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import useComponentPermission from 'hooks/useComponentPermission';
import {
type ConfirmableAction,
useConfirmableAction,
} from 'hooks/useConfirmableAction';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import { useAppContext } from 'providers/App/App';
import type { DashboardSection } from '../../../utils';
import type { PanelActionsConfig } from '../Panel';
import { useClonePanel } from '../hooks/useClonePanel';
import { useDeletePanel } from '../hooks/useDeletePanel';
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
import { PANEL_ACTION_META } from './panelActionMeta';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
// Stable fallback so renders without layout context don't churn the mutation
// hooks' deps (a fresh [] each render would re-create their callbacks).
const EMPTY_SECTIONS: DashboardSection[] = [];
// Placeholder for the V1-parity actions whose V2 implementations land in
// later milestones (view, clone, download, create-alerts).
function notImplementedYet(feature: string): void {
// eslint-disable-next-line no-alert -- temporary placeholder, see above
alert(`${feature} option clicked`);
}
interface UsePanelActionItemsArgs {
panelId: string;
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); */
panelKind: PanelKind;
/** Layout context for move/delete — absent outside editable mode. */
panelActions?: PanelActionsConfig;
}
export interface PanelActionItems {
items: MenuItem[];
/**
* Two-step confirm flow for the destructive Delete action — the menu defers
* to it instead of deleting on click. The presentational menu renders
* ConfirmDeleteDialog from this.
*/
deleteConfirm: ConfirmableAction;
}
/**
* Resolves the panel actions menu items (the V1 WidgetHeader action set plus
* V2's "Move to section"). Every action passes three gates before it appears:
*
* kind — what the panel kind declares it supports (PanelDefinition.actions);
* unknown kinds support no kind-gated actions.
* role — componentPermission lookup for the current user (PANEL_ACTION_META;
* actions without a permission key are open to every role, V1 parity).
* context — runtime state: dashboard editable (store), layout config present.
* View and Download remain available on read-only dashboards, as in V1.
*
* Items are composed as groups with dividers inserted between non-empty
* groups, so adding an action never touches divider bookkeeping.
*/
export function usePanelActionItems({
panelId,
panelKind,
panelActions,
}: UsePanelActionItemsArgs): PanelActionItems {
const { user } = useAppContext();
const [canEditWidget, canMove, canDelete] = useComponentPermission(
[
// edit_widget gates both Edit and Clone, exactly as in V1.
PANEL_ACTION_META.edit.permission ?? 'edit_widget',
PANEL_ACTION_META.move.permission ?? 'edit_dashboard',
PANEL_ACTION_META.delete.permission ?? 'delete_widget',
],
user.role,
);
// Folds in the dashboard lock + edit_dashboard permission (set once by
// DashboardContainer). Mutating actions respect it; view/download don't.
const isEditable = useDashboardStore((s) => s.isEditable);
const openPanelEditor = useOpenPanelEditor();
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
// supplies data (`sections`), so no callbacks are threaded through it.
const sections = panelActions?.sections ?? EMPTY_SECTIONS;
const movePanel = useMovePanelToSection({ sections });
const deletePanel = useDeletePanel({ sections });
const clonePanel = useClonePanel({ sections });
const kindActions = getPanelDefinition(panelKind)?.actions;
// Delete is destructive, so the menu item opens a confirmation prompt rather
// than deleting on click; the actual mutation runs on confirm.
const deleteConfirm = useConfirmableAction(
useCallback(async (): Promise<void> => {
if (!panelActions) {
return;
}
await deletePanel({
panelId,
layoutIndex: panelActions.currentLayoutIndex,
});
}, [deletePanel, panelActions, panelId]),
);
// Stable opener — used in the items memo without rebuilding it when the
// dialog's open/pending state changes.
const { request: requestDelete } = deleteConfirm;
const items = useMemo<MenuItem[]>(() => {
// Group 1 — open/author the panel: View, Edit, Clone.
const panelGroup: MenuItem[] = [];
if (kindActions?.view) {
panelGroup.push({
key: 'view-panel',
label: 'View',
icon: <Fullscreen size={14} />,
onClick: (): void => notImplementedYet('View'),
});
}
if (isEditable && canEditWidget && kindActions?.edit) {
panelGroup.push({
key: 'edit-panel',
label: 'Edit panel',
icon: <PenLine size={14} />,
onClick: (): void => openPanelEditor(panelId),
});
}
// Clone needs the section context (source spec + dimensions) to place the
// copy, so — unlike Edit — it requires panelActions.
if (isEditable && canEditWidget && panelActions && kindActions?.clone) {
panelGroup.push({
key: 'clone-panel',
label: 'Clone',
icon: <Copy size={14} />,
onClick: (): void =>
void clonePanel({
panelId,
layoutIndex: panelActions.currentLayoutIndex,
}),
});
}
// Group 2 — derive from the panel's data: Download, Create Alerts.
const dataGroup: MenuItem[] = [];
if (kindActions?.download) {
dataGroup.push({
key: 'download-panel',
label: 'Download as CSV',
icon: <CloudDownload size={14} />,
onClick: (): void => notImplementedYet('Download'),
});
}
if (isEditable && kindActions?.createAlert) {
dataGroup.push({
key: 'create-alert',
label: 'Create Alerts',
icon: <Bell size={14} />,
onClick: (): void => notImplementedYet('Create Alerts'),
});
}
// Group 3 — layout: Move to section.
const moveGroup: MenuItem[] = [];
if (canMove && panelActions) {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== panelActions.currentLayoutIndex,
);
moveGroup.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
...(targets.length === 0
? { disabled: true }
: {
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: panelActions.currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
}),
});
}
// Group 4 — danger: Delete.
const deleteGroup: MenuItem[] =
canDelete && panelActions
? [
{
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete panel',
onClick: (): void => requestDelete(),
},
]
: [];
return [panelGroup, dataGroup, moveGroup, deleteGroup]
.filter((group) => group.length > 0)
.flatMap((group, index) =>
index === 0 ? group : [{ type: 'divider' as const }, ...group],
);
}, [
isEditable,
canEditWidget,
canMove,
canDelete,
kindActions,
panelActions,
sections,
panelId,
openPanelEditor,
movePanel,
clonePanel,
requestDelete,
]);
return { items, deleteConfirm };
}

View File

@@ -6,12 +6,8 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import type {
PanelPagination,
PanelQueryData,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { panelStatusFromError } from '../PanelStatus/utils';
import styles from './PanelBody.module.scss';
interface PanelBodyProps {
@@ -25,14 +21,7 @@ interface PanelBodyProps {
error: Error | null;
refetch: () => void;
onDragSelect: (start: number, end: number) => void;
/** Dashboard-wide preferences (cursor sync, …); absent in the editor preview. */
dashboardPreference?: DashboardPreference;
/** Render context — defaults to the dashboard view; the editor preview passes EDIT. */
panelMode?: PanelMode;
/** Header search term — only consumed by kinds that declare header search. */
searchTerm?: string;
/** Server-side paging handles — only consumed by raw/list renderers. */
pagination?: PanelPagination;
dashboardPreference: DashboardPreference;
}
/**
@@ -55,9 +44,6 @@ function PanelBody({
refetch,
onDragSelect,
dashboardPreference,
panelMode = PanelMode.DASHBOARD_VIEW,
searchTerm,
pagination,
}: PanelBodyProps): JSX.Element {
// Surface a hard failure only when there's no (stale) data to show; otherwise
// keep the last-good chart and let the header indicate the refresh.
@@ -66,14 +52,11 @@ function PanelBody({
const hasData = !!data.response;
if (error && !hasData) {
// Parse the API error the same way the header status popover does, so the
// body shows the backend's message (not the raw axios "status code 4xx").
const errorDetail = panelStatusFromError(error);
return (
<div className={styles.error} data-testid="panel-error">
<TriangleAlert size={20} className={styles.errorIcon} />
<Typography.Text className={styles.errorMessage}>
{errorDetail?.message || 'Failed to load panel data'}
{error.message || 'Failed to load panel data'}
</Typography.Text>
<Button variant="outlined" color="secondary" onClick={refetch}>
Retry
@@ -84,7 +67,7 @@ function PanelBody({
// First load only — background refetches keep the response populated so the
// chart stays mounted instead of blinking.
if (isLoading) {
if (isLoading && !hasData) {
return (
<div className={styles.body} data-testid="panel-loading">
<Spin indicator={<Loader size={14} className="animate-spin" />} />
@@ -101,11 +84,9 @@ function PanelBody({
isLoading={isLoading}
error={error}
onDragSelect={onDragSelect}
panelMode={panelMode}
panelMode={PanelMode.DASHBOARD_VIEW}
enableDrillDown={false}
dashboardPreference={dashboardPreference}
searchTerm={searchTerm}
pagination={pagination}
/>
</div>
);

View File

@@ -30,7 +30,7 @@
.actions {
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
cursor: default;
}
@@ -39,17 +39,3 @@
color: var(--l2-foreground);
flex-shrink: 0;
}
// Per-panel time-preference pill (e.g. `6h`), shown when the panel overrides
// the dashboard time window.
.timePill {
flex-shrink: 0;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
line-height: 16px;
color: var(--l3-foreground);
background: var(--l3-background);
border: 1px solid var(--l3-border);
cursor: default;
}

View File

@@ -1,58 +1,39 @@
import { useMemo, type ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import { Loader } from '@signozhq/icons';
import cx from 'classnames';
import type { PanelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
import type { Warning } from 'types/api';
import type { PanelActionsConfig } from '../Panel';
import PanelActionsMenu from '../PanelActionsMenu/PanelActionsMenu';
import PanelHeaderSearch from './PanelHeaderSearch';
import PanelStatusPopover from '../PanelStatus/PanelStatusPopover';
import {
panelStatusFromError,
panelStatusFromWarning,
} from '../PanelStatus/utils';
import styles from './PanelHeader.module.scss';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface PanelHeaderProps {
title: ReactNode;
panelId: string;
/** Full plugin kind — drives kind-gated menu actions; */
panelKind: PanelKind;
/** Background refresh in flight — shows a subtle spinner without blinking the chart. */
isFetching: boolean;
/** Latest query error, if any — surfaced as a header error indicator. */
error?: Error | null;
/** Non-fatal query warning lifted from the response payload. */
warning?: WarningDTO;
/** Per-panel relative time-preference label; null when it follows the dashboard window. */
timeLabel?: PanelTimePreferenceLabel | null;
/** Layout context for move/delete — absent outside editable sectioned mode. */
warning?: Warning;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
/** Kind declares header search (`headerControls.search`) — renders the box. */
searchable?: boolean;
/** Current search term; the shell owns it, the renderer applies the filter. */
searchTerm?: string;
/** Pushes a new search term up to the shell. */
onSearchChange?: (value: string) => void;
}
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
function PanelHeader({
title,
panelId,
panelKind,
isFetching,
error,
warning,
timeLabel,
panelActions,
searchable,
searchTerm,
onSearchChange,
}: PanelHeaderProps): JSX.Element {
const errorDetail = useMemo(() => panelStatusFromError(error), [error]);
@@ -76,26 +57,19 @@ function PanelHeader({
{/* `panel-no-drag` opts this region out of the grid drag handle so the
actions menu is clickable instead of starting a panel drag. */}
<div className={cx('panel-no-drag', styles.actions)}>
{searchable && onSearchChange && (
<PanelHeaderSearch value={searchTerm ?? ''} onChange={onSearchChange} />
)}
{timeLabel && (
<TooltipSimple title={timeLabel.full} arrow>
<span className={styles.timePill} data-testid="panel-time-preference">
{timeLabel.short}
</span>
</TooltipSimple>
)}
{errorDetail && <PanelStatusPopover variant="error" detail={errorDetail} />}
{warningDetail && (
<PanelStatusPopover variant="warning" detail={warningDetail} />
)}
{/* Renders nothing when no action survives its gates (kind/role/context). */}
<PanelActionsMenu
panelId={panelId}
panelKind={panelKind}
panelActions={panelActions}
/>
{panelActions && (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={panelActions.currentLayoutIndex}
sections={panelActions.sections}
onMovePanel={panelActions.onMovePanel}
onDeletePanel={panelActions.onDeletePanel}
/>
)}
</div>
</div>
);

View File

@@ -1,9 +0,0 @@
// Expanded state: a compact input that fits the header row.
.input {
width: 180px;
}
.clear {
--button-height: 18px;
--button-padding: 0;
}

View File

@@ -1,93 +0,0 @@
import { useState, type ChangeEvent, type KeyboardEvent } from 'react';
import { Input } from '@signozhq/ui/input';
import { Search, X } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './PanelHeaderSearch.module.scss';
import { Button } from '@signozhq/ui/button';
interface PanelHeaderSearchProps {
/** Current filter term, owned by the panel shell. */
value: string;
/** Pushes the new term up; the renderer applies the filter. */
onChange: (value: string) => void;
}
/**
* Collapsible header search (V1 parity): a search icon that expands into an
* input on click and collapses again once it's empty and blurred. Purely a
* controlled input over `value` — it owns only its expanded/collapsed chrome,
* never the term itself.
*/
function PanelHeaderSearch({
value,
onChange,
}: PanelHeaderSearchProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
const collapseIfEmpty = (): void => {
if (!value) {
setExpanded(false);
}
};
const clear = (): void => {
onChange('');
setExpanded(false);
};
if (!expanded) {
return (
<TooltipSimple title="Search" arrow>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => setExpanded(true)}
data-testid="panel-header-search-trigger"
aria-label="Search"
>
<Search size={14} />
</Button>
</TooltipSimple>
);
}
return (
<Input
autoFocus
size={14}
value={value}
placeholder="Search…"
containerClassName={styles.input}
testId="panel-header-search-input"
prefix={<Search size={14} />}
suffix={
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.clear}
onClick={clear}
data-testid="panel-header-search-clear"
aria-label="Clear search"
>
<X size={14} />
</Button>
}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
onChange(e.target.value)
}
onBlur={collapseIfEmpty}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Escape') {
clear();
}
}}
/>
);
}
export default PanelHeaderSearch;

View File

@@ -1,77 +1,41 @@
import { BookOpenText, CircleX, TriangleAlert } from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { BookOpenText } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import type { PanelStatusDetail, PanelStatusVariant } from './types';
import type { PanelStatusDetail } from './types';
import styles from './PanelStatusPopover.module.scss';
import { Button } from '@signozhq/ui/button';
interface PanelStatusContentProps {
variant: PanelStatusVariant;
detail: PanelStatusDetail;
}
const VARIANT_ICON = {
error: { Icon: CircleX, color: Color.BG_CHERRY_500 },
warning: { Icon: TriangleAlert, color: Color.BG_AMBER_500 },
};
/**
* Popover card for a panel status (error or warning): a variant-coloured icon +
* code/summary header with an optional docs button, a MESSAGES count pill, and
* the per-item messages list. Pure presentation — the trigger icon is owned by
* `PanelStatusPopover`.
* Popover body for a panel status (error or warning): a code + summary header
* with an optional docs link, followed by any per-item messages. Pure
* presentation — the variant's icon/colour is owned by `PanelStatusPopover`.
*/
function PanelStatusContent({
variant,
detail,
}: PanelStatusContentProps): JSX.Element {
function PanelStatusContent({ detail }: PanelStatusContentProps): JSX.Element {
const { code, message, docsUrl, messages } = detail;
const { Icon, color } = VARIANT_ICON[variant];
return (
<section className={styles.content} data-testid="panel-status-content">
<header className={styles.summary}>
<div className={styles.summaryLeft}>
<span className={styles.iconWrapper}>
<Icon size={16} color={color} />
</span>
<div className={styles.summaryText}>
{code && <h2 className={styles.code}>{code}</h2>}
<p className={styles.message}>{message}</p>
</div>
<div className={styles.summaryText}>
<h2 className={styles.code}>{code}</h2>
<p className={styles.message}>{message}</p>
</div>
{docsUrl && (
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<BookOpenText size={14} />}
<Typography.Link
className={styles.docsLink}
href={docsUrl}
target="_blank"
rel="noreferrer"
data-testid="panel-status-docs"
>
<a
href={docsUrl}
className={styles.docsLink}
target="_blank"
rel="noreferrer"
data-testid="panel-status-docs"
>
Open Docs
</a>
</Button>
<BookOpenText size={14} />
<span>Open Docs</span>
</Typography.Link>
)}
</header>
{messages.length > 0 && (
<div className={styles.messageBadge}>
<span className={styles.badge}>
<span className={styles.badgeDot} />
<span className={styles.badgeText}>MESSAGES</span>
<span className={styles.badgeCount}>{messages.length}</span>
</span>
<span className={styles.badgeLine} />
</div>
)}
{messages.length > 0 && (
<ul className={styles.messageList}>
{messages.map((m) => (

View File

@@ -1,5 +1,3 @@
@use '../../../../../../styles/scrollbar' as *;
.trigger {
display: inline-flex;
align-items: center;
@@ -7,150 +5,61 @@
flex-shrink: 0;
}
// Strip the tooltip's own padding/width cap so the card content (which owns its
// 16px section padding) frames cleanly — a padding-less surface, like the
// shared WarningPopover, restyled with V2 tokens.
.tooltipContent {
max-width: 520px !important;
padding: 0 !important;
overflow: hidden;
}
.content {
display: flex;
flex-direction: column;
min-width: 320px;
gap: 8px;
max-width: 600px;
padding: 12px;
}
// === Summary header: icon + code/message, optional docs button ===
.summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 16px;
}
.summaryLeft {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.iconWrapper {
display: inline-flex;
align-items: center;
flex-shrink: 0;
margin-top: 2px;
}
.summaryText {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.code {
margin: 0;
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
font-size: 12px;
font-weight: 600;
color: var(--l2-foreground);
}
.message {
margin: 0;
color: var(--l2-foreground);
font-size: 13px;
line-height: 18px;
margin: 4px 0 0;
font-size: 12px;
color: var(--l1-foreground);
word-break: break-word;
}
.docsLink {
text-decoration: none;
color: var(--l2-foreground);
}
// === MESSAGES count pill + dotted rule ===
.messageBadge {
display: flex;
display: flex !important;
align-items: center;
gap: 12px;
padding: 0 16px 12px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border: 1px solid var(--l1-border);
border-radius: 20px;
}
.badgeDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bg-sakura-500);
}
.badgeText {
color: var(--l1-foreground);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.5px;
}
.badgeCount {
color: var(--l2-foreground);
justify-content: center;
gap: 4px;
flex-shrink: 0;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.badgeLine {
flex: 1;
height: 8px;
background-image: radial-gradient(
circle,
var(--l3-background) 1px,
transparent 2px
);
background-size: 8px 11px;
}
// === Per-item messages ===
.messageList {
margin: 0;
padding: 0 16px 16px;
list-style: none;
max-height: 260px;
padding-left: 16px;
max-height: 240px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
@include custom-scrollbar;
}
.messageItem {
position: relative;
padding-left: 14px;
color: var(--l2-foreground);
font-family: 'Geist Mono', monospace;
font-size: 12px;
line-height: 18px;
color: var(--l1-foreground);
word-break: break-word;
}
.messageItem::before {
content: '';
position: absolute;
left: 2px;
top: 7px;
width: 2px;
height: 4px;
border-radius: 50px;
background: var(--l1-border);
}

View File

@@ -32,13 +32,9 @@ function PanelStatusPopover({
const Icon = variant === 'error' ? CircleX : TriangleAlert;
return (
<TooltipSimple
title={<PanelStatusContent variant={variant} detail={detail} />}
side="top"
align="end"
arrow
tooltipContentProps={{ className: styles.tooltipContent }}
>
<TooltipSimple title={<PanelStatusContent detail={detail} />} arrow>
{/* Wrapping span gives a ref-able, hoverable trigger (icon
components don't forward refs) and a stable testid anchor. */}
<span
className={styles.trigger}
aria-label={ariaLabel}

View File

@@ -1,7 +1,7 @@
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import { StatusCodes } from 'http-status-codes';
import type { Warning } from 'types/api';
import { panelStatusFromError, panelStatusFromWarning } from '../utils';
@@ -61,14 +61,16 @@ describe('panelStatusFromWarning', () => {
expect(panelStatusFromWarning(undefined)).toBeNull();
});
it('maps a warning to the normalized status shape (no code — V5 warnings carry none)', () => {
const warning: WarningDTO = {
it('maps a warning to the normalized status shape', () => {
const warning: Warning = {
code: 'partial_data',
message: 'Some series were dropped',
url: 'https://docs/warn',
warnings: [{ message: 'series A truncated' }],
};
expect(panelStatusFromWarning(warning)).toStrictEqual({
code: 'partial_data',
message: 'Some series were dropped',
docsUrl: 'https://docs/warn',
messages: ['series A truncated'],

View File

@@ -8,8 +8,8 @@ export type PanelStatusVariant = 'error' | 'warning';
* per-item messages).
*/
export interface PanelStatusDetail {
/** Short status code (e.g. an error/warning code) shown as the heading. Only present in error cases. */
code?: string;
/** Short status code (e.g. an error/warning code) shown as the heading. */
code: string;
/** Human-readable summary line. */
message: string;
/** Optional docs link; renders an "Open Docs" action when present. */

View File

@@ -1,7 +1,7 @@
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import type { Warning } from 'types/api';
import type { PanelStatusDetail } from './types';
@@ -41,17 +41,16 @@ export function panelStatusFromError(
/** Adapts a query warning into the normalized status shape. */
export function panelStatusFromWarning(
warning: WarningDTO | undefined,
warning: Warning | null | undefined,
): PanelStatusDetail | null {
if (!warning) {
return null;
}
return {
message: warning.message || 'Warning',
code: warning.code,
message: warning.message,
docsUrl: warning.url || undefined,
messages: (warning.warnings ?? [])
.map((w) => w.message)
.filter((message): message is string => Boolean(message)),
messages: (warning.warnings ?? []).map((w) => w.message),
};
}

View File

@@ -1,29 +1,18 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import type { ReactElement } from 'react';
import type { Warning } from 'types/api';
import PanelHeader from '../PanelHeader/PanelHeader';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
// PanelHeader's status indicators render a radix tooltip, which needs a
// TooltipProvider ancestor (supplied globally by AppLayout at runtime).
const renderWithProvider = (ui: ReactElement): ReturnType<typeof render> =>
render(<TooltipProvider>{ui}</TooltipProvider>);
// The actions menu has its own gating logic (kind/role/context) and its own
// tests; stub it so this test exercises only the header's status indicators.
jest.mock(
'../PanelActionsMenu/PanelActionsMenu',
() =>
function MockPanelActionsMenu(): null {
return null;
},
);
const baseProps = {
title: 'My panel',
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
kind: 'TimeSeries',
panelId: 'panel-1',
isFetching: false,
};
@@ -52,65 +41,3 @@ describe('PanelHeader status indicators', () => {
expect(screen.queryByTestId('panel-status-warning')).not.toBeInTheDocument();
});
});
describe('PanelHeader search', () => {
it('renders no search affordance when the panel is not searchable', () => {
renderWithProvider(<PanelHeader {...baseProps} />);
expect(
screen.queryByTestId('panel-header-search-trigger'),
).not.toBeInTheDocument();
});
it('expands the collapsed trigger into an input and reports changes', () => {
const onSearchChange = jest.fn();
renderWithProvider(
<PanelHeader
{...baseProps}
searchable
searchTerm=""
onSearchChange={onSearchChange}
/>,
);
fireEvent.click(screen.getByTestId('panel-header-search-trigger'));
const input = screen.getByTestId('panel-header-search-input');
fireEvent.change(input, { target: { value: 'frontend' } });
expect(onSearchChange).toHaveBeenCalledWith('frontend');
});
it('clears the term and collapses when the clear button is pressed', () => {
const onSearchChange = jest.fn();
renderWithProvider(
<PanelHeader
{...baseProps}
searchable
searchTerm="frontend"
onSearchChange={onSearchChange}
/>,
);
fireEvent.click(screen.getByTestId('panel-header-search-trigger'));
fireEvent.click(screen.getByTestId('panel-header-search-clear'));
expect(onSearchChange).toHaveBeenCalledWith('');
expect(screen.getByTestId('panel-header-search-trigger')).toBeInTheDocument();
});
});
describe('PanelHeader time-preference pill', () => {
it('shows the pill with the short label when the panel overrides the dashboard time', () => {
renderWithProvider(
<PanelHeader
{...baseProps}
timeLabel={{ short: '6h', full: 'Last 6 hr' }}
/>,
);
expect(screen.getByTestId('panel-time-preference')).toHaveTextContent('6h');
});
it('renders no pill when the panel follows the dashboard time', () => {
renderWithProvider(<PanelHeader {...baseProps} timeLabel={null} />);
expect(screen.queryByTestId('panel-time-preference')).not.toBeInTheDocument();
});
});

View File

@@ -1,121 +0,0 @@
import { renderHook } from '@testing-library/react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import type { DashboardSection } from '../../../../utils';
import { useClonePanel } from '../useClonePanel';
jest.mock('api/generated/services/dashboard', () => ({
patchDashboardV2: jest.fn().mockResolvedValue(undefined),
}));
const mockToastPromise = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
toast: { promise: (...args: unknown[]): unknown => mockToastPromise(...args) },
}));
jest.mock('uuid', () => ({ v4: (): string => 'cloned-id' }));
const mockPatch = patchDashboardV2 as unknown as jest.Mock;
const sourcePanel = {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardSection['items'][number]['panel'];
function sections(): DashboardSection[] {
return [
{
id: 'section-0',
layoutIndex: 0,
title: 'Overview',
repeatVariable: undefined,
items: [
{ id: 'p1', x: 0, y: 0, width: 8, height: 5, panel: sourcePanel },
{ id: 'p2', x: 8, y: 0, width: 4, height: 5, panel: sourcePanel },
],
},
];
}
describe('useClonePanel', () => {
beforeEach(() => {
jest.clearAllMocks();
useDashboardStore.setState({ dashboardId: 'dash-1', refetch: jest.fn() });
});
it('patches an add of the deep-copied spec + a new item under the same section', async () => {
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await result.current({ panelId: 'p1', layoutIndex: 0 });
expect(mockPatch).toHaveBeenCalledWith({ id: 'dash-1' }, [
{
op: 'add',
path: '/spec/panels/cloned-id',
value: sourcePanel,
},
{
op: 'add',
path: '/spec/layouts/0/spec/items/-',
value: {
// Same dimensions as the source panel (p1: 8x5).
x: 0,
// Bottom of the section: max(y + height) over existing items = 5.
y: 5,
width: 8,
height: 5,
content: { $ref: '#/spec/panels/cloned-id' },
},
},
]);
});
it('deep-copies the spec — the cloned value is not the same object reference', async () => {
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await result.current({ panelId: 'p1', layoutIndex: 0 });
const ops = mockPatch.mock.calls[0][1];
expect(ops[0].value).toStrictEqual(sourcePanel);
expect(ops[0].value).not.toBe(sourcePanel);
});
it('no-ops when the panel is not found in the section', async () => {
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await result.current({ panelId: 'missing', layoutIndex: 0 });
expect(mockPatch).not.toHaveBeenCalled();
expect(mockToastPromise).not.toHaveBeenCalled();
});
it('reports in-flight → done/failed state via toast.promise', async () => {
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await result.current({ panelId: 'p1', layoutIndex: 0 });
expect(mockToastPromise).toHaveBeenCalledWith(
expect.any(Promise),
expect.objectContaining({
loading: 'Cloning panel…',
success: 'Panel cloned',
error: 'Failed to clone panel',
}),
);
});
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
mockPatch.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await expect(
result.current({ panelId: 'p1', layoutIndex: 0 }),
).resolves.toBeUndefined();
expect(mockToastPromise).toHaveBeenCalled();
});
});

View File

@@ -1,84 +0,0 @@
import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { cloneDeep } from 'lodash-es';
import { v4 as uuid } from 'uuid';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { addPanelToSectionOps, panelRef } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
}
export interface ClonePanelArgs {
panelId: string;
layoutIndex: number;
}
/**
* Duplicates a panel: deep-copies the source panel's spec under a fresh id and
* drops a new grid item — same dimensions as the source — at the bottom of the
* same section, as one atomic patch. Mirrors V1's clone (verbatim spec copy, no
* rename) and reuses the same add-panel op builder as useAddPanelToSection.
*/
export function useClonePanel({
sections,
}: Params): (args: ClonePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
return useCallback(
async ({ panelId, layoutIndex }: ClonePanelArgs): Promise<void> => {
const section = sections.find((s) => s.layoutIndex === layoutIndex);
const source = section?.items.find((i) => i.id === panelId);
if (!dashboardId || !section || !source?.panel) {
return;
}
const newPanelId = uuid();
// Place at a fresh row at the bottom of the same section.
const nextY = section.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
const clone = patchDashboardV2(
{ id: dashboardId },
addPanelToSectionOps({
panelId: newPanelId,
panel: cloneDeep(source.panel),
layoutIndex,
item: {
x: 0,
y: nextY,
width: source.width,
height: source.height,
content: { $ref: panelRef(newPanelId) },
},
}),
);
// Surface in-flight → done/failed state to the user (toast.promise also
// reports the failure, so no separate error modal is needed here).
toast.promise(clone, {
loading: 'Cloning panel…',
success: 'Panel cloned',
error: 'Failed to clone panel',
position: 'top-center',
});
// Refetch only on success; the rejection is already surfaced by the
// toast, so swallow it to avoid an unhandled rejection.
try {
await clone;
refetch();
} catch {
// no-op — toast.promise owns the error UX.
}
},
[sections, dashboardId, refetch],
);
}

View File

@@ -8,6 +8,8 @@ import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/pan
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import type { DashboardSection } from '../../../utils';
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { useDeleteSection } from '../hooks/useDeleteSection';
@@ -24,8 +26,10 @@ interface SectionProps {
section: DashboardSection;
/** Adds a panel to this section; present only in editable sectioned mode. */
onAddPanel?: (args: AddPanelArgs) => void;
/** All sections — layout context for the panel menu's move/delete actions. */
/** All sections + per-panel handlers, for the panel "Move to section" / delete actions. */
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
}
@@ -34,6 +38,8 @@ function Section({
section,
onAddPanel,
sections,
onMovePanel,
onDeletePanel,
dragHandle,
}: SectionProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
@@ -86,6 +92,8 @@ function Section({
layoutIndex={section.layoutIndex}
isVisible={isVisible}
sections={sections}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
);

View File

@@ -2,6 +2,8 @@ import { useMemo } from 'react';
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
import type { DashboardSection } from '../../../utils';
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
import Panel from '../../Panel/Panel';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { usePersistLayout } from '../hooks/usePersistLayout';
@@ -14,8 +16,10 @@ interface SectionGridProps {
layoutIndex: number;
/** Forwarded to panels — true when the parent section is in the viewport. */
isVisible?: boolean;
/** All sections — layout context for the panel menu's move/delete actions. */
/** All sections + handlers — present only in editable sectioned mode (panel "Move to section" / delete). */
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function SectionGrid({
@@ -23,6 +27,8 @@ function SectionGrid({
layoutIndex,
isVisible,
sections,
onMovePanel,
onDeletePanel,
}: SectionGridProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const rglLayout = useMemo<Layout[]>(
@@ -56,9 +62,6 @@ function SectionGrid({
margin={[8, 8]}
>
{items.map((item) => (
// A layout item can reference a panel id that no longer exists in the
// panels map (orphan); render an empty grid cell for it rather than a
// panel with no content.
<div key={item.id}>
{item.panel && (
<Panel
@@ -66,10 +69,12 @@ function SectionGrid({
panelId={item.id}
isVisible={isVisible}
panelActions={
isEditable
isEditable && onMovePanel && onDeletePanel
? {
currentLayoutIndex: layoutIndex,
sections: sections ?? [],
onMovePanel,
onDeletePanel,
}
: undefined
}

Some files were not shown because too many files have changed in this diff Show More