Compare commits

..

22 Commits

Author SHA1 Message Date
Abhi Kumar
b1e9b935c5 chore(dashboards-v2): tidy panel-editor query helpers
- useLegendSeries: drop redundant optional chaining on panel.spec.
- usePanelTypeSwitch: default null queries to [] for getBuilderQueries.
- Remove the unused getPanelKindLabel util.
2026-06-26 13:58:41 +05:30
Abhi Kumar
d4849984da refactor(dashboards-v2): rename Formatting section to "Formatting & Units"
Serialize section header test ids (lowercase, spaces → dashes) so a
multi-word title doesn't break the data-testid, and update the test.
2026-06-26 13:58:00 +05:30
Abhi Kumar
1e841949ab feat(dashboards-v2): redesign span-gaps as a "Disconnect values" control
Replace the raw seconds input with a Never/Threshold toggle plus a
duration "Threshold value" field. The threshold is stored verbatim as a
duration string ("10m", "5s") — the wire format the backend expects — and
parsed back to seconds only for rendering and validation. Threads the
query step interval through the config pane to seed/floor the threshold,
and rejects invalid or below-step-interval entries inline (V1 parity).
2026-06-26 13:54:13 +05:30
Abhi Kumar
478b4b3cd0 fix(dashboards-v2): allow clearing the threshold value input
The threshold "Value" field was a controlled numeric input, so an
emptied field snapped back to 0 (Number("") is 0, not NaN) and the
seeded 0 could never be removed. Hold a local string so the field can
be cleared and edited; shared by all threshold row variants.
2026-06-26 13:45:55 +05:30
Abhi Kumar
811c7bba90 feat(dashboards-v2): add "Move out of section" panel action
The "Move to section" submenu only listed titled sections, so a panel
in a titled section couldn't be moved back to the untitled root. Add a
direct "Move out of section" action, shown when the panel sits in a
titled section and an untitled root section exists to receive it.
2026-06-26 13:45:41 +05:30
Abhi Kumar
5a5c10b333 fix(dashboards-v2): place toolbar-created panels in the root section
The top-right "New Panel" button creates a panel with no section context,
which createPanelOps resolved to the LAST section instead of the root.
Fall back to the first (root) section when no valid index is given; still
create an untitled section when the dashboard has none.
2026-06-26 13:45:26 +05:30
Abhi Kumar
ed8c8046f4 fix(dashboards-v2): disable panel types unsupported by the datasource
A new panel's builder is seeded with the kind's default signal, but
`spec.queries` stays empty until the query is modified — so the type
switcher saw an undefined datasource and never disabled incompatible
types (e.g. List on a metrics panel, which then breaks rendering).
Resolve the signal with a fallback to the kind's default signal so
compatibility is enforced from the first render.
2026-06-26 13:44:56 +05:30
Abhi Kumar
ec88a1560a refactor(dashboards-v2): let consumers control panel-type icon rendering
Store icon component references in PANEL_TYPES instead of pre-rendered
elements, so each consumer (selection modal, type switcher) decides the
icon size and other props at the render site. Renames constants.tsx ->
constants.ts now that it holds no JSX.
2026-06-26 03:58:53 +05:30
Abhi Kumar
27b73064c2 feat(dashboards-v2): reuse findFreeSlot for panel clone placement
Clone now places the copy beside the last row when it fits, else wraps
to a fresh row at the section bottom — matching the new-panel save path
instead of always starting a new row.
2026-06-26 03:44:26 +05:30
Abhi Kumar
55569c5986 feat(dashboards-v2): switch panel visualization type in the editor
Wire the panel-type switcher into the config pane: every kind now declares a
`visualization` section (with `switchPanelKind`) so the switcher is reachable
from any panel, VisualizationSection renders it, and ConfigPane threads the
current kind/signal and an onChange down through SectionSlot. The editor drives
the in-place kind switch via usePanelTypeSwitch, so switching is reversible
within an edit session and a first visit rebuilds the query + resets the spec
(carrying unit/decimals).
2026-06-26 01:41:45 +05:30
Abhi Kumar
9d9d6c9b93 feat(dashboards-v2): add the PanelTypeSwitcher control
A presentational visualization-type selector (built on ConfigSelect) that lists
the panel kinds and disables types whose supported signals exclude the panel's
current datasource (V1 parity — e.g. List needs logs/traces, not metrics). Rename
the Value option label to Number so the switcher (and the type-selection modal)
read consistently. Not yet rendered anywhere.
2026-06-26 01:41:45 +05:30
Abhi Kumar
2fe2744340 feat(dashboards-v2): add panel-type switch logic
Two building blocks for switching a panel's kind in place:

- getSwitchedPluginSpec: on a first-time switch to a new kind, rebuild the
  plugin spec from the kind's defaults, carrying over unit/decimals (and seeding
  List columns from the signal).
- usePanelTypeSwitch: a per-kind session cache that makes switches reversible
  (Table -> List -> Table restores the original query + spec) and rebuilds the
  query for the new panel type on a first visit.

Both are standalone and not yet wired into the editor.
2026-06-26 01:41:45 +05:30
Abhi Kumar
c512fbafc9 refactor(dashboards-v2): extend ConfigSelect for the type switcher
Add per-option `disabled` and let `icon` be an arbitrary `ReactNode` (not just
a `SegmentIconName`) so the upcoming panel-type switcher can disable unsupported
types and render panel-type glyphs. Also widen the ConfigSegmented group to full
width so segmented controls fill the config pane.
2026-06-26 01:41:45 +05:30
Abhi Kumar
aabf1fb18c chore: pr review changes 2026-06-26 01:41:45 +05:30
Abhi Kumar
757da5cd57 refactor(dashboards-v2): type panel signals as TelemetrytypesSignalDTO
Replace the query-builder `DataSource` enum with `TelemetrytypesSignalDTO`
across the panel signal plumbing — the panel definitions' `supportedSignals`,
the List columns editor/suggestions/defaults, and the editor query-sync and
column-seeding hooks — and make the signal non-optional where a panel always
resolves one. Drops the now-dead `DataSource` imports.
2026-06-26 01:41:42 +05:30
Abhi Kumar
b4fc02f18e refactor(dashboards-v2): address PR review feedback
- getPanelDefinition: make PanelRegistry total over PanelKind so it never
  returns undefined (a missing kind is now a compile error)
- toQueryEnvelopes/hasRunnableQueries: accept `... | null` and drop the
  `?? []` at call sites
- consolidate panel loading to a single `isFetching` signal (Panel,
  PreviewPane, PanelEditor)
- rename defaultDataSource -> signal; use TelemetrytypesSignalDTO enum for
  the List default columns
- useCreatePanel now owns the panel-type picker state, deduping the three
  add-panel triggers; rename pluginKind -> panelKind
- LabelThresholdRow: extract onSave into a callback; ThresholdsSection test
  uses userEvent
- PreviewPane: drop the optional chaining on the required display field
- trim over-verbose comments
2026-06-26 01:41:27 +05:30
Abhi Kumar
9295c384a8 docs(dashboards-v2): trim verbose comments to minimal-why style
Condense multi-paragraph block comments and drop comments that restate
the code across the new-panel editor surface, keeping only the non-obvious
"why" and concise JSDoc on functions and type fields. Comment-only — no
code, types, or test assertions changed.
2026-06-26 01:28:55 +05:30
Abhi Kumar
9ea086e6d8 feat(dashboards-v2): create new panels from the editor
Add a draft-first create flow: picking a panel type opens the editor at
`/panel/new?panelKind=…&layoutIndex=…` on an in-memory default panel, and
nothing is persisted until save — cancelling leaves the dashboard
untouched (V1 parity). On save a new panel is minted (uuid) and added to
the target section via `createPanelOps` (resolves the section, or creates
one when the dashboard has none).

