Compare commits

..

1 Commits

Author SHA1 Message Date
srikanthccv
04ec681eda fix(alerts): surface individual validation errors in API response 2026-06-17 16:41:28 +05:30
149 changed files with 309 additions and 8599 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -1,99 +0,0 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
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 ?? [];
// 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}
/>
))}
</div>
</div>
</>
)}
</div>
);
}
export default ConfigPane;

View File

@@ -1,70 +0,0 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import {
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[];
}
/**
* 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,
}: 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, read, write } = 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?: { unit?: string } } | undefined
)?.formatting?.unit;
return (
<SettingsSection title={title} icon={<Icon size={15} />}>
<Component
value={read(spec)}
controls={controls}
onChange={(next): void => onChangeSpec(write(spec, next))}
legendSeries={legendSeries}
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
/>
</SettingsSection>
);
}
export default SectionSlot;

View File

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

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

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

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

View File

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

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

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

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

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

@@ -1,62 +0,0 @@
import { ColorPicker } from 'antd';
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
type="button"
className={styles.reset}
onClick={onReset}
data-testid={`legend-color-reset-${label}`}
>
Reset
</button>
)}
</div>
);
}
export default LegendColorRow;

View File

@@ -1,61 +0,0 @@
.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;
}
.reset {
flex: none;
padding: 0;
border: none;
background: transparent;
color: var(--bg-robin-400);
font-size: 12px;
cursor: pointer;
}
.empty {
font-size: 12px;
color: var(--text-vanilla-400);
}

View File

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

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

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

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

@@ -1,145 +0,0 @@
/**
* 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

@@ -1,169 +0,0 @@
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>>;
read: (spec: PanelSpec) => SectionSpecMap[K] | undefined;
write: (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 readPluginSlice<T>(spec: PanelSpec, key: string): T | undefined {
return (spec.plugin?.spec as PluginSpecSlice | undefined)?.[key] as
| T
| undefined;
}
function writePluginSlice(
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,
read: (spec): PanelFormattingSlice | undefined =>
readPluginSlice<PanelFormattingSlice>(spec, 'formatting'),
write: (spec, formatting): PanelSpec =>
writePluginSlice(spec, 'formatting', formatting),
},
axes: {
Component: AxesSection,
read: (spec): DashboardtypesAxesDTO | undefined =>
readPluginSlice<DashboardtypesAxesDTO>(spec, 'axes'),
write: (spec, axes): PanelSpec => writePluginSlice(spec, 'axes', axes),
},
legend: {
Component: LegendSection,
read: (spec): DashboardtypesLegendDTO | undefined =>
readPluginSlice<DashboardtypesLegendDTO>(spec, 'legend'),
write: (spec, legend): PanelSpec => writePluginSlice(spec, 'legend', legend),
},
chartAppearance: {
Component: ChartAppearanceSection,
read: (spec): DashboardtypesTimeSeriesChartAppearanceDTO | undefined =>
readPluginSlice<DashboardtypesTimeSeriesChartAppearanceDTO>(
spec,
'chartAppearance',
),
write: (spec, chartAppearance): PanelSpec =>
writePluginSlice(spec, 'chartAppearance', chartAppearance),
},
visualization: {
Component: VisualizationSection,
read: (spec): DashboardtypesBarChartVisualizationDTO | undefined =>
readPluginSlice<DashboardtypesBarChartVisualizationDTO>(
spec,
'visualization',
),
write: (spec, visualization): PanelSpec =>
writePluginSlice(spec, 'visualization', visualization),
},
buckets: {
Component: BucketsSection,
read: (spec): DashboardtypesHistogramBucketsDTO | undefined =>
readPluginSlice<DashboardtypesHistogramBucketsDTO>(spec, 'histogramBuckets'),
write: (spec, buckets): PanelSpec =>
writePluginSlice(spec, 'histogramBuckets', buckets),
},
contextLinks: {
Component: ContextLinksSection,
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.
read: (spec): DashboardLinkDTO[] | undefined => spec.links,
write: (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,
read: (spec): AnyThreshold[] | undefined =>
readPluginSlice<AnyThreshold[]>(spec, 'thresholds'),
write: (spec, thresholds): PanelSpec =>
writePluginSlice(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
* `read` → `Component` → `write` 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;
}>;
read: (spec: PanelSpec) => unknown;
write: (spec: PanelSpec, value: unknown) => PanelSpec;
}
export function resolveSectionEditor(
kind: SectionKind,
): ErasedSectionDescriptor | undefined {
return SECTION_REGISTRY[kind] as unknown as
| ErasedSectionDescriptor
| undefined;
}

View File

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

View File

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

@@ -1,83 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
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', () => {
const onChange = jest.fn();
render(
<AxesSection
value={undefined}
controls={{ minMax: true }}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-soft-min'), {
target: { value: '5' },
});
expect(onChange).toHaveBeenCalledWith({ softMin: 5 });
});
it('clears a soft bound to null when the field is emptied', () => {
const onChange = jest.fn();
render(
<AxesSection
value={{ softMax: 100 }}
controls={{ minMax: true }}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-soft-max'), {
target: { value: '' },
});
expect(onChange).toHaveBeenCalledWith({ softMax: null });
});
it('toggles the logarithmic scale through onChange', () => {
const onChange = jest.fn();
render(
<AxesSection
value={{ isLogScale: false }}
controls={{ logScale: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText('Log'));
expect(onChange).toHaveBeenCalledWith({ isLogScale: true });
});
});

View File

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

View File

@@ -1,75 +0,0 @@
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 ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import styles from './BucketsSection.module.scss';
type NumericBound = '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

@@ -1,68 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
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('writes a numeric bucket count and clears it to null when emptied', () => {
const onChange = jest.fn();
const { rerender } = render(
<BucketsSection
value={undefined}
controls={{ count: true }}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-bucket-count'), {
target: { value: '20' },
});
expect(onChange).toHaveBeenLastCalledWith({ bucketCount: 20 });
rerender(
<BucketsSection
value={{ bucketCount: 20 }}
controls={{ count: true }}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-bucket-count'), {
target: { value: '' },
});
expect(onChange).toHaveBeenLastCalledWith({ bucketCount: null });
});
it('toggles merge-active-queries through onChange', () => {
const onChange = jest.fn();
render(
<BucketsSection
value={{ mergeAllActiveQueries: false }}
controls={{ mergeQueries: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByTestId('panel-editor-v2-merge-queries'));
expect(onChange).toHaveBeenCalledWith({ mergeAllActiveQueries: true });
});
});

View File

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

@@ -1,198 +0,0 @@
import { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesComparisonThresholdDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
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', () => {
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
fireEvent.click(screen.getByTestId('comparison-threshold-edit-0'));
expect(screen.getByTestId('comparison-threshold-value-0')).toHaveValue(80);
fireEvent.change(screen.getByTestId('comparison-threshold-value-0'), {
target: { value: '90' },
});
fireEvent.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', () => {
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
fireEvent.click(screen.getByTestId('comparison-threshold-edit-0'));
fireEvent.change(screen.getByTestId('comparison-threshold-value-0'), {
target: { value: '90' },
});
fireEvent.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', () => {
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
fireEvent.click(screen.getByTestId('comparison-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-comparison-threshold'),
);
// New row opens in edit mode.
expect(
screen.getByTestId('comparison-threshold-value-0'),
).toBeInTheDocument();
fireEvent.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', () => {
render(
<ComparisonThresholdsSection
value={[
{
value: 80,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.above,
unit: 'ms',
},
]}
yAxisUnit="bytes"
onChange={jest.fn()}
/>,
);
fireEvent.click(screen.getByTestId('comparison-threshold-edit-0'));
expect(
screen.getByTestId('comparison-threshold-unit-invalid-0'),
).toBeInTheDocument();
});
});

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,79 +0,0 @@
import { Spin } from 'antd';
import { Loader, Spline } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { EQueryType } from 'types/common/dashboard';
import styles from './PreviewPane.module.scss';
interface PreviewPaneProps {
panelId: string;
panel: DashboardtypesPanelDTO;
/** Resolved definition for the panel kind; undefined when the kind is unsupported. */
panelDef: RenderablePanelDefinition | undefined;
data: PanelQueryData;
isLoading: boolean;
error: Error | null;
/** Drag-to-zoom on a time-axis chart → updates the (URL-synced) time window. */
onDragSelect: (start: number, end: number) => void;
}
/**
* Live preview for the panel editor. Presentational: the draft panel renders through the
* same registry the dashboard grid uses (`panelDef.Renderer`), so the preview is the
* production renderer — only `panelMode` differs (DASHBOARD_EDIT). The query result is
* owned by the editor root (`usePanelQuery`) and passed in, so the same result is shared
* with the config pane.
*/
function PreviewPane({
panelId,
panel,
panelDef,
data,
isLoading,
error,
onDragSelect,
}: PreviewPaneProps): JSX.Element {
return (
<div className={styles.preview}>
<div className={styles.header}>
<div className={styles.queryType}>
<Spline size={14} />
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
</div>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<div className={styles.container}>
<div className={styles.surface}>
{/* eslint-disable-next-line no-nested-ternary -- 3-way branch on render state */}
{!panelDef ? (
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
This panel type is not yet supported in V2.
</div>
) : isLoading && !data.response ? (
<div className={styles.state} data-testid="panel-editor-v2-loading">
<Spin indicator={<Loader size={14} className="animate-spin" />} />
</div>
) : (
<panelDef.Renderer
panelId={panelId}
panel={panel}
data={data}
isLoading={isLoading}
error={error}
panelMode={PanelMode.DASHBOARD_EDIT}
enableDrillDown={false}
onDragSelect={onDragSelect}
/>
)}
</div>
</div>
</div>
);
}
export default PreviewPane;

