Compare commits

...

5 Commits

Author SHA1 Message Date
Abhi Kumar
17c0dce718 fix(dashboard): apply pie multi-column scalar fix to v2 panels
Mirror the V1 pie fix in the V2 PieChartPanel. preparePieData already reads
the scalar table (via prepareScalarTables) but used
columns.find(isValueColumn), so only the first value column was plotted — a
ClickHouse `count() AS col1, sum() AS col2` collapsed to a single slice.

It now emits one slice per (row × value column); with multiple value columns
the column name distinguishes the slices (prefixed by the group when
grouped). Single-value and grouped panels are unchanged — a single value
column iterates exactly once.

Per the V1/V2 split, this duplicates the behaviour into V2 land rather than
sharing the V1 helper.
2026-07-01 01:20:56 +05:30
Abhi Kumar
05775a8f44 feat(dashboards-v2): reactive threshold editing with live preview
Threshold edits now stream to the spec as the user types, so the panel preview
reflects them before Save. The per-row draft is mirrored into the spec via an
onLiveChange effect in useThresholdDraft; because edits reach the spec live,
ThresholdsSection snapshots the saved value on edit entry and restores it on
Discard. Save keeps the value, Discard rolls back, and add-then-discard still
removes the row.
2026-06-30 22:58:27 +05:30
Abhi Kumar
bd64ef6ad2 feat(dashboards-v2): auto-seed panel unit from the metric with a mismatch warning
V1 parity for the formatting unit selector: when the selected metric carries a
unit, a new panel auto-initializes its unit from it, and choosing a different
unit shows the "Unit mismatch" warning. Reuses the shared useGetYAxisUnit hook
and YAxisUnitSelector read-only.

The resolution + auto-seed runs at the editor level (not inside the collapsible
FormattingSection) so it applies even while that section is closed; the
resolved metric unit is threaded down via context purely to drive the warning.
Seeding is gated to new panels — editing never overwrites a saved unit.
2026-06-30 22:58:27 +05:30
Abhi Kumar
4f09c2834d fix(dashboards-v2): span-gaps Disconnect Values reactivity + fillOnlyBelow flag
Fixes three issues in the chart-appearance span-gaps control and makes the
fillOnlyBelow flag authoritative end-to-end:

- Threshold default now seeds from the live query step interval (which arrives
  async) instead of being captured once at mount, so it no longer falls back
  to 1m.
- The threshold input no longer re-commits an unchanged value on blur, so
  clicking "Never" reliably switches mode (the blur/toggle race is gone).
- Invalid input is validated live as the user types, surfacing the error
  immediately rather than only on blur.
- Selecting Threshold writes fillOnlyBelow: true (+ duration); Never writes
  fillOnlyBelow: false and drops the duration. The selected mode is derived
  from fillOnlyBelow, and the renderer honors it (explicit false spans every
  gap), with back-compat for panels saved before the flag existed.