All "Add panel" triggers (section header, empty section, empty dashboard,
toolbar) route through a single useCreatePanel hook + the V2 type picker;
the leftover V1 global modal and useAddPanelToSection are removed.

New panels seed sensible defaults from the kind's supported signals: the
query datasource (e.g. List → logs, not the unsupported metrics default)
and, for List, the signal's default columns — so the Columns control
isn't empty on first open. New panels always re-serialize their query for
the kind on save, so the persisted query is valid even if untouched.

echo "--- working tree after commits (should be only dev hack) ---"; git status --short
2026-06-26 01:12:07 +05:30
Abhi Kumar
a5c8f413ff style(dashboards-v2): format list panel header with oxfmt 2026-06-26 01:03:54 +05:30
Abhi Kumar
46c7f2c285 feat(dashboards-v2): move list columns editor below the query builder
Replace the config-pane Columns section with a dnd-kit reorderable editor
rendered beneath the query builder (V1 parity); sanitize selectFields to the
field-key DTO so saved columns drop non-contract keys (isIndexed).
2026-06-26 01:03:52 +05:30
Abhi Kumar
c6330182f2 feat(dashboards-v2): list columns editor with datasource column switch 2026-06-26 01:03:38 +05:30
Abhi Kumar
afe643647a feat(dashboards-v2): list panel kind (logs/traces) with row detail 2026-06-26 01:02:40 +05:30
48 changed files with 1480 additions and 184 deletions

View File

@@ -1,11 +1,8 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import type { LegendSeries } from '../hooks/useLegendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
@@ -20,10 +17,14 @@ interface ConfigPaneProps {
/** The panel spec — the single editing surface (title/description + section slices). */
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Switch the panel to another visualization kind. */
onChangePanelKind: (kind: PanelKind) => 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[];
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
stepInterval?: number;
}
/**
@@ -36,15 +37,15 @@ function ConfigPane({
panelKind,
spec,
onChangeSpec,
onChangePanelKind,
legendSeries,
tableColumns,
stepInterval,
}: ConfigPaneProps): JSX.Element {
const definition = getPanelDefinition(panelKind);
const sections = definition.sections;
const signal = getBuilderQueries(spec.queries || [])[0]?.signal as
| TelemetrytypesSignalDTO
| undefined;
const signal = resolveSignal(spec.queries, definition.supportedSignals[0]);
// 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.
@@ -95,6 +96,9 @@ function ConfigPane({
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
stepInterval={stepInterval}
/>
))}
</div>

View File

@@ -0,0 +1,6 @@
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
.field {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -0,0 +1,54 @@
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import styles from './PanelTypeSwitcher.module.scss';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
panelKind: PanelKind;
/** Panel's current datasource — drives the disabled rule. */
signal?: TelemetrytypesSignalDTO;
onChange: (kind: PanelKind) => void;
}
/**
* Visualization-type selector (rendered inside the Visualization section). Types whose
* supported signals exclude the panel's current datasource are disabled (V1 parity —
* e.g. List needs logs/traces, not metrics). The datasource is unknown for
* PromQL/ClickHouse queries, in which case no type is disabled.
*/
function PanelTypeSwitcher({
panelKind,
signal,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = PANEL_TYPES.map(({ pluginKind, label, Icon }) => {
const definition = getPanelDefinition(pluginKind as PanelKind);
return {
value: pluginKind,
label,
icon: <Icon size={14} />,
disabled:
!!signal && !!definition && !definition.supportedSignals.includes(signal),
};
});
return (
<div className={styles.field}>
<Typography.Text>Panel Type</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-type-switcher"
value={panelKind}
items={items}
onChange={(value): void => onChange(value as PanelKind)}
/>
</div>
);
}
export default PanelTypeSwitcher;

View File

@@ -0,0 +1,73 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import PanelTypeSwitcher from '../PanelTypeSwitcher';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
function openDropdown(): void {
fireEvent.mouseDown(screen.getByRole('combobox'));
}
describe('PanelTypeSwitcher', () => {
beforeEach(() => {
jest.clearAllMocks();
// List supports only logs/traces; every other kind also supports metrics.
mockGetPanelDefinition.mockImplementation((kind: string) => ({
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']
: ['metrics', 'logs', 'traces'],
}));
});
it('fires onChange with the chosen plugin kind', () => {
const onChange = jest.fn();
render(
<PanelTypeSwitcher panelKind="signoz/TimeSeriesPanel" onChange={onChange} />,
);
openDropdown();
fireEvent.click(screen.getByText('List'));
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
});
it('disables types whose supported signals exclude the current datasource', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
signal={TelemetrytypesSignalDTO.metrics}
onChange={jest.fn()}
/>,
);
openDropdown();
const disabled = Array.from(
document.querySelectorAll('.ant-select-item-option-disabled'),
).map((el) => el.textContent);
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
expect(disabled).toContain('List');
expect(disabled).not.toContain('Time Series');
});
it('does not disable any type when the datasource is unknown', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
onChange={jest.fn()}
/>,
);
openDropdown();
expect(
document.querySelectorAll('.ant-select-item-option-disabled'),
).toHaveLength(0);
});
});

View File