View File

@@ -1,102 +0,0 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { flattenTimeSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { useLegendSeries } from '../useLegendSeries';
jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: jest.fn() }));
jest.mock('lib/getLabelName', () => jest.fn(() => 'base'));
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
generateColor: jest.fn((label: string) => `color:${label}`),
}));
jest.mock('constants/theme', () => ({
themeColors: { chartcolors: ['dark'], lightModeColor: ['light'] },
}));
jest.mock(
'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries',
() => ({ getBuilderQueries: jest.fn(() => []) }),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel',
() => ({ resolveSeriesLabelV5: jest.fn() }),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData',
() => ({
flattenTimeSeries: jest.fn(),
getTimeSeriesResults: jest.fn(() => []),
}),
);
const mockUseIsDarkMode = useIsDarkMode as unknown as jest.Mock;
const mockFlatten = flattenTimeSeries as unknown as jest.Mock;
const mockResolveLabel = resolveSeriesLabelV5 as unknown as jest.Mock;
const mockGenerateColor = generateColor as unknown as jest.Mock;
const PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
// Each flattened series carries the label resolveSeriesLabelV5 should report.
function seriesWithLabels(labels: string[]): { __label: string }[] {
return labels.map((__label) => ({ __label }));
}
describe('useLegendSeries', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsDarkMode.mockReturnValue(true);
mockResolveLabel.mockImplementation((s: { __label: string }) => s.__label);
});
it('is empty when there are no series', () => {
mockFlatten.mockReturnValue([]);
const { result } = renderHook(() => useLegendSeries(PANEL, DATA));
expect(result.current).toStrictEqual([]);
});
it('maps each series to a { label, defaultColor } pair', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a', 'b']));
const { result } = renderHook(() => useLegendSeries(PANEL, DATA));
expect(result.current).toStrictEqual([
{ label: 'a', defaultColor: 'color:a' },
{ label: 'b', defaultColor: 'color:b' },
]);
});
it('dedupes by label, keeping first-seen order', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a', 'b', 'a', 'c']));
const { result } = renderHook(() => useLegendSeries(PANEL, DATA));
expect(result.current.map((s) => s.label)).toStrictEqual(['a', 'b', 'c']);
// The duplicate 'a' must not generate a second color.
expect(
mockGenerateColor.mock.calls.filter(([label]) => label === 'a'),
).toHaveLength(1);
});
it('skips series that resolve to an empty label', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['', 'a', '']));
const { result } = renderHook(() => useLegendSeries(PANEL, DATA));
expect(result.current).toStrictEqual([
{ label: 'a', defaultColor: 'color:a' },
]);
});
it('uses the dark palette in dark mode and the light palette otherwise', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a']));
const dark = renderHook(() => useLegendSeries(PANEL, DATA));
expect(mockGenerateColor).toHaveBeenLastCalledWith('a', ['dark']);
dark.unmount();
mockUseIsDarkMode.mockReturnValue(false);
renderHook(() => useLegendSeries(PANEL, DATA));
expect(mockGenerateColor).toHaveBeenLastCalledWith('a', ['light']);
});
});