2026-06-30 19:51:12 +05:30
Abhi Kumar
dcbc21b1b9 feat(dashboards-v2): carry thresholds across a panel visualization-type switch
When switching a panel's visualization kind mid-edit, thresholds now carry
over if the target kind supports them, remapped to the target's variant
(label/comparison/table) — keeping the shared core (color, value, unit) and
seeding any variant-required fields (operator, format, column) with sensible
defaults so the carried threshold stays functional. Kinds without a Thresholds
section drop them. The carry is a first-visit seed; reversible round-trips
still restore from the per-kind session cache.
2026-06-30 19:50:21 +05:30
28 changed files with 999 additions and 136 deletions

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { SolidAlertTriangle } from '@signozhq/icons';
import { Select, Tooltip } from 'antd';
import type { DefaultOptionType } from 'antd/es/select';
import classNames from 'classnames';
import cx from 'classnames';
import { UniversalYAxisUnitMappings } from './constants';
import { UniversalYAxisUnit, YAxisUnitSelectorProps } from './types';
@@ -72,9 +72,7 @@ function YAxisUnitSelector({
}, [categoriesOverride, source]);
return (
<div
className={classNames('y-axis-unit-selector-component', containerClassName)}
>
<div className={cx('y-axis-unit-selector-component', containerClassName)}>
<Select
showSearch
value={universalUnit}
@@ -84,12 +82,17 @@ function YAxisUnitSelector({
loading={loading}
suffixIcon={
incompatibleUnitMessage ? (
<Tooltip title={incompatibleUnitMessage}>
<SolidAlertTriangle role="img" aria-label="warning" size="md" />
<Tooltip
title={incompatibleUnitMessage}
overlayClassName="y-axis-unit-warning-tooltip"
>
<span className="y-axis-unit-warning" role="img" aria-label="warning">
<SolidAlertTriangle size="md" />
</span>
</Tooltip>
) : undefined
}
className={classNames({
className={cx({
'warning-state': incompatibleUnitMessage,
})}
data-testid={dataTestId}

View File

@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { YAxisCategoryNames } from '../constants';
import { UniversalYAxisUnit, YAxisSource } from '../types';
@@ -6,9 +7,13 @@ import YAxisUnitSelector from '../YAxisUnitSelector';
describe('YAxisUnitSelector', () => {
const mockOnChange = jest.fn();
// antd injects its `pointer-events` styles via cssinjs in jsdom, but the SCSS
// overrides aren't loaded — skip the pointer-events check so hovers/clicks register.
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
mockOnChange.mockClear();
user = userEvent.setup({ pointerEventsCheck: 0 });
});
it('renders with default placeholder', () => {
@@ -34,7 +39,7 @@ describe('YAxisUnitSelector', () => {
expect(screen.queryByText('Custom placeholder')).toBeInTheDocument();
});
it('calls onChange when a value is selected', () => {
it('calls onChange when a value is selected', async () => {
render(
<YAxisUnitSelector
value=""
@@ -44,9 +49,8 @@ describe('YAxisUnitSelector', () => {
);
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
const option = screen.getByText('Bytes (B)');
fireEvent.click(option);
await user.click(select);
await user.click(screen.getByText('Bytes (B)'));
expect(mockOnChange).toHaveBeenCalledWith('By', {
children: 'Bytes (B)',
@@ -55,7 +59,7 @@ describe('YAxisUnitSelector', () => {
});
});
it('filters options based on search input', () => {
it('filters options based on search input', async () => {
render(
<YAxisUnitSelector
value=""
@@ -65,14 +69,13 @@ describe('YAxisUnitSelector', () => {
);
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'bytes/sec' } });
await user.click(select);
await user.type(select, 'bytes/sec');
expect(screen.getByText('Bytes/sec')).toBeInTheDocument();
});
it('shows all categories and their units', () => {
it('shows all categories and their units', async () => {
render(
<YAxisUnitSelector
value=""
@@ -80,9 +83,8 @@ describe('YAxisUnitSelector', () => {
source={YAxisSource.ALERTS}
/>,
);
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
await user.click(screen.getByRole('combobox'));
// Check for category headers
expect(screen.getByText('Data')).toBeInTheDocument();
@@ -93,7 +95,7 @@ describe('YAxisUnitSelector', () => {
expect(screen.getByText('Seconds (s)')).toBeInTheDocument();
});
it('shows warning message when incompatible unit is selected', () => {
it('shows warning message when incompatible unit is selected', async () => {
render(
<YAxisUnitSelector
source={YAxisSource.ALERTS}
@@ -104,12 +106,12 @@ describe('YAxisUnitSelector', () => {
);
const warningIcon = screen.getByLabelText('warning');
expect(warningIcon).toBeInTheDocument();
fireEvent.mouseOver(warningIcon);
return screen
.findByText(
await user.hover(warningIcon);
await expect(
screen.findByText(
'Unit mismatch. The metric was sent with unit Seconds (s), but Bytes (B) is selected.',
)
.then((el) => expect(el).toBeInTheDocument());
),
).resolves.toBeInTheDocument();
});
it('does not show warning message when compatible unit is selected', () => {
@@ -125,7 +127,7 @@ describe('YAxisUnitSelector', () => {
expect(warningIcon).not.toBeInTheDocument();
});
it('uses categories override to render custom units', () => {
it('uses categories override to render custom units', async () => {
const customCategories = [
{
name: YAxisCategoryNames.Data,
@@ -147,9 +149,7 @@ describe('YAxisUnitSelector', () => {
/>,
);
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
await user.click(screen.getByRole('combobox'));
expect(screen.getByText('Custom Bytes (B)')).toBeInTheDocument();
expect(screen.queryByText('Bytes (B)')).not.toBeInTheDocument();

View File

@@ -4,6 +4,13 @@
}
}
// Re-enable hover on the warning icon: its `.ant-select-arrow` parent sets
// `pointer-events: none`, which would otherwise suppress the tooltip.
.y-axis-unit-warning {
display: inline-flex;
pointer-events: auto;
}
.warning-state {
.ant-select-selector {
border-color: var(--bg-amber-400) !important;
@@ -17,3 +24,7 @@
right: 28px;
}
}
.y-axis-unit-warning-tooltip {
max-width: 240px;
}

View File

@@ -32,6 +32,8 @@ interface ConfigPaneProps {
tableColumns: TableColumnOption[];
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
stepInterval?: number;
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
metricUnit?: string;
}
/**
@@ -49,6 +51,7 @@ function ConfigPane({
legendSeries,
tableColumns,
stepInterval,
metricUnit,
}: ConfigPaneProps): JSX.Element {
const definition = getPanelDefinition(panelKind);
const sections = definition.sections;
@@ -108,6 +111,7 @@ function ConfigPane({
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
))}
</div>

View File

@@ -34,6 +34,7 @@ function SectionSlot({
onChangePanelKind,
queryType,
stepInterval,
metricUnit,
}: 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.
@@ -74,6 +75,7 @@ function SectionSlot({
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
</SettingsSection>
);

View File

@@ -19,4 +19,6 @@ export interface SectionEditorContext {
yAxisUnit?: string;
queryType?: EQueryType;
stepInterval?: number;
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
metricUnit?: string;
}

View File

@@ -46,11 +46,10 @@ function DisconnectValuesField({
onChange,
}: DisconnectValuesFieldProps): JSX.Element {
const duration = value?.fillLessThan || undefined;
const isThreshold = !!duration;
// Remember the last threshold so toggling Never → Threshold restores it.
const [lastDuration, setLastDuration] = useState(
duration ?? defaultDuration(stepInterval),
);
// `fillOnlyBelow` is authoritative; fall back to a stored duration for legacy panels.
const isThreshold = value?.fillOnlyBelow ?? !!duration;
// Remember the last committed threshold so Never → Threshold restores it.
const [lastDuration, setLastDuration] = useState<string | undefined>(duration);
useEffect(() => {
if (duration) {
@@ -59,11 +58,17 @@ function DisconnectValuesField({
}, [duration]);
const handleMode = (mode: DisconnectValuesMode): void => {
onChange(
mode === DisconnectValuesMode.THRESHOLD
? { ...value, fillLessThan: lastDuration }
: undefined,
);
if (mode === DisconnectValuesMode.THRESHOLD) {
onChange({
...value,
fillOnlyBelow: true,
// Seed from the live stepInterval (async — undefined until results load), not mount.
fillLessThan: lastDuration ?? defaultDuration(stepInterval),
});
return;
}
// Never spans every gap; drop the duration so the renderer reads a clean "span all".
onChange({ ...value, fillOnlyBelow: false, fillLessThan: undefined });
};
return (
@@ -79,14 +84,16 @@ function DisconnectValuesField({
onChange={handleMode}
/>
</div>
{isThreshold && (
{isThreshold && duration && (
<div className={styles.field}>
<Typography.Text>Threshold value</Typography.Text>
<DisconnectValuesThresholdInput
testId={`${testId}-value`}
value={lastDuration}
value={duration}
minValue={stepInterval}
onChange={(next): void => onChange({ ...value, fillLessThan: next })}
onChange={(next): void =>
onChange({ ...value, fillOnlyBelow: true, fillLessThan: next })
}
/>
</div>
)}

View File

@@ -14,6 +14,28 @@ interface DisconnectValuesThresholdInputProps {
onChange: (duration: string) => void;
}
/**
* Inline error for a raw duration, or `null` when valid and in range. The parse is
* guarded: `isValidTimeSpan` passes some strings `intervalToSeconds` throws on (e.g. "5x").
*/
function validationError(raw: string, minValue?: number): string | null {
let seconds: number;
try {
seconds = rangeUtil.isValidTimeSpan(raw)
? rangeUtil.intervalToSeconds(raw)
: NaN;
} catch {
seconds = NaN;
}
if (!Number.isFinite(seconds) || seconds <= 0) {
return 'Enter a valid duration (e.g. 30s, 1m, 1h)';
}
if (minValue !== undefined && seconds < minValue) {
return `Threshold should be > ${rangeUtil.secondsToHms(minValue)}`;
}
return null;
}
/**
* Duration input for the span-gaps threshold: shows/accepts and reports a human
* duration ("30s", "1m", "1h"), which is the value stored verbatim in
@@ -36,24 +58,21 @@ function DisconnectValuesThresholdInput({
setError(null);
}, [value]);
// Validate live so an invalid entry surfaces immediately, not only on blur.
const handleText = (raw: string): void => {
setText(raw);
setError(raw ? validationError(raw, minValue) : null);
};
const commit = (raw: string): void => {
if (!raw) {
// Skip no-op commits: blur fires when clicking the Never toggle, and re-emitting
// the unchanged value there would race the toggle and snap back to Threshold.
if (!raw || raw === value) {
return;
}
let seconds: number;
try {
seconds = rangeUtil.isValidTimeSpan(raw)
? rangeUtil.intervalToSeconds(raw)
: NaN;
} catch {
seconds = NaN;
}
if (!Number.isFinite(seconds) || seconds <= 0) {
setError('Enter a valid duration (e.g. 30s, 1m, 1h)');
return;
}
if (minValue !== undefined && seconds < minValue) {
setError(`Threshold should be > ${rangeUtil.secondsToHms(minValue)}`);
const message = validationError(raw, minValue);
if (message) {
setError(message);
return;
}
setError(null);
@@ -69,12 +88,9 @@ function DisconnectValuesThresholdInput({
status={error ? 'error' : undefined}
prefix={<span className={styles.thresholdPrefix}>&gt;</span>}
value={text}
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
setText(e.target.value);
if (error) {
setError(null);
}
}}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
handleText(e.target.value)
}
onBlur={(e): void => commit(e.currentTarget.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {

View File

@@ -1,9 +1,34 @@
import { useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DashboardtypesLineStyleDTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesLineStyleDTO,
type DashboardtypesTimeSeriesChartAppearanceDTO,
} from 'api/generated/services/sigNoz.schemas';
import ChartAppearanceSection from '../ChartAppearanceSection';
/** Stateful wrapper that feeds onChange back as the spec, mirroring the real editor. */
function StatefulSpanGaps({
initial,
stepInterval,
}: {
initial?: DashboardtypesTimeSeriesChartAppearanceDTO;
stepInterval?: number;
}): JSX.Element {
const [value, setValue] = useState<
DashboardtypesTimeSeriesChartAppearanceDTO | undefined
>(initial);
return (
<ChartAppearanceSection
value={value}
controls={{ spanGaps: true }}
stepInterval={stepInterval}
onChange={setValue}
/>
);
}
// 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.
@@ -139,7 +164,7 @@ describe('ChartAppearanceSection', () => {
await user.click(screen.getByText('Threshold'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '1m' },
spanGaps: { fillOnlyBelow: true, fillLessThan: '1m' },
});
});
@@ -162,7 +187,7 @@ describe('ChartAppearanceSection', () => {
await user.tab();
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '5m' },
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
});
});
@@ -183,7 +208,7 @@ describe('ChartAppearanceSection', () => {
await user.tab();
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '300' },
spanGaps: { fillOnlyBelow: true, fillLessThan: '300' },
});
});
@@ -200,7 +225,24 @@ describe('ChartAppearanceSection', () => {
await user.click(screen.getByText('Never'));
expect(onChange).toHaveBeenLastCalledWith({ spanGaps: undefined });
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: false, fillLessThan: undefined },
});
});
it('selects Never when fillOnlyBelow is false even if a duration lingers', () => {
render(
<ChartAppearanceSection
value={{ spanGaps: { fillOnlyBelow: false, fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={jest.fn()}
/>,
);
// The flag is authoritative: a stale fillLessThan must not show Threshold.
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
).not.toBeInTheDocument();
});
it('shows an error and does not commit an invalid duration', async () => {
@@ -244,4 +286,117 @@ describe('ChartAppearanceSection', () => {
expect(screen.getByText(/Threshold should be >/)).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it('seeds the threshold from the step interval when switching to Threshold', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
stepInterval={300}
onChange={onChange}
/>,
);
await user.click(screen.getByText('Threshold'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
});
});
it('seeds from the step interval even when it arrives after mount', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
// The step interval is undefined until the query response carries step metadata,
// so the panel first renders without it and receives it on a later render.
const { rerender } = render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
rerender(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
stepInterval={300}
onChange={onChange}
/>,
);
await user.click(screen.getByText('Threshold'));
// Regression: a value seeded at mount would still be the 1m fallback.
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
});
});
it('shows a validation error while typing, before blur', async () => {
const user = userEvent.setup();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={jest.fn()}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
await user.clear(input);
await user.type(input, 'abc');
// No blur / Enter — the error must already be visible.
expect(screen.getByText(/valid duration/i)).toBeInTheDocument();
});
it('does not re-commit the threshold when blurred without a change', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
await user.click(input);
await user.tab();
expect(onChange).not.toHaveBeenCalled();
});
it('fully switches from Threshold to Never (the input disappears)', async () => {
const user = userEvent.setup();
render(<StatefulSpanGaps initial={{ spanGaps: { fillLessThan: '1m' } }} />);
expect(
screen.getByTestId('panel-editor-v2-span-gaps-value'),
).toBeInTheDocument();
// Focus the input first so clicking Never also fires its blur (the toggle race).
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-value'));
await user.click(screen.getByText('Never'));
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
).not.toBeInTheDocument();
});
it('remembers the last threshold when toggling Never → Threshold', async () => {
const user = userEvent.setup();
render(<StatefulSpanGaps initial={{ spanGaps: { fillLessThan: '5m' } }} />);
await user.click(screen.getByText('Never'));
await user.click(screen.getByText('Threshold'));
expect(screen.getByTestId('panel-editor-v2-span-gaps-value')).toHaveValue(
'5m',
);
});
});

View File

@@ -14,7 +14,7 @@ import ColumnUnits from './ColumnUnits';
import styles from './FormattingSection.module.scss';
type FormattingSectionProps = SectionEditorProps<SectionKind.Formatting> &
Pick<SectionEditorContext, 'tableColumns'>;
Pick<SectionEditorContext, 'tableColumns' | 'metricUnit'>;
// `full` means "show the raw value, no rounding"; the digits round to that many places.
const DECIMAL_OPTIONS: {
@@ -39,6 +39,7 @@ function FormattingSection({
controls,
onChange,
tableColumns = [],
metricUnit,
}: FormattingSectionProps): JSX.Element {
return (
<>
@@ -50,6 +51,7 @@ function FormattingSection({
data-testid="panel-editor-v2-unit"
source={YAxisSource.DASHBOARDS}
value={value?.unit}
initialValue={metricUnit}
onChange={(unit): void => onChange({ ...value, unit })}
/>
</div>

View File

@@ -3,6 +3,8 @@ import userEvent from '@testing-library/user-event';
import FormattingSection from '../FormattingSection';
// Auto-seeding is covered by useMetricYAxisUnit's tests; here `metricUnit` is just a prop.
// Open the Decimals select (clicking its antd selector) and pick the option with the
// given visible label.
async function pickDecimal(label: string): Promise<void> {
@@ -71,4 +73,31 @@ describe('FormattingSection', () => {
decimalPrecision: '2',
});
});
it('warns when the selected unit mismatches the metric unit', () => {
// metric sent in seconds, but bytes is selected.
render(
<FormattingSection
value={{ unit: 'By' }}
controls={{ unit: true }}
metricUnit="s"
onChange={jest.fn()}
/>,
);
expect(screen.getByLabelText('warning')).toBeInTheDocument();
});
it('shows no warning when the selected unit matches the metric unit', () => {
render(
<FormattingSection
value={{ unit: 's' }}
controls={{ unit: true }}
metricUnit="s"
onChange={jest.fn()}
/>,
);
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
});
});

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
@@ -82,6 +82,15 @@ function ThresholdsSection({
// 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);
// The saved threshold captured on edit entry, restored if the edit is discarded
// (edits stream into the spec live, so Discard can't just drop a local draft).
const editSnapshot = useRef<AnyThreshold | null>(null);
const updateAt =
(index: number) =>
(next: AnyThreshold): void => {
onChange(thresholds.map((t, i) => (i === index ? next : t)));
};
const addThreshold = (): void => {
const nextIndex = thresholds.length;
@@ -90,6 +99,11 @@ function ThresholdsSection({
setUnsavedIndex(nextIndex);
};
const beginEdit = (index: number): void => {
editSnapshot.current = thresholds[index] ?? null;
setEditingIndex(index);
};
const saveAt =
(index: number) =>
(next: AnyThreshold): void => {
@@ -105,11 +119,15 @@ function ThresholdsSection({
};
const discardAt = (index: number) => (): void => {
// Discarding a row that was never saved removes it; otherwise just exit edit.
// A never-saved row is removed; otherwise revert the live edits to the snapshot.
if (index === unsavedIndex) {
removeAt(index);
return;
}
const original = editSnapshot.current;
if (original) {
onChange(thresholds.map((t, i) => (i === index ? original : t)));
}
setEditingIndex(null);
};
@@ -120,8 +138,9 @@ function ThresholdsSection({
index,
yAxisUnit,
isEditing: editingIndex === index,
onEdit: (): void => setEditingIndex(index),
onEdit: (): void => beginEdit(index),
onSave: saveAt(index),
onLiveChange: updateAt(index),
onDiscard: discardAt(index),
onRemove: (): void => removeAt(index),
};

View File

@@ -36,9 +36,16 @@ const THRESHOLDS: DashboardtypesComparisonThresholdDTO[] = [
},
];
// Stateful harness for flows that depend on the value updating (add/discard).
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
const [value, setValue] = useState<DashboardtypesComparisonThresholdDTO[]>([]);
// Stateful harness for flows that depend on the value updating (add/discard/live).
function Harness({
yAxisUnit,
initial = [],
}: {
yAxisUnit?: string;
initial?: DashboardtypesComparisonThresholdDTO[];
}): JSX.Element {
const [value, setValue] =
useState<DashboardtypesComparisonThresholdDTO[]>(initial);
return (
<ComparisonThresholdsSection
value={value}
@@ -142,24 +149,44 @@ describe('ComparisonThresholdsSection', () => {
expect(valueInput).toHaveValue(5);
});
it('does not commit edits when Discard is clicked', async () => {
it('reflects edits live (before Save) so the preview can react', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
await user.clear(screen.getByTestId('comparison-threshold-value-0'));
await user.type(screen.getByTestId('comparison-threshold-value-0'), '90');
// No Save click — the latest edit is already pushed up for the preview.
expect(onChange).toHaveBeenLastCalledWith([
{
value: 90,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.above,
unit: 'percent',
format: DashboardtypesThresholdFormatDTO.background,
},
]);
});
it('reverts the live edits to the saved value on Discard', async () => {
const user = userEvent.setup();
render(<Harness initial={THRESHOLDS} />);
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
await user.clear(screen.getByTestId('comparison-threshold-value-0'));
await user.type(screen.getByTestId('comparison-threshold-value-0'), '90');
await user.click(screen.getByTestId('comparison-threshold-discard-0'));
expect(onChange).not.toHaveBeenCalled();
// Back to view mode.
// Back to view mode, and re-opening shows the rolled-back 80, not 90.
expect(
screen.queryByTestId('comparison-threshold-value-0'),
).not.toBeInTheDocument();
expect(screen.getByTestId('comparison-threshold-edit-0')).toBeInTheDocument();
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
expect(screen.getByTestId('comparison-threshold-value-0')).toHaveValue(80);
});
it('removes a threshold from view mode', async () => {

View File

@@ -10,10 +10,16 @@ const THRESHOLDS: DashboardtypesThresholdWithLabelDTO[] = [
{ value: 80, color: '#F5B225', label: 'High', unit: 'percent' },
];
// Stateful harness for flows that depend on the value updating (add/discard);
// Stateful harness for flows that depend on the value updating (add/discard/live);
// omits `controls` to exercise the default `label` variant.
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
const [value, setValue] = useState<AnyThreshold[]>([]);
function Harness({
yAxisUnit,
initial = [],
}: {
yAxisUnit?: string;
initial?: AnyThreshold[];
}): JSX.Element {
const [value, setValue] = useState<AnyThreshold[]>(initial);
return (
<ThresholdsSection value={value} onChange={setValue} yAxisUnit={yAxisUnit} />
);
@@ -70,19 +76,34 @@ describe('ThresholdsSection', () => {
]);
});
it('does not commit edits when Discard is clicked', () => {
it('reflects edits live (before Save) so the preview can react', () => {
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' },
});
// No Save click — the edit is already pushed up for the preview to render.
expect(onChange).toHaveBeenLastCalledWith([
{ value: 90, color: '#F5B225', label: 'High', unit: 'percent' },
]);
});
it('reverts the live edits to the saved value on Discard', () => {
render(<Harness initial={THRESHOLDS} />);
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, and re-opening shows the rolled-back 80, not 90.
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('threshold-edit-0'));
expect(screen.getByTestId('threshold-value-0')).toHaveValue(80);
});
it('removes a threshold from view mode', () => {

View File

@@ -27,6 +27,7 @@ interface ComparisonThresholdRowProps {
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesComparisonThresholdDTO) => void;
onLiveChange: (next: DashboardtypesComparisonThresholdDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
@@ -42,10 +43,15 @@ function ComparisonThresholdRow({
isEditing,
onEdit,
onSave,
onLiveChange,
onDiscard,
onRemove,
}: ComparisonThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
const { draft, setDraft, setValue } = useThresholdDraft(
threshold,
isEditing,
onLiveChange,
);
const symbol = threshold.operator ? OPERATOR_SYMBOL[threshold.operator] : '';
const summary = (

View File

@@ -20,6 +20,7 @@ interface LabelThresholdRowProps {
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesThresholdWithLabelDTO) => void;
onLiveChange: (next: DashboardtypesThresholdWithLabelDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
@@ -32,10 +33,15 @@ function LabelThresholdRow({
isEditing,
onEdit,
onSave,
onLiveChange,
onDiscard,
onRemove,
}: LabelThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
const { draft, setDraft, setValue } = useThresholdDraft(
threshold,
isEditing,
onLiveChange,
);
// Persist an empty-string label when none was entered — the spec requires a string.
const handleSave = useCallback((): void => {

View File

@@ -28,6 +28,7 @@ interface TableThresholdRowProps {
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesTableThresholdDTO) => void;
onLiveChange: (next: DashboardtypesTableThresholdDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
@@ -45,10 +46,15 @@ function TableThresholdRow({
isEditing,
onEdit,
onSave,
onLiveChange,
onDiscard,
onRemove,
}: TableThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
const { draft, setDraft, setValue } = useThresholdDraft(
threshold,
isEditing,
onLiveChange,
);
// Stored columnName is the query key; resolve its label + configured unit.
const columnUnit = tableColumns.find((c) => c.key === draft.columnName)?.unit;

View File

@@ -9,12 +9,14 @@ interface ThresholdDraft<T> {
/**
* 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.
* threshold on each entry into edit mode and exposes the numeric `value` setter all
* variants use. `onLiveChange` mirrors the draft into the spec as the user edits, so the
* panel preview updates live (without Save); the section reverts it on Discard.
*/
export function useThresholdDraft<T extends { value: number }>(
threshold: T,
isEditing: boolean,
onLiveChange?: (draft: T) => void,
): ThresholdDraft<T> {
const [draft, setDraft] = useState<T>(threshold);
@@ -25,6 +27,13 @@ export function useThresholdDraft<T extends { value: number }>(
// eslint-disable-next-line react-hooks/exhaustive-deps -- snapshot only on edit entry
}, [isEditing]);
useEffect(() => {
if (isEditing) {
onLiveChange?.(draft);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- propagate on draft change only
}, [draft]);
const setValue = (raw: string): void => {
const next = Number(raw);
setDraft((d) => ({ ...d, value: Number.isNaN(next) ? d.value : next }));

View File

@@ -1,6 +1,8 @@
import {
TelemetrytypesSignalDTO,
DashboardtypesComparisonOperatorDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
@@ -95,4 +97,141 @@ describe('getSwitchedPluginSpec', () => {
expect(result.legend?.position).toBe('bottom');
});
describe('thresholds', () => {
it('does not carry thresholds when the new kind has no thresholds section', () => {
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toBeUndefined();
});
it('carries thresholds verbatim within the label variant (color/value/unit/label)', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/BarChartPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' },
]);
});
it('remaps label thresholds into the comparison variant, defaulting operator + format', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'comparison' } }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/NumberPanel',
TelemetrytypesSignalDTO.logs,
);
// The label is dropped; operator/format are seeded so the threshold can match.
expect(result.thresholds).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.text,
},
]);
});
it('remaps comparison thresholds into the table variant, keeping operator/format and seeding a column', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'table' } }],
});
const old = specWith({
thresholds: [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
},
],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TablePanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
columnName: '',
},
]);
});
it('drops the table-only columnName when remapping into the label variant', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
});
const old = specWith({
thresholds: [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.background,
columnName: 'p99',
},
],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([{ value: 80, color: '#F1575F' }]);
});
it('defaults the variant to label when the thresholds section omits controls', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: {} }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{ value: 80, color: '#F1575F', label: 'warn' },
]);
});
});
});

View File

@@ -1,13 +1,18 @@
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
TelemetrytypesTelemetryFieldKeyDTO,
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
type TelemetrytypesSignalDTO,
type TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import {
SectionKind,
type AnyThreshold,
type PanelFormattingSlice,
type SectionConfig,
SectionKind,
type ThresholdVariant,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import {
buildDefaultPluginSpec,
@@ -24,13 +29,73 @@ import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
export interface SwitchedPluginSpec extends DefaultPluginSpec {
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
thresholds?: AnyThreshold[];
}
/** Every field any threshold variant can hold; switching reads across shapes to remap. */
interface AnyThresholdFields {
color: string;
value: number;
unit?: string;
operator?: DashboardtypesComparisonOperatorDTO;
format?: DashboardtypesThresholdFormatDTO;
columnName?: string;
label?: string;
}
/** The threshold variant a kind edits, or `undefined` when it has no Thresholds section. */
function getThresholdVariant(
sections: SectionConfig[],
): ThresholdVariant | undefined {
const section = sections.find(
(s): s is Extract<SectionConfig, { kind: SectionKind.Thresholds }> =>
s.kind === SectionKind.Thresholds,
);
return section ? (section.controls.variant ?? 'label') : undefined;
}
/**
* Remaps a threshold to the target kind's variant: keeps the shared core (color, value,
* unit) plus any cross-variant fields, and seeds the rest with the variant's defaults so
* the carried threshold stays functional (a comparison/table threshold needs an operator
* to match, a table threshold a column).
*/
function toThresholdVariant(
source: AnyThresholdFields,
variant: ThresholdVariant,
): AnyThreshold {
const core = {
color: source.color,
value: source.value,
...(source.unit !== undefined && { unit: source.unit }),
};
if (variant === 'comparison') {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.text,
};
}
if (variant === 'table') {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.background,
columnName: source.columnName ?? '',
};
}
return {
...core,
...(source.label !== undefined && { label: source.label }),
};
}
/**
* Builds the plugin spec for a first-visit switch to `newKind`: the kind's declared
* section defaults (so the config pane opens populated, matching new-panel seeding) plus
* the only cross-kind config worth keeping — unit + decimal precision. Switching into a
* List seeds the current signal's default columns so the columns control isn't empty.
* the cross-kind config worth keeping — unit + decimal precision, and thresholds when the
* new kind supports them (remapped to its variant). Switching into a List seeds the
* current signal's default columns so the columns control isn't empty.
*
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
*/
@@ -66,5 +131,19 @@ export function getSwitchedPluginSpec(
}
}
const thresholdVariant = getThresholdVariant(sections);
if (thresholdVariant) {
const oldThresholds = (
oldSpec.plugin.spec as {
thresholds?: AnyThreshold[] | null;
}
).thresholds;
if (oldThresholds && oldThresholds.length > 0) {
result.thresholds = oldThresholds.map((threshold) =>
toThresholdVariant(threshold as AnyThresholdFields, thresholdVariant),
);
}
}
return result;
}

View File

@@ -0,0 +1,103 @@
import { renderHook } from '@testing-library/react';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useMetricYAxisUnit } from '../useMetricYAxisUnit';
jest.mock('hooks/useGetYAxisUnit', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
function mockMetricUnit(
yAxisUnit: string | undefined,
isLoading = false,
): void {
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
}
describe('useMetricYAxisUnit', () => {
beforeEach(() => jest.clearAllMocks());
it('seeds the unit from the metric on a new panel', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).toHaveBeenCalledWith('bytes');
});
it('does not seed when not a new panel', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: false, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('does not seed when the metric has no unit', () => {
mockMetricUnit(undefined);
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('does not seed when the unit already matches the metric', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: 'bytes', onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('re-seeds when the resolved metric unit changes', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
const { rerender } = renderHook(
(props: { unit: string | undefined }) =>
useMetricYAxisUnit({
isNewPanel: true,
unit: props.unit,
onSelectUnit,
}),
{ initialProps: { unit: undefined as string | undefined } },
);
expect(onSelectUnit).toHaveBeenLastCalledWith('bytes');
// The metric changes; the panel now holds the previously-seeded unit.
mockMetricUnit('ms');
rerender({ unit: 'bytes' });
expect(onSelectUnit).toHaveBeenLastCalledWith('ms');
});
it('returns the resolved metric unit and loading state', () => {
mockMetricUnit('bytes', true);
const { result } = renderHook(() =>
useMetricYAxisUnit({
isNewPanel: false,
unit: undefined,
onSelectUnit: jest.fn(),
}),
);
expect(result.current.metricUnit).toBe('bytes');
expect(result.current.isLoading).toBe(true);
});
});

View File

@@ -0,0 +1,36 @@
import { useEffect } from 'react';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
interface UseMetricYAxisUnitArgs {
/** Only a new panel auto-seeds; editing never overwrites the saved unit. */
isNewPanel: boolean;
unit: string | undefined;
onSelectUnit: (unit: string) => void;
}
interface UseMetricYAxisUnitResult {
metricUnit: string | undefined;
isLoading: boolean;
}
/**
* Resolves the selected metric's unit and, on a new panel only, seeds the formatting unit
* from it (V1 parity); returns the unit for the selector's mismatch warning.
*/
export function useMetricYAxisUnit({
isNewPanel,
unit,
onSelectUnit,
}: UseMetricYAxisUnitArgs): UseMetricYAxisUnitResult {
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
useEffect(() => {
if (isNewPanel && metricUnit && metricUnit !== unit) {
onSelectUnit(metricUnit);
}
// Re-seed only when the resolved metric unit changes, not on every unit edit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewPanel, metricUnit]);
return { metricUnit, isLoading };
}

View File

@@ -8,6 +8,8 @@ import {
import { toast } from '@signozhq/ui/sonner';
import {
type DashboardtypesPanelDTO,
type DashboardtypesPanelFormattingDTO,
type DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -30,6 +32,7 @@ import { useLegendSeries } from './hooks/useLegendSeries';
import { usePanelQuery } from '../hooks/usePanelQuery';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
import { useMetricYAxisUnit } from './hooks/useMetricYAxisUnit';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
@@ -123,6 +126,33 @@ function PanelEditorContainer({
// Switch the panel's visualization kind in place (reversible per session).
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
// At editor level, not the collapsible FormattingSection, so seeding runs while closed.
const formattingUnit = (
spec.plugin.spec as {
formatting?: DashboardtypesPanelFormattingDTO;
}
).formatting?.unit;
const seedFormattingUnit = useCallback(
(unit: string): void => {
const pluginSpec = spec.plugin.spec as {
formatting?: DashboardtypesPanelFormattingDTO;
};
setSpec({
...spec,
plugin: {
...spec.plugin,
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, unit } },
},
} as DashboardtypesPanelSpecDTO);
},
[spec, setSpec],
);
const { metricUnit } = useMetricYAxisUnit({
isNewPanel: isNew,
unit: formattingUnit,
onSelectUnit: seedFormattingUnit,
});
// Spec and query dirtiness are tracked independently so query re-serialization
// never false-dirties. A new panel is always savable (you're creating it).
const isDirty = isNew || isSpecDirty || isQueryDirty;
@@ -250,6 +280,7 @@ function PanelEditorContainer({
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
</ResizablePanel>
</ResizablePanelGroup>

View File

@@ -0,0 +1,130 @@
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { preparePieData } from '../prepareData';
function tableWith(
columns: PanelTable['columns'],
rows: PanelTable['rows'],
overrides: Partial<PanelTable> = {},
): PanelTable {
return { queryName: 'A', legend: '', columns, rows, ...overrides };
}
const args = (tables: PanelTable[]): Parameters<typeof preparePieData>[0] => ({
tables,
isDarkMode: true,
});
describe('preparePieData', () => {
it('renders a slice per value column for a multi-column ClickHouse scalar', () => {
const table = tableWith(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { col1: 23399927, col2: 588691297 } }],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['col1', 23399927],
['col2', 588691297],
]);
});
it('keeps one slice per group row for a single value column', () => {
const table = tableWith(
[
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' },
],
[
{ data: { 'service.name': 'adservice', A: 100 } },
{ data: { 'service.name': 'cartservice', A: 200 } },
],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['adservice', 100],
['cartservice', 200],
]);
});
it('prefixes the group when multiple value columns are grouped', () => {
const table = tableWith(
[
{ name: 'env', queryName: 'A', isValueColumn: false, id: 'env' },
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { env: 'prod', col1: 10, col2: 20 } }],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => s.label)).toStrictEqual([
'prod · col1',
'prod · col2',
]);
});
it('falls back to legend/query name when a single value column has no group', () => {
const table = tableWith(
[{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' }],
[{ data: { A: 42 } }],
{ legend: 'requests' },
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['requests', 42],
]);
});
it('honours customColors over the generated palette', () => {
const table = tableWith(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { col1: 10, col2: 20 } }],
);
const slices = preparePieData({
tables: [table],
isDarkMode: true,
customColors: { col1: '#ff0000' },
});
expect(slices[0].color).toBe('#ff0000');
expect(slices[1].color).not.toBe('#ff0000');
});
it('drops non-positive and non-numeric values', () => {
const table = tableWith(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
{ name: 'col3', queryName: 'A', isValueColumn: true, id: 'col3' },
],
[{ data: { col1: 5, col2: 0, col3: 'n/a' } }],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => s.label)).toStrictEqual(['col1']);
});
it('returns no slices for empty tables', () => {
expect(preparePieData(args([]))).toStrictEqual([]);
});
});

View File

@@ -11,11 +11,7 @@ export interface PreparePieDataArgs {
isDarkMode: boolean;
}
/**
* Turns the scalar tables of a V5 response into pie slices (one per group row):
* value column → value, group column(s) → label. Colours honour `customColors`
* then fall back to the deterministic palette; non-positive/non-numeric dropped.
*/
/** One pie slice per (row × value column); column name labels slices when a query has several value columns. */
export function preparePieData({
tables,
customColors,
@@ -27,26 +23,35 @@ export function preparePieData({
const slices: PieSlice[] = [];
tables.forEach((table) => {
const valueColumn = table.columns.find((column) => column.isValueColumn);
if (!valueColumn) {
const valueColumns = table.columns.filter((column) => column.isValueColumn);
if (valueColumns.length === 0) {
return;
}
const valueKey = valueColumn.id || valueColumn.name;
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
const hasMultipleValueColumns = valueColumns.length > 1;
table.rows.forEach((row) => {
const value = Number(row.data[valueKey]);
const label =
labelColumns
.map((column) => row.data[column.id || column.name])
.filter((part) => part != null)
.map(String)
.join(', ') ||
table.legend ||
table.queryName ||
'';
const color = customColors?.[label] ?? generateColor(label, colorMap);
slices.push({ label, value, color });
const groupLabel = labelColumns
.map((column) => row.data[column.id || column.name])
.filter((part) => part != null)
.map(String)
.join(', ');
valueColumns.forEach((column) => {
let label: string;
if (hasMultipleValueColumns) {
label = groupLabel ? `${groupLabel} · ${column.name}` : column.name;
} else {
label = groupLabel || table.legend || table.queryName || '';
}
const color = customColors?.[label] ?? generateColor(label, colorMap);
slices.push({
label,
value: Number(row.data[column.id || column.name]),
color,
});
});
});
});

View File

@@ -108,7 +108,9 @@ function addSeries({
// `customColors` is nullable on the spec; coerce so `addSeries` always gets
// a defined record (it dereferences keys without a guard).
const colorMapping = spec.legend?.customColors ?? {};
const spanGaps = resolveSpanGaps(chartAppearance?.spanGaps?.fillLessThan);
const spanGaps = chartAppearance?.spanGaps
? resolveSpanGaps(chartAppearance?.spanGaps)
: true;
const lineStyle = chartAppearance?.lineStyle
? LINE_STYLE_MAP[chartAppearance.lineStyle]

View File

@@ -1,22 +1,35 @@
import { resolveSpanGaps } from '../resolvers';
describe('resolveSpanGaps', () => {
it('spans all gaps (true) when unset', () => {
expect(resolveSpanGaps(undefined)).toBe(true);
expect(resolveSpanGaps('')).toBe(true);
});
it('parses a duration string into seconds', () => {
expect(resolveSpanGaps('5s')).toBe(5);
expect(resolveSpanGaps('10m')).toBe(600);
expect(resolveSpanGaps('1h')).toBe(3600);
it('parses a duration string into seconds when thresholding', () => {
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '5s' })).toBe(5);
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '10m' })).toBe(
600,
);
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '1h' })).toBe(
3600,
);
});
it('tolerates a bare seconds number (back-compat)', () => {
expect(resolveSpanGaps('600')).toBe(600);
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '600' })).toBe(
600,
);
});
it('falls back to true for unparseable input', () => {
expect(resolveSpanGaps('abc')).toBe(true);
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: 'abc' })).toBe(
true,
);
});
it('spans all gaps when fillOnlyBelow is explicitly false, ignoring any duration', () => {
expect(resolveSpanGaps({ fillOnlyBelow: false, fillLessThan: '5m' })).toBe(
true,
);
});
it('treats a duration with no fillOnlyBelow flag as a threshold (legacy panels)', () => {
expect(resolveSpanGaps({ fillLessThan: '5m' })).toBe(300);
});
});

View File

@@ -2,6 +2,7 @@ import { rangeUtil } from '@grafana/data';
import {
DashboardtypesLegendPositionDTO,
DashboardtypesPrecisionOptionDTO,
type DashboardtypesSpanGapsDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
@@ -39,15 +40,14 @@ export function resolveDecimalPrecision(
}
/**
* `spec.chartAppearance.spanGaps.fillLessThan` is a duration string on the wire
* ("10m", "5s"). Empty/missing → span all gaps (default); otherwise forward the
* threshold in seconds so uPlot only bridges short runs of nulls. Tolerates a
* bare seconds number for back-compat.
* Resolves `spanGaps` to uPlot's value. `fillOnlyBelow: false` spans every gap regardless
* of `fillLessThan`; a duration with no flag still thresholds (panels predating the flag).
*/
export function resolveSpanGaps(
fillLessThan: string | undefined,
spanGaps: DashboardtypesSpanGapsDTO,
): boolean | number {
if (!fillLessThan) {
const fillLessThan = spanGaps.fillLessThan;
if (spanGaps.fillOnlyBelow === false || !fillLessThan) {
return true;
}
const seconds = rangeUtil.isValidTimeSpan(fillLessThan)