@@ -8,6 +8,7 @@ import {
type SectionConfig,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { PanelKind } from '../../../Panels/types/panelKind';
import type { LegendSeries } from '../../hooks/useLegendSeries';
import type { TableColumnOption } from '../../hooks/useTableColumns';
import { resolveSectionEditor } from '../sectionRegistry';
@@ -23,6 +24,11 @@ interface SectionSlotProps {
tableColumns: TableColumnOption[];
/** Panel's telemetry signal, for editors that fetch field suggestions (List columns). */
signal?: TelemetrytypesSignalDTO;
/** Current panel kind + switch handler, for the visualization section's type switcher. */
panelKind: PanelKind;
onChangePanelKind: (kind: PanelKind) => void;
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
stepInterval?: number;
}
/**
@@ -38,6 +44,9 @@ function SectionSlot({
legendSeries,
tableColumns,
signal,
panelKind,
onChangePanelKind,
stepInterval,
}: 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.
@@ -60,7 +69,12 @@ function SectionSlot({
.formatting?.unit;
return (
<SettingsSection title={title} icon={<Icon size={15} />}>
<SettingsSection
title={title}
icon={<Icon size={15} />}
// Open Visualization by default so the type switcher is visible.
defaultOpen={config.kind === 'visualization'}
>
<Component
value={get(spec)}
controls={controls}
@@ -69,6 +83,9 @@ function SectionSlot({
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
stepInterval={stepInterval}
/>
</SettingsSection>
);

View File

@@ -26,13 +26,15 @@ function SettingsSection({
}: SettingsSectionProps): JSX.Element {
const [isOpen, setIsOpen] = useState(defaultOpen);
const serializedTitle = title.toLowerCase().replace(/\s+/g, '-');
return (
<section className={styles.section}>
<button
type="button"
className={styles.header}
aria-expanded={isOpen}
data-testid={`config-section-${title}`}
data-testid={`config-section-${serializedTitle}`}
onClick={(): void => setIsOpen((prev) => !prev)}
>
{icon && (

View File

@@ -21,6 +21,7 @@ function renderConfigPane(
panelKind: 'signoz/TimeSeriesPanel',
spec: spec(),
onChangeSpec: jest.fn(),
onChangePanelKind: jest.fn(),
legendSeries: [],
tableColumns: [],
...overrides,
@@ -56,6 +57,8 @@ describe('ConfigPane', () => {
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();
expect(
screen.getByTestId('config-section-formatting-&-units'),
).toBeInTheDocument();
});
});

View File

@@ -1,5 +1,5 @@
.group {
width: min(350px, 100%);
width: 100%;
}
.segment {

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import { Select } from 'antd';
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
@@ -7,7 +8,9 @@ import styles from './ConfigSelect.module.scss';
export interface ConfigSelectItem {
value: string;
label: string;
icon?: SegmentIconName;
/** A `SegmentIconName` string (resolved to a glyph), or an arbitrary icon node. */
icon?: ReactNode;
disabled?: boolean;
}
interface ConfigSelectProps {
@@ -40,9 +43,14 @@ function ConfigSelect({
virtual={false}
options={items.map((item) => ({
value: item.value,
disabled: item.disabled,
label: item.icon ? (
<span className={styles.item}>
<SegmentIcon name={item.icon} />
{typeof item.icon === 'string' ? (
<SegmentIcon name={item.icon as SegmentIconName} />
) : (
item.icon
)}
{item.label}
</span>
) : (

View File

@@ -157,6 +157,13 @@ export interface ErasedSectionDescriptor {
// The panel's telemetry signal; read by editors that fetch field-key
// suggestions scoped to it (List column picker).
signal?: unknown;
// Current panel kind + switch handler; read by the visualization section's
// type switcher.
panelKind?: unknown;
onChangePanelKind?: unknown;
// Query step interval (seconds); read by chart appearance to floor the
// span-gaps threshold.
stepInterval?: unknown;
}>;
get: (spec: PanelSpec) => unknown;
update: (spec: PanelSpec, value: unknown) => PanelSpec;

View File

@@ -3,3 +3,14 @@
flex-direction: column;
gap: 8px;
}
.thresholdField {
display: flex;
flex-direction: column;
gap: 8px;
}
.thresholdPrefix {
padding-right: 4px;
opacity: 0.6;
}

View File

@@ -1,6 +1,4 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import {
DashboardtypesFillModeDTO,
DashboardtypesLineInterpolationDTO,
@@ -11,6 +9,7 @@ import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContaine
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import DisconnectValuesField from './DisconnectValuesField';
import styles from './ChartAppearanceSection.module.scss';
@@ -77,16 +76,11 @@ 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 },
});
};
stepInterval,
}: SectionEditorProps<'chartAppearance'> & {
/** Query step interval (seconds) for the span-gaps threshold floor. */
stepInterval?: number;
}): JSX.Element {
return (
<>
{controls.lineStyle && (
@@ -146,16 +140,12 @@ function ChartAppearanceSection({
)}
{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>
<DisconnectValuesField
testId="panel-editor-v2-span-gaps"
value={value?.spanGaps}
stepInterval={stepInterval}
onChange={(spanGaps): void => onChange({ ...value, spanGaps })}
/>
)}
</>
);

View File

@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react';
import { rangeUtil } from '@grafana/data';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesSpanGapsDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import DisconnectValuesThresholdInput from './DisconnectValuesThresholdInput';
import styles from './ChartAppearanceSection.module.scss';
const DEFAULT_THRESHOLD = '1m';
const MODE_NEVER = 'never';
const MODE_THRESHOLD = 'threshold';
const MODE_OPTIONS = [
{ value: MODE_NEVER, label: 'Never' },
{ value: MODE_THRESHOLD, label: 'Threshold' },
];
interface DisconnectValuesFieldProps {
testId: string;
value: DashboardtypesSpanGapsDTO | undefined;
/** Query step interval (seconds): seeds the default threshold and floors it. */
stepInterval?: number;
onChange: (next: DashboardtypesSpanGapsDTO | undefined) => void;
}
/** Default threshold duration: the step interval (smallest meaningful), else 1m. */
function defaultDuration(stepInterval?: number): string {
return stepInterval && stepInterval > 0
? rangeUtil.secondsToHms(stepInterval)
: DEFAULT_THRESHOLD;
}
/**
* "Disconnect values": Never (span every gap — the chart default) vs Threshold
* (only bridge gaps shorter than a duration). The threshold persists as a
* duration string in `spanGaps.fillLessThan` ("10m", "5s") — the wire format the
* backend expects.
*/
function DisconnectValuesField({
testId,
value,
stepInterval,
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),
);
useEffect(() => {
if (duration) {
setLastDuration(duration);
}
}, [duration]);
const handleMode = (mode: string): void => {
onChange(
mode === MODE_THRESHOLD
? { ...value, fillLessThan: lastDuration }
: undefined,
);
};
return (
<>
<div className={styles.field}>
<Typography.Text>Disconnect values</Typography.Text>
<ConfigSegmented
testId={testId}
value={isThreshold ? MODE_THRESHOLD : MODE_NEVER}
items={MODE_OPTIONS}
onChange={handleMode}
/>
</div>
{isThreshold && (
<div className={styles.field}>
<Typography.Text>Threshold value</Typography.Text>
<DisconnectValuesThresholdInput
testId={`${testId}-value`}
value={lastDuration}
minValue={stepInterval}
onChange={(next): void => onChange({ ...value, fillLessThan: next })}
/>
</div>
)}
</>
);
}
export default DisconnectValuesField;

View File

@@ -0,0 +1,94 @@
import { type ChangeEvent, useEffect, useState } from 'react';
import { rangeUtil } from '@grafana/data';
import { Callout } from '@signozhq/ui/callout';
import { Input } from 'antd';
import styles from './ChartAppearanceSection.module.scss';
interface DisconnectValuesThresholdInputProps {
testId: string;
/** Current threshold as a duration string (e.g. "1m") — the stored wire value. */
value: string;
/** Smallest allowed threshold (the query step interval), in seconds. */
minValue?: number;
onChange: (duration: string) => void;
}
/**
* Duration input for the span-gaps threshold: shows/accepts and reports a human
* duration ("30s", "1m", "1h"), which is the value stored verbatim in
* `fillLessThan` (a bare number is read as seconds). It is only parsed to seconds
* to validate against the query step interval. Invalid entries, or values below
* that floor, surface an inline error and are not committed (V1 parity).
*/
function DisconnectValuesThresholdInput({
testId,
value,
minValue,
onChange,
}: DisconnectValuesThresholdInputProps): JSX.Element {
const [text, setText] = useState(value);
const [error, setError] = useState<string | null>(null);
// Resync the displayed duration when the committed value changes upstream.
useEffect(() => {
setText(value);
setError(null);
}, [value]);
const commit = (raw: string): void => {
if (!raw) {
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)}`);
return;
}
setError(null);
// Store the user's duration string as-is — the wire format the backend wants.
onChange(raw);
};
return (
<div className={styles.thresholdField}>
<Input
data-testid={testId}
type="text"
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);
}
}}
onBlur={(e): void => commit(e.currentTarget.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
commit(e.currentTarget.value);
}
}}
/>
{error && (
<Callout type="error" size="small" showIcon>
{error}
</Callout>
)}
</div>
);
}
export default DisconnectValuesThresholdInput;

View File

@@ -108,9 +108,24 @@ describe('ChartAppearanceSection', () => {
expect(onChange).toHaveBeenCalledWith({ showPoints: true });
});
it('writes a span-gaps threshold and clears it when emptied', () => {
it('defaults to "Never" (no threshold) and hides the threshold input', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByText('Never')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
).not.toBeInTheDocument();
});
it('switching to "Threshold" seeds the default 1m threshold', () => {
const onChange = jest.fn();
const { rerender } = render(
render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
@@ -118,23 +133,103 @@ describe('ChartAppearanceSection', () => {
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
target: { value: '60' },
});
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '60' },
});
fireEvent.click(screen.getByText('Threshold'));
rerender(
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '1m' },
});
});
it('stores the threshold as a duration string (not seconds)', () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '60' } }}
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
target: { value: '' },
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
expect(input).toHaveValue('1m');
fireEvent.change(input, { target: { value: '5m' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '5m' },
});
});
it('stores the entry verbatim (bare number kept as typed, not converted)', () => {
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');
fireEvent.change(input, { target: { value: '300' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '300' },
});
});
it('switching back to "Never" clears the threshold', () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText('Never'));
expect(onChange).toHaveBeenLastCalledWith({ spanGaps: undefined });
});
it('shows an error and does not commit an invalid duration', () => {
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');
fireEvent.change(input, { target: { value: 'abc' } });
fireEvent.blur(input);
expect(screen.getByText(/valid duration/i)).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it('rejects a threshold below the query step interval', () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '2m' } }}
controls={{ spanGaps: true }}
stepInterval={120}
onChange={onChange}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
// 1m (60s) is below the 2m (120s) step interval.
fireEvent.change(input, { target: { value: '1m' } });
fireEvent.blur(input);
expect(screen.getByText(/Threshold should be >/)).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
});

View File

@@ -123,6 +123,25 @@ describe('ComparisonThresholdsSection', () => {
]);
});
it('lets the value input be cleared instead of snapping back to 0', async () => {
const user = userEvent.setup();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={jest.fn()} />,
);
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
const valueInput = screen.getByTestId('comparison-threshold-value-0');
// Regression: clearing used to coerce "" → 0 and refill the field, so the
// seeded value could never be removed.
await user.clear(valueInput);
expect(valueInput).toHaveValue(null);
// And a fresh value can be typed into the now-empty field.
await user.type(valueInput, '5');
expect(valueInput).toHaveValue(5);
});
it('does not commit edits when Discard is clicked', async () => {
const user = userEvent.setup();
const onChange = jest.fn();

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
@@ -16,6 +17,12 @@ function ThresholdValueField({
value,
onChange,
}: ThresholdValueFieldProps): JSX.Element {
const [raw, setRaw] = useState(String(value));
useEffect(() => {
setRaw((prev) => (Number(prev) === value ? prev : String(value)));
}, [value]);
return (
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Value</Typography.Text>
@@ -23,8 +30,11 @@ function ThresholdValueField({
data-testid={testId}
type="number"
placeholder="Value"
value={value}
onChange={(e): void => onChange(e.target.value)}
value={raw}
onChange={(e): void => {
setRaw(e.target.value);
onChange(e.target.value);
}}
/>
</div>
);

View File

@@ -1,26 +1,50 @@
import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesTimePreferenceDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { PanelKind } from '../../../../Panels/types/panelKind';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import PanelTypeSwitcher from '../../PanelTypeSwitcher/PanelTypeSwitcher';
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
import styles from './VisualizationSection.module.scss';
type VisualizationSectionProps = SectionEditorProps<'visualization'> & {
/** Current panel kind + switch handler, forwarded by SectionSlot for the type switcher. */
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;
/** Panel's datasource, forwarded by SectionSlot — scopes the switcher's disabled types. */
signal?: TelemetrytypesSignalDTO;
};
/**
* 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.
* Edits the `visualization` slice: the panel-type switcher (`switchPanelKind`, every
* kind), the per-panel time preference, 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 fields its spec supports.
*/
function VisualizationSection({
value,
controls,
onChange,
}: SectionEditorProps<'visualization'>): JSX.Element {
panelKind,
onChangePanelKind,
signal,
}: VisualizationSectionProps): JSX.Element {
return (
<>
{controls.switchPanelKind && panelKind && onChangePanelKind && (
<PanelTypeSwitcher
panelKind={panelKind}
signal={signal}
onChange={onChangePanelKind}
/>
)}
{controls.timePreference && (
<div className={styles.field}>
<Typography.Text>Panel time preference</Typography.Text>

View File

@@ -4,6 +4,14 @@ import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.s
import VisualizationSection from '../VisualizationSection';
// The type switcher resolves each kind's supported signals; stub it so the test
// doesn't pull the whole panel registry (renderers, chart libs).
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(() => ({
supportedSignals: ['metrics', 'logs', 'traces'],
})),
}));
// 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();
@@ -17,7 +25,12 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={undefined}
controls={{ timePreference: true, stacking: true, fillSpans: true }}
controls={{
switchPanelKind: true,
timePreference: true,
stacking: true,
fillSpans: true,
}}
onChange={jest.fn()}
/>,
);
@@ -35,7 +48,10 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={undefined}
controls={{ timePreference: true }}
controls={{
switchPanelKind: true,
timePreference: true,
}}
onChange={jest.fn()}
/>,
);
@@ -56,7 +72,10 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={undefined}
controls={{ timePreference: true }}
controls={{
switchPanelKind: true,
timePreference: true,
}}
onChange={onChange}
/>,
);
@@ -74,7 +93,10 @@ describe('VisualizationSection', () => {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
stackedBarChart: false,
}}
controls={{ stacking: true }}
controls={{
switchPanelKind: true,
stacking: true,
}}
onChange={onChange}
/>,
);
@@ -92,7 +114,10 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={{ fillSpans: false }}
controls={{ fillSpans: true }}
controls={{
switchPanelKind: true,
fillSpans: true,
}}
onChange={onChange}
/>,
);
@@ -101,4 +126,43 @@ describe('VisualizationSection', () => {
expect(onChange).toHaveBeenCalledWith({ fillSpans: true });
});
it('renders the type switcher and switches kind when switchPanelKind is set', async () => {
const onChangePanelKind = jest.fn();
render(
<VisualizationSection
value={undefined}
controls={{ switchPanelKind: true }}
onChange={jest.fn()}
panelKind="signoz/TimeSeriesPanel"
onChangePanelKind={onChangePanelKind}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-type-switcher'),
).toBeInTheDocument();
await pickOption('panel-editor-v2-type-switcher', 'Table');
expect(onChangePanelKind).toHaveBeenCalledWith('signoz/TablePanel');
});
it('hides the type switcher when switchPanelKind is not set', () => {
render(
<VisualizationSection
value={undefined}
controls={{
switchPanelKind: false,
timePreference: true,
}}
onChange={jest.fn()}
panelKind="signoz/TimeSeriesPanel"
onChangePanelKind={jest.fn()}
/>,
);
expect(
screen.queryByTestId('panel-editor-v2-type-switcher'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,90 @@
import {
TelemetrytypesSignalDTO,
type DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { defaultColumnsForSignal } from '../ListColumnsEditor/selectFields';
import { getSwitchedPluginSpec } from '../getSwitchedPluginSpec';
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
}));
jest.mock('../ListColumnsEditor/selectFields', () => ({
defaultColumnsForSignal: jest.fn(),
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
const mockDefaultColumnsForSignal =
defaultColumnsForSignal as unknown as jest.Mock;
function specWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Panel' },
plugin: { kind: 'signoz/TablePanel', spec: pluginSpec },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
}
describe('getSwitchedPluginSpec', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDefaultColumnsForSignal.mockReturnValue([]);
});
it('carries only unit + decimalPrecision when the new kind has a formatting section', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'formatting', controls: { unit: true, decimals: true } }],
});
const old = specWith({
formatting: { unit: 'ms', decimalPrecision: 2, columnUnits: { A: 'bytes' } },
axes: { logScale: true },
});
const result = getSwitchedPluginSpec(old, 'signoz/TimeSeriesPanel');
expect(result.formatting).toStrictEqual({ unit: 'ms', decimalPrecision: 2 });
// Type-specific config from the old kind is dropped.
expect((result as { axes?: unknown }).axes).toBeUndefined();
});
it('does not carry formatting when the new kind has no formatting section', () => {
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const old = specWith({ formatting: { unit: 'ms' } });
const result = getSwitchedPluginSpec(
old,
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.formatting).toBeUndefined();
});
it('seeds List columns from the signal when switching into a List', () => {
const columns = [{ name: 'body' }];
mockDefaultColumnsForSignal.mockReturnValue(columns);
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const result = getSwitchedPluginSpec(
specWith({}),
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(mockDefaultColumnsForSignal).toHaveBeenCalledWith(
TelemetrytypesSignalDTO.logs,
);
expect(result.selectFields).toBe(columns);
});
it('includes the kind section defaults (e.g. legend position)', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'legend', controls: { position: true } }],
});
const result = getSwitchedPluginSpec(specWith({}), 'signoz/PieChartPanel');
expect(result.legend?.position).toBe('bottom');
});
});

View File

@@ -0,0 +1,67 @@
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
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 type { PanelFormattingSlice } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import {
buildDefaultPluginSpec,
type DefaultPluginSpec,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/buildDefaultPluginSpec';
import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
/**
* Plugin spec produced on a first-time switch to a new kind. A partial cross-section
* of the per-kind spec union; the caller assigns it to `plugin.spec` (typed `unknown`)
* at the boundary.
*/
export interface SwitchedPluginSpec extends DefaultPluginSpec {
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
}
/**
* 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.
*
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
*/
export function getSwitchedPluginSpec(
oldSpec: DashboardtypesPanelSpecDTO,
newKind: PanelKind,
signal?: TelemetrytypesSignalDTO,
): SwitchedPluginSpec {
const sections = getPanelDefinition(newKind)?.sections ?? [];
const result: SwitchedPluginSpec = buildDefaultPluginSpec(sections);
if (sections.some((section) => section.kind === 'formatting')) {
const oldFormatting = (
oldSpec.plugin.spec as {
formatting?: PanelFormattingSlice;
}
).formatting;
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
...(oldFormatting?.unit !== undefined && { unit: oldFormatting.unit }),
...(oldFormatting?.decimalPrecision !== undefined && {
decimalPrecision: oldFormatting.decimalPrecision,
}),
};
if (Object.keys(carried).length > 0) {
result.formatting = carried;
}
}
if (sections.some((section) => section.kind === 'columns') && signal) {
const columns = defaultColumnsForSignal(signal);
if (columns.length > 0) {
result.selectFields = columns;
}
}
return result;
}

View File

@@ -0,0 +1,190 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { handleQueryChange } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
import { usePanelTypeSwitch } from '../usePanelTypeSwitch';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('container/NewWidget/utils', () => ({
handleQueryChange: jest.fn(),
PANEL_TYPE_TO_QUERY_TYPES: {
graph: ['builder', 'clickhouse', 'promql'],
table: ['builder', 'clickhouse'],
list: ['builder'],
value: ['builder', 'clickhouse', 'promql'],
bar: ['builder', 'clickhouse', 'promql'],
pie: ['builder', 'clickhouse'],
histogram: ['builder', 'clickhouse', 'promql'],
},
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
toPerses: jest.fn(),
}));
jest.mock('../../getSwitchedPluginSpec', () => ({
getSwitchedPluginSpec: jest.fn(),
}));
jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
getBuilderQueries: jest.fn(),
}));
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
const mockToPerses = toPerses as unknown as jest.Mock;
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
// Opaque sentinels — the leaf utilities are mocked, so only identity matters.
const TABLE_PLUGIN_SPEC = { table: true } as unknown;
const TABLE_QUERIES = [{ id: 'table-q' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const LIST_PLUGIN_SPEC = { list: true } as unknown;
const LIST_QUERIES = [{ id: 'list-q' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const TRANSFORMED = {
id: 'transformed',
queryType: 'builder',
} as unknown as Query;
const CONVERTED = [{ id: 'converted' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const SWITCHED_SPEC = { switched: true } as unknown;
function makeSpec(
kind: string,
pluginSpec: unknown,
queries: NonNullable<DashboardtypesPanelSpecDTO['queries']>,
): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Panel' },
plugin: { kind, spec: pluginSpec },
queries,
} as unknown as DashboardtypesPanelSpecDTO;
}
const tableSpec = makeSpec(
'signoz/TablePanel',
TABLE_PLUGIN_SPEC,
TABLE_QUERIES,
);
const listSpec = makeSpec('signoz/ListPanel', LIST_PLUGIN_SPEC, LIST_QUERIES);
function builderState(currentQuery: Query): {
currentQuery: Query;
redirectWithQueryBuilderData: jest.Mock;
} {
return { currentQuery, redirectWithQueryBuilderData: jest.fn() };
}
describe('usePanelTypeSwitch', () => {
beforeEach(() => {
jest.clearAllMocks();
mockHandleQueryChange.mockReturnValue(TRANSFORMED);
mockToPerses.mockReturnValue(CONVERTED);
mockGetSwitchedPluginSpec.mockReturnValue(SWITCHED_SPEC);
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
});
it('does nothing when switching to the current kind', () => {
const setSpec = jest.fn();
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
expect(setSpec).not.toHaveBeenCalled();
expect(state.redirectWithQueryBuilderData).not.toHaveBeenCalled();
});
it('on first visit: transforms the query and resets the spec to the new kind', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const state = builderState(tableQuery);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
expect(setSpec).toHaveBeenCalledTimes(1);
const next = setSpec.mock.calls[0][0] as DashboardtypesPanelSpecDTO;
expect(next.plugin.kind).toBe('signoz/ListPanel');
expect(next.plugin.spec).toBe(SWITCHED_SPEC);
expect(next.queries).toBe(CONVERTED);
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(TRANSFORMED);
});
it('coerces the query type when the new kind disallows it (promql → List)', () => {
const setSpec = jest.fn();
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// List allows only Query Builder, so the promql query is coerced to 'builder'.
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
expect((queryArg as Query).queryType).toBe('builder');
});
it('restores the original kind verbatim on switch-back (reversibility)', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
let state = builderState(tableQuery);
mockUseQueryBuilder.mockImplementation(() => state);
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelTypeSwitch({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);
// Leave Table for List (stashes Table in its pristine state).
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// Parent re-renders as a List panel; the builder now holds the List query.
state = builderState(listQuery);
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
// Switch back to Table → restored from the stash, not re-transformed.
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
const restored = setSpec.mock.calls[
setSpec.mock.calls.length - 1
][0] as DashboardtypesPanelSpecDTO;
expect(restored.plugin.kind).toBe('signoz/TablePanel');
expect(restored.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
expect(restored.queries).toBe(TABLE_QUERIES);
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(tableQuery);
// The restore path must not run the query transform again.
expect(mockHandleQueryChange).toHaveBeenCalledTimes(1);
});
});

View File

@@ -40,7 +40,7 @@ export function useLegendSeries(
getTimeSeriesResults(data?.response),
data.legendMap,
);
const builderQueries = getBuilderQueries(panel?.spec?.queries || []);
const builderQueries = getBuilderQueries(panel.spec.queries || []);
const byLabel = new Map<string, string>();
series.forEach((s) => {

View File

@@ -0,0 +1,137 @@
import { useCallback, useRef } from 'react';
import type {
DashboardtypesPanelPluginDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
handleQueryChange,
PANEL_TYPE_TO_QUERY_TYPES,
type PartialPanelTypes,
} from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../../Panels/types/panelKind';
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import {
getSwitchedPluginSpec,
type SwitchedPluginSpec,
} from '../getSwitchedPluginSpec';
/** What a kind looks like when you leave it; restored verbatim if you return. */
interface KindState {
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
queries: DashboardtypesQueryDTO[] | null;
builderQuery: Query;
}
interface UsePanelTypeSwitchArgs {
spec: DashboardtypesPanelSpecDTO;
panelType: PANEL_TYPES;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
}
interface UsePanelTypeSwitchApi {
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
onChangePanelKind: (newKind: PanelKind) => void;
}
/**
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
* renderer, config sections, query-builder tabs and request type for free; this hook adds
* the two things that don't: a per-kind session cache that makes switching reversible
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
*/
export function usePanelTypeSwitch({
spec,
panelType,
setSpec,
}: UsePanelTypeSwitchArgs): UsePanelTypeSwitchApi {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
// Latest spec/query/type, read inside the stable callback without re-subscribing.
const specRef = useRef(spec);
specRef.current = spec;
const queryRef = useRef(currentQuery);
queryRef.current = currentQuery;
const panelTypeRef = useRef(panelType);
panelTypeRef.current = panelType;
const onChangePanelKind = useCallback(
(newKind: PanelKind): void => {
const currentSpec = specRef.current;
const oldKind = currentSpec.plugin.kind as PanelKind;
if (newKind === oldKind) {
return;
}
const query = queryRef.current;
cacheRef.current.set(oldKind, {
pluginSpec: currentSpec.plugin.spec,
queries: currentSpec.queries ?? null,
builderQuery: query,
});
const newPanelType = PANEL_KIND_TO_PANEL_TYPE[newKind];
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
// dynamically-chosen kind can't be correlated with its spec statically (as in
// `createDefaultPanel`). The surrounding spec stays fully typed.
const buildSpec = (
pluginSpec: DashboardtypesPanelPluginDTO['spec'] | SwitchedPluginSpec,
queries: DashboardtypesQueryDTO[] | null,
): DashboardtypesPanelSpecDTO => ({
...currentSpec,
plugin: {
...currentSpec.plugin,
kind: newKind,
spec: pluginSpec,
} as DashboardtypesPanelPluginDTO,
queries,
});
// Revisit → restore the stash verbatim (the reversibility path).
const cached = cacheRef.current.get(newKind);
if (cached) {
setSpec(buildSpec(cached.pluginSpec, cached.queries));
redirectWithQueryBuilderData(cached.builderQuery);
return;
}
// First visit → coerce the query type if the new panel disallows it, then
// rebuild the builder query for the new type.
const supported = PANEL_TYPE_TO_QUERY_TYPES[newPanelType] ?? [];
const queryType = supported.includes(query.queryType)
? query.queryType
: supported[0];
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...query, queryType },
panelTypeRef.current,
);
const signal = getBuilderQueries(currentSpec.queries || [])[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(
buildSpec(
getSwitchedPluginSpec(currentSpec, newKind, signal),
toPerses(transformed, newPanelType),
),
);
redirectWithQueryBuilderData(transformed);
},
[setSpec, redirectWithQueryBuilderData],
);
return { onChangePanelKind };
}

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import {
ResizableHandle,
ResizablePanel,
@@ -18,6 +18,7 @@ import {
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
@@ -29,6 +30,7 @@ import { usePanelQuery } from '../hooks/usePanelQuery';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useTableColumns } from './hooks/useTableColumns';
@@ -113,6 +115,9 @@ function PanelEditorContainer({
signal: defaultSignal,
});
// Switch the panel's visualization kind in place (reversible per session).
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
// 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;
@@ -146,6 +151,14 @@ function PanelEditorContainer({
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
// Smallest query step interval (seconds) — the floor for the span-gaps
// threshold. Undefined until results carry step metadata.
const stepInterval = useMemo((): number | undefined => {
const intervals = getExecStats(data.response)?.stepIntervals;
const values = intervals ? Object.values(intervals) : [];
return values.length ? Math.min(...values) : undefined;
}, [data.response]);
const onSave = useCallback(async (): Promise<void> => {
try {
// Bake the live query into the spec so unstaged edits are saved too.
@@ -226,8 +239,10 @@ function PanelEditorContainer({
panelKind={draft.spec.plugin.kind}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
/>
</ResizablePanel>
</ResizablePanelGroup>

View File

@@ -3,7 +3,10 @@ import type { SectionConfig } from '../../types/sections';
// Bar stacking lives in `visualization.stackedBarChart`, so it's a `visualization`
// control, not `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true, stacking: true } },
{
kind: 'visualization',
controls: { switchPanelKind: true, timePreference: true, stacking: true },
},
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, logScale: true } },
{ kind: 'legend', controls: { position: true } },