View File

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

View File

@@ -1,214 +0,0 @@
import { renderHook } from '@testing-library/react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getIsQueryModified } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { fromPerses, toPerses } from '../../../queryV5/persesQueryAdapters';
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
useShareBuilderUrl: jest.fn(),
}));
jest.mock('container/NewWidget/utils', () => ({
getIsQueryModified: jest.fn(),
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
fromPerses: jest.fn(),
toPerses: jest.fn(),
}));
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockUseShareBuilderUrl = useShareBuilderUrl as unknown as jest.Mock;
const mockGetIsQueryModified = getIsQueryModified as unknown as jest.Mock;
const mockFromPerses = fromPerses as unknown as jest.Mock;
const mockToPerses = toPerses as unknown as jest.Mock;
// Opaque fixtures — the adapters are mocked, so only identity matters here.
const SAVED_QUERIES = [{ id: 'saved' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const CONVERTED_QUERIES = [{ id: 'converted' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const SEED_V1 = { id: 'seed', queryType: 'builder' } as unknown as Query;
const STAGED_V1 = { id: 'staged', queryType: 'builder' } as unknown as Query;
function makeDraft(
queries = SAVED_QUERIES,
kind = 'signoz/TimeSeriesPanel',
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'Panel' },
plugin: { kind, spec: {} },
queries,
},
} as unknown as DashboardtypesPanelDTO;
}
function builderState(
overrides: Partial<{
currentQuery: Query;
stagedQuery: Query | null;
handleRunQuery: jest.Mock;
}> = {},
): {
currentQuery: Query;
stagedQuery: Query | null;
handleRunQuery: jest.Mock;
} {
return {
currentQuery: { id: 'current', queryType: 'builder' } as unknown as Query,
stagedQuery: STAGED_V1,
handleRunQuery: jest.fn(),
...overrides,
};
}
describe('usePanelEditorQuerySync', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFromPerses.mockReturnValue(SEED_V1);
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
mockGetIsQueryModified.mockReturnValue(false);
mockUseQueryBuilder.mockReturnValue(builderState());
});
function setup(
opts: {
draft?: DashboardtypesPanelDTO;
setSpec?: jest.Mock;
refetch?: jest.Mock;
} = {},
): {
result: { current: { runQuery: () => void } };
setSpec: jest.Mock;
refetch: jest.Mock;
rerender: () => void;
} {
const setSpec = opts.setSpec ?? jest.fn();
const refetch = opts.refetch ?? jest.fn();
const draft = opts.draft ?? makeDraft();
const { result, rerender } = renderHook(() =>
usePanelEditorQuerySync({
draft,
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
refetch,
}),
);
return { result, setSpec, refetch, rerender };
}
it('seeds the builder from the saved queries via the URL', () => {
setup();
expect(mockFromPerses).toHaveBeenCalledWith(
SAVED_QUERIES,
PANEL_TYPES.TIME_SERIES,
);
expect(mockUseShareBuilderUrl).toHaveBeenCalledWith({
defaultValue: SEED_V1,
});
});
it('does not touch the draft on mount for an unedited panel', () => {
const { setSpec, refetch } = setup();
// Mount runs the type-change effect once; an unedited query must no-op.
expect(setSpec).not.toHaveBeenCalled();
expect(refetch).not.toHaveBeenCalled();
});
it('compares the live query against the provider baseline (first stagedQuery)', () => {
const currentQuery = { id: 'current', queryType: 'builder' } as Query;
mockUseQueryBuilder.mockReturnValue(builderState({ currentQuery }));
const { result } = setup();
result.current.runQuery();
expect(mockGetIsQueryModified).toHaveBeenCalledWith(currentQuery, STAGED_V1);
});
describe('runQuery', () => {
it('stages the query (handleRunQuery)', () => {
const handleRunQuery = jest.fn();
mockUseQueryBuilder.mockReturnValue(builderState({ handleRunQuery }));
const { result } = setup();
result.current.runQuery();
expect(handleRunQuery).toHaveBeenCalledTimes(1);
});
it('commits a modified query into the draft and does not force a refetch', () => {
mockGetIsQueryModified.mockReturnValue(true);
const { result, setSpec, refetch } = setup();
result.current.runQuery();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
expect(refetch).not.toHaveBeenCalled();
});
it('forces a refetch and leaves the draft alone when the query is unchanged', () => {
mockGetIsQueryModified.mockReturnValue(false);
const { result, setSpec, refetch } = setup();
result.current.runQuery();
expect(setSpec).not.toHaveBeenCalled();
expect(refetch).toHaveBeenCalledTimes(1);
});
});
describe('query-type switch', () => {
it('commits the active query when the query type changes', () => {
const state = builderState({
currentQuery: { id: 'a', queryType: 'builder' } as Query,
});
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Switch query type → the effect should commit.
state.currentQuery = { id: 'b', queryType: 'promql' } as Query;
rerender();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
});
it('does not commit when the active query type is unchanged', () => {
const state = builderState({
currentQuery: { id: 'a', queryType: 'builder' } as Query,
});
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Same query type, different object → effect must not re-fire.
state.currentQuery = { id: 'b', queryType: 'builder' } as Query;
rerender();
expect(setSpec).not.toHaveBeenCalled();
});
});
});

