Compare commits

...

4 Commits

Author SHA1 Message Date
Ashwin Bhatkal
a4ff4093cd feat(dashboard-v2): variable settings editor UX polishes (#11775)
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
* feat(dashboard-v2): variable model, adapters, validation & cycle detection

* feat(dashboard-v2): variable editor building-block components

* feat(dashboard-v2): variable form with type tabs & per-type fields

* feat(dashboard-v2): variables list with drag reorder & inline delete

* refactor(dashboard-v2): migrate variables bar to the overhauled model
2026-06-23 12:16:00 +00:00
Abhi kumar
b2acf3e856 feat(dashboards-v2): config pane + section editors (#11770)
* feat(dashboards-v2): panel query hook with pagination and time window

* refactor(dashboards-v2): trim comment noise and use JSDoc for function docs

Reduce over-commenting flagged in review across the V2 dashboard code:
remove comments that restate the code, shrink verbose blocks to a line, and
keep only the non-obvious "why". Function/helper docs are written as JSDoc;
inline implementation notes stay as line comments.

* fix(dashboards-v2): harden list pagination against bad inputs

Clamp the page size and offset to finite, positive values before deriving
pageIndex/canNext so the pager can never produce NaN/Infinity/negative state,
and ignore a non-positive setPageSize so the size state stays valid. canNext
now uses `rowCount >= pageSize` to also cover an over-fetch.

Add edge-case tests: empty/non-raw response, full vs partial page, nextCursor,
goNext advancing the page, and a rejected zero page size.

* feat(dashboards-v2): config pane shell with reusable form controls

* feat(dashboards-v2): chart panel section editors

* feat(dashboards-v2): thresholds section editor with per-variant rows

* refactor(dashboards-v2): address config-pane review feedback

- Drop unnecessary optional chaining on the always-present panel spec
  (display / plugin / data); type the formatting cast via PanelFormattingSlice.
- Rename the section-lens read/write to get/update (+ slice helpers).
- Derive the histogram NumericBound from the buckets DTO.
- Use userEvent (via tests/test-utils) in the Axes/Buckets/Thresholds tests.
- Use the SigNoz Button for the legend-color Reset action.

* feat(dashboards-v2): table panel (#11771)

* feat(dashboards-v2): table panel renderer with record-table prep

* feat(dashboards-v2): resizable, persisted table columns

* fix(dashboards-v2): keep clickhouse table columns distinct

A scalar/table panel backed by a clickhouse_sql query collapsed every value
column onto the query name: getColName/getColId had no clickhouse handling,
so all value columns resolved to the query name ("A") and the row builder
overwrote each cell with the last column's value.

clickhouse_sql queries carry no aggregation metadata but the response column
holds the user's real SQL alias in col.name. Thread the request's
clickhouse_sql query names through, and name/key those columns by col.name so
each stays distinct. Ports the V2 half of the merged V1 fix (#11794).

* feat(dashboard-v2) - list panel (#11766)

* feat(dashboards-v2): prepare V5 raw tables for the list panel

* feat(dashboards-v2): list panel kind (logs/traces) with row detail

* feat(dashboards-v2): list columns editor with datasource column switch

* feat(dashboards-v2): move list columns editor below the query builder

Replace the config-pane Columns section with a dnd-kit reorderable editor
rendered beneath the query builder (V1 parity); sanitize selectFields to the
field-key DTO so saved columns drop non-contract keys (isIndexed).

* style(dashboards-v2): format list panel header with oxfmt

* refactor(dashboards-v2): address list-panel PR review feedback

- type the telemetry signal as TelemetrytypesSignalDTO (enum) and drop the
  raw-string comparisons across the list panel renderer, columns, row
  interaction, prep util, and the columns-switch hook
- make prepareRawTable's selectFields a required array; make the log
  drawer's selectedTab always defined (defaults to OVERVIEW)
- read/write the list spec via spec.plugin.spec (plugin is non-optional)
- move isPlainObject (lodash) / coerceToString / toFiniteNumber out of
  rawRowToILog into shared utils
- replace the columns editor's hand-built popover + native buttons with
  @signozhq/ui Button and a searchable Combobox
- switch ListColumnsEditor tests to userEvent; trim verbose comments

* refactor(dashboards-v2): de-duplicate panel cell + column-unit helpers

- collapse the per-panel stringifyCell copies onto the shared coerceToString
  util (list + table column builders)
- extract the column-unit resolver to Panels/utils/getColumnUnit; the table
  renderer and the editor's column options now share one implementation,
  aligning the editor onto the renderer's "empty entry = no unit" semantics
- export a single TableRowData type from tableColumns (with the antd key)
  instead of two divergent local copies

* refactor(dashboards-v2): clean up comments across ListPanel components

* fix(dashboards-v2): drop duplicated list-pagination test block

The `describe('list pagination')` block in usePanelQuery.test.tsx was
duplicated byte-for-byte, tripping oxlint sonarjs(no-identical-functions)
on the repeated rawResponse/withResponse helpers and failing lint CI.
2026-06-23 11:20:16 +00:00
Abhi kumar
949d18f028 feat(dashboards-v2): panel editor foundation + qb/perses adapter (#11769)
* feat(dashboards-v2): pure-V5 perses query adapter and request builder

* feat(dashboards-v2): panel query hook with pagination and time window

* refactor(dashboards-v2): per-kind panel definitions and registry

* feat(dashboards-v2): role and kind-gated panel actions with header chrome

* feat(dashboards-v2): panel editor route with live preview and query builder

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dashboards-v2): make the editor dirty-check immune to query re-serialization

* fix(dashboards-v2): persist raw/list panels as a bare BuilderQuery

* feat(dashboards-v2): optional footer slot below the query builder

* refactor(dashboards-v2): render the editor preview through the shared panel body

The editor preview duplicated PanelBody's loading/error/renderer state
machine. Delegate to PanelBody so the preview is the production render
path, differing only by panelMode (DASHBOARD_EDIT) and the forwarded
server pager. PanelBody's panelMode and dashboardPreference become
optional (the preview has no dashboard-wide preferences), and its error
state now surfaces the backend message via panelStatusFromError instead
of the raw axios "status code 4xx".

* feat(dashboards-v2): richer panel status popover for errors and warnings

Replace the plain status tooltip with a card: variant-coloured icon, the
error/warning code and message, an optional Open Docs link, a MESSAGES
count pill, and the per-item message list. Hosted in @signozhq/ui
TooltipSimple with its padding/width cap stripped so the card owns its
own layout. Bump the header actions gap so the status icon sits clear of
its neighbours.

* refactor(dashboards-v2): address panel-editor foundation PR review

- fix failing lint: drop the unused Typography import in PanelStatusContent
- drop redundant optional chaining on the required spec/plugin (usePanelQuery,
  PanelEditor index, HistogramPanel sections)
- move ComparisonThresholdShape into types/threshold
- derive PanelActionId from keyof PanelActionCapabilities
- fold the per-kind header search flag into actions (drop PanelHeaderControls)
- default PanelHeader searchTerm to ''
- drive the editor's discard prompt through useConfirmableAction
- trim over-verbose comments; PanelHeader test uses userEvent

* refactor(dashboards-v2): trim comment noise and use JSDoc for function docs

Reduce over-commenting flagged in review across the V2 dashboard code:
remove comments that restate the code, shrink verbose blocks to a line, and
keep only the non-obvious "why". Function/helper docs are written as JSDoc;
inline implementation notes stay as line comments.

* fix(dashboards-v2): harden list pagination against bad inputs

Clamp the page size and offset to finite, positive values before deriving
pageIndex/canNext so the pager can never produce NaN/Infinity/negative state,
and ignore a non-positive setPageSize so the size state stays valid. canNext
now uses `rowCount >= pageSize` to also cover an over-fetch.

Add edge-case tests: empty/non-raw response, full vs partial page, nextCursor,
goNext advancing the page, and a rejected zero page size.

* refactor(dashboards-v2): localize the perses adapter's envelope casts

The adapter bridges the generated query-envelope DTO (enum type, undiscriminated
spec) and the hand-written QueryEnvelope (typed spec) the V1 mappers consume —
nominally distinct types for the same wire shape, so a structural cast is
unavoidable. Confine those casts to two named `*Envelopes` converters and a
builder-query predicate, keep an explicit typed checkpoint for the composite
spec, and correct the stale "Orval erases spec to unknown" comments.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:39:08 +00:00
Aditya Singh
8180436432 chore: update code owner (#11821) 2026-06-23 09:18:00 +00:00
249 changed files with 13649 additions and 1648 deletions

69
.github/CODEOWNERS vendored
View File

@@ -199,3 +199,72 @@ go.mod @therealpandey
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
## Logs
/frontend/src/pages/Logs/ @SigNoz/events-frontend
/frontend/src/pages/LogsExplorer/ @SigNoz/events-frontend
/frontend/src/pages/LogsModulePage/ @SigNoz/events-frontend
/frontend/src/pages/LogsSettings/ @SigNoz/events-frontend
/frontend/src/pages/LiveLogs/ @SigNoz/events-frontend
/frontend/src/container/LogsExplorerChart/ @SigNoz/events-frontend
/frontend/src/container/LogsExplorerContext/ @SigNoz/events-frontend
/frontend/src/container/LogsExplorerList/ @SigNoz/events-frontend
/frontend/src/container/LogsExplorerTable/ @SigNoz/events-frontend
/frontend/src/container/LogsExplorerViews/ @SigNoz/events-frontend
/frontend/src/container/LogsFilters/ @SigNoz/events-frontend
/frontend/src/container/LogsSearchFilter/ @SigNoz/events-frontend
/frontend/src/container/LogsTable/ @SigNoz/events-frontend
/frontend/src/container/LogsAggregate/ @SigNoz/events-frontend
/frontend/src/container/LogsContextList/ @SigNoz/events-frontend
/frontend/src/container/LogsIndexToFields/ @SigNoz/events-frontend
/frontend/src/container/LogsLoading/ @SigNoz/events-frontend
/frontend/src/container/LogsPanelTable/ @SigNoz/events-frontend
/frontend/src/container/LogControls/ @SigNoz/events-frontend
/frontend/src/container/LogDetailedView/ @SigNoz/events-frontend
/frontend/src/container/LogExplorerQuerySection/ @SigNoz/events-frontend
/frontend/src/container/LogLiveTail/ @SigNoz/events-frontend
/frontend/src/container/LiveLogs/ @SigNoz/events-frontend
/frontend/src/container/EmptyLogsSearch/ @SigNoz/events-frontend
/frontend/src/container/NoLogs/ @SigNoz/events-frontend
/frontend/src/components/Logs/ @SigNoz/events-frontend
/frontend/src/components/LogDetail/ @SigNoz/events-frontend
/frontend/src/components/LogsFormatOptionsMenu/ @SigNoz/events-frontend
/frontend/src/hooks/logs/ @SigNoz/events-frontend
## Logs Pipelines
/frontend/src/pages/Pipelines/ @SigNoz/events-frontend
/frontend/src/container/PipelinePage/ @SigNoz/events-frontend
## Traces / Trace Explorer
/frontend/src/pages/Trace/ @SigNoz/events-frontend
/frontend/src/pages/TracesExplorer/ @SigNoz/events-frontend
/frontend/src/pages/TracesModulePage/ @SigNoz/events-frontend
/frontend/src/container/Trace/ @SigNoz/events-frontend
/frontend/src/container/TracesExplorer/ @SigNoz/events-frontend
/frontend/src/container/TracesTableComponent/ @SigNoz/events-frontend
## Trace Funnels
/frontend/src/pages/TracesFunnels/ @SigNoz/events-frontend
/frontend/src/pages/TracesFunnelDetails/ @SigNoz/events-frontend
/frontend/src/hooks/TracesFunnels/ @SigNoz/events-frontend
## Trace Details
/frontend/src/pages/TraceDetailsV3/ @SigNoz/events-frontend
/frontend/src/pages/TraceDetailOldRedirect/ @SigNoz/events-frontend
/frontend/src/hooks/trace/ @SigNoz/events-frontend
## Exceptions
/frontend/src/pages/AllErrors/ @SigNoz/events-frontend
/frontend/src/pages/ErrorDetails/ @SigNoz/events-frontend
/frontend/src/container/AllError/ @SigNoz/events-frontend
/frontend/src/container/ErrorDetails/ @SigNoz/events-frontend
## External APIs
/frontend/src/pages/ApiMonitoring/ @SigNoz/events-frontend
/frontend/src/container/ApiMonitoring/ @SigNoz/events-frontend
## Messaging Queues
/frontend/src/pages/MessagingQueues/ @SigNoz/events-frontend
/frontend/src/components/MessagingQueues/ @SigNoz/events-frontend
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend

View File

@@ -29,6 +29,18 @@ 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,6 +122,13 @@ 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,6 +11,7 @@ import {
ChannelsNew,
CreateNewAlerts,
DashboardPage,
DashboardPanelEditorPage,
DashboardsListPage,
DashboardWidget,
EditRulesPage,
@@ -196,6 +197,13 @@ 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

@@ -44,4 +44,5 @@ export enum LOCALSTORAGE {
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
}

View File

@@ -24,6 +24,7 @@ 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,6 +408,9 @@ 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 ||
@@ -418,7 +421,8 @@ 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;
isPublicDashboard ||
isPanelEditorV2;
const [showTrialExpiryBanner, setShowTrialExpiryBanner] = useState(false);

View File

@@ -7,15 +7,17 @@
&--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

@@ -0,0 +1,61 @@
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

@@ -0,0 +1,45 @@
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,3 +1,5 @@
@use '../../../../styles/scrollbar' as *;
.legend-search-container {
flex-shrink: 0;
width: 100%;
@@ -15,6 +17,10 @@
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;
@@ -33,6 +39,11 @@
}
.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%;
@@ -67,18 +78,7 @@
}
}
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
border-radius: 0.5rem;
}
@include custom-scrollbar;
}
}
@@ -108,6 +108,10 @@
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;
lineConfig.fill = `${finalFillColor}70`;
} else if (fillMode === FillMode.Gradient) {
lineConfig.fill = (self: uPlot): CanvasGradient =>
generateGradientFill(self, finalFillColor, 'rgba(0, 0, 0, 0)');

View File

@@ -1,24 +1,34 @@
import { useEffect, useMemo, useState } from 'react';
import { SelectSimple } from '@signozhq/ui/select';
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
// eslint-disable-next-line signoz/no-antd-components -- fixed-option signal picker
import { Select } from 'antd';
import { CustomSelect } from 'components/NewSelect';
import TextToolTip from 'components/TextToolTip';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import useDebounce from 'hooks/useDebounce';
import { isRetryableError } from 'utils/errorUtils';
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
import {
DYNAMIC_SIGNAL_LABEL,
DYNAMIC_SIGNALS,
type DynamicSignalOption,
signalForApi,
} from '../variableFormModel';
import styles from './VariableForm.module.scss';
interface DynamicVariableFieldsProps {
attribute: string;
signal: TelemetrySignal;
signal: DynamicSignalOption;
onChange: (patch: {
dynamicAttribute?: string;
dynamicSignal?: TelemetrySignal;
dynamicSignal?: DynamicSignalOption;
}) => void;
onPreview: (values: (string | number)[]) => void;
/** Inline error shown under the attribute field (e.g. duplicate attribute). */
attributeError?: string;
}
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
@@ -27,18 +37,24 @@ function DynamicVariableFields({
signal,
onChange,
onPreview,
attributeError,
}: DynamicVariableFieldsProps): JSX.Element {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const apiSignal = signalForApi(signal);
const { data: keyData, isLoading } = useGetFieldKeys({
signal,
const {
data: keyData,
isLoading,
error,
refetch,
} = useGetFieldKeys({
signal: apiSignal,
name: debouncedSearch || undefined,
});
// `keys` is a Record keyed BY field name; the field names are the map keys.
// When the API reports the list is `complete`, search filters locally.
const isComplete = keyData?.data?.complete === true;
// CustomSelect filters the supplied options locally as the user types.
const options = useMemo(
() =>
Object.keys(keyData?.data?.keys ?? {}).map((name) => ({
@@ -49,7 +65,7 @@ function DynamicVariableFields({
);
const { data: valueData } = useGetFieldValues({
signal,
signal: apiSignal,
name: attribute,
enabled: !!attribute,
});
@@ -62,40 +78,60 @@ function DynamicVariableFields({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [valueData]);
const errorMessage = error ? (error as Error).message || null : null;
return (
<>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<div className={cx(styles.labelContainer, styles.sourceLabel)}>
<Typography.Text className={styles.label}>Source</Typography.Text>
<TextToolTip
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<SelectSimple
<Select
className={styles.sortSelect}
popupMatchSelectWidth={false}
value={signal}
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
options={DYNAMIC_SIGNALS.map((s) => ({
label: DYNAMIC_SIGNAL_LABEL[s],
value: s,
}))}
onChange={(value): void =>
onChange({ dynamicSignal: value as TelemetrySignal })
onChange({ dynamicSignal: value as DynamicSignalOption })
}
testId="variable-signal-select"
data-testid="variable-signal-select"
/>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Attribute</Typography.Text>
</div>
<Select
<CustomSelect
className={styles.searchSelect}
showSearch
value={attribute || undefined}
placeholder="Select a telemetry field"
loading={isLoading}
filterOption={isComplete}
options={options}
onSearch={setSearch}
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
options={options}
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
noDataMessage="No fields found"
errorMessage={errorMessage}
onRetry={(): void => {
void refetch();
}}
showRetryButton={error ? isRetryableError(error) : true}
data-testid="variable-field-select"
/>
</div>
{attributeError ? (
<Typography.Text className={styles.errorText}>
{attributeError}
</Typography.Text>
) : null}
</>
);
}

View File

@@ -0,0 +1,139 @@
import { Badge } from '@signozhq/ui/badge';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- fixed-option sort picker
import { Select } from 'antd';
import { CustomSelect } from 'components/NewSelect';
import {
VARIABLE_SORT_LABEL,
VARIABLE_SORTS,
type VariableFormModel,
type VariableSort,
} from '../variableFormModel';
import styles from './VariableForm.module.scss';
interface ListVariableFieldsProps {
model: VariableFormModel;
onChange: (patch: Partial<VariableFormModel>) => void;
previewValues: (string | number)[];
previewError: string | null;
defaultValue: string;
onDefaultValueChange: (value: string) => void;
/** Whether the "ALL values" toggle applies to this type (QUERY / CUSTOM). */
showAllOptionField: boolean;
}
/**
* Rows shared by the list-style variables (Query / Custom / Dynamic): the value
* preview, sort, multi-select / ALL toggles and the default-value picker.
*/
function ListVariableFields({
model,
onChange,
previewValues,
previewError,
defaultValue,
onDefaultValueChange,
showAllOptionField,
}: ListVariableFieldsProps): JSX.Element {
return (
<>
<div className={cx(styles.row, styles.previewSection)}>
<Typography.Text className={styles.previewLabel}>
Preview of Values
</Typography.Text>
<div className={styles.previewValues}>
{previewError ? (
<Typography.Text className={styles.previewError}>
{previewError}
</Typography.Text>
) : (
previewValues.map((value, idx) => (
<Badge
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
key={`${value}-${idx}`}
color="vanilla"
>
{value.toString()}
</Badge>
))
)}
</div>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
</div>
<Select
className={styles.sortSelect}
popupMatchSelectWidth={false}
value={model.sort}
options={VARIABLE_SORTS.map((sort) => ({
label: VARIABLE_SORT_LABEL[sort],
value: sort,
}))}
onChange={(value): void => onChange({ sort: value as VariableSort })}
data-testid="variable-sort-select"
/>
</div>
<div className={cx(styles.row, styles.multiSection)}>
<Typography.Text className={styles.rowLabel}>
Enable multiple values to be checked
</Typography.Text>
<Switch
value={model.multiSelect}
onChange={(checked): void =>
onChange({
multiSelect: checked,
showAllOption: checked ? model.showAllOption : false,
})
}
testId="variable-multi-switch"
/>
</div>
{model.multiSelect && showAllOptionField ? (
<div className={cx(styles.row, styles.allOptionSection)}>
<Typography.Text className={styles.rowLabel}>
Include an option for ALL values
</Typography.Text>
<Switch
value={model.showAllOption}
onChange={(checked): void => onChange({ showAllOption: checked })}
testId="variable-all-switch"
/>
</div>
) : null}
<div className={cx(styles.row, styles.defaultValueSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Default Value</Typography.Text>
<Typography.Text className={styles.defaultValueDesc}>
{model.type === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value'}
</Typography.Text>
</div>
<CustomSelect
className={styles.searchSelect}
showSearch
allowClear
placeholder="Select a default value"
value={defaultValue || undefined}
onChange={(value): void => onDefaultValueChange((value as string) ?? '')}
options={previewValues.map((value) => ({
label: value.toString(),
value: value.toString(),
}))}
data-testid="variable-default-select"
/>
</div>
</>
);
}
export default ListVariableFields;

View File

@@ -3,14 +3,14 @@ import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import Editor from 'components/Editor';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
import type { VariableSort } from '../variableModel';
import styles from './VariableForm.module.scss';
interface QueryVariableFieldsProps {
queryValue: string;
sort: VariableSort;
/** Sibling variable selections, so dependent `$vars` in the query resolve. */
variables: PayloadVariables;
onChange: (queryValue: string) => void;
onPreview: (values: (string | number)[]) => void;
onError: (message: string | null) => void;
@@ -19,7 +19,7 @@ interface QueryVariableFieldsProps {
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
function QueryVariableFields({
queryValue,
sort,
variables,
onChange,
onPreview,
onError,
@@ -30,20 +30,21 @@ function QueryVariableFields({
setIsRunning(true);
onError(null);
try {
const res = await dashboardVariablesQuery({
query: queryValue,
variables: {},
});
const res = await dashboardVariablesQuery({ query: queryValue, variables });
if (res.statusCode === 200 && res.payload) {
onPreview(
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
);
onPreview(res.payload.variableValues ?? []);
} else {
onError(res.error || 'Failed to run query');
onPreview([]);
}
} catch (err) {
onError((err as Error).message || 'Failed to run query');
// `dashboardVariablesQuery` throws `{ message, details: { error } }`.
const detail = (err as { details?: { error?: string } }).details?.error;
const message =
detail && detail.includes('Syntax error:')
? 'Please make sure query is valid and dependent variables are selected'
: detail || (err as Error).message || 'Failed to run query';
onError(message);
onPreview([]);
} finally {
setIsRunning(false);

View File

@@ -5,22 +5,8 @@
.container {
display: flex;
flex-direction: column;
border: 1px solid var(--l1-border);
border-radius: 3px;
}
.allVariables {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--l1-border);
}
.allVariablesBtn {
--button-height: 24px;
--button-padding: 0;
color: var(--muted-foreground);
border: 1px solid var(--l2-border);
}
.content {
@@ -42,6 +28,12 @@
width: 200px;
}
.sourceLabel {
display: flex;
align-items: center;
gap: 6px;
}
.label {
color: var(--l2-foreground);
font-family: Inter;
@@ -59,7 +51,7 @@
.textarea,
.defaultInput {
padding: 6px 6px 6px 8px;
border: 1px solid var(--l1-border);
border: 1px solid var(--l2-border);
border-radius: 2px;
background: var(--l3-background);
}
@@ -78,48 +70,89 @@
color: var(--bg-amber-500);
}
/* Variable type segmented group */
/* Variable type — Tabs root composing the picker row + per-type body panels. */
.typeSection {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 40px;
margin-bottom: 0;
}
/* Picker row (label left, tabs right); the bottom divider separates type from
config. Single line — the tab row scrolls (never wraps) when narrow. */
.typePicker {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--l2-border);
@media (max-width: 1440px) {
flex-wrap: wrap;
}
}
/* Active tab panel — reset the Tabs default padding; body rows handle spacing. */
.typePanel {
padding: 0 !important;
}
.typeContent {
display: flex;
flex-direction: column;
gap: 20px;
}
.typeLabelContainer {
display: flex;
align-items: center;
gap: 8px;
width: auto;
white-space: nowrap;
}
.typeBtnGroup {
display: grid;
grid-template-columns: repeat(4, max-content);
height: 32px;
flex-shrink: 0;
border: 1px solid var(--l1-border);
/* Horizontal scroll so the tab row never wraps to a second line. The scrollbar
is hidden — the row stays a single crisp line and scrolls only when narrow. */
.typeTabsScroll {
justify-self: flex-end;
--tab-list-wrapper-secondary-padding-left: 0;
}
/* Connected segmented control, mirroring Overview's SegmentedControl: no outer
padding, segments divided by 1px borders, active segment filled + bold. */
.typeTabs {
display: inline-flex;
flex-wrap: nowrap;
width: max-content;
gap: 0;
padding: 0;
border: 1px solid var(--l2-border);
border-radius: 2px;
background: var(--l2-background);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
background: transparent;
}
.typeBtn {
--button-height: 32px;
display: flex;
.typeTab {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 114px;
gap: 6px;
min-height: 24px;
padding: 6px 14px;
white-space: nowrap;
border-radius: 0;
color: var(--l2-foreground);
& + & {
border-left: 1px solid var(--l1-border);
&:not(:last-child) {
border-right: 1px solid var(--l2-border);
}
}
.typeBtnSelected {
background: var(--l1-border);
color: var(--l1-foreground);
&[data-state='active'] {
color: var(--l1-foreground);
font-weight: 500;
// override the Tabs component's default (transparent) active background.
background: var(--l3-background) !important;
}
}
.betaTag {
@@ -138,7 +171,7 @@
.editorWrap {
height: 240px;
overflow: hidden;
border: 1px solid var(--l1-border);
border: 1px solid var(--l2-border);
border-radius: 2px;
}
@@ -154,7 +187,7 @@
.customSection :global(.custom-collapse) {
width: 100%;
border: 1px solid var(--l1-border);
border: 1px solid var(--l2-border);
border-radius: 3px 3px 0 0;
:global(.ant-collapse-item) {
@@ -208,7 +241,7 @@
min-height: 88px;
margin-bottom: 0;
padding-bottom: 8px;
border: 1px solid var(--l1-border);
border: 1px solid var(--l2-border);
border-radius: 3px;
}
@@ -271,13 +304,9 @@
letter-spacing: -0.07px;
}
.sortSelect {
width: 192px;
}
.defaultValueSection {
display: grid;
grid-template-columns: max-content 1fr;
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
margin-bottom: 0;
@@ -297,14 +326,21 @@
letter-spacing: -0.06px;
}
/* All variable selects (Source / Attribute / Sort / Default Value) share width
and a consistent --l2-border outline. */
.sortSelect,
.searchSelect {
width: 100%;
width: 240px;
flex-shrink: 0;
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
}
/* Footer */
.footer {
.actionButtons {
width: 100%;
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 12px;
}

View File

@@ -1,350 +1,199 @@
import { useEffect, useState } from 'react';
import { ArrowLeft, Check, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
import { TabsContent, TabsRoot } from '@signozhq/ui/tabs';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse/searchable Select: no @signozhq/ui equivalent
import { Collapse, Input as AntdInput, Select } from 'antd';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse: no @signozhq/ui equivalent
import { Collapse, Input as AntdInput } from 'antd';
import {
VARIABLE_SORTS,
type VariableFormModel,
type VariableSort,
type VariableType,
} from '../variableModel';
import type { VariableType } from '../variableFormModel';
import DynamicVariableFields from './DynamicVariableFields';
import ListVariableFields from './ListVariableFields';
import QueryVariableFields from './QueryVariableFields';
import VariableTypeSelector from './VariableTypeSelector';
import { useVariableForm } from './useVariableForm';
import VariableTypeTabs from './VariableTypeTabs';
import styles from './VariableForm.module.scss';
const SORT_LABEL: Record<VariableSort, string> = {
DISABLED: 'Disabled',
ASC: 'Ascending',
DESC: 'Descending',
};
function getNameError(name: string, existingNames: string[]): string | null {
if (name === '') {
return 'Variable name is required';
}
if (/\s/.test(name)) {
return 'Variable name cannot contain whitespaces';
}
if (existingNames.includes(name)) {
return 'Variable name already exists';
}
return null;
}
interface VariableFormProps {
initial: VariableFormModel;
/** Names of the other variables, for uniqueness validation. */
existingNames: string[];
isSaving: boolean;
onClose: () => void;
onSave: (model: VariableFormModel) => void;
}
import BackToAllVariables from '../components/BackToAllVariables/BackToAllVariables';
import { VariableFormProps } from '../types';
import VariableInfoForm from '../components/VariableInfoForm/VariableInfoForm';
/**
* In-drawer variable editor reproducing the V1 VariableItem layout, built on
* @signozhq components (antd kept only for the monaco editor, TextArea, Collapse
* and searchable selects). Master→detail: renders in place of the list.
* and searchable selects). Master→detail: renders in place of the list. Form
* state/handlers live in {@link useVariableForm}; the shared list-type rows in
* {@link ListVariableFields}.
*/
function VariableForm({
initial,
existingNames,
siblings,
isNew,
isSaving,
onClose,
onSave,
}: VariableFormProps): JSX.Element {
const [model, setModel] = useState<VariableFormModel>(initial);
const [previewValues, setPreviewValues] = useState<(string | number)[]>([]);
const [previewError, setPreviewError] = useState<string | null>(null);
const [defaultValue, setDefaultValue] = useState<string>(
((initial.defaultValue as { value?: string })?.value ?? '') as string,
);
const {
model,
set,
onNameChange,
selectType,
onCustomChange,
onDynamicChange,
setRawPreview,
previewValues,
previewError,
setPreviewError,
defaultValue,
setDefaultValue,
visibleNameError,
nameError,
attributeError,
cycleError,
isListType,
showAllOptionField,
payloadVariables,
handleSave,
} = useVariableForm({ initial, siblings, isNew, onSave });
useEffect(() => {
setModel(initial);
setPreviewValues([]);
setPreviewError(null);
setDefaultValue(
((initial.defaultValue as { value?: string })?.value ?? '') as string,
);
}, [initial]);
const set = (patch: Partial<VariableFormModel>): void =>
setModel((prev) => ({ ...prev, ...patch }));
const selectType = (type: VariableType): void => {
set({ type });
setPreviewValues([]);
setPreviewError(null);
};
const onCustomChange = (value: string): void => {
set({ customValue: value });
setPreviewValues(
sortValues(commaValuesParser(value), model.sort) as (string | number)[],
);
};
const trimmedName = model.name.trim();
const nameError = getNameError(trimmedName, existingNames);
const isListType =
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
const handleSave = (): void => {
onSave({
...model,
name: trimmedName,
defaultValue: defaultValue ? { value: defaultValue } : undefined,
});
};
// Shared list rows (preview/sort/multi/default) for the list-type variables;
// rendered as a sibling inside each list-type panel. Only the active panel
// mounts (Tabs unmounts the rest), so reusing one element is safe.
const listFields = isListType ? (
<ListVariableFields
model={model}
onChange={set}
previewValues={previewValues}
previewError={previewError}
defaultValue={defaultValue}
onDefaultValueChange={setDefaultValue}
showAllOptionField={showAllOptionField}
/>
) : null;
return (
<>
<div className={styles.container}>
<div className={styles.allVariables}>
<Button
variant="ghost"
color="secondary"
className={styles.allVariablesBtn}
prefix={<ArrowLeft size={14} />}
onClick={onClose}
testId="variable-form-back"
>
All variables
</Button>
</div>
<div className={styles.container}>
<BackToAllVariables onClose={onClose} />
<div className={styles.content}>
{/* Name */}
<div className={cx(styles.row, styles.column)}>
<Typography.Text className={styles.label}>Name</Typography.Text>
<Input
className={styles.input}
value={model.name}
placeholder="Unique name of the variable"
onChange={(e): void => set({ name: e.target.value })}
testId="variable-name-input"
/>
{nameError ? (
<Typography.Text className={styles.errorText}>
{nameError}
</Typography.Text>
) : null}
</div>
<div className={styles.content}>
<VariableInfoForm
title={model.name}
description={model.description}
onTitleChange={onNameChange}
onDescriptionChange={(value): void => set({ description: value })}
visibleNameError={visibleNameError}
/>
{/* Description */}
<div className={cx(styles.row, styles.column)}>
<Typography.Text className={styles.label}>Description</Typography.Text>
<AntdInput.TextArea
className={styles.textarea}
value={model.description}
placeholder="Enter a description for the variable"
rows={3}
onChange={(e): void => set({ description: e.target.value })}
data-testid="variable-description-input"
/>
</div>
<TabsRoot
className={styles.typeSection}
value={model.type}
onValueChange={(next): void => selectType(next as VariableType)}
>
<VariableTypeTabs />
{/* Variable Type */}
<VariableTypeSelector value={model.type} onChange={selectType} />
{/* Type-specific body */}
{model.type === 'DYNAMIC' ? (
<DynamicVariableFields
attribute={model.dynamicAttribute}
signal={model.dynamicSignal}
onChange={(patch): void => set(patch)}
onPreview={setPreviewValues}
/>
) : null}
{model.type === 'QUERY' ? (
<QueryVariableFields
queryValue={model.queryValue}
sort={model.sort}
onChange={(queryValue): void => set({ queryValue })}
onPreview={setPreviewValues}
onError={setPreviewError}
/>
) : null}
{model.type === 'CUSTOM' ? (
<div className={cx(styles.row, styles.customSection)}>
<Collapse
collapsible="header"
rootClassName="custom-collapse"
defaultActiveKey={['1']}
items={[
{
key: '1',
label: 'Options',
children: (
<AntdInput.TextArea
value={model.customValue}
placeholder="Enter options separated by commas."
rootClassName="comma-input"
onChange={(e): void => onCustomChange(e.target.value)}
data-testid="variable-custom-input"
/>
),
},
]}
<TabsContent value="DYNAMIC" className={styles.typePanel}>
<div className={styles.typeContent}>
<DynamicVariableFields
attribute={model.dynamicAttribute}
signal={model.dynamicSignal}
onChange={onDynamicChange}
onPreview={setRawPreview}
attributeError={attributeError}
/>
{listFields}
</div>
) : null}
</TabsContent>
{model.type === 'TEXT' ? (
<div className={cx(styles.row, styles.textboxSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Default Value
</Typography.Text>
</div>
<Input
className={styles.defaultInput}
value={model.textValue}
placeholder="Enter a default value (if any)..."
onChange={(e): void => set({ textValue: e.target.value })}
testId="variable-text-input"
<TabsContent value="QUERY" className={styles.typePanel}>
<div className={styles.typeContent}>
<QueryVariableFields
queryValue={model.queryValue}
variables={payloadVariables}
onChange={(queryValue): void => set({ queryValue })}
onPreview={setRawPreview}
onError={setPreviewError}
/>
{listFields}
</div>
) : null}
</TabsContent>
{/* Shared rows for list-type variables */}
{isListType ? (
<>
<div className={cx(styles.row, styles.previewSection)}>
<Typography.Text className={styles.previewLabel}>
Preview of Values
</Typography.Text>
<div className={styles.previewValues}>
{previewError ? (
<Typography.Text className={styles.previewError}>
{previewError}
</Typography.Text>
) : (
previewValues.map((value, idx) => (
<Badge
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
key={`${value}-${idx}`}
color="vanilla"
>
{value.toString()}
</Badge>
))
)}
</div>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
</div>
<SelectSimple
className={styles.sortSelect}
value={model.sort}
items={VARIABLE_SORTS.map((sort) => ({
label: SORT_LABEL[sort],
value: sort,
}))}
onChange={(value): void => set({ sort: value as VariableSort })}
testId="variable-sort-select"
<TabsContent value="CUSTOM" className={styles.typePanel}>
<div className={styles.typeContent}>
<div className={cx(styles.row, styles.customSection)}>
<Collapse
collapsible="header"
rootClassName="custom-collapse"
defaultActiveKey={['1']}
items={[
{
key: '1',
label: 'Options',
children: (
<AntdInput.TextArea
value={model.customValue}
placeholder="Enter options separated by commas."
rootClassName="comma-input"
onChange={(e): void => onCustomChange(e.target.value)}
data-testid="variable-custom-input"
/>
),
},
]}
/>
</div>
{listFields}
</div>
</TabsContent>
<div className={cx(styles.row, styles.multiSection)}>
<Typography.Text className={styles.rowLabel}>
Enable multiple values to be checked
</Typography.Text>
<Switch
value={model.multiSelect}
onChange={(checked): void => {
set({
multiSelect: checked,
showAllOption: checked ? model.showAllOption : false,
});
}}
testId="variable-multi-switch"
/>
</div>
{model.multiSelect && showAllOptionField ? (
<div className={cx(styles.row, styles.allOptionSection)}>
<Typography.Text className={styles.rowLabel}>
Include an option for ALL values
</Typography.Text>
<Switch
value={model.showAllOption}
onChange={(checked): void => set({ showAllOption: checked })}
testId="variable-all-switch"
/>
</div>
) : null}
<div className={cx(styles.row, styles.defaultValueSection)}>
<TabsContent value="TEXT" className={styles.typePanel}>
<div className={styles.typeContent}>
<div className={cx(styles.row, styles.textboxSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Default Value
</Typography.Text>
<Typography.Text className={styles.defaultValueDesc}>
{model.type === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value'}
</Typography.Text>
</div>
<Select
className={styles.searchSelect}
showSearch
allowClear
placeholder="Select a default value"
value={defaultValue || undefined}
onChange={(value): void => setDefaultValue(value ?? '')}
options={previewValues.map((value) => ({
label: value.toString(),
value: value.toString(),
}))}
data-testid="variable-default-select"
<Input
className={styles.defaultInput}
value={model.textValue}
placeholder="Enter a default value (if any)..."
onChange={(e): void => set({ textValue: e.target.value })}
testId="variable-text-input"
/>
</div>
</>
) : null}
</div>
</TabsContent>
</TabsRoot>
{cycleError ? (
<Typography.Text className={styles.errorText}>
{cycleError}
</Typography.Text>
) : null}
<div className={styles.actionButtons}>
<Button
variant="outlined"
color="secondary"
prefix={<X size={14} />}
onClick={onClose}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
prefix={<Check size={14} />}
disabled={!!nameError || !!attributeError}
loading={isSaving}
onClick={handleSave}
testId="variable-save"
>
Save Variable
</Button>
</div>
</div>
<div className={styles.footer}>
<Button
variant="solid"
color="secondary"
prefix={<X size={14} />}
onClick={onClose}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
prefix={<Check size={14} />}
disabled={!!nameError}
loading={isSaving}
onClick={handleSave}
testId="variable-save"
>
Save Variable
</Button>
</div>
</>
</div>
);
}

View File

@@ -1,99 +0,0 @@
import {
ClipboardType,
DatabaseZap,
Info,
LayoutList,
Pyramid,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
import type { VariableType } from '../variableModel';
import styles from './VariableForm.module.scss';
interface VariableTypeSelectorProps {
value: VariableType;
onChange: (type: VariableType) => void;
}
/** The segmented Dynamic / Textbox / Custom / Query type picker. */
function VariableTypeSelector({
value,
onChange,
}: VariableTypeSelectorProps): JSX.Element {
return (
<div className={cx(styles.row, styles.typeSection)}>
<div className={styles.typeLabelContainer}>
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
<TextToolTip
text="Learn more about supported variable types"
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
urlText="here"
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<div className={styles.typeBtnGroup}>
<Button
variant="ghost"
color="secondary"
prefix={<Pyramid size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'DYNAMIC',
})}
onClick={(): void => onChange('DYNAMIC')}
testId="variable-type-dynamic"
>
Dynamic
<Badge color="robin" className={styles.betaTag}>
Beta
</Badge>
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<ClipboardType size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'TEXT',
})}
onClick={(): void => onChange('TEXT')}
testId="variable-type-textbox"
>
Textbox
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<LayoutList size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'CUSTOM',
})}
onClick={(): void => onChange('CUSTOM')}
testId="variable-type-custom"
>
Custom
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<DatabaseZap size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'QUERY',
})}
onClick={(): void => onChange('QUERY')}
testId="variable-type-query"
>
Query
<Badge color="amber" className={styles.betaTag}>
Not Recommended
</Badge>
</Button>
</div>
</div>
);
}
export default VariableTypeSelector;

View File

@@ -0,0 +1,93 @@
import {
ClipboardType,
DatabaseZap,
Info,
LayoutList,
Pyramid,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { TabsList, TabsTrigger } from '@signozhq/ui/tabs';
import { Typography } from '@signozhq/ui/typography';
import TextToolTip from 'components/TextToolTip';
import styles from './VariableForm.module.scss';
/**
* Presentational trigger row for the variable-type tabs (label + segmented
* triggers). Must render inside a `TabsRoot`, which owns the active state and
* change handling; the matching `TabsContent` panels are siblings in the root.
*/
function VariableTypeTabs(): JSX.Element {
return (
<div className={styles.typePicker}>
<div className={styles.typeLabelContainer}>
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
<TextToolTip
text="Learn more about supported variable types"
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
urlText="here"
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<div className={styles.typeTabsScroll}>
<TabsList variant="secondary" className={styles.typeTabs}>
<TabsTrigger
value="DYNAMIC"
className={styles.typeTab}
testId="variable-type-dynamic"
>
<Pyramid size={14} />
Dynamic
<Badge color="robin" className={styles.betaTag}>
Beta
</Badge>
</TabsTrigger>
<TabsTrigger
value="TEXT"
className={styles.typeTab}
testId="variable-type-textbox"
>
<ClipboardType size={14} />
Textbox
</TabsTrigger>
<TabsTrigger
value="CUSTOM"
className={styles.typeTab}
testId="variable-type-custom"
>
<LayoutList size={14} />
Custom
</TabsTrigger>
<TabsTrigger
value="QUERY"
className={styles.typeTab}
testId="variable-type-query"
>
<DatabaseZap size={14} />
Query
<Badge color="amber" className={styles.betaTag}>
Not Recommended
</Badge>
<span
className={styles.betaTag}
onClick={(e): void => e.stopPropagation()}
role="presentation"
>
<TextToolTip
text="Learn why we don't recommend"
url="https://signoz.io/docs/userguide/manage-variables/#why-avoid-clickhouse-query-variables"
urlText="here"
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</span>
</TabsTrigger>
</TabsList>
</div>
</div>
);
}
export default VariableTypeTabs;

View File

@@ -0,0 +1,191 @@
import { useEffect, useMemo, useState } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
import type { VariableSelectionMap } from '../../../VariablesBar/selectionTypes';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { detectVariableCycle } from '../variableDependencies';
import {
sortValuesByOrder,
type VariableFormModel,
type VariableType,
} from '../variableFormModel';
import { getAttributeError, getNameError } from './variableValidation';
// Stable reference so the zustand selector never returns a fresh object (which
// would make useSyncExternalStore loop) when this dashboard has no selections.
const EMPTY_SELECTIONS: VariableSelectionMap = {};
interface UseVariableFormArgs {
initial: VariableFormModel;
siblings: VariableFormModel[];
isNew: boolean;
onSave: (model: VariableFormModel) => void;
}
export interface UseVariableForm {
model: VariableFormModel;
set: (patch: Partial<VariableFormModel>) => void;
onNameChange: (value: string) => void;
selectType: (type: VariableType) => void;
onCustomChange: (value: string) => void;
onDynamicChange: (patch: Partial<VariableFormModel>) => void;
setRawPreview: (values: (string | number)[]) => void;
previewValues: (string | number)[];
previewError: string | null;
setPreviewError: (message: string | null) => void;
defaultValue: string;
setDefaultValue: (value: string) => void;
visibleNameError: string | null;
nameError: string | null;
attributeError: string | undefined;
cycleError: string | null;
isListType: boolean;
showAllOptionField: boolean;
payloadVariables: PayloadVariables;
handleSave: () => void;
}
const readDefaultValue = (model: VariableFormModel): string =>
((model.defaultValue as { value?: string })?.value ?? '') as string;
/** Form state, derivations and handlers for the variable editor. */
export function useVariableForm({
initial,
siblings,
isNew,
onSave,
}: UseVariableFormArgs): UseVariableForm {
const [model, setModel] = useState<VariableFormModel>(initial);
// Raw, unsorted preview; `previewValues` applies the chosen sort so a shown
// preview re-sorts when Sort changes.
const [rawPreview, setRawPreview] = useState<(string | number)[]>([]);
const [previewError, setPreviewError] = useState<string | null>(null);
const [cycleError, setCycleError] = useState<string | null>(null);
// In add mode, mirror the chosen attribute into the name until the user types.
const [nameTouched, setNameTouched] = useState(false);
const [defaultValue, setDefaultValue] = useState<string>(
readDefaultValue(initial),
);
useEffect(() => {
setModel(initial);
setRawPreview([]);
setPreviewError(null);
setCycleError(null);
setNameTouched(false);
setDefaultValue(readDefaultValue(initial));
}, [initial]);
const set = (patch: Partial<VariableFormModel>): void =>
setModel((prev) => ({ ...prev, ...patch }));
const previewValues = useMemo(
() => sortValuesByOrder(rawPreview, model.sort) as (string | number)[],
[rawPreview, model.sort],
);
const existingNames = useMemo(() => siblings.map((v) => v.name), [siblings]);
const existingDynamicAttributes = useMemo(
() =>
siblings
.filter((v) => v.type === 'DYNAMIC' && v.dynamicAttribute)
.map((v) => v.dynamicAttribute),
[siblings],
);
// Sibling selections feed the Query "Test Run" so dependent `$vars` resolve.
const dashboardId = useDashboardStore((s) => s.dashboardId);
const selections = useDashboardStore(
(s) => s.variableValues[dashboardId ?? ''] ?? EMPTY_SELECTIONS,
);
const payloadVariables = useMemo<PayloadVariables>(() => {
const out: PayloadVariables = {};
siblings.forEach((v) => {
if (v.name) {
out[v.name] = selections[v.name]?.value ?? null;
}
});
return out;
}, [siblings, selections]);
const trimmedName = model.name.trim();
const nameError = getNameError(trimmedName, existingNames, initial.name);
// Surface the message only once the field is dirty; Save stays disabled regardless.
const visibleNameError = nameTouched ? nameError : null;
const attributeError = getAttributeError(model, existingDynamicAttributes);
const isListType =
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
const onNameChange = (value: string): void => {
setNameTouched(true);
set({ name: value });
};
const selectType = (type: VariableType): void => {
set({ type });
setRawPreview([]);
setPreviewError(null);
};
const onCustomChange = (value: string): void => {
set({ customValue: value });
setRawPreview(commaValuesParser(value));
};
// In add mode, mirror the selected attribute into the name until the user
// edits the name themselves (matches the V1 dynamic-variable behaviour).
const onDynamicChange = (patch: Partial<VariableFormModel>): void => {
if (isNew && !nameTouched && patch.dynamicAttribute) {
set({ ...patch, name: patch.dynamicAttribute });
} else {
set(patch);
}
};
const handleSave = (): void => {
const next: VariableFormModel = {
...model,
name: trimmedName,
defaultValue: defaultValue ? { value: defaultValue } : undefined,
};
const cycle = detectVariableCycle([...siblings, next]);
if (cycle) {
setCycleError(
`Cannot save: circular dependency detected between variables: ${cycle.join(
' → ',
)}`,
);
return;
}
setCycleError(null);
onSave(next);
};
return {
model,
set,
onNameChange,
selectType,
onCustomChange,
onDynamicChange,
setRawPreview,
previewValues,
previewError,
setPreviewError,
defaultValue,
setDefaultValue,
visibleNameError,
nameError,
attributeError,
cycleError,
isListType,
showAllOptionField,
payloadVariables,
handleSave,
};
}

View File

@@ -0,0 +1,37 @@
import type { VariableFormModel } from '../variableFormModel';
/**
* Name validation, mirroring V1: empty / whitespace are rejected, and the name
* set includes self, but keeping your own (original) name is always allowed.
*/
export function getNameError(
name: string,
existingNames: string[],
originalName: string,
): string | null {
if (name === '') {
return 'Variable name is required';
}
if (/\s/.test(name)) {
return 'Variable name cannot contain whitespaces';
}
if (name !== originalName && existingNames.includes(name)) {
return 'Variable name already exists';
}
return null;
}
/** Rejects a dynamic variable reusing an attribute already bound elsewhere. */
export function getAttributeError(
model: VariableFormModel,
existingDynamicAttributes: string[],
): string | undefined {
if (
model.type === 'DYNAMIC' &&
model.dynamicAttribute &&
existingDynamicAttributes.includes(model.dynamicAttribute)
) {
return 'A variable with this attribute key already exists';
}
return undefined;
}

View File

@@ -0,0 +1,140 @@
import type { CSSProperties } from 'react';
import { Check, GripVertical, PenLine, Trash2, X } from '@signozhq/icons';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { VariableFormModel } from './variableFormModel';
import styles from './Variables.module.scss';
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
TEXT: 'Text',
DYNAMIC: 'Dynamic',
};
interface VariableRowProps {
variable: VariableFormModel;
index: number;
canEdit: boolean;
/** True when this row's delete is awaiting inline confirmation. */
isConfirmingDelete: boolean;
onEdit: (index: number) => void;
onRequestDelete: (index: number) => void;
onConfirmDelete: (index: number) => void;
onCancelDelete: () => void;
}
/** A single draggable variable row (drag handle + meta + inline actions). */
function VariableRow({
variable,
index,
canEdit,
isConfirmingDelete,
onEdit,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
}: VariableRowProps): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: variable.name });
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
...(isDragging ? { position: 'relative', zIndex: 1, opacity: 0.8 } : {}),
};
return (
<div
ref={setNodeRef}
style={style}
className={styles.row}
data-testid={`variable-row-${variable.name}`}
>
<div className={styles.rowMain}>
{canEdit ? (
<span
ref={setActivatorNodeRef}
className={styles.dragHandle}
aria-label="Reorder variable"
{...attributes}
{...listeners}
>
<GripVertical size={14} />
</span>
) : null}
<Typography.Text className={styles.varName}>
${variable.name}
</Typography.Text>
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
{variable.description ? (
<Typography.Text className={styles.varDesc}>
{variable.description}
</Typography.Text>
) : null}
</div>
{canEdit && isConfirmingDelete ? (
<div className={styles.rowActions}>
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</div>
) : null}
{canEdit && !isConfirmingDelete ? (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</div>
) : null}
</div>
);
}
export default VariableRow;

View File

@@ -2,13 +2,11 @@
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 16px;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
justify-content: flex-end;
gap: 16px;
}
@@ -30,14 +28,6 @@
color: var(--l2-foreground);
}
.empty {
padding: 32px;
text-align: center;
border: 1px dashed var(--l1-border);
border-radius: 4px;
color: var(--l2-foreground);
}
.list {
display: flex;
flex-direction: column;
@@ -62,6 +52,15 @@
min-width: 0;
}
.dragHandle {
display: flex;
flex-shrink: 0;
align-items: center;
color: var(--l3-foreground);
cursor: grab;
touch-action: none;
}
.varName {
font-weight: 500;
color: var(--l1-foreground);

View File

@@ -1,24 +1,20 @@
import type { DragEndEvent } from '@dnd-kit/core';
import {
Check,
ChevronDown,
ChevronUp,
PenLine,
Trash2,
X,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
DndContext,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import type { VariableFormModel } from './variableModel';
import VariableRow from './VariableRow';
import type { VariableFormModel } from './variableFormModel';
import styles from './Variables.module.scss';
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
TEXT: 'Text',
DYNAMIC: 'Dynamic',
};
interface VariablesListProps {
variables: VariableFormModel[];
canEdit: boolean;
@@ -41,98 +37,48 @@ function VariablesList({
onCancelDelete,
onMove,
}: VariablesListProps): JSX.Element {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 1 } }),
);
const handleDragEnd = ({ active, over }: DragEndEvent): void => {
if (!over || active.id === over.id) {
return;
}
const from = variables.findIndex((v) => v.name === active.id);
const to = variables.findIndex((v) => v.name === over.id);
if (from !== -1 && to !== -1) {
onMove(from, to);
}
};
return (
<div className={styles.list} data-testid="variables-list">
{variables.map((variable, index) => (
<div
className={styles.row}
key={variable.name || `variable-${index}`}
data-testid={`variable-row-${variable.name}`}
>
<div className={styles.rowMain}>
<Typography.Text className={styles.varName}>
${variable.name}
</Typography.Text>
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
{variable.description ? (
<Typography.Text className={styles.varDesc}>
{variable.description}
</Typography.Text>
) : null}
</div>
{canEdit && confirmingIndex === index ? (
<div className={styles.rowActions}>
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</div>
) : null}
{canEdit && confirmingIndex !== index ? (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === 0}
onClick={(): void => onMove(index, index - 1)}
aria-label="Move up"
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === variables.length - 1}
onClick={(): void => onMove(index, index + 1)}
aria-label="Move down"
>
<ChevronDown size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</div>
) : null}
<DndContext
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={variables.map((v) => v.name)}
strategy={verticalListSortingStrategy}
>
<div className={styles.list} data-testid="variables-list">
{variables.map((variable, index) => (
<VariableRow
key={variable.name || `variable-${index}`}
variable={variable}
index={index}
canEdit={canEdit}
isConfirmingDelete={confirmingIndex === index}
onEdit={onEdit}
onRequestDelete={onRequestDelete}
onConfirmDelete={onConfirmDelete}
onCancelDelete={onCancelDelete}
/>
))}
</div>
))}
</div>
</SortableContext>
</DndContext>
);
}

View File

@@ -0,0 +1,26 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
const AddVariableButton = ({
isEditable,
setIsEditing,
}: {
isEditable: boolean;
setIsEditing: (state: { type: 'new' }) => void;
}): JSX.Element => {
return (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
size="md"
onClick={(): void => setIsEditing({ type: 'new' })}
testId="add-variable"
disabled={!isEditable}
>
Add variable
</Button>
);
};
export default AddVariableButton;

View File

@@ -0,0 +1,11 @@
.backToAllVariables {
gap: 8px;
padding: 8px;
border-bottom: 1px solid var(--l3-border);
}
.backToAllVariablesButton {
--button-font-size: 14px;
--button-padding: var(--spacing-5) var(--spacing-3);
color: var(--l1-foreground);
}

View File

@@ -0,0 +1,28 @@
import { ArrowLeft } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from './BackToAllVariables.module.scss';
import { VariableFormProps } from '../../types';
const BackToAllVariables = ({
onClose,
}: {
onClose: VariableFormProps['onClose'];
}): JSX.Element => {
return (
<div className={styles.backToAllVariables}>
<Button
variant="ghost"
color="secondary"
className={styles.backToAllVariablesButton}
prefix={<ArrowLeft size={14} />}
onClick={onClose}
testId="variable-form-back"
size="md"
>
All variables
</Button>
</div>
);
};
export default BackToAllVariables;

View File

@@ -0,0 +1,25 @@
.noVariablesCard {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.noVariablesCopy {
display: flex;
flex-direction: column;
gap: 2px;
}
.noVariablesTitle {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.noVariablesInfo {
color: var(--l3-foreground);
font-size: 13px;
line-height: 18px;
}

View File

@@ -0,0 +1,28 @@
import { Typography } from '@signozhq/ui/typography';
import AddVariableButton from '../AddVariableButton';
import { EditingState } from '../../types';
import styles from './NoVariables.module.scss';
const NoVariablesCard = ({
isEditable,
setIsEditing,
}: {
isEditable: boolean;
setIsEditing: React.Dispatch<React.SetStateAction<EditingState | null>>;
}): JSX.Element => {
return (
<div className={styles.noVariablesCard}>
<div className={styles.noVariablesCopy}>
<Typography.Text className={styles.noVariablesTitle}>
No variables yet
</Typography.Text>
<Typography.Text className={styles.noVariablesInfo}>
Create a variable to parameterize your panel queries.
</Typography.Text>
</div>
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
</div>
);
};
export default NoVariablesCard;

View File

@@ -0,0 +1,25 @@
.infoItemContainer {
display: flex;
flex-direction: column;
gap: 4px;
}
.infoTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
}
.variableNameInput {
border-radius: 2px;
border: 1px solid var(--l2-border);
&::placeholder {
color: var(--l3-foreground);
}
}
.descriptionTextArea {
border-radius: 2px;
border: 1px solid var(--l2-border);
}

View File

@@ -0,0 +1,60 @@
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
import { Input as AntdInput } from 'antd';
import styles from './VariableInfoForm.module.scss';
import variableFormStyles from '../../VariableForm/VariableForm.module.scss';
interface VariableInfoFormProps {
title: string;
description: string;
onTitleChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
visibleNameError: string | null;
}
function VariableInfoForm({
title,
description,
onTitleChange,
onDescriptionChange,
visibleNameError,
}: VariableInfoFormProps): JSX.Element {
return (
<>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Name</Typography>
<Input
testId="variable-name"
className={styles.variableNameInput}
value={title}
onChange={(e): void => onTitleChange(e.target.value)}
placeholder="Unique name of the variable"
/>
{visibleNameError ? (
<Typography.Text className={variableFormStyles.errorText}>
<sup>*</sup>&nbsp;
{visibleNameError}
</Typography.Text>
) : null}
</div>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Description</Typography>
<AntdInput.TextArea
className={styles.descriptionTextArea}
value={description}
placeholder="Enter a description for the variable"
data-testid="dashboard-desc"
rows={3}
onChange={(e): void => onDescriptionChange(e.target.value)}
/>
</div>
</>
);
}
export default VariableInfoForm;

View File

@@ -1,75 +1,75 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import settingsStyles from '../DashboardSettings.module.scss';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useSaveVariables } from './useSaveVariables';
import { dtoToFormModel } from './variableAdapters';
import {
emptyVariableFormModel,
type VariableFormModel,
} from './variableModel';
} from './variableFormModel';
import VariableForm from './VariableForm/VariableForm';
import VariablesList from './VariablesList';
import styles from './Variables.module.scss';
import AddVariableButton from './components/AddVariableButton';
import NoVariablesCard from './components/NoVariablesCard/NoVariablesCard';
import { EditingState } from './types';
interface VariablesSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
/** `null` index = adding a new variable; a number = editing that row. */
type EditingState = { index: number | null } | null;
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const { save, isSaving } = useSaveVariables();
const initialModels = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
const initialFormModels = useMemo(
() => dashboard.spec.variables.map(dtoToFormModel),
[dashboard.spec.variables],
);
const [variables, setVariables] = useState<VariableFormModel[]>(initialModels);
const [variables, setVariables] =
useState<VariableFormModel[]>(initialFormModels);
// Resync from the dashboard after a save round-trips (refetch bumps updatedAt).
useEffect(() => {
setVariables(initialModels);
setVariables(initialFormModels);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
const [editing, setEditing] = useState<EditingState>(null);
const [isEditing, setIsEditing] = useState<EditingState>(null);
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);
const editingModel: VariableFormModel | null = useMemo(() => {
if (!editing) {
const editingFormModel: VariableFormModel | null = useMemo(() => {
if (!isEditing) {
return null;
}
return editing.index === null
return isEditing.type === 'new'
? emptyVariableFormModel()
: variables[editing.index];
}, [editing, variables]);
: variables[isEditing.index];
}, [isEditing, variables]);
const existingNames = useMemo(() => {
const self = editing?.index ?? null;
return variables.filter((_, i) => i !== self).map((v) => v.name);
}, [variables, editing]);
const siblings = useMemo(() => {
const self = isEditing?.type === 'edit' ? isEditing.index : null;
return variables.filter((_, i) => i !== self);
}, [variables, isEditing]);
const persist = (next: VariableFormModel[]): void => {
setVariables(next);
void save(next);
};
const handleFormSave = (model: VariableFormModel): void => {
const handleFormSave = (Formmodel: VariableFormModel): void => {
const next = [...variables];
if (editing?.index == null) {
next.push(model);
} else {
next[editing.index] = model;
if (isEditing?.type === 'new') {
next.push(Formmodel);
} else if (isEditing?.type === 'edit') {
next[isEditing.index] = Formmodel;
}
setEditing(null);
setIsEditing(null);
persist(next);
};
@@ -88,14 +88,14 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
setConfirmDeleteIndex(null);
};
// Detail view — edit/new form replaces the list in place (no modal).
if (editingModel) {
if (editingFormModel) {
return (
<VariableForm
initial={editingModel}
existingNames={existingNames}
initial={editingFormModel}
siblings={siblings}
isNew={isEditing?.type === 'new'}
isSaving={isSaving}
onClose={(): void => setEditing(null)}
onClose={(): void => setIsEditing(null)}
onSave={handleFormSave}
/>
);
@@ -103,42 +103,25 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
// Master view — the variables list.
return (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.titleRow}>
<Typography.Text className={styles.title}>Variables</Typography.Text>
<Typography.Text className={styles.subtitle}>
Define variables to parameterize panel queries.
</Typography.Text>
</div>
{isEditable ? (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={(): void => setEditing({ index: null })}
testId="add-variable"
>
New variable
</Button>
) : null}
</div>
<div className={cx(styles.container, settingsStyles.settingsCard)}>
{variables.length === 0 ? (
<div className={styles.empty}>
<Typography.Text>No variables defined yet.</Typography.Text>
</div>
<NoVariablesCard isEditable={isEditable} setIsEditing={setIsEditing} />
) : (
<VariablesList
variables={variables}
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setEditing({ index })}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
/>
<>
<div className={styles.header}>
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
</div>
<VariablesList
variables={variables}
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setIsEditing({ type: 'edit', index })}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
/>
</>
)}
</div>
);

View File

@@ -0,0 +1,18 @@
import { VariableFormModel } from './variableFormModel';
/** `null` index = adding a new variable; a number = editing that row. */
export type EditingState =
| { type: 'new' }
| { type: 'edit'; index: number }
| null;
export interface VariableFormProps {
initial: VariableFormModel;
/** The other variables (excluding this one), for uniqueness & cycle checks. */
siblings: VariableFormModel[];
/** True when adding a new variable (enables auto-naming from the attribute). */
isNew: boolean;
isSaving: boolean;
onClose: () => void;
onSave: (model: VariableFormModel) => void;
}

View File

@@ -6,7 +6,7 @@ import APIError from 'types/api/error';
import { useDashboardStore } from '../../store/useDashboardStore';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableModel';
import type { VariableFormModel } from './variableFormModel';
import { buildVariablesPatch } from './variablePatchOps';
interface UseSaveVariables {

View File

@@ -4,7 +4,6 @@ import {
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesListVariableSpecDTO,
@@ -14,21 +13,24 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import {
DYNAMIC_SIGNAL_ALL,
type DynamicSignalOption,
emptyVariableFormModel,
PLUGIN_KIND,
type TelemetrySignal,
signalForApi,
VARIABLE_SORT_DISABLED,
type VariableFormModel,
type VariableSort,
} from './variableModel';
} from './variableFormModel';
/** DTO envelope → flat form model (for display / editing). */
export function dtoToFormModel(
dto: DashboardtypesVariableDTO,
): VariableFormModel {
const base = emptyVariableFormModel();
const display = dto.spec?.display;
const display = dto.spec.display;
const common: VariableFormModel = {
...base,
// TODO
name: dto.spec?.name ?? display?.name ?? '',
description: display?.description ?? '',
};
@@ -50,7 +52,7 @@ export function dtoToFormModel(
...common,
multiSelect: spec.allowMultiple ?? false,
showAllOption: spec.allowAllValue ?? false,
sort: (spec.sort as VariableSort) ?? 'DISABLED',
sort: (spec.sort as VariableSort) ?? VARIABLE_SORT_DISABLED,
defaultValue: spec.defaultValue,
};
const plugin = spec.plugin;
@@ -67,7 +69,9 @@ export function dtoToFormModel(
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
dynamicSignal: (plugin.spec.signal as TelemetrySignal) ?? 'traces',
// An omitted wire signal means "all telemetry".
dynamicSignal:
(plugin.spec.signal as DynamicSignalOption) ?? DYNAMIC_SIGNAL_ALL,
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).
@@ -95,7 +99,7 @@ function buildPlugin(
kind: DynamicPluginKind['signoz/DynamicVariable'],
spec: {
name: model.dynamicAttribute,
signal: model.dynamicSignal as TelemetrytypesSignalDTO,
signal: signalForApi(model.dynamicSignal),
},
};
case 'QUERY':
@@ -114,7 +118,6 @@ export function formModelToDto(
const display = {
name: model.name,
description: model.description,
hidden: model.hidden,
};
if (model.type === 'TEXT') {
@@ -135,7 +138,10 @@ export function formModelToDto(
name: model.name,
display,
allowMultiple: model.multiSelect,
allowAllValue: model.showAllOption,
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
// which forced showALLOption true on save); other types respect the toggle.
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
sort: model.sort,
defaultValue: model.defaultValue,
plugin: buildPlugin(model),
@@ -149,5 +155,3 @@ export function variableTypeOf(
): VariableFormModel['type'] {
return dtoToFormModel(dto).type;
}
export { PLUGIN_KIND };

View File

@@ -0,0 +1,35 @@
import {
buildDependencies,
buildDependencyGraph,
} from 'container/DashboardContainer/DashboardVariablesSelection/util';
import type { IDashboardVariable } from 'types/api/dashboard/getAll';
import type { VariableFormModel } from './variableFormModel';
/**
* Detects a circular reference among QUERY variables (a query referencing
* another that, transitively, references it back). Reuses the V1 dependency
* graph helpers, which key off `name` / `type` / `queryValue` only.
*
* Returns the names forming the cycle, or `null` when the set is acyclic.
*/
export function detectVariableCycle(
variables: VariableFormModel[],
): string[] | null {
const asDbVariables = variables
.filter((variable) => variable.name)
.map(
(variable) =>
({
name: variable.name,
type: variable.type,
queryValue: variable.queryValue,
}) as IDashboardVariable,
);
const { hasCycle, cycleNodes } = buildDependencyGraph(
buildDependencies(asDbVariables),
);
return hasCycle ? (cycleNodes ?? []) : null;
}

View File

@@ -0,0 +1,154 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
import { sortBy } from 'lodash-es';
/**
* The four variable types the editor exposes. No generated enum exists for this
* — it's a UI grouping over the wire's envelope + plugin kinds: the TextVariable
* envelope → `TEXT`, and a ListVariable's `DashboardtypesVariablePluginKindDTO`
* (`signoz/QueryVariable` | `signoz/CustomVariable` | `signoz/DynamicVariable`)
* → `QUERY` | `CUSTOM` | `DYNAMIC`. Replace with a generated enum if the backend
* ever exposes a single variable-kind type.
*/
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
/** Telemetry signal — the generated enum (traces / logs / metrics). */
export type TelemetrySignal = TelemetrytypesSignalDTO;
/**
* Signal selected in the dynamic-variable editor. `'all'` is UI-only (the
* generated `TelemetrytypesSignalDTO` has no "all") — it searches across every
* signal and maps to an omitted `signal` on the wire (see {@link signalForApi}).
*/
export const DYNAMIC_SIGNAL_ALL = 'all' as const;
export type DynamicSignalOption = TelemetrySignal | typeof DYNAMIC_SIGNAL_ALL;
/**
* Sort order for list-variable values. The wire (Perses) validates `sort`
* against a fixed method set. There is no generated TS enum for it
* (`DashboardtypesListOrderDTO` is the query-builder order, a different field),
* so we mirror the Perses `Sort` tokens here.
*/
export const VARIABLE_SORT = {
DISABLED: 'none',
ASC: 'alphabetical-asc',
DESC: 'alphabetical-desc',
NUMERICAL_ASC: 'numerical-asc',
NUMERICAL_DESC: 'numerical-desc',
CI_ASC: 'alphabetical-ci-asc',
CI_DESC: 'alphabetical-ci-desc',
} as const;
export type VariableSort = (typeof VARIABLE_SORT)[keyof typeof VARIABLE_SORT];
/** Persisted "no sort" value (Perses `none`). */
export const VARIABLE_SORT_DISABLED: VariableSort = VARIABLE_SORT.DISABLED;
export const VARIABLE_SORTS: VariableSort[] = [
VARIABLE_SORT.DISABLED,
VARIABLE_SORT.ASC,
VARIABLE_SORT.DESC,
VARIABLE_SORT.NUMERICAL_ASC,
VARIABLE_SORT.NUMERICAL_DESC,
VARIABLE_SORT.CI_ASC,
VARIABLE_SORT.CI_DESC,
];
export const VARIABLE_SORT_LABEL: Record<VariableSort, string> = {
[VARIABLE_SORT.DISABLED]: 'Disabled',
[VARIABLE_SORT.ASC]: 'Alphabetical (ascending)',
[VARIABLE_SORT.DESC]: 'Alphabetical (descending)',
[VARIABLE_SORT.NUMERICAL_ASC]: 'Numerical (ascending)',
[VARIABLE_SORT.NUMERICAL_DESC]: 'Numerical (descending)',
[VARIABLE_SORT.CI_ASC]: 'Alphabetical, case-insensitive (ascending)',
[VARIABLE_SORT.CI_DESC]: 'Alphabetical, case-insensitive (descending)',
};
export const DYNAMIC_SIGNALS: DynamicSignalOption[] = [
DYNAMIC_SIGNAL_ALL,
TelemetrytypesSignalDTO.traces,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.metrics,
];
export const DYNAMIC_SIGNAL_LABEL: Record<DynamicSignalOption, string> = {
[DYNAMIC_SIGNAL_ALL]: 'All telemetry',
[TelemetrytypesSignalDTO.traces]: 'Traces',
[TelemetrytypesSignalDTO.logs]: 'Logs',
[TelemetrytypesSignalDTO.metrics]: 'Metrics',
};
/** Maps the editor's signal selection to the wire value (`'all'` → omitted). */
export function signalForApi(
signal: DynamicSignalOption,
): TelemetrySignal | undefined {
return signal === DYNAMIC_SIGNAL_ALL ? undefined : signal;
}
type SortableValues = (string | number | boolean)[];
/** Sorts preview / option values by the variable's chosen order (no-op when disabled). */
export function sortValuesByOrder(
values: SortableValues,
sort: VariableSort,
): SortableValues {
switch (sort) {
case VARIABLE_SORT.ASC:
return sortBy(values);
case VARIABLE_SORT.DESC:
return sortBy(values).reverse();
case VARIABLE_SORT.NUMERICAL_ASC:
return sortBy(values, (value) => Number(value));
case VARIABLE_SORT.NUMERICAL_DESC:
return sortBy(values, (value) => Number(value)).reverse();
case VARIABLE_SORT.CI_ASC:
return sortBy(values, (value) => String(value).toLowerCase());
case VARIABLE_SORT.CI_DESC:
return sortBy(values, (value) => String(value).toLowerCase()).reverse();
default:
return values;
}
}
export interface VariableFormModel {
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
name: string;
description: string;
type: VariableType;
// List-variable common fields (Query / Custom / Dynamic).
multiSelect: boolean;
showAllOption: boolean;
sort: VariableSort;
// Type-specific.
queryValue: string; // QUERY
customValue: string; // CUSTOM
textValue: string; // TEXT
textConstant: boolean; // TEXT
dynamicAttribute: string; // DYNAMIC — the telemetry field name
dynamicSignal: DynamicSignalOption; // DYNAMIC — the telemetry signal
/**
* Runtime-selected default, not editable in the management tab yet; carried
* through edits so saving a definition doesn't clobber it.
*/
defaultValue?: VariableDefaultValueDTO;
}
export function emptyVariableFormModel(): VariableFormModel {
return {
name: '',
description: '',
type: 'DYNAMIC',
multiSelect: false,
showAllOption: false,
sort: VARIABLE_SORT_DISABLED,
queryValue: '',
customValue: '',
textValue: '',
textConstant: false,
dynamicAttribute: '',
dynamicSignal: DYNAMIC_SIGNAL_ALL,
};
}

View File

@@ -1,102 +0,0 @@
import { sortBy } from 'lodash-es';
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
/**
* Flat, UI-friendly representation of a V2 dashboard variable. The wire format
* (`DashboardtypesVariableDTO`) is a nested envelope/plugin union that is awkward
* to bind a form to; `variableAdapters` converts between this model and the DTO.
*/
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
export type VariableSort = 'DISABLED' | 'ASC' | 'DESC';
export type TelemetrySignal = 'traces' | 'logs' | 'metrics';
/** Wire `kind` discriminators (string values of the generated enums). */
export const ENVELOPE_KIND = {
LIST: 'ListVariable',
TEXT: 'TextVariable',
} as const;
export const PLUGIN_KIND = {
QUERY: 'signoz/QueryVariable',
CUSTOM: 'signoz/CustomVariable',
DYNAMIC: 'signoz/DynamicVariable',
} as const;
export const VARIABLE_SORTS: VariableSort[] = ['DISABLED', 'ASC', 'DESC'];
export const TELEMETRY_SIGNALS: TelemetrySignal[] = [
'traces',
'logs',
'metrics',
];
export interface VariableFormModel {
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
name: string;
description: string;
hidden: boolean;
type: VariableType;
// List-variable common fields (Query / Custom / Dynamic).
multiSelect: boolean;
showAllOption: boolean;
sort: VariableSort;
// Type-specific.
queryValue: string; // QUERY
customValue: string; // CUSTOM
textValue: string; // TEXT
textConstant: boolean; // TEXT
dynamicAttribute: string; // DYNAMIC — the telemetry field name
dynamicSignal: TelemetrySignal; // DYNAMIC — the telemetry signal
/**
* Runtime-selected default, not editable in the management tab yet; carried
* through edits so saving a definition doesn't clobber it.
*/
defaultValue?: VariableDefaultValueDTO;
}
export function emptyVariableFormModel(): VariableFormModel {
return {
name: '',
description: '',
hidden: false,
type: 'QUERY',
multiSelect: false,
showAllOption: false,
sort: 'DISABLED',
queryValue: '',
customValue: '',
textValue: '',
textConstant: false,
dynamicAttribute: '',
dynamicSignal: 'traces',
};
}
/** Maps the dynamic-variable signal to the field-values API signal. */
export function signalForApi(
signal: TelemetrySignal,
): TelemetrySignal | undefined {
return signal;
}
type SortableValues = (string | number | boolean)[];
/** Sorts option/preview values by the variable's chosen order (no-op when disabled). */
export function sortValuesByOrder(
values: SortableValues,
sort: VariableSort,
): SortableValues {
if (sort === 'ASC') {
return sortBy(values);
}
if (sort === 'DESC') {
return sortBy(values).reverse();
}
return values;
}

View File

@@ -0,0 +1,90 @@
.config {
display: flex;
flex-direction: column;
flex: 1;
// padding: 18px 18px 44px;
background-color: var(--l1-background);
overflow-y: auto;
overflow-x: hidden;
//TODO: replace this with custom-scrollbar mixin
// Thin, unobtrusive scrollbar (replaces the chunky native bar).
$thumb: color-mix(in srgb, var(--bg-vanilla-100) 16%, transparent);
scrollbar-width: thin;
scrollbar-color: $thumb transparent;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: $thumb;
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
}
.heading {
margin-bottom: 18px;
padding: 16px 16px 0 16px;
}
.title {
display: flex;
align-items: baseline;
gap: 9px;
white-space: nowrap;
}
.subtitle {
font-size: 12px;
color: var(--text-vanilla-400);
}
.eyebrow {
display: block;
margin: 0 2px 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--l1-foreground);
}
.group {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.divider {
height: 1px;
background: var(--l2-border);
margin: 18px 0;
}
.sectionsContainer {
padding: 0 16px;
}
.sections {
display: flex;
flex-direction: column;
& > * + * {
border-top: 1px solid var(--l2-border);
}
}

View File

@@ -0,0 +1,108 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import type { LegendSeries } from '../hooks/useLegendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import SectionSlot from './SectionSlot/SectionSlot';
import styles from './ConfigPane.module.scss';
import { PanelKind } from '../../Panels/types/panelKind';
interface ConfigPaneProps {
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); drives which sections show. */
panelKind: PanelKind;
/** The panel spec — the single editing surface (title/description + section slices). */
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Panel's resolved series, provided to sections that need them (legend colors). */
legendSeries: LegendSeries[];
/** Table panel's resolved value columns, for the table-only editors. */
tableColumns: TableColumnOption[];
}
/**
* Right-hand configuration pane. Renders the always-present general fields (title +
* description) followed by the panel kind's configuration sections (Formatting, Axes,
* …). The section list is declared per kind (`PanelDefinition.sections`) and rendered
* generically via the section registry — only sections with a built editor appear.
*/
function ConfigPane({
panelKind,
spec,
onChangeSpec,
legendSeries,
tableColumns,
}: ConfigPaneProps): JSX.Element {
const definition = getPanelDefinition(panelKind);
const sections = definition?.sections ?? [];
const signal = getBuilderQueries(spec.queries)[0]?.signal as
| TelemetrytypesSignalDTO
| undefined;
// Title/description are just a slice of the spec — edit them through the same
// onChangeSpec path the sections use, so there's a single editing surface.
const setDisplayField = (field: 'name' | 'description', value: string): void =>
onChangeSpec({ ...spec, display: { ...spec.display, [field]: value } });
return (
<div className={styles.config}>
<header className={styles.heading}>
<Typography.Text>Panel settings</Typography.Text>
</header>
<div className={styles.group}>
<div className={styles.field}>
<Typography.Text>Title</Typography.Text>
<Input
data-testid="panel-editor-v2-title"
value={spec.display.name}
placeholder="Panel title"
onChange={(e): void => setDisplayField('name', e.target.value)}
/>
</div>
<div className={styles.field}>
<Typography.Text>Description</Typography.Text>
<Input.TextArea
data-testid="panel-editor-v2-description"
value={spec.display.description ?? ''}
placeholder="Add a description"
rows={3}
onChange={(e): void => setDisplayField('description', e.target.value)}
/>
</div>
</div>
{sections.length > 0 && (
<>
<div className={styles.divider} />
<div className={styles.sectionsContainer}>
<span className={styles.eyebrow}>Display</span>
<div className={styles.sections}>
{sections.map((config) => (
<SectionSlot
key={config.kind}
config={config}
spec={spec}
onChangeSpec={onChangeSpec}
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
/>
))}
</div>
</div>
</>
)}
</div>
);
}
export default ConfigPane;

View File

@@ -0,0 +1,77 @@
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
type PanelFormattingSlice,
SECTION_METADATA,
type SectionConfig,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { LegendSeries } from '../../hooks/useLegendSeries';
import type { TableColumnOption } from '../../hooks/useTableColumns';
import { resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
interface SectionSlotProps {
config: SectionConfig;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Resolved series, forwarded to editors that need them (legend colors). */
legendSeries: LegendSeries[];
/** Table panel's resolved value columns, for the table-only editors. */
tableColumns: TableColumnOption[];
/** Panel's telemetry signal, for editors that fetch field suggestions (List columns). */
signal?: TelemetrytypesSignalDTO;
}
/**
* Renders one configuration section: its collapsible wrapper plus the registered editor
* for `config.kind`, wired through the registry's spec lens. Renders nothing when the
* kind has no editor yet (sections roll out incrementally), so a kind can declare a
* section before its editor exists.
*/
function SectionSlot({
config,
spec,
onChangeSpec,
legendSeries,
tableColumns,
signal,
}: SectionSlotProps): JSX.Element | null {
// A kind can hide a section based on current spec state (e.g. Histogram legend once
// queries are merged) — skip it before resolving the editor.
if (config.isHidden?.(spec)) {
return null;
}
const editor = resolveSectionEditor(config.kind);
if (!editor) {
return null;
}
const { title, icon: Icon } = SECTION_METADATA[config.kind];
const { Component, get, update } = editor;
// Atomic sections carry no `controls`; controlled ones do.
const controls = 'controls' in config ? config.controls : undefined;
// The panel's formatting unit, forwarded to editors that scope to it (thresholds
// restrict their unit picker to this unit's category, as in V1).
const yAxisUnit = (spec.plugin.spec as { formatting?: PanelFormattingSlice })
.formatting?.unit;
return (
<SettingsSection title={title} icon={<Icon size={15} />}>
<Component
value={get(spec)}
controls={controls}
onChange={(next): void => onChangeSpec(update(spec, next))}
legendSeries={legendSeries}
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
/>
</SettingsSection>
);
}
export default SectionSlot;

View File

@@ -0,0 +1,54 @@
.header {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
height: 44px;
padding: 0 4px;
border: none;
background: transparent;
cursor: pointer;
color: var(--text-vanilla-100);
border-radius: 4px;
}
.iconTile {
display: grid;
place-items: center;
width: 27px;
height: 27px;
flex: none;
border-radius: 3px;
background: var(--l3-background);
color: var(--l3-foreground);
transition: all 0.15s ease;
}
.iconTileOpen {
background: color-mix(in srgb, var(--bg-robin-400) 14%, transparent);
color: var(--bg-robin-400);
}
.title {
flex: 1;
text-align: left;
font-weight: 600;
color: var(--l2-foreground);
}
.chevron {
flex: none;
color: var(--l2-border);
transition: transform 0.15s ease;
&.open {
transform: rotate(180deg);
}
}
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 2px 0 18px;
}

View File

@@ -0,0 +1,54 @@
import { type ReactNode, useState } from 'react';
import { ChevronDown } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './SettingsSection.module.scss';
interface SettingsSectionProps {
title: string;
icon?: ReactNode;
defaultOpen?: boolean;
children: ReactNode;
}
/**
* Collapsible container for one configuration section in the V2 panel editor's
* ConfigPane. Header shows an icon tile (accented when expanded), the title, and a
* rotating chevron; sections are separated by hairline dividers (no surrounding boxes),
* matching the Configure-panel design.
*/
function SettingsSection({
title,
icon,
defaultOpen = false,
children,
}: SettingsSectionProps): JSX.Element {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<section className={styles.section}>
<button
type="button"
className={styles.header}
aria-expanded={isOpen}
data-testid={`config-section-${title}`}
onClick={(): void => setIsOpen((prev) => !prev)}
>
{icon && (
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
{icon}
</span>
)}
<Typography.Text className={styles.title}>{title}</Typography.Text>
<ChevronDown
size={15}
className={cx(styles.chevron, { [styles.open]: isOpen })}
/>
</button>
{isOpen && <div className={styles.body}>{children}</div>}
</section>
);
}
export default SettingsSection;

View File

@@ -0,0 +1,69 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigPane from '../ConfigPane';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
function spec(unit?: string): DashboardtypesPanelSpecDTO {
return {
display: { name: 'CPU', description: 'usage' },
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: unit ? { formatting: { unit } } : {},
},
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
}
function renderConfigPane(
overrides: Partial<React.ComponentProps<typeof ConfigPane>> = {},
): React.ComponentProps<typeof ConfigPane> {
const props: React.ComponentProps<typeof ConfigPane> = {
panelKind: 'signoz/TimeSeriesPanel',
spec: spec(),
onChangeSpec: jest.fn(),
legendSeries: [],
tableColumns: [],
...overrides,
};
render(<ConfigPane {...props} />);
return props;
}
describe('ConfigPane', () => {
it('renders the seeded title and description', () => {
renderConfigPane();
expect(screen.getByTestId('panel-editor-v2-title')).toHaveValue('CPU');
expect(screen.getByTestId('panel-editor-v2-description')).toHaveValue(
'usage',
);
});
it('reports title edits through onChangeSpec (into spec.display)', () => {
const { onChangeSpec } = renderConfigPane();
fireEvent.change(screen.getByTestId('panel-editor-v2-title'), {
target: { value: 'Memory' },
});
expect(onChangeSpec).toHaveBeenCalledWith(
expect.objectContaining({
display: { name: 'Memory', description: 'usage' },
}),
);
});
it('renders the Formatting section for a kind that declares it', () => {
renderConfigPane();
// The TimeSeries kind declares a Formatting section; its collapsible header shows.
expect(screen.getByTestId('config-section-Formatting')).toBeInTheDocument();
});
it('omits the Formatting section for an unknown kind', () => {
renderConfigPane({ panelKind: 'signoz/UnknownPanel' as PanelKind });
expect(
screen.queryByTestId('config-section-Formatting'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,10 @@
.group {
width: min(350px, 100%);
}
.segment {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}

View File

@@ -0,0 +1,59 @@
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
import styles from './ConfigSegmented.module.scss';
export interface ConfigSegmentedItem {
value: string;
label: string;
icon?: SegmentIconName;
}
interface ConfigSegmentedProps {
testId: string;
value: string | undefined;
items: ConfigSegmentedItem[];
onChange: (value: string) => void;
}
/**
* Inline segmented control for short option sets in the config pane (line style, fill
* mode, axis scale, legend position). Each segment carries an optional muted glyph that
* brightens with the selected state (it inherits the toggle's `currentColor`). Built on
* the Periscope ToggleGroup so it stays theme-faithful.
*/
function ConfigSegmented({
testId,
value,
items,
onChange,
}: ConfigSegmentedProps): JSX.Element {
return (
<ToggleGroupSimple
type="single"
testId={testId}
className={styles.group}
value={value}
items={items.map((item) => ({
value: item.value,
'aria-label': item.label,
label: (
<span className={styles.segment}>
{item.icon && <SegmentIcon name={item.icon} />}
{item.label}
</span>
),
}))}
// Single toggle-groups emit '' when the active segment is re-clicked; ignore that
// so a required choice (e.g. scale, position) can't be cleared to an empty value.
onChange={(next: string): void => {
if (next) {
onChange(next);
}
}}
/>
);
}
export default ConfigSegmented;

View File

@@ -0,0 +1,10 @@
// Fill the section field so the select lines up with the other full-width controls.
.select {
width: 100%;
}
.item {
display: inline-flex;
align-items: center;
gap: 9px;
}

View File

@@ -0,0 +1,56 @@
import { Select } from 'antd';
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
import styles from './ConfigSelect.module.scss';
export interface ConfigSelectItem {
value: string;
label: string;
icon?: SegmentIconName;
}
interface ConfigSelectProps {
testId: string;
value: string | undefined;
placeholder?: string;
items: ConfigSelectItem[];
onChange: (value: string) => void;
}
/**
* Single-select dropdown for the panel editor's config sections. Built on antd's
* `Select` so it matches the rest of the editor's antd controls; the menu portals to
* `document.body` (antd default) so the surrounding `overflow:auto` pane can't clip it.
*/
function ConfigSelect({
testId,
value,
placeholder,
items,
onChange,
}: ConfigSelectProps): JSX.Element {
return (
<Select<string>
className={styles.select}
data-testid={testId}
value={value}
placeholder={placeholder}
onChange={onChange}
virtual={false}
options={items.map((item) => ({
value: item.value,
label: item.icon ? (
<span className={styles.item}>
<SegmentIcon name={item.icon} />
{item.label}
</span>
) : (
item.label
),
}))}
/>
);
}
export default ConfigSelect;

View File

@@ -0,0 +1,30 @@
.card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l2-background-60);
}
.text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--l2-foreground);
}
.description {
font-size: 12px;
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,43 @@
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import styles from './ConfigSwitch.module.scss';
interface ConfigSwitchProps {
testId: string;
/** Shown uppercased as the card title. */
title: string;
/** Optional helper line under the title. */
description?: string;
value: boolean;
onChange: (checked: boolean) => void;
}
/**
* Boolean toggle rendered as a bordered card: an uppercase title with an optional
* description on the left and a Switch on the right. The standard presentation for
* on/off panel-config controls (e.g. "Show points").
*/
function ConfigSwitch({
testId,
title,
description,
value,
onChange,
}: ConfigSwitchProps): JSX.Element {
return (
<div className={styles.card}>
<div className={styles.text}>
<span className={styles.title}>{title}</span>
{description && (
<Typography.Text className={styles.description}>
{description}
</Typography.Text>
)}
</div>
<Switch testId={testId} value={value} onChange={onChange} />
</div>
);
}
export default ConfigSwitch;

View File

@@ -0,0 +1,64 @@
import { ColorPicker } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './LegendColors.module.scss';
interface LegendColorRowProps {
label: string;
/** Effective color shown in the swatch (override or auto). */
color: string;
/** True when the series has an explicit override (enables Reset). */
isOverridden: boolean;
onChange: (hex: string) => void;
onReset: () => void;
}
/**
* One series row in the legend-colors list: an antd ColorPicker swatch trigger, the
* series label, and a Reset action shown only when the color is overridden. `onChange`
* fires on commit (`onChangeComplete`) so dragging the picker doesn't churn the spec.
*/
function LegendColorRow({
label,
color,
isOverridden,
onChange,
onReset,
}: LegendColorRowProps): JSX.Element {
return (
<div className={styles.row}>
<ColorPicker
value={color}
size="small"
showText={false}
trigger="click"
onChangeComplete={(next): void => onChange(next.toHexString())}
>
<button
type="button"
className={styles.trigger}
data-testid={`legend-color-${label}`}
>
<span className={styles.swatch} style={{ backgroundColor: color }} />
<Typography.Text className={styles.label} title={label}>
{label}
</Typography.Text>
</button>
</ColorPicker>
{isOverridden && (
<Button
size="sm"
color="secondary"
variant="ghost"
onClick={onReset}
testId={`legend-color-reset-${label}`}
>
Reset
</Button>
)}
</div>
);
}
export default LegendColorRow;

View File

@@ -0,0 +1,51 @@
.container {
display: flex;
flex-direction: column;
gap: 8px;
}
.list {
width: 100%;
}
.row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
height: 34px;
}
.trigger {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
min-width: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
}
.swatch {
width: 18px;
height: 18px;
flex: none;
border: 1px solid var(--l2-border);
border-radius: 4px;
}
.label {
overflow: hidden;
font-size: 12px;
color: var(--l2-foreground);
white-space: nowrap;
text-overflow: ellipsis;
}
.empty {
font-size: 12px;
color: var(--text-vanilla-400);
}

View File

@@ -0,0 +1,83 @@
import { useState } from 'react';
import { Search } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import { Virtuoso } from 'react-virtuoso';
import type { LegendSeries } from '../../../hooks/useLegendSeries';
import LegendColorRow from './LegendColorRow';
import {
clearSeriesColor,
filterLegendSeries,
resolveSeriesColor,
setSeriesColor,
} from './legendColors.utils';
import styles from './LegendColors.module.scss';
interface LegendColorsProps {
/** Panel's resolved series (from the shared preview query). */
series: LegendSeries[];
value: DashboardtypesLegendDTOCustomColors | undefined;
onChange: (next: Record<string, string>) => void;
}
/**
* Per-series color overrides for the legend: a searchable, virtualized list of the
* panel's resolved series, each with an antd ColorPicker swatch. Picking a color writes
* `{ [seriesLabel]: hex }` into `legend.customColors` — the same label the chart keys its
* color lookup on; Reset drops the override. Virtualized so panels with hundreds of
* series stay responsive. Until the query produces series, shows a hint.
*/
function LegendColors({
series,
value,
onChange,
}: LegendColorsProps): JSX.Element {
const [query, setQuery] = useState('');
if (series.length === 0) {
return (
<Typography.Text className={styles.empty}>
Run the panel to customise series colors.
</Typography.Text>
);
}
const filtered = filterLegendSeries(series, query);
return (
<div className={styles.container} data-testid="panel-editor-v2-legend-colors">
<Input
data-testid="panel-editor-v2-legend-search"
placeholder="Search series…"
value={query}
prefix={<Search size={14} />}
onChange={(e): void => setQuery(e.target.value)}
/>
{filtered.length === 0 ? (
<Typography.Text className={styles.empty}>
No series match {query}.
</Typography.Text>
) : (
<Virtuoso
className={styles.list}
style={{ height: Math.min(filtered.length * 34, 240) }}
data={filtered}
itemContent={(_, s): JSX.Element => (
<LegendColorRow
label={s.label}
color={resolveSeriesColor(value, s.label, s.defaultColor)}
isOverridden={value?.[s.label] !== undefined}
onChange={(hex): void => onChange(setSeriesColor(value, s.label, hex))}
onReset={(): void => onChange(clearSeriesColor(value, s.label))}
/>
)}
/>
)}
</div>
);
}
export default LegendColors;

View File

@@ -0,0 +1,42 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
import LegendColors from '../LegendColors';
const SERIES: LegendSeries[] = [
{ label: 'frontend', defaultColor: '#ff0000' },
{ label: 'cartservice', defaultColor: '#00ff00' },
];
describe('LegendColors', () => {
it('shows a hint when there are no resolved series', () => {
render(<LegendColors series={[]} value={undefined} onChange={jest.fn()} />);
expect(
screen.queryByTestId('panel-editor-v2-legend-colors'),
).not.toBeInTheDocument();
expect(screen.getByText(/run the panel/i)).toBeInTheDocument();
});
it('renders the search box once series are present', () => {
render(
<LegendColors series={SERIES} value={undefined} onChange={jest.fn()} />,
);
expect(
screen.getByTestId('panel-editor-v2-legend-search'),
).toBeInTheDocument();
});
it('shows a no-match message when the search filters everything out', () => {
render(
<LegendColors series={SERIES} value={undefined} onChange={jest.fn()} />,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-legend-search'), {
target: { value: 'zzz' },
});
expect(screen.getByText(/no series match/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,63 @@
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
import {
clearSeriesColor,
filterLegendSeries,
resolveSeriesColor,
setSeriesColor,
} from '../legendColors.utils';
const SERIES: LegendSeries[] = [
{ label: 'frontend', defaultColor: '#ff0000' },
{ label: 'cartservice', defaultColor: '#00ff00' },
{ label: 'frontendproxy', defaultColor: '#0000ff' },
];
describe('legendColors.utils', () => {
describe('filterLegendSeries', () => {
it('returns all series for an empty/whitespace query', () => {
expect(filterLegendSeries(SERIES, '')).toHaveLength(3);
expect(filterLegendSeries(SERIES, ' ')).toHaveLength(3);
});
it('matches case-insensitive substrings', () => {
expect(
filterLegendSeries(SERIES, 'FRONT').map((s) => s.label),
).toStrictEqual(['frontend', 'frontendproxy']);
expect(filterLegendSeries(SERIES, 'cart')).toHaveLength(1);
expect(filterLegendSeries(SERIES, 'zzz')).toHaveLength(0);
});
});
describe('resolveSeriesColor', () => {
it('prefers the override, falling back to the default', () => {
expect(resolveSeriesColor({ frontend: '#111' }, 'frontend', '#ff0000')).toBe(
'#111',
);
expect(resolveSeriesColor(undefined, 'frontend', '#ff0000')).toBe('#ff0000');
expect(resolveSeriesColor(null, 'frontend', '#ff0000')).toBe('#ff0000');
});
});
describe('setSeriesColor', () => {
it('adds/overwrites a label without mutating the input', () => {
const value = { frontend: '#111' };
const next = setSeriesColor(value, 'cartservice', '#222');
expect(next).toStrictEqual({ frontend: '#111', cartservice: '#222' });
expect(value).toStrictEqual({ frontend: '#111' });
});
it('handles null/undefined base', () => {
expect(setSeriesColor(undefined, 'a', '#1')).toStrictEqual({ a: '#1' });
expect(setSeriesColor(null, 'a', '#1')).toStrictEqual({ a: '#1' });
});
});
describe('clearSeriesColor', () => {
it('removes a label without mutating the input', () => {
const value = { frontend: '#111', cartservice: '#222' };
const next = clearSeriesColor(value, 'frontend');
expect(next).toStrictEqual({ cartservice: '#222' });
expect(value).toStrictEqual({ frontend: '#111', cartservice: '#222' });
});
});
});

View File

@@ -0,0 +1,43 @@
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import type { LegendSeries } from '../../../hooks/useLegendSeries';
/** Case-insensitive substring filter over series labels. Empty query → all series. */
export function filterLegendSeries(
series: LegendSeries[],
query: string,
): LegendSeries[] {
const q = query.trim().toLowerCase();
if (!q) {
return series;
}
return series.filter((s) => s.label.toLowerCase().includes(q));
}
/** The effective color for a series: the override if set, else its auto color. */
export function resolveSeriesColor(
value: DashboardtypesLegendDTOCustomColors | undefined,
label: string,
defaultColor: string,
): string {
return value?.[label] ?? defaultColor;
}
/** Set an override for `label`, returning a new customColors record. */
export function setSeriesColor(
value: DashboardtypesLegendDTOCustomColors | undefined,
label: string,
hex: string,
): Record<string, string> {
return { ...value, [label]: hex };
}
/** Drop the override for `label` (revert to the auto color), returning a new record. */
export function clearSeriesColor(
value: DashboardtypesLegendDTOCustomColors | undefined,
label: string,
): Record<string, string> {
const next = { ...value };
delete next[label];
return next;
}

View File

@@ -0,0 +1,145 @@
/**
* Small glyph icons for the panel-editor segmented/select controls, ported from the
* Configure-panel design. They render at 14px and inherit `currentColor` so the
* surrounding control can dim them when unselected and brighten them when active.
*/
export type SegmentIconName =
| 'solid-line'
| 'dashed-line'
| 'fill-none'
| 'fill-solid'
| 'fill-gradient'
| 'pos-bottom'
| 'pos-right'
| 'scale-linear'
| 'scale-log'
| 'interp-linear'
| 'interp-spline'
| 'interp-step-before'
| 'interp-step-after';
function Svg({ children }: { children: React.ReactNode }): JSX.Element {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
style={{ flex: 'none' }}
aria-hidden
>
{children}
</svg>
);
}
const FILLED = { fill: 'currentColor', stroke: 'none' } as const;
export function SegmentIcon({
name,
}: {
name: SegmentIconName;
}): JSX.Element | null {
switch (name) {
case 'solid-line':
return (
<Svg>
<path d="M2 8 H14" />
</Svg>
);
case 'dashed-line':
return (
<Svg>
<path d="M2 8 H4.5" />
<path d="M6.75 8 H9.25" />
<path d="M11.5 8 H14" />
</Svg>
);
case 'fill-none':
return (
<Svg>
<path d="M2 11 L6 6 L10 9 L14 5" />
</Svg>
);
case 'fill-solid':
return (
<Svg>
<path
d="M2 10.5 L6 5.5 L10 8.5 L14 4.5 V13.5 H2 Z"
fill="currentColor"
fillOpacity={0.85}
stroke="none"
/>
<path d="M2 10.5 L6 5.5 L10 8.5 L14 4.5" />
</Svg>
);
case 'fill-gradient':
return (
<Svg>
<path
d="M2 10.5 L6 5.5 L10 8.5 L14 4.5 V13.5 H2 Z"
fill="currentColor"
fillOpacity={0.3}
stroke="none"
/>
<path d="M2 10.5 L6 5.5 L10 8.5 L14 4.5" />
</Svg>
);
case 'pos-bottom':
return (
<Svg>
<rect x={2} y={2.5} width={12} height={9} rx={1.2} />
<rect x={2} y={9} width={12} height={2.5} {...FILLED} />
</Svg>
);
case 'pos-right':
return (
<Svg>
<rect x={2} y={2.5} width={12} height={9} rx={1.2} />
<rect x={10.5} y={2.5} width={3.5} height={9} {...FILLED} />
</Svg>
);
case 'scale-linear':
return (
<Svg>
<path d="M2.5 13 L13.5 3" />
</Svg>
);
case 'scale-log':
return (
<Svg>
<path d="M2.5 13 C5 13, 8 4.5, 13.5 3" />
</Svg>
);
case 'interp-linear':
return (
<Svg>
<path d="M2 12 L6 5 L10 9 L14 4" />
</Svg>
);
case 'interp-spline':
return (
<Svg>
<path d="M2 12 C5 3, 9 3, 14 8" />
</Svg>
);
case 'interp-step-before':
return (
<Svg>
<path d="M2 6 H6 V10 H10 V4.5 H14" />
</Svg>
);
case 'interp-step-after':
return (
<Svg>
<path d="M2 10 H6 V5 H10 V9.5 H14" />
</Svg>
);
default:
return null;
}
}

View File

@@ -0,0 +1,171 @@
import type { ComponentType } from 'react';
import type {
DashboardLinkDTO,
DashboardtypesAxesDTO,
DashboardtypesBarChartVisualizationDTO,
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesTimeSeriesChartAppearanceDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
AnyThreshold,
PanelFormattingSlice,
SectionEditorProps,
SectionKind,
SectionSpecMap,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import AxesSection from './sections/AxesSection/AxesSection';
import BucketsSection from './sections/BucketsSection/BucketsSection';
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
import FormattingSection from './sections/FormattingSection/FormattingSection';
import LegendSection from './sections/LegendSection/LegendSection';
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
type PanelSpec = DashboardtypesPanelSpecDTO;
/**
* Pairs a section kind with its editor component and a typed lens into the panel spec.
* The lens reads/writes over the WHOLE panel spec, so a section can target either the
* plugin spec (`spec.plugin.spec.<key>`) or a panel-level field (e.g. `spec.links`).
*/
export interface SectionDescriptor<K extends SectionKind> {
Component: ComponentType<SectionEditorProps<K>>;
get: (spec: PanelSpec) => SectionSpecMap[K] | undefined;
update: (spec: PanelSpec, value: SectionSpecMap[K]) => PanelSpec;
}
// The plugin spec is a discriminated union over panel kinds; reading/writing a shared
// slice (formatting, axes, …) by key is the one place the union must be narrowed. The
// helper concentrates that cast so the registry entries stay declarative.
type PluginSpecSlice = Partial<Record<string, unknown>>;
function getPluginSlice<T>(spec: PanelSpec, key: string): T | undefined {
return (spec.plugin.spec as PluginSpecSlice)[key] as T | undefined;
}
function updatePluginSlice(
spec: PanelSpec,
key: string,
value: unknown,
): PanelSpec {
return {
...spec,
plugin: {
...spec.plugin,
spec: { ...(spec.plugin.spec as PluginSpecSlice), [key]: value },
},
} as PanelSpec;
}
/**
* Registry of section editors. Partial by design: only sections with a built editor
* appear here, so ConfigPane renders exactly those and silently skips the rest. Adding
* a section editor = one entry here + one component file.
*/
export const SECTION_REGISTRY: {
[K in SectionKind]?: SectionDescriptor<K>;
} = {
formatting: {
Component: FormattingSection,
get: (spec): PanelFormattingSlice | undefined =>
getPluginSlice<PanelFormattingSlice>(spec, 'formatting'),
update: (spec, formatting): PanelSpec =>
updatePluginSlice(spec, 'formatting', formatting),
},
axes: {
Component: AxesSection,
get: (spec): DashboardtypesAxesDTO | undefined =>
getPluginSlice<DashboardtypesAxesDTO>(spec, 'axes'),
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
},
legend: {
Component: LegendSection,
get: (spec): DashboardtypesLegendDTO | undefined =>
getPluginSlice<DashboardtypesLegendDTO>(spec, 'legend'),
update: (spec, legend): PanelSpec =>
updatePluginSlice(spec, 'legend', legend),
},
chartAppearance: {
Component: ChartAppearanceSection,
get: (spec): DashboardtypesTimeSeriesChartAppearanceDTO | undefined =>
getPluginSlice<DashboardtypesTimeSeriesChartAppearanceDTO>(
spec,
'chartAppearance',
),
update: (spec, chartAppearance): PanelSpec =>
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
},
visualization: {
Component: VisualizationSection,
get: (spec): DashboardtypesBarChartVisualizationDTO | undefined =>
getPluginSlice<DashboardtypesBarChartVisualizationDTO>(
spec,
'visualization',
),
update: (spec, visualization): PanelSpec =>
updatePluginSlice(spec, 'visualization', visualization),
},
buckets: {
Component: BucketsSection,
get: (spec): DashboardtypesHistogramBucketsDTO | undefined =>
getPluginSlice<DashboardtypesHistogramBucketsDTO>(spec, 'histogramBuckets'),
update: (spec, buckets): PanelSpec =>
updatePluginSlice(spec, 'histogramBuckets', buckets),
},
contextLinks: {
Component: ContextLinksSection,
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.
get: (spec): DashboardLinkDTO[] | undefined => spec.links,
update: (spec, links): PanelSpec => ({ ...spec, links }),
},
// One editor for every threshold variant (label / comparison / table); the kind's
// `controls.variant` picks the row editor + element shape. All persist to the same
// plugin.spec.thresholds key.
thresholds: {
Component: ThresholdsSection,
get: (spec): AnyThreshold[] | undefined =>
getPluginSlice<AnyThreshold[]>(spec, 'thresholds'),
update: (spec, thresholds): PanelSpec =>
updatePluginSlice(spec, 'thresholds', thresholds),
},
};
/**
* A section descriptor with the kind correlation erased. `SECTION_REGISTRY[kind]` and a
* `SectionConfig` are both unions keyed by the same `kind`, but TS can't prove the lookup
* and the config refer to the same member — the classic correlated-union limitation. The
* resolver below narrows once here (the single localized cast), so render sites compose
* `get` → `Component` → `update` without any further casts.
*/
export interface ErasedSectionDescriptor {
Component: ComponentType<{
value: unknown;
controls?: unknown;
onChange: (next: unknown) => void;
// Forwarded to every editor; only sections that need the panel's resolved series
// (legend colors) read it. Optional so editors can ignore it.
legendSeries?: unknown;
// The panel's formatting unit; read by editors that scope to it (thresholds).
yAxisUnit?: unknown;
// The Table panel's resolved value columns; read by the table-only editors
// (column units, per-column thresholds) to offer real columns.
tableColumns?: unknown;
// The panel's telemetry signal; read by editors that fetch field-key
// suggestions scoped to it (List column picker).
signal?: unknown;
}>;
get: (spec: PanelSpec) => unknown;
update: (spec: PanelSpec, value: unknown) => PanelSpec;
}
export function resolveSectionEditor(
kind: SectionKind,
): ErasedSectionDescriptor | undefined {
return SECTION_REGISTRY[kind] as unknown as
| ErasedSectionDescriptor
| undefined;
}

View File

@@ -0,0 +1,11 @@
.bounds {
display: flex;
gap: 8px;
}
.field {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
}

View File

@@ -0,0 +1,80 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import styles from './AxesSection.module.scss';
type SoftBound = 'softMin' | 'softMax';
const SCALE_OPTIONS = [
{ value: 'linear', label: 'Linear', icon: 'scale-linear' as const },
{ value: 'log', label: 'Log', icon: 'scale-log' as const },
];
/**
* Edits the `axes` slice of a panel spec: soft Y-axis min/max bounds and the
* linear/logarithmic scale toggle. Each control is gated by its `controls` flag.
*/
function AxesSection({
value,
controls,
onChange,
}: SectionEditorProps<'axes'>): JSX.Element {
// An empty field clears the bound (null); otherwise parse to a number, ignoring
// transient non-numeric input (e.g. a lone "-") by leaving the bound unset.
const handleBound =
(bound: SoftBound) =>
(e: ChangeEvent<HTMLInputElement>): void => {
const raw = e.target.value;
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
onChange({ ...value, [bound]: next });
};
return (
<>
{controls.minMax && (
<div className={styles.bounds}>
<div className={styles.field}>
<Typography.Text>Soft min</Typography.Text>
<Input
data-testid="panel-editor-v2-soft-min"
type="number"
placeholder="Auto"
value={value?.softMin ?? ''}
onChange={handleBound('softMin')}
/>
</div>
<div className={styles.field}>
<Typography.Text>Soft max</Typography.Text>
<Input
data-testid="panel-editor-v2-soft-max"
type="number"
placeholder="Auto"
value={value?.softMax ?? ''}
onChange={handleBound('softMax')}
/>
</div>
</div>
)}
{controls.logScale && (
<div className={styles.field}>
<Typography.Text>Y-axis scale</Typography.Text>
<ConfigSegmented
testId="panel-editor-v2-log-scale"
value={value?.isLogScale ? 'log' : 'linear'}
items={SCALE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, isLogScale: next === 'log' })
}
/>
</div>
)}
</>
);
}
export default AxesSection;

View File

@@ -0,0 +1,82 @@
import { render, screen, userEvent } from 'tests/test-utils';
import AxesSection from '../AxesSection';
describe('AxesSection', () => {
it('renders soft bounds and the log-scale switch when both controls are enabled', () => {
render(
<AxesSection
value={undefined}
controls={{ minMax: true, logScale: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-soft-min')).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-soft-max')).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-log-scale')).toBeInTheDocument();
});
it('hides the soft bounds when minMax is off', () => {
render(
<AxesSection
value={undefined}
controls={{ logScale: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.queryByTestId('panel-editor-v2-soft-min'),
).not.toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-log-scale')).toBeInTheDocument();
});
it('writes a numeric soft min through onChange', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<AxesSection
value={undefined}
controls={{ minMax: true }}
onChange={onChange}
/>,
);
await user.type(screen.getByTestId('panel-editor-v2-soft-min'), '5');
expect(onChange).toHaveBeenCalledWith({ softMin: 5 });
});
it('clears a soft bound to null when the field is emptied', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<AxesSection
value={{ softMax: 100 }}
controls={{ minMax: true }}
onChange={onChange}
/>,
);
await user.clear(screen.getByTestId('panel-editor-v2-soft-max'));
expect(onChange).toHaveBeenCalledWith({ softMax: null });
});
it('toggles the logarithmic scale through onChange', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<AxesSection
value={{ isLogScale: false }}
controls={{ logScale: true }}
onChange={onChange}
/>,
);
await user.click(screen.getByText('Log'));
expect(onChange).toHaveBeenCalledWith({ isLogScale: true });
});
});

View File

@@ -0,0 +1,5 @@
.field {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -0,0 +1,80 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardtypesHistogramBucketsDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import styles from './BucketsSection.module.scss';
// The two numeric bounds of the histogram-buckets spec (derived from the BE DTO).
type NumericBound = keyof Pick<
DashboardtypesHistogramBucketsDTO,
'bucketCount' | 'bucketWidth'
>;
/**
* Edits the `histogramBuckets` slice of a Histogram panel spec: bucket count / width
* and whether to merge all active queries into one set of buckets. Each control is gated
* by its `controls` flag.
*/
function BucketsSection({
value,
controls,
onChange,
}: SectionEditorProps<'buckets'>): JSX.Element {
// Empty clears the bound to null (chart auto-sizes); otherwise parse to a number,
// ignoring transient non-numeric input by leaving it unset.
const handleNumber =
(bound: NumericBound) =>
(e: ChangeEvent<HTMLInputElement>): void => {
const raw = e.target.value;
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
onChange({ ...value, [bound]: next });
};
return (
<>
{controls.count && (
<div className={styles.field}>
<Typography.Text>Bucket count</Typography.Text>
<Input
data-testid="panel-editor-v2-bucket-count"
type="number"
placeholder="Auto"
value={value?.bucketCount ?? ''}
onChange={handleNumber('bucketCount')}
/>
</div>
)}
{controls.width && (
<div className={styles.field}>
<Typography.Text>Bucket width</Typography.Text>
<Input
data-testid="panel-editor-v2-bucket-width"
type="number"
placeholder="Auto"
value={value?.bucketWidth ?? ''}
onChange={handleNumber('bucketWidth')}
/>
</div>
)}
{controls.mergeQueries && (
<ConfigSwitch
testId="panel-editor-v2-merge-queries"
title="Merge active queries"
description="Bucket all active queries together into one distribution"
value={value?.mergeAllActiveQueries ?? false}
onChange={(checked): void =>
onChange({ ...value, mergeAllActiveQueries: checked })
}
/>
)}
</>
);
}
export default BucketsSection;

View File

@@ -0,0 +1,41 @@
import { render, screen, userEvent } from 'tests/test-utils';
import BucketsSection from '../BucketsSection';
describe('BucketsSection', () => {
it('renders only the controls whose flag is set', () => {
render(
<BucketsSection
value={undefined}
controls={{ count: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-bucket-count'),
).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-bucket-width'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-merge-queries'),
).not.toBeInTheDocument();
});
it('toggles merge-active-queries through onChange', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<BucketsSection
value={{ mergeAllActiveQueries: false }}
controls={{ mergeQueries: true }}
onChange={onChange}
/>,
);
await user.click(screen.getByTestId('panel-editor-v2-merge-queries'));
expect(onChange).toHaveBeenCalledWith({ mergeAllActiveQueries: true });
});
});

View File

@@ -0,0 +1,5 @@
.field {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -0,0 +1,164 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import {
DashboardtypesFillModeDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import styles from './ChartAppearanceSection.module.scss';
const LINE_STYLE_OPTIONS = [
{
value: DashboardtypesLineStyleDTO.solid,
label: 'Solid',
icon: 'solid-line' as const,
},
{
value: DashboardtypesLineStyleDTO.dashed,
label: 'Dashed',
icon: 'dashed-line' as const,
},
];
const LINE_INTERPOLATION_OPTIONS = [
{
value: DashboardtypesLineInterpolationDTO.linear,
label: 'Linear',
icon: 'interp-linear' as const,
},
{
value: DashboardtypesLineInterpolationDTO.spline,
label: 'Spline',
icon: 'interp-spline' as const,
},
{
value: DashboardtypesLineInterpolationDTO.step_before,
label: 'Step before',
icon: 'interp-step-before' as const,
},
{
value: DashboardtypesLineInterpolationDTO.step_after,
label: 'Step after',
icon: 'interp-step-after' as const,
},
];
const FILL_MODE_OPTIONS = [
{
value: DashboardtypesFillModeDTO.none,
label: 'None',
icon: 'fill-none' as const,
},
{
value: DashboardtypesFillModeDTO.solid,
label: 'Solid',
icon: 'fill-solid' as const,
},
{
value: DashboardtypesFillModeDTO.gradient,
label: 'Gradient',
icon: 'fill-gradient' as const,
},
];
/**
* Edits the `chartAppearance` slice of a TimeSeries panel spec: line style /
* interpolation, fill mode, point markers, and the connect-null-gaps threshold. Each
* control is gated by its `controls` flag.
*/
function ChartAppearanceSection({
value,
controls,
onChange,
}: SectionEditorProps<'chartAppearance'>): JSX.Element {
// `spanGaps.fillLessThan` is a stringified seconds threshold: empty means "connect
// every gap" (the chart default), a number means "only bridge gaps shorter than this".
const handleSpanGaps = (e: ChangeEvent<HTMLInputElement>): void => {
const raw = e.target.value;
onChange({
...value,
spanGaps: raw === '' ? undefined : { ...value?.spanGaps, fillLessThan: raw },
});
};
return (
<>
{controls.lineStyle && (
<div className={styles.field}>
<Typography.Text>Line style</Typography.Text>
<ConfigSegmented
testId="panel-editor-v2-line-style"
value={value?.lineStyle}
items={LINE_STYLE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, lineStyle: next as DashboardtypesLineStyleDTO })
}
/>
</div>
)}
{controls.lineInterpolation && (
<div className={styles.field}>
<Typography.Text>Line interpolation</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-line-interpolation"
placeholder="Select interpolation…"
value={value?.lineInterpolation}
items={LINE_INTERPOLATION_OPTIONS}
onChange={(next): void =>
onChange({
...value,
lineInterpolation: next as DashboardtypesLineInterpolationDTO,
})
}
/>
</div>
)}
{controls.fillMode && (
<div className={styles.field}>
<Typography.Text>Fill mode</Typography.Text>
<ConfigSegmented
testId="panel-editor-v2-fill-mode"
value={value?.fillMode}
items={FILL_MODE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, fillMode: next as DashboardtypesFillModeDTO })
}
/>
</div>
)}
{controls.showPoints && (
<ConfigSwitch
testId="panel-editor-v2-show-points"
title="Show points"
description="Display individual data points on the chart"
value={value?.showPoints ?? false}
onChange={(checked): void => onChange({ ...value, showPoints: checked })}
/>
)}
{controls.spanGaps && (
<div className={styles.field}>
<Typography.Text>Connect gaps shorter than (s)</Typography.Text>
<Input
data-testid="panel-editor-v2-span-gaps"
type="number"
placeholder="All gaps"
value={value?.spanGaps?.fillLessThan ?? ''}
onChange={handleSpanGaps}
/>
</div>
)}
</>
);
}
export default ChartAppearanceSection;

View File

@@ -0,0 +1,140 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DashboardtypesLineStyleDTO } from 'api/generated/services/sigNoz.schemas';
import ChartAppearanceSection from '../ChartAppearanceSection';
// Open the antd Select by clicking its selector, then pick the option by label. The
// line-style and fill-mode controls are ConfigSegmented (buttons), so this helper is
// only used for the line-interpolation ConfigSelect.
async function pickOption(triggerTestId: string, label: string): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(triggerTestId);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
await user.click(await screen.findByRole('option', { name: label }));
}
const ALL_CONTROLS = {
lineStyle: true,
lineInterpolation: true,
fillMode: true,
showPoints: true,
spanGaps: true,
};
describe('ChartAppearanceSection', () => {
it('renders every control that is enabled', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={ALL_CONTROLS}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-line-style')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-line-interpolation'),
).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-fill-mode')).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-show-points')).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-span-gaps')).toBeInTheDocument();
});
it('renders only the controls whose flag is set', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ lineStyle: true, fillMode: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-line-style')).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-fill-mode')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-line-interpolation'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-show-points'),
).not.toBeInTheDocument();
});
it('writes the chosen fill mode through the segmented control', () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ lineStyle: DashboardtypesLineStyleDTO.solid }}
controls={{ fillMode: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText('Gradient'));
expect(onChange).toHaveBeenCalledWith({
lineStyle: 'solid',
fillMode: 'gradient',
});
});
it('writes the chosen line interpolation through the dropdown', async () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={undefined}
controls={{ lineInterpolation: true }}
onChange={onChange}
/>,
);
await pickOption('panel-editor-v2-line-interpolation', 'Spline');
expect(onChange).toHaveBeenCalledWith({ lineInterpolation: 'spline' });
});
it('toggles show points through onChange', () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ showPoints: false }}
controls={{ showPoints: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByTestId('panel-editor-v2-show-points'));
expect(onChange).toHaveBeenCalledWith({ showPoints: true });
});
it('writes a span-gaps threshold and clears it when emptied', () => {
const onChange = jest.fn();
const { rerender } = render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
target: { value: '60' },
});
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '60' },
});
rerender(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '60' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
target: { value: '' },
});
expect(onChange).toHaveBeenLastCalledWith({ spanGaps: undefined });
});
});