View File

@@ -3,6 +3,10 @@ import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{
kind: 'visualization',
controls: { switchPanelKind: true },
},
{
kind: 'legend',
controls: { position: true },

View File

@@ -1,3 +1,8 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [];
export const sections: SectionConfig[] = [
{
kind: 'visualization',
controls: { switchPanelKind: true },
},
];

View File

@@ -1,7 +1,10 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true } },
{
kind: 'visualization',
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'thresholds', controls: { variant: 'comparison' } },
{ kind: 'contextLinks' },

View File

@@ -3,7 +3,10 @@ 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.
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true } },
{
kind: 'visualization',
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'legend', controls: { position: true } },
{ kind: 'contextLinks' },

View File

@@ -4,7 +4,10 @@ import type { SectionConfig } from '../../types/sections';
// single column set). It exposes the per-panel time scope, formatting (decimals +
// per-column units), per-column thresholds, and context links.
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true } },
{
kind: 'visualization',
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: 'formatting', controls: { decimals: true, columnUnits: true } },
{ kind: 'thresholds', controls: { variant: 'table' } },
{ kind: 'contextLinks' },

View File

@@ -1,7 +1,10 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true, fillSpans: true } },
{
kind: 'visualization',
controls: { switchPanelKind: true, timePreference: true, fillSpans: true },
},
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, logScale: true } },
{ kind: 'legend', controls: { position: true, colors: true } },