View File

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

View File

@@ -1,107 +0,0 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { useTableColumns } from '../useTableColumns';
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables',
() => ({ prepareScalarTables: jest.fn() }),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData',
() => ({ getScalarResults: jest.fn(() => []) }),
);
const mockPrepareScalarTables = prepareScalarTables as unknown as jest.Mock;
const DATA = {
response: undefined,
legendMap: {},
requestPayload: undefined,
} as unknown as PanelQueryData;
function tablePanel(
columnUnits: Record<string, string> = {},
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'T' },
plugin: { kind: 'signoz/TablePanel', spec: { formatting: { columnUnits } } },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
function tableWith(columns: unknown[]): void {
mockPrepareScalarTables.mockReturnValue([{ columns, rows: [] }]);
}
describe('useTableColumns', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns [] for a non-table panel kind', () => {
const panel = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const { result } = renderHook(() => useTableColumns(panel, DATA));
expect(result.current).toStrictEqual([]);
expect(mockPrepareScalarTables).not.toHaveBeenCalled();
});
it('returns [] when there is no scalar table with columns', () => {
mockPrepareScalarTables.mockReturnValue([{ columns: [], rows: [] }]);
const { result } = renderHook(() => useTableColumns(tablePanel(), DATA));
expect(result.current).toStrictEqual([]);
});
it('keeps only value columns and maps them to key/label', () => {
tableWith([
{ id: 'service.name', name: 'service.name', isValueColumn: false },
{ id: 'A', name: 'p99', isValueColumn: true },
]);
const { result } = renderHook(() => useTableColumns(tablePanel(), DATA));
expect(result.current).toStrictEqual([
{ key: 'A', label: 'p99', unit: undefined },
]);
});
it('falls back to the column name when the column has no id', () => {
tableWith([{ name: 'count', isValueColumn: true }]);
const { result } = renderHook(() => useTableColumns(tablePanel(), DATA));
expect(result.current[0].key).toBe('count');
});
it('resolves a column unit by its key', () => {
tableWith([{ id: 'A', name: 'p99', isValueColumn: true }]);
const { result } = renderHook(() =>
useTableColumns(tablePanel({ A: 'ms' }), DATA),
);
expect(result.current[0].unit).toBe('ms');
});
it('falls back to the base query name for a multi-aggregation column key', () => {
tableWith([{ id: 'A.p99', name: 'p99', isValueColumn: true }]);
const { result } = renderHook(() =>
useTableColumns(tablePanel({ A: 'bytes' }), DATA),
);
expect(result.current[0].unit).toBe('bytes');
});
});