View File

@@ -0,0 +1,32 @@
.list {
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border: 1px solid var(--l2-border);
border-radius: 6px;
}
.rowFooter {
display: flex;
align-items: center;
justify-content: space-between;
}
.newTab {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.newTabLabel {
font-size: 12px;
color: var(--text-vanilla-400);
}

View File

@@ -0,0 +1,94 @@
import { Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import styles from './ContextLinksSection.module.scss';
/**
* Edits the panel's context links (`spec.links`): a list of label + URL rows with an
* "open in new tab" toggle, plus add/remove. Atomic section — no per-kind sub-controls.
* URLs may reference dashboard/query variables; that interpolation is resolved at render
* time, so this editor just captures the raw strings.
*/
function ContextLinksSection({
value,
onChange,
}: SectionEditorProps<'contextLinks'>): JSX.Element {
const links = value ?? [];
const updateAt = (index: number, patch: Partial<DashboardLinkDTO>): void =>
onChange(
links.map((link, i) => (i === index ? { ...link, ...patch } : link)),
);
const addLink = (): void =>
onChange([...links, { name: '', url: '', targetBlank: true }]);
const removeAt = (index: number): void =>
onChange(links.filter((_, i) => i !== index));
return (
<div className={styles.list}>
{links.map((link, index) => (
// Links have no stable id on the wire; index is the row identity here.
// eslint-disable-next-line react/no-array-index-key
<div className={styles.row} key={index}>
<Input
data-testid={`context-link-label-${index}`}
placeholder="Label"
value={link.name ?? ''}
onChange={(e): void => updateAt(index, { name: e.target.value })}
/>
<Input
data-testid={`context-link-url-${index}`}
placeholder="https://… or /path?var=$variable"
value={link.url ?? ''}
onChange={(e): void => updateAt(index, { url: e.target.value })}
/>
<div className={styles.rowFooter}>
<div className={styles.newTab}>
<Switch
testId={`context-link-newtab-${index}`}
value={link.targetBlank ?? false}
onChange={(checked: boolean): void =>
updateAt(index, { targetBlank: checked })
}
/>
<Typography.Text className={styles.newTabLabel}>
Open in new tab
</Typography.Text>
</div>
<Button
type="button"
variant="ghost"
color="destructive"
size="icon"
aria-label={`Remove link ${index + 1}`}
data-testid={`context-link-remove-${index}`}
onClick={(): void => removeAt(index)}
>
<Trash2 size={14} />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
data-testid="panel-editor-v2-add-link"
onClick={addLink}
>
Add link
</Button>
</div>
);
}
export default ContextLinksSection;

View File

@@ -0,0 +1,54 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import ContextLinksSection from '../ContextLinksSection';
const LINKS: DashboardLinkDTO[] = [
{ name: 'Docs', url: 'https://signoz.io', targetBlank: true },
];
describe('ContextLinksSection', () => {
it('renders only the add button when there are no links', () => {
render(<ContextLinksSection value={undefined} onChange={jest.fn()} />);
expect(screen.getByTestId('panel-editor-v2-add-link')).toBeInTheDocument();
expect(screen.queryByTestId('context-link-label-0')).not.toBeInTheDocument();
});
it('appends a blank link (open-in-new-tab on) when Add link is clicked', () => {
const onChange = jest.fn();
render(<ContextLinksSection value={[]} onChange={onChange} />);
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
expect(onChange).toHaveBeenCalledWith([
{ name: '', url: '', targetBlank: true },
]);
});
it('renders existing links and edits a label through onChange', () => {
const onChange = jest.fn();
render(<ContextLinksSection value={LINKS} onChange={onChange} />);
expect(screen.getByTestId('context-link-label-0')).toHaveValue('Docs');
expect(screen.getByTestId('context-link-url-0')).toHaveValue(
'https://signoz.io',
);
fireEvent.change(screen.getByTestId('context-link-label-0'), {
target: { value: 'Runbook' },
});
expect(onChange).toHaveBeenCalledWith([
{ name: 'Runbook', url: 'https://signoz.io', targetBlank: true },
]);
});
it('removes a link through onChange', () => {
const onChange = jest.fn();
render(<ContextLinksSection value={LINKS} onChange={onChange} />);
fireEvent.click(screen.getByTestId('context-link-remove-0'));
expect(onChange).toHaveBeenCalledWith([]);
});
});

View File

@@ -0,0 +1,65 @@
import { Typography } from '@signozhq/ui/typography';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import type { TableColumnOption } from '../../../hooks/useTableColumns';
import styles from './FormattingSection.module.scss';
interface ColumnUnitsProps {
/** Resolved value columns of the panel's current table result. */
columns: TableColumnOption[];
/** Current per-column unit map (`formatting.columnUnits`), keyed by column key. */
value: Record<string, string>;
onChange: (next: Record<string, string>) => void;
}
/**
* Per-column unit picker for Table panels: one unit selector per resolved value
* column, writing `{ [columnKey]: unitId }` keyed by the query identifier (V1
* parity). Clearing a column's unit drops its entry. Until the panel produces
* columns, shows a hint.
*/
function ColumnUnits({
columns,
value,
onChange,
}: ColumnUnitsProps): JSX.Element {
if (columns.length === 0) {
return (
<Typography.Text className={styles.columnUnitsHint}>
Run the panel to set per-column units.
</Typography.Text>
);
}
const setUnit = (columnKey: string, unit: string | undefined): void => {
const next = { ...value };
if (unit) {
next[columnKey] = unit;
} else {
delete next[columnKey];
}
onChange(next);
};
return (
<div className={styles.columnUnits}>
{columns.map((column) => (
<div className={styles.columnField} key={column.key}>
<Typography.Text>{column.label}</Typography.Text>
<YAxisUnitSelector
data-testid={`panel-editor-v2-column-unit-${column.key}`}
placeholder="Select unit"
source={YAxisSource.DASHBOARDS}
value={value[column.key]}
containerClassName={styles.columnUnitSelector}
onChange={(unit): void => setUnit(column.key, unit)}
/>
</div>
))}
</div>
);
}
export default ColumnUnits;

View File

@@ -0,0 +1,37 @@
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.unitSelector {
:global(.ant-select) {
width: 100%;
}
}
// Stacked per-column unit pickers; each column keeps the standard field layout.
.columnUnits {
display: flex;
flex-direction: column;
gap: 12px;
:global(.ant-select) {
width: 100%;
}
}
.columnUnitsHint {
font-size: 12px;
color: var(--l2-foreground);
}
.columnField {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
}
.columnUnitSelector {
flex: 1;
}

View File

@@ -0,0 +1,89 @@
import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { TableColumnOption } from '../../../hooks/useTableColumns';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ColumnUnits from './ColumnUnits';
import styles from './FormattingSection.module.scss';
type FormattingSectionProps = SectionEditorProps<'formatting'> & {
/** Table panel's resolved value columns; required for the column-units editor. */
tableColumns?: TableColumnOption[];
};
// `full` means "show the raw value, no rounding"; the digits round to that many places.
const DECIMAL_OPTIONS: {
value: DashboardtypesPrecisionOptionDTO;
label: string;
}[] = [
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_0, label: '0 decimals' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_1, label: '1 decimal' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_2, label: '2 decimals' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_3, label: '3 decimals' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_4, label: '4 decimals' },
{ value: DashboardtypesPrecisionOptionDTO.full, label: 'Full' },
];
/**
* Edits the `formatting` slice of a panel spec (unit + decimal precision). Which
* controls show is driven by the per-kind `controls` flags; the spec slice itself
* is uniform across every kind that declares the Formatting section.
*/
function FormattingSection({
value,
controls,
onChange,
tableColumns = [],
}: FormattingSectionProps): JSX.Element {
return (
<>
{controls.unit && (
<div className={styles.field}>
<Typography.Text>Unit</Typography.Text>
<YAxisUnitSelector
containerClassName={styles.unitSelector}
data-testid="panel-editor-v2-unit"
source={YAxisSource.DASHBOARDS}
value={value?.unit}
onChange={(unit): void => onChange({ ...value, unit })}
/>
</div>
)}
{controls.decimals && (
<div className={styles.field}>
<Typography.Text>Decimals</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-decimals"
placeholder="Select decimals…"
value={value?.decimalPrecision}
items={DECIMAL_OPTIONS}
onChange={(next): void =>
onChange({
...value,
decimalPrecision: next as DashboardtypesPrecisionOptionDTO,
})
}
/>
</div>
)}
{controls.columnUnits && (
<div className={styles.field}>
<Typography.Text>Column units</Typography.Text>
<ColumnUnits
columns={tableColumns}
value={value?.columnUnits ?? {}}
onChange={(columnUnits): void => onChange({ ...value, columnUnits })}
/>
</div>
)}
</>
);
}
export default FormattingSection;

View File

@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import FormattingSection from '../FormattingSection';
// Open the Decimals select (clicking its antd selector) and pick the option with the
// given visible label.
async function pickDecimal(label: string): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId('panel-editor-v2-decimals');
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
await user.click(await screen.findByRole('option', { name: label }));
}
describe('FormattingSection', () => {
it('renders Unit and Decimals when both controls are enabled', () => {
render(
<FormattingSection
value={undefined}
controls={{ unit: true, decimals: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-unit')).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-decimals')).toBeInTheDocument();
});
it('hides a control when its flag is off', () => {
render(
<FormattingSection
value={undefined}
controls={{ decimals: true }}
onChange={jest.fn()}
/>,
);
expect(screen.queryByTestId('panel-editor-v2-unit')).not.toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-decimals')).toBeInTheDocument();
});
it('writes the chosen decimal precision through onChange', async () => {
const onChange = jest.fn();
render(
<FormattingSection
value={undefined}
controls={{ decimals: true }}
onChange={onChange}
/>,
);
await pickDecimal('Full');
expect(onChange).toHaveBeenCalledWith({ decimalPrecision: 'full' });
});
it('merges the edit into the existing formatting slice', async () => {
const onChange = jest.fn();
render(
<FormattingSection
value={{ unit: 'bytes' }}
controls={{ decimals: true }}
onChange={onChange}
/>,
);
await pickDecimal('2 decimals');
expect(onChange).toHaveBeenCalledWith({
unit: 'bytes',
decimalPrecision: '2',
});
});
});

View File

@@ -0,0 +1,5 @@
.field {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -0,0 +1,73 @@
import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesLegendPositionDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import LegendColors from '../../controls/LegendColors/LegendColors';
import type { LegendSeries } from '../../../hooks/useLegendSeries';
import styles from './LegendSection.module.scss';
type LegendSectionProps = SectionEditorProps<'legend'> & {
/** Panel's resolved series, forwarded by SectionSlot for the colors control. */
legendSeries?: LegendSeries[];
};
const POSITION_OPTIONS = [
{
value: DashboardtypesLegendPositionDTO.bottom,
label: 'Bottom',
icon: 'pos-bottom' as const,
},
{
value: DashboardtypesLegendPositionDTO.right,
label: 'Right',
icon: 'pos-right' as const,
},
];
/**
* Edits the `legend` slice of a panel spec: legend position and per-series color
* overrides. The colors control reads the panel's resolved series from context (the
* shared preview query) and writes `customColors` keyed by series label.
*/
function LegendSection({
value,
controls,
onChange,
legendSeries,
}: LegendSectionProps): JSX.Element {
return (
<>
{controls.position && (
<div className={styles.field}>
<Typography.Text>Position</Typography.Text>
<ConfigSegmented
testId="panel-editor-v2-legend-position"
items={POSITION_OPTIONS}
value={value?.position}
onChange={(next): void =>
onChange({
...value,
position: next as DashboardtypesLegendPositionDTO,
})
}
/>
</div>
)}
{controls.colors && (
<div className={styles.field}>
<Typography.Text>Series colors</Typography.Text>
<LegendColors
series={legendSeries ?? []}
value={value?.customColors}
onChange={(customColors): void => onChange({ ...value, customColors })}
/>
</div>
)}
</>
);
}
export default LegendSection;

View File

@@ -0,0 +1,68 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { DashboardtypesLegendPositionDTO } from 'api/generated/services/sigNoz.schemas';
import LegendSection from '../LegendSection';
describe('LegendSection', () => {
it('renders the position toggle with both options when position is enabled', () => {
render(
<LegendSection
value={undefined}
controls={{ position: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-legend-position'),
).toBeInTheDocument();
expect(screen.getByText('Bottom')).toBeInTheDocument();
expect(screen.getByText('Right')).toBeInTheDocument();
});
it('renders nothing when position is not enabled', () => {
render(
<LegendSection value={undefined} controls={{}} onChange={jest.fn()} />,
);
expect(
screen.queryByTestId('panel-editor-v2-legend-position'),
).not.toBeInTheDocument();
});
it('writes the chosen position through onChange', () => {
const onChange = jest.fn();
render(
<LegendSection
value={{ position: undefined }}
controls={{ position: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText('Right'));
expect(onChange).toHaveBeenCalledWith({ position: 'right' });
});
it('preserves other legend fields when changing position', () => {
const onChange = jest.fn();
render(
<LegendSection
value={{
position: DashboardtypesLegendPositionDTO.bottom,
customColors: { a: '#fff' },
}}
controls={{ position: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText('Right'));
expect(onChange).toHaveBeenCalledWith({
position: 'right',
customColors: { a: '#fff' },
});
});
});

View File

@@ -0,0 +1,50 @@
import { ChevronDown } from '@signozhq/icons';
import { ColorPicker } from 'antd';
import styles from './ThresholdsSection.module.scss';
interface ThresholdColorSelectProps {
value: string;
testId?: string;
onChange: (hex: string) => void;
}
// Named presets from the SigNoz palette (cherry / amber / forest / robin). They surface
// as quick swatches in the picker; the full picker below covers any custom color.
const PRESETS: { label: string; value: string }[] = [
{ label: 'Red', value: '#F1575F' },
{ label: 'Orange', value: '#F5B225' },
{ label: 'Green', value: '#2BB673' },
{ label: 'Blue', value: '#4E74F8' },
];
/**
* Threshold color control: an antd ColorPicker with the palette presets plus a full
* custom picker, in a single popover (so moving from the trigger into the picker never
* dismisses it). The trigger shows the current swatch and its preset name, or "Custom".
*/
function ThresholdColorSelect({
value,
testId,
onChange,
}: ThresholdColorSelectProps): JSX.Element {
const current = PRESETS.find(
(p) => p.value.toLowerCase() === value?.toLowerCase(),
);
return (
<ColorPicker
value={value}
onChangeComplete={(c): void => onChange(c.toHexString())}
presets={[{ label: 'Defaults', colors: PRESETS.map((p) => p.value) }]}
>
<button type="button" className={styles.colorTrigger} data-testid={testId}>
<span className={styles.dot} style={{ backgroundColor: value }} />
<span className={styles.colorLabel}>{current?.label ?? 'Custom'}</span>
<ChevronDown size={13} />
</button>
</ColorPicker>
);
}
export default ThresholdColorSelect;

View File

@@ -0,0 +1,104 @@
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
// ── View mode: compact summary row ──────────────────────────────────────────
.viewRow {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 4px 0 10px;
border: 1px solid var(--l2-border);
background-color: var(--l2-background);
border-radius: 6px;
}
.viewValue {
flex: none;
font-size: 13px;
font-weight: 500;
color: var(--text-vanilla-100);
}
.viewLabel {
overflow: hidden;
font-size: 12px;
color: var(--text-vanilla-400);
white-space: nowrap;
text-overflow: ellipsis;
}
.spacer {
flex: 1;
}
// ── Edit mode: labelled form ────────────────────────────────────────────────
.editRow {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
background-color: var(--l2-background);
border: 1px solid var(--bg-robin-400);
border-radius: 6px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.fieldLabel {
font-size: 12px;
color: var(--text-vanilla-400);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
// ── Shared ──────────────────────────────────────────────────────────────────
.dot {
width: 12px;
height: 12px;
flex: none;
border-radius: 50%;
}
.colorTrigger {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: transparent;
color: var(--text-vanilla-100);
cursor: pointer;
}
.colorLabel {
flex: 1;
font-size: 13px;
text-align: left;
}
// Match Formatting: make the YAxisUnitSelector fill the row width.
.unitSelector {
:global(.ant-select) {
width: 100%;
}
}
.invalidUnit {
font-size: 11px;
color: var(--bg-cherry-400);
}

View File

@@ -0,0 +1,181 @@
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesComparisonThresholdDTO,
type DashboardtypesTableThresholdDTO,
DashboardtypesThresholdFormatDTO,
type DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
AnyThreshold,
ThresholdVariant,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { TableColumnOption } from '../../../hooks/useTableColumns';
import ComparisonThresholdRow from './rows/ComparisonThresholdRow';
import LabelThresholdRow from './rows/LabelThresholdRow';
import TableThresholdRow from './rows/TableThresholdRow';
import styles from './ThresholdsSection.module.scss';
// New thresholds default to red (the first palette preset); the user recolors per rule.
const DEFAULT_THRESHOLD_COLOR = '#F1575F';
// Add-button testId per variant — kept stable so existing E2E/unit selectors hold.
const ADD_TESTID: Record<ThresholdVariant, string> = {
label: 'panel-editor-v2-add-threshold',
comparison: 'panel-editor-v2-add-comparison-threshold',
table: 'panel-editor-v2-add-table-threshold',
};
// Seed for a freshly-added row, in the shape the variant's editor + spec expect.
function defaultThreshold(
variant: ThresholdVariant,
tableColumns: TableColumnOption[],
): AnyThreshold {
switch (variant) {
case 'comparison':
return {
value: 0,
color: DEFAULT_THRESHOLD_COLOR,
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.text,
};
case 'table':
return {
columnName: tableColumns[0]?.key ?? '',
value: 0,
color: DEFAULT_THRESHOLD_COLOR,
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.background,
};
default:
return { value: 0, color: DEFAULT_THRESHOLD_COLOR, label: '' };
}
}
type ThresholdsSectionProps = {
value: AnyThreshold[] | undefined;
/** `variant` picks the row editor + element shape; defaults to `label`. */
controls?: { variant?: ThresholdVariant };
onChange: (next: AnyThreshold[]) => void;
/** Panel formatting unit; scopes each row's unit picker to its category (V1 parity). */
yAxisUnit?: string;
/** Table panel's resolved value columns (table variant only). */
tableColumns?: TableColumnOption[];
};
/**
* Edits the `thresholds` slice for every panel kind. All variants share the same
* list mechanics (one row edits at a time; a freshly-added row opens in edit mode and
* is removed if discarded before saving) and differ only in the row editor, picked by
* `controls.variant`: `label` (TimeSeries/Bar), `comparison` (Number), `table` (Table).
*/
function ThresholdsSection({
value,
controls,
onChange,
yAxisUnit,
tableColumns = [],
}: ThresholdsSectionProps): JSX.Element {
const variant = controls?.variant ?? 'label';
const thresholds = value ?? [];
// Which row is being edited, and whether it was just added (so Discard removes it).
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [unsavedIndex, setUnsavedIndex] = useState<number | null>(null);
const addThreshold = (): void => {
const nextIndex = thresholds.length;
onChange([...thresholds, defaultThreshold(variant, tableColumns)]);
setEditingIndex(nextIndex);
setUnsavedIndex(nextIndex);
};
const saveAt =
(index: number) =>
(next: AnyThreshold): void => {
onChange(thresholds.map((t, i) => (i === index ? next : t)));
setEditingIndex(null);
setUnsavedIndex(null);
};
const removeAt = (index: number): void => {
onChange(thresholds.filter((_, i) => i !== index));
setEditingIndex(null);
setUnsavedIndex(null);
};
const discardAt = (index: number) => (): void => {
// Discarding a row that was never saved removes it; otherwise just exit edit.
if (index === unsavedIndex) {
removeAt(index);
return;
}
setEditingIndex(null);
};
const renderRow = (threshold: AnyThreshold, index: number): JSX.Element => {
// Shared row controls; the threshold value is narrowed per variant at this
// branch boundary — the slice only ever holds the active variant's shape.
const common = {
index,
yAxisUnit,
isEditing: editingIndex === index,
onEdit: (): void => setEditingIndex(index),
onSave: saveAt(index),
onDiscard: discardAt(index),
onRemove: (): void => removeAt(index),
};
if (variant === 'comparison') {
return (
<ComparisonThresholdRow
// eslint-disable-next-line react/no-array-index-key
key={index}
threshold={threshold as DashboardtypesComparisonThresholdDTO}
{...common}
/>
);
}
if (variant === 'table') {
return (
<TableThresholdRow
// eslint-disable-next-line react/no-array-index-key
key={index}
threshold={threshold as DashboardtypesTableThresholdDTO}
tableColumns={tableColumns}
{...common}
/>
);
}
return (
<LabelThresholdRow
// eslint-disable-next-line react/no-array-index-key
key={index}
threshold={threshold as DashboardtypesThresholdWithLabelDTO}
{...common}
/>
);
};
return (
<div className={styles.list}>
{thresholds.map(renderRow)}
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
data-testid={ADD_TESTID[variant]}
onClick={addThreshold}
>
Add threshold
</Button>
</div>
);
}
export default ThresholdsSection;

View File

@@ -0,0 +1,202 @@
import { useState } from 'react';
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesComparisonThresholdDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { render, screen, userEvent } from 'tests/test-utils';
import UnifiedThresholdsSection from '../ThresholdsSection';
// The comparison editor is the unified ThresholdsSection in its `comparison` variant;
// this wrapper pins the variant so the suite reads as the comparison editor's spec.
function ComparisonThresholdsSection(props: {
value: DashboardtypesComparisonThresholdDTO[] | undefined;
onChange: (next: DashboardtypesComparisonThresholdDTO[]) => void;
yAxisUnit?: string;
}): JSX.Element {
return (
<UnifiedThresholdsSection
value={props.value}
onChange={props.onChange as (next: AnyThreshold[]) => void}
yAxisUnit={props.yAxisUnit}
controls={{ variant: 'comparison' }}
/>
);
}
const THRESHOLDS: DashboardtypesComparisonThresholdDTO[] = [
{
value: 80,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.above,
unit: 'percent',
format: DashboardtypesThresholdFormatDTO.background,
},
];
// Stateful harness for flows that depend on the value updating (add/discard).
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
const [value, setValue] = useState<DashboardtypesComparisonThresholdDTO[]>([]);
return (
<ComparisonThresholdsSection
value={value}
onChange={setValue}
yAxisUnit={yAxisUnit}
/>
);
}
describe('ComparisonThresholdsSection', () => {
it('renders only the add button when there are no thresholds', () => {
render(
<ComparisonThresholdsSection value={undefined} onChange={jest.fn()} />,
);
expect(
screen.getByTestId('panel-editor-v2-add-comparison-threshold'),
).toBeInTheDocument();
expect(
screen.queryByTestId('comparison-threshold-edit-0'),
).not.toBeInTheDocument();
});
it('shows an existing threshold in view mode (no form until Edit)', () => {
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={jest.fn()} />,
);
expect(screen.getByTestId('comparison-threshold-edit-0')).toBeInTheDocument();
// Operator symbol + value render in the summary.
expect(screen.getByText(/> 80/)).toBeInTheDocument();
// The editable fields are hidden until the row is edited.
expect(
screen.queryByTestId('comparison-threshold-value-0'),
).not.toBeInTheDocument();
});
it('formats the view-mode value through its unit (e.g. currency symbol)', () => {
render(
<ComparisonThresholdsSection
value={[
{
value: 3100,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.below,
unit: 'currencyUSD',
},
]}
onChange={jest.fn()}
/>,
);
const row = screen.getByTestId('comparison-threshold-edit-0').closest('div');
// Unit-aware: shows the currency symbol, never the raw unit id.
expect(row).toHaveTextContent('$');
expect(row).not.toHaveTextContent('currencyUSD');
});
it('edits a threshold value and commits it on Save', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
const valueInput = screen.getByTestId('comparison-threshold-value-0');
expect(valueInput).toHaveValue(80);
await user.clear(valueInput);
await user.type(valueInput, '90');
await user.click(screen.getByTestId('comparison-threshold-save-0'));
expect(onChange).toHaveBeenCalledWith([
{
value: 90,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.above,
unit: 'percent',
format: DashboardtypesThresholdFormatDTO.background,
},
]);
});
it('does not commit edits when Discard is clicked', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
await user.clear(screen.getByTestId('comparison-threshold-value-0'));
await user.type(screen.getByTestId('comparison-threshold-value-0'), '90');
await user.click(screen.getByTestId('comparison-threshold-discard-0'));
expect(onChange).not.toHaveBeenCalled();
// Back to view mode.
expect(
screen.queryByTestId('comparison-threshold-value-0'),
).not.toBeInTheDocument();
expect(screen.getByTestId('comparison-threshold-edit-0')).toBeInTheDocument();
});
it('removes a threshold from view mode', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
await user.click(screen.getByTestId('comparison-threshold-remove-0'));
expect(onChange).toHaveBeenCalledWith([]);
});
it('adds a threshold that opens in edit mode, and discards it away', async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(
screen.getByTestId('panel-editor-v2-add-comparison-threshold'),
);
// New row opens in edit mode.
expect(
screen.getByTestId('comparison-threshold-value-0'),
).toBeInTheDocument();
await user.click(screen.getByTestId('comparison-threshold-discard-0'));
// Discarding a never-saved row removes it entirely.
expect(
screen.queryByTestId('comparison-threshold-value-0'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('comparison-threshold-edit-0'),
).not.toBeInTheDocument();
});
it('flags a threshold unit in a different category than the y-axis unit', async () => {
const user = userEvent.setup();
render(
<ComparisonThresholdsSection
value={[
{
value: 80,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.above,
unit: 'ms',
},
]}
yAxisUnit="bytes"
onChange={jest.fn()}
/>,
);
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
expect(
screen.getByTestId('comparison-threshold-unit-invalid-0'),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,122 @@
import { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ThresholdsSection from '../ThresholdsSection';
const THRESHOLDS: DashboardtypesThresholdWithLabelDTO[] = [
{ value: 80, color: '#F5B225', label: 'High', unit: 'percent' },
];
// Stateful harness for flows that depend on the value updating (add/discard). No
// `controls` is passed, exercising the default `label` variant.
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
const [value, setValue] = useState<AnyThreshold[]>([]);
return (
<ThresholdsSection value={value} onChange={setValue} yAxisUnit={yAxisUnit} />
);
}
describe('ThresholdsSection', () => {
it('renders only the add button when there are no thresholds', () => {
render(<ThresholdsSection value={undefined} onChange={jest.fn()} />);
expect(
screen.getByTestId('panel-editor-v2-add-threshold'),
).toBeInTheDocument();
expect(screen.queryByTestId('threshold-edit-0')).not.toBeInTheDocument();
});
it('shows an existing threshold in view mode (no form until Edit)', () => {
render(<ThresholdsSection value={THRESHOLDS} onChange={jest.fn()} />);
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
expect(screen.getByText('High')).toBeInTheDocument();
// The editable fields are hidden until the row is edited.
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
});
it('edits a threshold value and commits it on Save', () => {
const onChange = jest.fn();
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
fireEvent.click(screen.getByTestId('threshold-edit-0'));
expect(screen.getByTestId('threshold-value-0')).toHaveValue(80);
fireEvent.change(screen.getByTestId('threshold-value-0'), {
target: { value: '90' },
});
fireEvent.click(screen.getByTestId('threshold-save-0'));
expect(onChange).toHaveBeenCalledWith([
{ value: 90, color: '#F5B225', label: 'High', unit: 'percent' },
]);
});
it('does not commit edits when Discard is clicked', () => {
const onChange = jest.fn();
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
fireEvent.click(screen.getByTestId('threshold-edit-0'));
fireEvent.change(screen.getByTestId('threshold-value-0'), {
target: { value: '90' },
});
fireEvent.click(screen.getByTestId('threshold-discard-0'));
expect(onChange).not.toHaveBeenCalled();
// Back to view mode.
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
});
it('removes a threshold from view mode', () => {
const onChange = jest.fn();
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
fireEvent.click(screen.getByTestId('threshold-remove-0'));
expect(onChange).toHaveBeenCalledWith([]);
});
it('adds a threshold that opens in edit mode, and discards it away', () => {
render(<Harness />);
fireEvent.click(screen.getByTestId('panel-editor-v2-add-threshold'));
// New row opens in edit mode.
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('threshold-discard-0'));
// Discarding a never-saved row removes it entirely.
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
expect(screen.queryByTestId('threshold-edit-0')).not.toBeInTheDocument();
});
it('flags a threshold unit in a different category than the y-axis unit', () => {
render(
<ThresholdsSection
value={[{ value: 80, color: '#F5B225', label: '', unit: 'ms' }]}
yAxisUnit="bytes"
onChange={jest.fn()}
/>,
);
fireEvent.click(screen.getByTestId('threshold-edit-0'));
expect(screen.getByTestId('threshold-unit-invalid-0')).toBeInTheDocument();
});
it('does not flag a threshold unit in the same category as the y-axis unit', () => {
render(
<ThresholdsSection
value={[{ value: 80, color: '#F5B225', label: '', unit: 'ms' }]}
yAxisUnit="s"
onChange={jest.fn()}
/>,
);
fireEvent.click(screen.getByTestId('threshold-edit-0'));
expect(
screen.queryByTestId('threshold-unit-invalid-0'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,117 @@
import {
type DashboardtypesComparisonOperatorDTO,
type DashboardtypesComparisonThresholdDTO,
type DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import { formatPanelValue } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/formatPanelValue';
import {
FORMAT_OPTIONS,
OPERATOR_OPTIONS,
OPERATOR_SYMBOL,
} from '../thresholdOptions';
import ThresholdColorField from './shared/ThresholdColorField';
import ThresholdRowShell from './shared/ThresholdRowShell';
import ThresholdSelectField from './shared/ThresholdSelectField';
import ThresholdUnitField from './shared/ThresholdUnitField';
import { useThresholdDraft } from './shared/useThresholdDraft';
import ThresholdValueField from './shared/ThresholdValueField';
import styles from '../ThresholdsSection.module.scss';
interface ComparisonThresholdRowProps {
index: number;
threshold: DashboardtypesComparisonThresholdDTO;
/** Panel formatting unit — scopes the unit picker to its category (V1 parity). */
yAxisUnit?: string;
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesComparisonThresholdDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
/**
* Comparison threshold (Number): value crosses an operator → recolor. Edit form is
* condition (operator), value, unit, color, display format.
*/
function ComparisonThresholdRow({
index,
threshold,
yAxisUnit,
isEditing,
onEdit,
onSave,
onDiscard,
onRemove,
}: ComparisonThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
const symbol = threshold.operator ? OPERATOR_SYMBOL[threshold.operator] : '';
const summary = (
<span className={styles.viewValue}>
{symbol} {formatPanelValue(threshold.value, threshold.unit)}
</span>
);
return (
<ThresholdRowShell
index={index}
testIdPrefix="comparison-threshold"
color={threshold.color}
isEditing={isEditing}
summary={summary}
onEdit={onEdit}
onSave={(): void => onSave(draft)}
onDiscard={onDiscard}
onRemove={onRemove}
>
<ThresholdSelectField
label="If value is"
testId={`comparison-threshold-operator-${index}`}
placeholder="Select condition"
value={draft.operator}
items={OPERATOR_OPTIONS}
onChange={(operator): void =>
setDraft((d) => ({
...d,
operator: operator as DashboardtypesComparisonOperatorDTO,
}))
}
/>
<ThresholdValueField
testId={`comparison-threshold-value-${index}`}
value={draft.value}
onChange={setValue}
/>
<ThresholdUnitField
testId={`comparison-threshold-unit-${index}`}
invalidTestId={`comparison-threshold-unit-invalid-${index}`}
value={draft.unit}
scopeUnit={yAxisUnit}
scopeLabel="y-axis unit"
onChange={(unit): void => setDraft((d) => ({ ...d, unit }))}
/>
<ThresholdColorField
testId={`comparison-threshold-color-${index}`}
value={draft.color}
onChange={(color): void => setDraft((d) => ({ ...d, color }))}
/>
<ThresholdSelectField
label="Display"
testId={`comparison-threshold-format-${index}`}
placeholder="Select display"
value={draft.format}
items={FORMAT_OPTIONS}
onChange={(format): void =>
setDraft((d) => ({
...d,
format: format as DashboardtypesThresholdFormatDTO,
}))
}
/>
</ThresholdRowShell>
);
}
export default ComparisonThresholdRow;

View File

@@ -0,0 +1,96 @@
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
import { formatPanelValue } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/formatPanelValue';
import ThresholdColorField from './shared/ThresholdColorField';
import ThresholdRowShell from './shared/ThresholdRowShell';
import ThresholdUnitField from './shared/ThresholdUnitField';
import { useThresholdDraft } from './shared/useThresholdDraft';
import ThresholdValueField from './shared/ThresholdValueField';
import styles from '../ThresholdsSection.module.scss';
interface LabelThresholdRowProps {
index: number;
threshold: DashboardtypesThresholdWithLabelDTO;
/** Panel formatting unit — scopes the unit picker to its category (V1 parity). */
yAxisUnit?: string;
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesThresholdWithLabelDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
/**
* Value + color + label threshold (TimeSeries / Bar): a line drawn on the chart. Edit
* form is color, value, unit, label.
*/
function LabelThresholdRow({
index,
threshold,
yAxisUnit,
isEditing,
onEdit,
onSave,
onDiscard,
onRemove,
}: LabelThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
const summary = (
<>
<span className={styles.viewValue}>
{formatPanelValue(threshold.value, threshold.unit)}
</span>
{threshold.label && (
<span className={styles.viewLabel}>{threshold.label}</span>
)}
</>
);
return (
<ThresholdRowShell
index={index}
testIdPrefix="threshold"
color={threshold.color}
isEditing={isEditing}
summary={summary}
onEdit={onEdit}
onSave={(): void => onSave(draft)}
onDiscard={onDiscard}
onRemove={onRemove}
>
<ThresholdColorField
testId={`threshold-color-${index}`}
value={draft.color}
onChange={(color): void => setDraft((d) => ({ ...d, color }))}
/>
<ThresholdValueField
testId={`threshold-value-${index}`}
value={draft.value}
onChange={setValue}
/>
<ThresholdUnitField
testId={`threshold-unit-${index}`}
invalidTestId={`threshold-unit-invalid-${index}`}
value={draft.unit}
scopeUnit={yAxisUnit}
scopeLabel="y-axis unit"
onChange={(unit): void => setDraft((d) => ({ ...d, unit }))}
/>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Label</Typography.Text>
<Input
data-testid={`threshold-label-${index}`}
placeholder="Optional"
value={draft.label ?? ''}
onChange={(e): void => setDraft((d) => ({ ...d, label: e.target.value }))}
/>
</div>
</ThresholdRowShell>
);
}
export default LabelThresholdRow;

View File

@@ -0,0 +1,141 @@
import {
type DashboardtypesComparisonOperatorDTO,
type DashboardtypesTableThresholdDTO,
type DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import { formatPanelValue } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/formatPanelValue';
import type { TableColumnOption } from '../../../../hooks/useTableColumns';
import {
FORMAT_OPTIONS,
OPERATOR_OPTIONS,
OPERATOR_SYMBOL,
} from '../thresholdOptions';
import ThresholdColorField from './shared/ThresholdColorField';
import ThresholdRowShell from './shared/ThresholdRowShell';
import ThresholdSelectField from './shared/ThresholdSelectField';
import ThresholdUnitField from './shared/ThresholdUnitField';
import { useThresholdDraft } from './shared/useThresholdDraft';
import ThresholdValueField from './shared/ThresholdValueField';
import styles from '../ThresholdsSection.module.scss';
interface TableThresholdRowProps {
index: number;
threshold: DashboardtypesTableThresholdDTO;
/** Resolved value columns (with their configured units); the rule targets one. */
tableColumns: TableColumnOption[];
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesTableThresholdDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
/**
* Per-column comparison threshold (Table): value in a column crosses an operator →
* recolor that column's cells. Edit form is column, condition (operator), value, unit,
* color, display format. The unit picker scopes to the selected column's unit (Table
* panels have no single panel-wide unit — V1 parity).
*/
function TableThresholdRow({
index,
threshold,
tableColumns,
isEditing,
onEdit,
onSave,
onDiscard,
onRemove,
}: TableThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
// Stored columnName is the query key; resolve its label + configured unit.
const columnUnit = tableColumns.find((c) => c.key === draft.columnName)?.unit;
const columnLabel =
tableColumns.find((c) => c.key === threshold.columnName)?.label ??
threshold.columnName;
const columnItems = tableColumns.map((column) => ({
value: column.key,
label: column.label,
}));
const symbol = threshold.operator ? OPERATOR_SYMBOL[threshold.operator] : '';
const summary = (
<>
<span className={styles.viewLabel}>{columnLabel}</span>
<span className={styles.viewValue}>
{symbol} {formatPanelValue(threshold.value, threshold.unit)}
</span>
</>
);
return (
<ThresholdRowShell
index={index}
testIdPrefix="table-threshold"
color={threshold.color}
isEditing={isEditing}
summary={summary}
onEdit={onEdit}
onSave={(): void => onSave(draft)}
onDiscard={onDiscard}
onRemove={onRemove}
>
<ThresholdSelectField
label="Column"
testId={`table-threshold-column-${index}`}
placeholder="Select column"
value={draft.columnName || undefined}
items={columnItems}
onChange={(columnName): void => setDraft((d) => ({ ...d, columnName }))}
/>
<ThresholdSelectField
label="If value is"
testId={`table-threshold-operator-${index}`}
placeholder="Select condition"
value={draft.operator}
items={OPERATOR_OPTIONS}
onChange={(operator): void =>
setDraft((d) => ({
...d,
operator: operator as DashboardtypesComparisonOperatorDTO,
}))
}
/>
<ThresholdValueField
testId={`table-threshold-value-${index}`}
value={draft.value}
onChange={setValue}
/>
<ThresholdUnitField
testId={`table-threshold-unit-${index}`}
invalidTestId={`table-threshold-unit-invalid-${index}`}
value={draft.unit}
scopeUnit={columnUnit}
scopeLabel="column unit"
onChange={(unit): void => setDraft((d) => ({ ...d, unit }))}
/>
<ThresholdColorField
testId={`table-threshold-color-${index}`}
value={draft.color}
onChange={(color): void => setDraft((d) => ({ ...d, color }))}
/>
<ThresholdSelectField
label="Display"
testId={`table-threshold-format-${index}`}
placeholder="Select display"
value={draft.format}
items={FORMAT_OPTIONS}
onChange={(format): void =>
setDraft((d) => ({
...d,
format: format as DashboardtypesThresholdFormatDTO,
}))
}
/>
</ThresholdRowShell>
);
}
export default TableThresholdRow;

View File

@@ -0,0 +1,27 @@
import { Typography } from '@signozhq/ui/typography';
import ThresholdColorSelect from '../../ThresholdColorSelect';
import styles from '../../ThresholdsSection.module.scss';
interface ThresholdColorFieldProps {
testId: string;
value: string;
onChange: (hex: string) => void;
}
/** Labelled color picker, shared by every threshold variant. */
function ThresholdColorField({
testId,
value,
onChange,
}: ThresholdColorFieldProps): JSX.Element {
return (
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Color</Typography.Text>
<ThresholdColorSelect value={value} testId={testId} onChange={onChange} />
</div>
);
}
export default ThresholdColorField;

View File

@@ -0,0 +1,103 @@
import type { ReactNode } from 'react';
import { Check, Pencil, Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from '../../ThresholdsSection.module.scss';
interface ThresholdRowShellProps {
index: number;
/** testId prefix per variant: `threshold` | `comparison-threshold` | `table-threshold`. */
testIdPrefix: string;
/** Swatch color shown in view mode. */
color: string;
isEditing: boolean;
/** Compact view-mode summary, rendered between the color dot and the actions. */
summary: ReactNode;
/** Edit-mode fields. */
children: ReactNode;
onEdit: () => void;
onSave: () => void;
onDiscard: () => void;
onRemove: () => void;
}
/**
* Shared chrome for a threshold row's V1-style view/edit modes: the view summary with
* Edit/Delete, and the edit form's Discard/Save actions. Each variant supplies its own
* `summary` and field `children`; everything else (layout, buttons, testIds) is shared.
*/
function ThresholdRowShell({
index,
testIdPrefix,
color,
isEditing,
summary,
children,
onEdit,
onSave,
onDiscard,
onRemove,
}: ThresholdRowShellProps): JSX.Element {
if (!isEditing) {
return (
<div className={styles.viewRow}>
<span className={styles.dot} style={{ backgroundColor: color }} />
{summary}
<div className={styles.spacer} />
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
aria-label={`Edit threshold ${index + 1}`}
data-testid={`${testIdPrefix}-edit-${index}`}
onClick={onEdit}
>
<Pencil size={14} />
</Button>
<Button
type="button"
variant="ghost"
color="destructive"
size="icon"
aria-label={`Remove threshold ${index + 1}`}
data-testid={`${testIdPrefix}-remove-${index}`}
onClick={onRemove}
>
<Trash2 size={14} />
</Button>
</div>
);
}
return (
<div className={styles.editRow}>
{children}
<div className={styles.actions}>
<Button
type="button"
variant="outlined"
color="secondary"
prefix={<X size={14} />}
data-testid={`${testIdPrefix}-discard-${index}`}
onClick={onDiscard}
>
Discard
</Button>
<Button
type="button"
variant="solid"
color="primary"
prefix={<Check size={14} />}
data-testid={`${testIdPrefix}-save-${index}`}
onClick={onSave}
>
Save
</Button>
</div>
</div>
);
}
export default ThresholdRowShell;

View File

@@ -0,0 +1,44 @@
import { Typography } from '@signozhq/ui/typography';
import ConfigSelect, {
type ConfigSelectItem,
} from '../../../../controls/ConfigSelect/ConfigSelect';
import styles from '../../ThresholdsSection.module.scss';
interface ThresholdSelectFieldProps {
label: string;
testId: string;
placeholder?: string;
value: string | undefined;
items: ConfigSelectItem[];
onChange: (value: string) => void;
}
/**
* Labelled single-select, shared by the threshold variants' enum fields
* (operator / display format / column).
*/
function ThresholdSelectField({
label,
testId,
placeholder,
value,
items,
onChange,
}: ThresholdSelectFieldProps): JSX.Element {
return (
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>{label}</Typography.Text>
<ConfigSelect
testId={testId}
placeholder={placeholder}
value={value}
items={items}
onChange={onChange}
/>
</div>
);
}
export default ThresholdSelectField;

View File

@@ -0,0 +1,57 @@
import { Typography } from '@signozhq/ui/typography';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import {
isThresholdUnitIncompatible,
thresholdUnitCategories,
} from '../../thresholdUnitCategories';
import styles from '../../ThresholdsSection.module.scss';
interface ThresholdUnitFieldProps {
testId: string;
invalidTestId: string;
value: string | undefined;
/** Unit whose category scopes the picker (panel y-axis unit, or the column's unit). */
scopeUnit: string | undefined;
/** How the scope reads in the mismatch message, e.g. "y-axis unit" / "column unit". */
scopeLabel: string;
onChange: (unit: string) => void;
}
/**
* Labelled unit picker, scoped to `scopeUnit`'s category (V1 parity) and flagging a
* threshold unit that resolves to a different category. Shared by every variant; only
* the scope source and its wording differ.
*/
function ThresholdUnitField({
testId,
invalidTestId,
value,
scopeUnit,
scopeLabel,
onChange,
}: ThresholdUnitFieldProps): JSX.Element {
return (
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Unit</Typography.Text>
<YAxisUnitSelector
containerClassName={styles.unitSelector}
data-testid={testId}
placeholder="Select unit"
source={YAxisSource.DASHBOARDS}
categoriesOverride={thresholdUnitCategories(scopeUnit)}
value={value}
onChange={onChange}
/>
{isThresholdUnitIncompatible(value, scopeUnit) && (
<Typography.Text className={styles.invalidUnit} data-testid={invalidTestId}>
Threshold unit ({value}) is not valid with the {scopeLabel} ({scopeUnit})
</Typography.Text>
)}
</div>
);
}
export default ThresholdUnitField;

View File

@@ -0,0 +1,33 @@
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import styles from '../../ThresholdsSection.module.scss';
interface ThresholdValueFieldProps {
testId: string;
value: number;
/** Receives the raw input string; the draft hook parses it. */
onChange: (raw: string) => void;
}
/** Labelled numeric "Value" input, shared by every threshold variant. */
function ThresholdValueField({
testId,
value,
onChange,
}: ThresholdValueFieldProps): JSX.Element {
return (
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Value</Typography.Text>
<Input
data-testid={testId}
type="number"
placeholder="Value"
value={value}
onChange={(e): void => onChange(e.target.value)}
/>
</div>
);
}
export default ThresholdValueField;

View File

@@ -0,0 +1,34 @@
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
interface ThresholdDraft<T> {
draft: T;
setDraft: Dispatch<SetStateAction<T>>;
/** Parse a raw input string into `value`, ignoring transient non-numeric input. */
setValue: (raw: string) => void;
}
/**
* Local draft for a threshold row, shared by every variant. Snapshots the saved
* threshold on each entry into edit mode (so Discard simply drops the draft and the
* next edit starts clean) and exposes the numeric `value` setter all variants use.
*/
export function useThresholdDraft<T extends { value: number }>(
threshold: T,
isEditing: boolean,
): ThresholdDraft<T> {
const [draft, setDraft] = useState<T>(threshold);
useEffect(() => {
if (isEditing) {
setDraft(threshold);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- snapshot only on edit entry
}, [isEditing]);
const setValue = (raw: string): void => {
const next = Number(raw);
setDraft((d) => ({ ...d, value: Number.isNaN(next) ? d.value : next }));
};
return { draft, setDraft, setValue };
}

View File

@@ -0,0 +1,47 @@
import {
DashboardtypesComparisonOperatorDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ConfigSelectItem } from '../../controls/ConfigSelect/ConfigSelect';
// Comparison operators offered in the "If value is" condition picker. Labels pair a
// word with its math symbol so the dropdown reads clearly while the view row can show
// the compact symbol (OPERATOR_SYMBOL below).
export const OPERATOR_OPTIONS: ConfigSelectItem[] = [
{ value: DashboardtypesComparisonOperatorDTO.above, label: 'Above (>)' },
{
value: DashboardtypesComparisonOperatorDTO.above_or_equal,
label: 'Above or equal (≥)',
},
{ value: DashboardtypesComparisonOperatorDTO.below, label: 'Below (<)' },
{
value: DashboardtypesComparisonOperatorDTO.below_or_equal,
label: 'Below or equal (≤)',
},
{ value: DashboardtypesComparisonOperatorDTO.equal, label: 'Equal (=)' },
{
value: DashboardtypesComparisonOperatorDTO.not_equal,
label: 'Not equal (≠)',
},
];
// Compact symbol shown in the collapsed (view-mode) summary row.
export const OPERATOR_SYMBOL: Record<
DashboardtypesComparisonOperatorDTO,
string
> = {
[DashboardtypesComparisonOperatorDTO.above]: '>',
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '≥',
[DashboardtypesComparisonOperatorDTO.below]: '<',
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '≤',
[DashboardtypesComparisonOperatorDTO.equal]: '=',
[DashboardtypesComparisonOperatorDTO.not_equal]: '≠',
};
// How the threshold recolors the panel: just the number ("text") or the whole tile
// ("background").
export const FORMAT_OPTIONS: ConfigSelectItem[] = [
{ value: DashboardtypesThresholdFormatDTO.background, label: 'Background' },
{ value: DashboardtypesThresholdFormatDTO.text, label: 'Text' },
];

View File

@@ -0,0 +1,54 @@
import {
type YAxisCategory,
YAxisSource,
} from 'components/YAxisUnitSelector/types';
import {
getYAxisCategories,
mapMetricUnitToUniversalUnit,
} from 'components/YAxisUnitSelector/utils';
// The unit category (Time, Data, …) a unit belongs to, or undefined if unrecognized.
function categoryForUnit(unit: string): YAxisCategory | undefined {
const universal = mapMetricUnitToUniversalUnit(unit);
return getYAxisCategories(YAxisSource.DASHBOARDS).find((c) =>
c.units.some((u) => u.id === universal),
);
}
/**
* Restricts the threshold unit picker to the panel's y-axis unit family, mirroring V1:
* a threshold is only meaningfully comparable to the axis when it shares its category
* (e.g. an `ms` axis → only Time units). Returns the single matching category, or
* `undefined` (all categories) when the panel has no unit set or it can't be mapped.
*/
export function thresholdUnitCategories(
yAxisUnit: string | undefined,
): YAxisCategory[] | undefined {
if (!yAxisUnit) {
return undefined;
}
const category = categoryForUnit(yAxisUnit);
return category ? [category] : undefined;
}
/**
* True when a threshold's unit belongs to a different category than the panel's y-axis
* unit (so the values can't be compared) — drives the V1-style mismatch message. Only
* flags when both units are set and resolve to distinct categories (e.g. a stale `ms`
* threshold left over after the axis unit was changed to bytes).
*/
export function isThresholdUnitIncompatible(
thresholdUnit: string | undefined,
yAxisUnit: string | undefined,
): boolean {
if (!thresholdUnit || !yAxisUnit) {
return false;
}
const thresholdCategory = categoryForUnit(thresholdUnit);
const axisCategory = categoryForUnit(yAxisUnit);
return Boolean(
thresholdCategory &&
axisCategory &&
thresholdCategory.name !== axisCategory.name,
);
}

View File

@@ -0,0 +1,5 @@
.field {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -0,0 +1,67 @@
import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
import styles from './VisualizationSection.module.scss';
/**
* Edits the `visualization` slice: the per-panel time preference (all kinds), bar
* stacking (`stackedBarChart`, Bar only), and gap filling (`fillSpans`, TimeSeries
* only). Each control is gated by its `controls` flag, so a kind only renders — and only
* writes — the visualization fields its spec actually supports.
*/
function VisualizationSection({
value,
controls,
onChange,
}: SectionEditorProps<'visualization'>): JSX.Element {
return (
<>
{controls.timePreference && (
<div className={styles.field}>
<Typography.Text>Panel time preference</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-time-preference"
placeholder="Select time scope…"
value={value?.timePreference}
items={TIME_PREFERENCE_OPTIONS}
onChange={(next): void =>
onChange({
...value,
timePreference: next as DashboardtypesTimePreferenceDTO,
})
}
/>
</div>
)}
{controls.stacking && (
<ConfigSwitch
testId="panel-editor-v2-stacked-bar-chart"
title="Stack series"
description="Stack bars from all series on top of each other"
value={value?.stackedBarChart ?? false}
onChange={(checked): void =>
onChange({ ...value, stackedBarChart: checked })
}
/>
)}
{controls.fillSpans && (
<ConfigSwitch
testId="panel-editor-v2-fill-spans"
title="Fill gaps"
description="Fill gaps in data with 0 for continuity"
value={value?.fillSpans ?? false}
onChange={(checked): void => onChange({ ...value, fillSpans: checked })}
/>
)}
</>
);
}
export default VisualizationSection;

View File

@@ -0,0 +1,104 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
import VisualizationSection from '../VisualizationSection';
// Open the antd Select by clicking its selector, then pick the option by label.
async function pickOption(triggerTestId: string, label: string): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(triggerTestId);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
await user.click(await screen.findByRole('option', { name: label }));
}
describe('VisualizationSection', () => {
it('renders every control that is enabled', () => {
render(
<VisualizationSection
value={undefined}
controls={{ timePreference: true, stacking: true, fillSpans: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-time-preference'),
).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-stacked-bar-chart'),
).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-fill-spans')).toBeInTheDocument();
});
it('renders only the controls whose flag is set', () => {
render(
<VisualizationSection
value={undefined}
controls={{ timePreference: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-time-preference'),
).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-stacked-bar-chart'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-fill-spans'),
).not.toBeInTheDocument();
});
it('writes the chosen time preference through the dropdown', async () => {
const onChange = jest.fn();
render(
<VisualizationSection
value={undefined}
controls={{ timePreference: true }}
onChange={onChange}
/>,
);
await pickOption('panel-editor-v2-time-preference', 'Last 1 hr');
expect(onChange).toHaveBeenCalledWith({ timePreference: 'last_1_hr' });
});
it('toggles bar stacking through onChange, preserving other fields', () => {
const onChange = jest.fn();
render(
<VisualizationSection
value={{
timePreference: DashboardtypesTimePreferenceDTO.global_time,
stackedBarChart: false,
}}
controls={{ stacking: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByTestId('panel-editor-v2-stacked-bar-chart'));
expect(onChange).toHaveBeenCalledWith({
timePreference: 'global_time',
stackedBarChart: true,
});
});
it('toggles fill spans through onChange', () => {
const onChange = jest.fn();
render(
<VisualizationSection
value={{ fillSpans: false }}
controls={{ fillSpans: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByTestId('panel-editor-v2-fill-spans'));
expect(onChange).toHaveBeenCalledWith({ fillSpans: true });
});
});

View File

@@ -0,0 +1,18 @@
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
import type { ConfigSelectItem } from '../../controls/ConfigSelect/ConfigSelect';
// Per-panel time scope. "Global Time" follows the dashboard's time picker; the rest pin
// the panel to a fixed relative window regardless of the dashboard range (V1 parity).
export const TIME_PREFERENCE_OPTIONS: ConfigSelectItem[] = [
{ value: DashboardtypesTimePreferenceDTO.global_time, label: 'Global Time' },
{ value: DashboardtypesTimePreferenceDTO.last_5_min, label: 'Last 5 min' },
{ value: DashboardtypesTimePreferenceDTO.last_15_min, label: 'Last 15 min' },
{ value: DashboardtypesTimePreferenceDTO.last_30_min, label: 'Last 30 min' },
{ value: DashboardtypesTimePreferenceDTO.last_1_hr, label: 'Last 1 hr' },
{ value: DashboardtypesTimePreferenceDTO.last_6_hr, label: 'Last 6 hr' },
{ value: DashboardtypesTimePreferenceDTO.last_1_day, label: 'Last 1 day' },
{ value: DashboardtypesTimePreferenceDTO.last_3_days, label: 'Last 3 days' },
{ value: DashboardtypesTimePreferenceDTO.last_1_week, label: 'Last 1 week' },
{ value: DashboardtypesTimePreferenceDTO.last_1_month, label: 'Last 1 month' },
];

View File

@@ -0,0 +1,22 @@
.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

@@ -0,0 +1,86 @@
import { useCallback } 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 { useConfirmableAction } from 'hooks/useConfirmableAction';
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 discard = useConfirmableAction(
useCallback(async (): Promise<void> => onClose(), [onClose]),
);
// Confirm before closing with unsaved edits; a pristine panel closes straight away.
const handleCloseClick = useCallback((): void => {
if (isDirty) {
discard.request();
} else {
onClose();
}
}, [isDirty, onClose, discard]);
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={discard.open}
onOpenChange={(next): void => {
if (!next) {
discard.cancel();
}
}}
title="Discard changes?"
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
confirmText="Discard"
confirmColor="destructive"
cancelText="Keep editing"
onConfirm={discard.confirm}
onCancel={discard.cancel}
data-testid="panel-editor-v2-discard-modal"
>
<Typography>Your unsaved edits to this panel will be lost.</Typography>
</ConfirmDialog>
</div>
);
}
export default Header;

View File

@@ -0,0 +1,38 @@
.editor {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--l2-border);
background-color: var(--l1-background);
}
.header {
display: flex;
align-items: center;
}
.title {
font-size: 12px;
font-weight: 500;
color: var(--l2-foreground);
}
.body {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.hint {
font-size: 11px;
color: var(--l3-foreground);
}

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