View File

@@ -89,8 +89,11 @@ export interface SectionControls {
spanGaps?: boolean;
};
buckets: { count?: boolean; width?: boolean; mergeQueries?: boolean };
// stackingstackedBarChart (Bar); fillSpans → fill gaps with 0 (TimeSeries).
// switchPanelKindthe visualization-type switcher (every kind, so you can switch
// away from any panel); stacking → stackedBarChart (Bar); fillSpans → fill gaps with
// 0 (TimeSeries).
visualization: {
switchPanelKind: boolean;
timePreference?: boolean;
stacking?: boolean;
fillSpans?: boolean;
@@ -128,7 +131,7 @@ export type SectionConfig =
// Per-section title + sidebar icon. Pure data; the editor component + spec lens
// live in the ConfigPane section registry.
export const SECTION_METADATA = {
formatting: { title: 'Formatting', icon: Hash },
formatting: { title: 'Formatting & Units', icon: Hash },
axes: { title: 'Axes', icon: Ruler },
legend: { title: 'Legend', icon: Layers },
chartAppearance: { title: 'Chart appearance', icon: Palette },

View File

@@ -0,0 +1,39 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { resolveSignal } from '../getBuilderQueries';
function builderQuery(signal: string): DashboardtypesQueryDTO {
return {
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { signal } } },
} as unknown as DashboardtypesQueryDTO;
}
const promqlQuery = {
spec: { plugin: { kind: 'signoz/PromQuery', spec: { query: 'up' } } },
} as unknown as DashboardtypesQueryDTO;
describe('resolveSignal', () => {
const DEFAULT = TelemetrytypesSignalDTO.metrics;
it("uses the first builder query's signal when present", () => {
expect(
resolveSignal([builderQuery('logs')], DEFAULT),
).toBe('logs');
});
it("prefers the builder signal over the default", () => {
expect(resolveSignal([builderQuery('traces')], DEFAULT)).toBe(
'traces',
);
});
it('falls back to the default signal when there are no queries (new panel)', () => {
expect(resolveSignal([], DEFAULT)).toBe('metrics');
expect(resolveSignal(null, DEFAULT)).toBe('metrics');
});
it('stays undefined when queries exist but none are builder queries (PromQL/ClickHouse)', () => {
expect(resolveSignal([promqlQuery], DEFAULT)).toBeUndefined();
});
});