View File

@@ -1,59 +0,0 @@
import { useMemo } from 'react';
import { themeColors } from 'constants/theme';
import { useIsDarkMode } from 'hooks/useDarkMode';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import {
flattenTimeSeries,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
export interface LegendSeries {
/** Resolved display label — the key `legend.customColors` is indexed by. */
label: string;
/** The series' auto-assigned color, shown when no override is set. */
defaultColor: string;
}
/**
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs, using the
* exact label resolution the time-series renderer applies (`flattenTimeSeries` →
* `resolveSeriesLabelV5`) and the same `generateColor` default. The legend-colors control
* keys overrides by these labels, so they must match what the chart draws. Deduplicated,
* order-preserving; empty until data arrives or for kinds without flat time-series data.
*/
export function useLegendSeries(
panel: DashboardtypesPanelDTO,
data: PanelQueryData,
): LegendSeries[] {
const isDarkMode = useIsDarkMode();
return useMemo(() => {
const palette = isDarkMode
? themeColors.chartcolors
: themeColors.lightModeColor;
const series = flattenTimeSeries(
getTimeSeriesResults(data?.response),
data.legendMap,
);
const builderQueries = getBuilderQueries(panel?.spec?.queries);
const byLabel = new Map<string, string>();
series.forEach((s) => {
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
if (label && !byLabel.has(label)) {
byLabel.set(label, generateColor(label, palette));
}
});
return Array.from(byLabel, ([label, defaultColor]) => ({
label,
defaultColor,
}));
}, [panel.spec.queries, data.response, data.legendMap, isDarkMode]);
}

View File

@@ -1,48 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { isEqual } from 'lodash-es';
import type { PanelEditorDraftApi } from '../types';
/**
* Owns the editable draft of a single panel. Seeded once from the loaded panel
* (`useState` initializer), then mutated locally until the user saves. Keeping
* the draft in the perses `DashboardtypesPanelDTO` shape lets the preview pane
* render it through the same renderer registry the dashboard uses, and lets the
* save hook patch it without any conversion.
*
* Everything the config pane edits — title/description, the per-kind plugin spec
* (formatting, axes, …), legend colors, context links — flows through the single
* `spec`/`setSpec` pair (the ConfigPane registry lens), so there is one editing path.
*/
export function usePanelEditorDraft(
initialPanel: DashboardtypesPanelDTO,
): PanelEditorDraftApi {
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
const setSpec = useCallback((next: DashboardtypesPanelSpecDTO): void => {
setDraft((prev) => ({ ...prev, spec: next }));
}, []);
const reset = useCallback((): void => {
setDraft(initialPanel);
}, [initialPanel]);
// Deep compare: any divergence from the loaded panel (display OR spec slices like
// formatting/axes/thresholds/links) marks the draft dirty.
const isDirty = useMemo(
() => !isEqual(draft, initialPanel),
[draft, initialPanel],
);
return {
draft,
spec: draft.spec,
setSpec,
isDirty,
reset,
};
}

View File

@@ -1,101 +0,0 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getIsQueryModified } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { isEqual } from 'lodash-es';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { fromPerses, toPerses } from '../../queryV5/persesQueryAdapters';
interface UsePanelEditorQuerySyncArgs {
draft: DashboardtypesPanelDTO;
panelType: PANEL_TYPES;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Re-fetch the preview when the query is unchanged. */
refetch: () => void;
}
interface UsePanelEditorQuerySyncApi {
/** Run the current query (Stage & Run / ⌘↵). */
runQuery: () => void;
}
/**
* Connects the shared query builder (global `QueryBuilderProvider`, URL-synced)
* to the V2 editor draft: seeds the builder from the panel on mount, and commits
* the active query into `draft.spec.queries` (what the preview fetches) on a
* query-type switch or Stage & Run.
*/
export function usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch,
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
// Saved queries verbatim + their V1 form used to seed the builder. Captured
// once — the draft itself is seeded by usePanelEditorDraft.
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only
const savedQueries = useMemo(() => draft.spec?.queries ?? [], []);
const seedQuery = useMemo(
() => fromPerses(savedQueries, panelType),
[savedQueries, panelType],
);
useShareBuilderUrl({ defaultValue: seedQuery });
// Change-detection baseline: the provider's normalized form of the saved
// query, published as the first `stagedQuery` on this route (cleared on route
// change, so never stale). Comparing against this — not the raw `fromPerses`
// seed — stops provider normalization from reading as an edit.
const baselineRef = useRef<Query | null>(null);
if (!baselineRef.current && stagedQuery) {
baselineRef.current = stagedQuery;
}
// Write `query` into the draft, or restore the saved queries when it isn't a
// genuine edit (so opening / re-running doesn't dirty the draft). Returns
// whether the draft changed.
const commitQuery = useCallback(
(query: Query): boolean => {
const baseline = baselineRef.current;
const next =
baseline && getIsQueryModified(query, baseline)
? toPerses(query, panelType)
: savedQueries;
if (isEqual(next, draft.spec?.queries ?? [])) {
return false;
}
setSpec({ ...draft.spec, queries: next });
return true;
},
[panelType, savedQueries, draft.spec, setSpec],
);
// Commit on a query-type switch so the preview matches the selected tab. Refs
// read the latest query/commit while the effect fires only on a type change.
const commitRef = useRef(commitQuery);
commitRef.current = commitQuery;
const queryRef = useRef(currentQuery);
queryRef.current = currentQuery;
useEffect(() => {
commitRef.current(queryRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps -- type change only
}, [currentQuery.queryType]);
// Stage & Run / ⌘↵: stage (V1 URL + step-interval semantics), commit, and
// re-fetch when unchanged so the same query can be re-run.
const runQuery = useCallback((): void => {
handleRunQuery();
if (!commitQuery(currentQuery)) {
refetch();
}
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
return { runQuery };
}

View File

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

View File

@@ -1,88 +0,0 @@
import { useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
export interface TableColumnOption {
/**
* Key the column's unit / threshold is stored under — the query identifier
* (`queryName`, or `queryName.expression` for multi-aggregation queries). Matches
* `PanelTableColumn.id` and the rendered column's `dataIndex` (V1 parity: column
* units and thresholds are keyed by the query, not the display name).
*/
key: string;
/** Display label shown in the editor — the resolved column name. */
label: string;
/**
* The column's configured unit (`formatting.columnUnits[key]`), if any. The
* per-column threshold editor scopes its unit picker to this unit's category
* (V1 parity), since Table panels have no single panel-wide unit.
*/
unit?: string;
}
// Resolve a column's unit by its key, falling back to the base query name (the legacy
// `queryName.expression` → `queryName` syntax) — mirrors the renderer's getColumnUnit.
function resolveColumnUnit(
key: string,
columnUnits: Record<string, string>,
): string | undefined {
if (columnUnits[key]) {
return columnUnits[key];
}
if (key.includes('.')) {
const baseQuery = key.split('.')[0];
if (columnUnits[baseQuery]) {
return columnUnits[baseQuery];
}
}
return undefined;
}
/**
* Resolves a Table panel's value (aggregation) columns into `{ key, label }`
* options, so the table-only config editors (column units, per-column thresholds)
* store the query-keyed value the renderer looks up by while showing the readable
* column name. Empty for non-table kinds or before data arrives.
*/
export function useTableColumns(
panel: DashboardtypesPanelDTO,
data: PanelQueryData,
): TableColumnOption[] {
return useMemo(() => {
if (panel?.spec?.plugin?.kind !== 'signoz/TablePanel') {
return [];
}
const table = prepareScalarTables({
results: getScalarResults(data?.response),
legendMap: data?.legendMap ?? {},
requestPayload: data?.requestPayload,
}).find((candidate) => candidate.columns.length > 0);
if (!table) {
return [];
}
const columnUnits =
(
panel?.spec?.plugin?.spec as
| { formatting?: { columnUnits?: Record<string, string> | null } }
| undefined
)?.formatting?.columnUnits ?? {};
return table.columns
.filter((column) => column.isValueColumn)
.map((column) => {
const key = column.id || column.name;
return {
key,
label: column.name,
unit: resolveColumnUnit(key, columnUnits),
};
});
}, [
panel.spec.plugin.kind,
panel.spec.plugin.spec,
data?.response,
data.legendMap,
data?.requestPayload,
]);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,125 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Table } from 'antd';
import type { DashboardtypesTablePanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { useResizeObserver } from 'hooks/useDimensions';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
import NoData from '../../components/NoData/NoData';
import { buildTableColumns, mapTableThresholds } from './tableColumns';
import { computeTableLayout, filterTableRows } from './utils';
import styles from './TablePanel.module.scss';
type TableRowData = Record<string, unknown> & { key: number };
function TablePanelRenderer({
panel,
data,
searchTerm = '',
}: PanelRendererProps<'signoz/TablePanel'>): JSX.Element {
// Measure the panel so each page roughly fills it (min 10 rows) and the
// header stays pinned while the body scrolls.
const containerRef = useRef<HTMLDivElement>(null);
const { height } = useResizeObserver(containerRef);
const { pageSize, scrollY } = useMemo(
() => computeTableLayout(height),
[height],
);
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/TablePanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
const spec = useMemo<DashboardtypesTablePanelSpecDTO>(
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesTablePanelSpecDTO,
[panel.spec.plugin.spec],
);
// V5 joins every query into a single scalar result, so the first non-empty
// table is the whole panel.
const table = useMemo(
() =>
prepareScalarTables({
results: getScalarResults(data?.response),
legendMap: data.legendMap ?? {},
requestPayload: data.requestPayload,
}).find((candidate) => candidate.columns.length > 0),
[data.response, data.legendMap, data.requestPayload],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const thresholdsByColumn = useMemo(
() => mapTableThresholds(spec.thresholds),
[spec.thresholds],
);
const columns = useMemo(
() =>
table
? buildTableColumns({
table,
columnUnits: spec.formatting?.columnUnits ?? {},
decimalPrecision,
thresholdsByColumn,
})
: [],
[table, spec.formatting?.columnUnits, decimalPrecision, thresholdsByColumn],
);
const dataSource = useMemo<TableRowData[]>(
() =>
table ? table.rows.map((row, index) => ({ key: index, ...row.data })) : [],
[table],
);
// Header search filters rows client-side (V1 parity). Falls back to the full
// set when the term is empty, so non-searching tables pay nothing.
const filteredDataSource = useMemo(
() => filterTableRows(dataSource, searchTerm),
[dataSource, searchTerm],
);
// Keep pagination in range as the filtered set shrinks: a new term snaps back
// to the first page so the user never lands on a now-empty page.
const [page, setPage] = useState(1);
useEffect(() => setPage(1), [searchTerm]);
return (
<div
ref={containerRef}
data-testid="table-panel-renderer"
className={PanelStyles.panelContainer}
>
{!table || dataSource.length === 0 ? (
<NoData />
) : (
<div className={styles.container}>
<Table
size="small"
columns={columns}
dataSource={filteredDataSource}
pagination={{
current: page,
pageSize,
hideOnSinglePage: true,
size: 'small',
onChange: setPage,
}}
scroll={{ x: 'max-content', y: scrollY }}
/>
</div>
)}
</div>
);
}
export default TablePanelRenderer;

View File

@@ -1,20 +0,0 @@
@use '../../../../../../styles/scrollbar' as *;
// Scroll the table within the panel rather than letting it grow the card.
.container {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
// Match the chart legend scrollbar (shared mixin).
@include custom-scrollbar;
// antd renders its own scroll containers when `scroll={{ x, y }}` is set —
// the pinned-header body (vertical) and the content (horizontal). Match them
// to the legend scrollbar too.
:global(.ant-table-body),
:global(.ant-table-content) {
@include custom-scrollbar;
}
}

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