View File

@@ -0,0 +1,22 @@
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('tolerates a bare seconds number (back-compat)', () => {
expect(resolveSpanGaps('600')).toBe(600);
});
it('falls back to true for unparseable input', () => {
expect(resolveSpanGaps('abc')).toBe(true);
});
});

View File

@@ -1,3 +1,4 @@
import { rangeUtil } from '@grafana/data';
import {
DashboardtypesLegendPositionDTO,
DashboardtypesPrecisionOptionDTO,
@@ -38,9 +39,10 @@ export function resolveDecimalPrecision(
}
/**
* `spec.chartAppearance.spanGaps.fillLessThan` is a stringified number on the
* wire. Empty/missing → span all gaps (default); numeric → forward the threshold
* so uPlot only bridges short runs of nulls.
* `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.
*/
export function resolveSpanGaps(
fillLessThan: string | undefined,
@@ -48,8 +50,10 @@ export function resolveSpanGaps(
if (!fillLessThan) {
return true;
}
const parsed = Number(fillLessThan);
return Number.isFinite(parsed) ? parsed : true;
const seconds = rangeUtil.isValidTimeSpan(fillLessThan)
? rangeUtil.intervalToSeconds(fillLessThan)
: Number(fillLessThan);
return Number.isFinite(seconds) && seconds > 0 ? seconds : true;
}
/** Legend position; missing/unknown falls back to `BOTTOM` (chart default, V1 parity). */

View File

@@ -1,4 +1,7 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
@@ -33,3 +36,21 @@ export function getBuilderQueries(
});
return flattened;
}
/**
* Datasource signal scoping panel-type compatibility (List needs logs/traces, not
* metrics): the builder query's signal if present; else `defaultSignal` for a new
* panel (queries empty until edited); else undefined for PromQL/ClickHouse.
*/
export function resolveSignal(
queries: DashboardtypesQueryDTO[] | null,
defaultSignal: TelemetrytypesSignalDTO,
): TelemetrytypesSignalDTO | undefined {
const builderSignal = getBuilderQueries(queries ?? [])[0]?.signal as
| TelemetrytypesSignalDTO
| undefined;
if (builderSignal) {
return builderSignal;
}
return queries?.length ? undefined : defaultSignal;
}

View File

@@ -52,6 +52,8 @@ function section(
}
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
// Index 0 is the untitled root (free-flow) section; index 1 is a titled section.
const TITLED_WITH_ROOT = [section(0, undefined), section(1, 'Latency')];
const baseArgs = {
panelId: 'panel-1',
@@ -177,6 +179,49 @@ describe('usePanelActionItems', () => {
});
});
it('offers "Move out of section" for a panel in a titled section when an untitled root exists', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
}),
);
expect(itemKeys(result.current)).toContain('move-to-root');
});
it('"Move out of section" moves the panel to the untitled root section', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
}),
);
const moveOut = result.current.items.find(
(i) => 'key' in i && i.key === 'move-to-root',
);
(moveOut as { onClick: () => void }).onClick();
expect(mockMovePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
fromLayoutIndex: 1,
toLayoutIndex: 0,
});
});
it('hides "Move out of section" when the panel already sits in the root section', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 0, sections: TITLED_WITH_ROOT },
}),
);
expect(itemKeys(result.current)).not.toContain('move-to-root');
});
it('hides "Move out of section" when every section is titled (no root)', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).not.toContain('move-to-root');
});
it('delete defers to a confirmation: the item opens the dialog, confirm runs the mutation', async () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const del = result.current.items.find(

View File

@@ -4,6 +4,7 @@ import {
CloudDownload,
Copy,
FolderInput,
FolderOutput,
Fullscreen,
PenLine,
Trash2,
@@ -23,7 +24,10 @@ import type { DashboardSection } from '../../../utils';
import type { PanelActionsConfig } from '../Panel';
import { useClonePanel } from '../hooks/useClonePanel';
import { useDeletePanel } from '../hooks/useDeletePanel';
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
import {
type MovePanelArgs,
useMovePanelToSection,
} from '../hooks/useMovePanelToSection';
import { PANEL_ACTION_META } from './panelActionMeta';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
@@ -37,6 +41,66 @@ function notImplementedYet(feature: string): void {
alert(`${feature} option clicked`);
}
interface MoveItemsArgs {
sections: DashboardSection[];
currentLayoutIndex: number;
panelId: string;
movePanel: (args: MovePanelArgs) => Promise<void>;
}
/**
* The "Move to section" submenu (other titled sections) plus a direct "Move out
* of section" to the untitled root, shown only when the panel sits in a titled
* section and a root section exists to receive it.
*/
function buildMoveItems({
sections,
currentLayoutIndex,
panelId,
movePanel,
}: MoveItemsArgs): MenuItem[] {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
);
const items: MenuItem[] = [
{
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
...(targets.length === 0
? { disabled: true }
: {
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
}),
},
];
const rootSection = sections.find((s) => !s.title);
if (rootSection && rootSection.layoutIndex !== currentLayoutIndex) {
items.push({
key: 'move-to-root',
label: 'Move out of section',
icon: <FolderOutput size={14} />,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: rootSection.layoutIndex,
}),
});
}
return items;
}
interface UsePanelActionItemsArgs {
panelId: string;
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); */
@@ -155,31 +219,15 @@ export function usePanelActionItems({
});
}
const moveGroup: MenuItem[] = [];
if (canMove && panelActions) {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== panelActions.currentLayoutIndex,
);
moveGroup.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
...(targets.length === 0
? { disabled: true }
: {
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: panelActions.currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
}),
});
}
const moveGroup: MenuItem[] =
canMove && panelActions
? buildMoveItems({
sections,
currentLayoutIndex: panelActions.currentLayoutIndex,
panelId,
movePanel,
})
: [];
const deleteGroup: MenuItem[] =
canDelete && panelActions

View File

@@ -25,17 +25,17 @@ function PanelTypeSelectionModal({
destroyOnClose
>
<div className={styles.grid}>
{PANEL_TYPES.map((type) => (
{PANEL_TYPES.map(({ pluginKind, label, Icon }) => (
<Button
key={type.pluginKind}
key={pluginKind}
type="button"
variant="ghost"
className={styles.typeButton}
data-testid={`panel-type-${type.pluginKind}`}
onClick={(): void => onSelect(type.pluginKind)}
data-testid={`panel-type-${pluginKind}`}
onClick={(): void => onSelect(pluginKind)}
>
{type.icon}
{type.label}
<Icon size={14} />
{label}
</Button>
))}
</div>

View File

@@ -0,0 +1,24 @@
import {
BarChart,
ChartLine,
ChartPie,
Hash,
List,
Table,
} from '@signozhq/icons';
import type { PanelType } from './types';
export const PANEL_TYPES: PanelType[] = [
{
pluginKind: 'signoz/TimeSeriesPanel',
label: 'Time Series',
Icon: ChartLine,
},
{ pluginKind: 'signoz/NumberPanel', label: 'Number', Icon: Hash },
{ pluginKind: 'signoz/TablePanel', label: 'Table', Icon: Table },
{ pluginKind: 'signoz/BarChartPanel', label: 'Bar Chart', Icon: BarChart },
{ pluginKind: 'signoz/PieChartPanel', label: 'Pie Chart', Icon: ChartPie },
{ pluginKind: 'signoz/HistogramPanel', label: 'Histogram', Icon: BarChart },
{ pluginKind: 'signoz/ListPanel', label: 'List', Icon: List },
];

View File

@@ -1,36 +0,0 @@
import {
BarChart,
ChartLine,
ChartPie,
Hash,
List,
Table,
} from '@signozhq/icons';
import type { PanelType } from './types';
export const PANEL_TYPES: PanelType[] = [
{
pluginKind: 'signoz/TimeSeriesPanel',
label: 'Time Series',
icon: <ChartLine size={16} />,
},
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
{
pluginKind: 'signoz/BarChartPanel',
label: 'Bar Chart',
icon: <BarChart size={16} />,
},
{
pluginKind: 'signoz/PieChartPanel',
label: 'Pie Chart',
icon: <ChartPie size={16} />,
},
{
pluginKind: 'signoz/HistogramPanel',
label: 'Histogram',
icon: <BarChart size={16} />,
},
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
];

View File

@@ -1,7 +1,16 @@
import type { IconSize } from '@signozhq/icons';
import type { ComponentType, SVGProps } from 'react';
import type { PanelKind } from '../../../Panels/types/panelKind';
type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
size?: number | IconSize;
strokeWidth?: number;
};
export interface PanelType {
pluginKind: PanelKind;
label: string;
icon: JSX.Element;
/** Icon component — the consumer renders it and controls size/color/etc. */
Icon: ComponentType<IconProps>;
}

View File

@@ -63,9 +63,10 @@ describe('useClonePanel', () => {
op: 'add',
path: '/spec/layouts/0/spec/items/-',
value: {
// Same dimensions as the source panel (p1: 8x5).
// Same dimensions as the source panel (p1: 8x5). The last row is
// full (8 + 4 = 12 cols), so the 8-wide clone wraps to a fresh row
// at the section bottom: max(y + height) = 5.
x: 0,
// Bottom of the section: max(y + height) over existing items = 5.
y: 5,
width: 8,
height: 5,
@@ -75,6 +76,27 @@ describe('useClonePanel', () => {
]);
});
it('places the clone beside the last row when it fits', async () => {
const oneNarrowItem: DashboardSection[] = [
{
id: 'section-0',
layoutIndex: 0,
title: 'Overview',
repeatVariable: undefined,
items: [{ id: 'p1', x: 0, y: 0, width: 4, height: 5, panel: sourcePanel }],
},
];
const { result } = renderHook(() =>
useClonePanel({ sections: oneNarrowItem }),
);
await result.current({ panelId: 'p1', layoutIndex: 0 });
const ops = mockPatch.mock.calls[0][1];
// Room in the last row (4 + 4 = 8 ≤ 12 cols) → sits to the right at y:0.
expect(ops[1].value).toMatchObject({ x: 4, y: 0, width: 4, height: 5 });
});
it('deep-copies the spec — the cloned value is not the same object reference', async () => {
const { result } = renderHook(() => useClonePanel({ sections: sections() }));

View File

@@ -5,7 +5,11 @@ import { v4 as uuid } from 'uuid';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { addPanelToSectionOps, panelRef } from '../../../patchOps';
import {
addPanelToSectionOps,
findFreeSlot,
panelRef,
} from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -20,8 +24,9 @@ export interface ClonePanelArgs {
/**
* Duplicates a panel: deep-copies the source spec under a fresh id and drops a
* same-size grid item at the bottom of the section, as one atomic patch. Mirrors
* V1's clone (verbatim spec copy, no rename).
* same-size grid item into the section via `findFreeSlot` (beside the last row
* if it fits, else a fresh row), as one atomic patch. Mirrors V1's clone
* (verbatim spec copy, no rename).
*/
export function useClonePanel({
sections,
@@ -38,10 +43,7 @@ export function useClonePanel({
}
const newPanelId = uuid();
const nextY = section.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
const { x, y } = findFreeSlot(section.items, source.width);
const clone = patchDashboardV2(
{ id: dashboardId },
@@ -50,8 +52,8 @@ export function useClonePanel({
panel: cloneDeep(source.panel),
layoutIndex,
item: {
x: 0,
y: nextY,
x,
y,
width: source.width,
height: source.height,
content: { $ref: panelRef(newPanelId) },

View File

@@ -107,7 +107,7 @@ describe('createPanelOps', () => {
expect(value.y).toBe(6);
});
it('falls back to the last section when no index is requested', () => {
it('falls back to the root (first) section when no index is requested', () => {
const layouts = [section([]), section([item(0, 6)])];
const ops = createPanelOps({
layouts,
@@ -116,11 +116,11 @@ describe('createPanelOps', () => {
panel,
});
expect(ops[1].path).toBe('/spec/layouts/1/spec/items/-');
expect(ops[1].path).toBe('/spec/layouts/0/spec/items/-');
});
it('falls back to the last section when the requested index is out of range', () => {
const layouts = [section([])];
it('falls back to the root (first) section when the requested index is out of range', () => {
const layouts = [section([item(0, 6)]), section([])];
const ops = createPanelOps({ layouts, layoutIndex: 5, panelId: 'p1', panel });
expect(ops[1].path).toBe('/spec/layouts/0/spec/items/-');
});

View File

@@ -121,7 +121,7 @@ export function addPanelToSectionOps({
interface CreatePanelOpsArgs {
/** Current sections, used to resolve the target and the next free row. */
layouts: DashboardtypesLayoutDTO[];
/** Preferred section (from the "Add panel" trigger); falls back to the last. */
/** Preferred section (from a section's "Add panel" trigger); falls back to the root (first) section. */
layoutIndex: number | undefined;
panelId: string;
panel: DashboardtypesPanelDTO;
@@ -132,13 +132,16 @@ const NEW_PANEL_SIZE = { width: 6, height: 6 };
/** Columns in the section grid — mirrors `cols` on SectionGrid's GridLayout. */
const GRID_COLS = 12;
/** Minimal placement fields shared by grid-item DTOs and flattened `GridItem`s. */
type PlacedItem = Pick<DashboardGridItemDTO, 'x' | 'y' | 'width' | 'height'>;
/**
* Placement for a new grid item: drop it right of the last row if there's room,
* else wrap to a fresh row at the bottom. Only the last row is considered (items
* sharing the greatest top-y); gaps in earlier rows are left alone.
*/
function findFreeSlot(
items: DashboardGridItemDTO[],
export function findFreeSlot(
items: PlacedItem[],
width: number,
): { x: number; y: number } {
const w = Math.min(width, GRID_COLS);
@@ -163,8 +166,8 @@ function findFreeSlot(
/**
* Ops to persist a brand-new panel (editor save path): resolve the target
* section (requested index if valid, else last, else a freshly-created one) and
* place the panel via `findFreeSlot`.
* section (requested index if valid, else the root/first section, else a
* freshly-created one) and place the panel via `findFreeSlot`.
*/
export function createPanelOps({
layouts,
@@ -174,14 +177,17 @@ export function createPanelOps({
}: CreatePanelOpsArgs): DashboardtypesJSONPatchOperationDTO[] {
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
const requested =
layoutIndex !== undefined && layouts[layoutIndex] !== undefined
? layoutIndex
: layouts.length - 1;
let targetIndex = requested;
let items: DashboardGridItemDTO[] = layouts[requested]?.spec.items ?? [];
if (targetIndex < 0) {
let targetIndex: number;
let items: DashboardGridItemDTO[];
if (layoutIndex !== undefined && layouts[layoutIndex] !== undefined) {
// Explicit section — a section's own "New Panel" trigger.
targetIndex = layoutIndex;
items = layouts[layoutIndex]?.spec.items ?? [];
} else if (layouts.length > 0) {
// No section specified (toolbar "New Panel") → the root (first) section.
targetIndex = 0;
items = layouts[0]?.spec.items ?? [];
} else {
// No sections yet — create an untitled one and target it.
ops.push(addSectionOp(''));
targetIndex = 0;

View File

@@ -137,13 +137,3 @@ export function layoutsToSections(
})
.filter((s): s is DashboardSection => s !== null);
}
export function getPanelKindLabel(
panel: DashboardtypesPanelDTO | undefined,
): string {
const kind = panel?.spec?.plugin?.kind;
if (!kind) {
return 'unknown';
}
return kind.replace(/^signoz\//, '');
}