Compare commits

...

31 Commits

Author SHA1 Message Date
Abhi Kumar
61681139c7 style(dashboards-v2): config pane section dividers and foreground tokens
Draw the divider between config sections from the ConfigPane (border-top
between adjacent sections) instead of a per-section border-bottom, and move
the legend/settings text from the legacy --text-vanilla-400 to the
--l*-foreground tokens.
2026-06-15 18:39:36 +05:30
Abhi Kumar
6c59e2e1c9 fix(dashboards-v2): keep the bottom legend from overflowing its wrapper
The legend's virtualized grid sat in a flex column with only height:100%,
so with the default min-height:auto it refused to shrink below its content
height and spilled past the capped legend rectangle. Add flex:1 +
min-height:0 so it scrolls within the allotted height, and make the wrapper
border-box + overflow:hidden so its padding stays inside the rectangle
instead of stealing chart space.
2026-06-15 18:39:18 +05:30
Abhi Kumar
658a37030e refactor(dashboards-v2): use antd inputs and selects in panel editor sections
Swap the panel editor config sections from @signozhq/ui inputs/select to
antd equivalents. ConfigSelect now wraps antd Select (virtual={false} so the
short option lists render fully) which covers every section select; the Input
swaps map testId to data-testid. Select test helpers open the dropdown via the
selector element, matching the antd open behaviour.

git commit -q -F /tmp/commit_a.txt && echo COMMITTED
2026-06-15 18:36:35 +05:30
Abhi Kumar
24df53c7b8 feat(dashboards-v2): wire fillSpans into the query request
Map the panel's visualization.fillSpans to the V5 request's formatOptions.fillGaps
(backend-fills missing points with 0), mirroring V1's `fillGaps: widget.fillSpans`.
buildQueryRangeRequest takes a fillGaps arg; usePanelQuery reads visualization.fillSpans
and includes it in the react-query key so toggling it refetches rather than reading a
stale response.

git commit -F "$GITDIR/SZ_CMSG.txt" 2>&1 | tail -4; echo "---log---"; git log --oneline -1
2026-06-15 16:46:29 +05:30
Abhi Kumar
cf5628e59c fix(dashboards-v2): format threshold values through their unit
Threshold view rows showed the raw unit id (e.g. "< 3100 currencyUSD"). Format the
value + unit through the shared, unit-aware formatPanelValue instead, so it reads
"< $3100" and matches how the panel renders the value. Applies to both the line
thresholds (TimeSeries/Bar) and the Number comparison thresholds.

git commit -F "$GITDIR/SZ_CMSG.txt" 2>&1 | tail -3; echo "---log---"; git log --oneline -1
2026-06-15 16:46:29 +05:30
Abhi Kumar
7319c30724 refactor(dashboards-v2): full-page modal editor with shared time picker
Present the panel editor as a full-page modal: a dimmed, padded backdrop around a
bordered, rounded surface, with entry/exit transitions (the exit defers unmount until
the close animation ends, with a timer fallback for prefers-reduced-motion).

Replace the bespoke PreviewTimePicker wrapper with the shared DateTimeSelectionV2 in
modal mode (isModalTimeSelection + disableUrlSync) so the preview gets the standard
picker and auto-refresh while staying isolated from global Redux time and the URL.
usePreviewQuery now owns the local interval + window and folds in the panel's time
preference, so editing it updates the preview live.

git commit -F "$GITDIR/SZ_CMSG.txt" 2>&1 | tail -4; echo "---log---"; git log --oneline -1
2026-06-15 16:46:29 +05:30
Abhi Kumar
0f722bac89 feat(dashboards-v2): visualization section + per-panel time preference
Add a Visualization config section, gated per kind to match V1's allow-flags:
- timePreference (TimeSeries/Bar/Number/Pie) — pins the panel to a fixed relative
  window; stacking (Bar) → stackedBarChart; fillSpans (TimeSeries) → fill gaps with 0.
- SectionSpecMap.visualization is typed as the widest (Bar) shape; the per-kind
  controls bag gates which fields each editor writes, with the union cast localized
  in the registry lens.

Honor the time preference on the dashboard render path: usePanelQuery reads
visualization.timePreference and resolves the window through resolvePanelTimeWindow
(override > relative preset > dashboard window; presets anchored to the dashboard
end so the cache stays stable), and the preference participates in the query key.

git commit -F "$GITDIR/SZ_CMSG.txt" 2>&1 | tail -5; echo "---log---"; git log --oneline -1
2026-06-15 16:46:29 +05:30
Abhi Kumar
07fa9506c0 fix(dashboards-v2): right-legend overflow and sizing
The right-positioned legend rendered its own scrollbars and clipped items. Drop
the legend-wrapper's overflow:auto and the legend-right padding override, and set
box-sizing:border-box on legend items so their padding doesn't overflow the track.

git commit -F "$GITDIR/SZ_CMSG.txt" 2>&1 | tail -6
2026-06-15 16:46:29 +05:30
Abhi Kumar
b0f2fc8d60 feat(dashboards-v2): buckets, context-links & threshold section editors
Add the remaining ConfigPane section editors to the panel-editor framework:

- Thresholds (TimeSeries/Bar): value+label line rules with V1-style view/edit
  modes, preset+custom color picker, and unit picker scoped to the y-axis unit's
  category with an incompatibility message.
- Comparison thresholds (Number): operator/value/unit/color/display rules in the
  ComparisonThresholdDTO shape the Number renderer already consumes.
- Buckets (Histogram): count / width / merge-active-queries.
- Context links (all kinds): panel-level link list editor.

Framework wiring: add comparisonThresholds + buckets/contextLinks to SectionSpecMap,
AtomicSectionKind and SECTION_METADATA; add an isHidden visibility predicate (hides
the Histogram legend once queries are merged); forward the panel's yAxisUnit through
SectionSlot to threshold editors; register each editor with its spec lens.

git commit -q -F "$GITDIR/SZ_CMSG.txt" && echo "committed" && git log --oneline -1
2026-06-15 16:46:29 +05:30
Abhi Kumar
665008b5ee fix(dashboards-v2): persist the full panel spec on save
The editor's config pane edits the whole draft spec (formatting, axes, legend,
chart appearance, legend.customColors, context links) via setSpec, but save only
patched /spec/display — so every control besides title/description was a no-op
once saved.

Replace the whole panel spec in one add op against /spec/panels/{id}/spec from
the draft, and pass draft.spec from onSave. The draft carries queries unchanged
until the V2 query builder lands, so a whole-spec replace is safe.

Also collapse the duplicate editing path: drop display/setDisplay from the draft
hook and edit title/description through the same spec/setSpec surface the config
sections use (title/description are just spec.display). One editing surface.
2026-06-15 16:46:29 +05:30
Abhi Kumar
f70d4823ad feat(dashboards-v2): legend per-series color overrides
Lift the preview query and its editor-local time up to the editor root
(usePreviewQuery) so the preview and config pane share one result; PreviewPane
becomes presentational. Derive the panel's resolved series + default colors
from that result (useLegendSeries), matching the renderer's label resolution.

Add a searchable, virtualized LegendColors control (react-virtuoso + antd
ColorPicker) that writes per-series overrides into legend.customColors, keyed
by the same label the chart colors by, with reset-to-default. Enabled on the
time-series legend.
2026-06-15 16:46:29 +05:30
Abhi Kumar
7d83108be2 feat(dashboards-v2): build the panel editor config pane (sections + controls)
Render each panel kind's config sections generically from the registry: a
collapsible SettingsSection with icon tiles, a SectionSlot that wires each
section through a typed spec lens, and General/Display grouping in ConfigPane.

Add the Formatting, Axes, Legend and Chart-appearance section editors built on
reusable controls — ConfigSelect (overlay-safe, full-width dropdown),
ConfigSegmented (icon segmented toggle) and ConfigSwitch (toggle card) — plus
the editor draft hook gaining spec/setSpec and a deep isDirty.

Includes the supporting fixes: lift body-portaled Select/cmd-K above the
overlay, a thin pane scrollbar, Radix pointer-capture jsdom shims for tests,
and a softer solid fill opacity.
2026-06-15 16:46:29 +05:30
Abhi Kumar
3befea28ee refactor(dashboards-v2): bind panel config sections to their spec slices
Redesign the section config types so each section maps to the exact spec
slice it edits (SectionSpecMap), and split sections into controlled (with a
per-kind sub-control subset) vs atomic groups.

Drop control flags that had drifted from the generated DTOs (axes.unit,
legend.mode, chartAppearance.stacked/fillOpacity, buckets.min/max) and the
unused columnUnits section. Each panel kind now declares an accurate,
type-checked SectionConfig[].
2026-06-15 16:46:29 +05:30
Abhi Kumar
cc24d8b6b7 feat(dashboards-v2): clone panel + delete confirmation in the actions menu
Implement the two remaining destructive/duplicating panel actions and the
shared confirm primitive they need.

- Clone: useClonePanel deep-copies the source panel's spec under a fresh
  id and drops a new grid item (same dimensions) at the bottom of the same
  section, as one atomic patch — reusing addPanelToSectionOps, mirroring
  V1 (verbatim copy, no rename). In-flight → done/failed feedback via
  toast.promise. The menu's Clone item is gated on panelActions (it needs
  the section context to place the copy).
- Delete confirmation: the Delete item now opens a ConfirmDeleteDialog
  instead of deleting on click; the mutation runs on confirm with a
  loading state, and the dialog closes only on success.
- useConfirmableAction: a generic, dependency-free two-step confirm flow
  (request → confirm/cancel + in-flight flag) extracted to src/hooks so it
  isn't inlined in the menu and can back other confirm surfaces. The menu
  returns { items, deleteConfirm } and the presentational PanelActionsMenu
  renders the dialog from it.
- Tests: useClonePanel (patch contents, deep-copy, no-op when missing,
  toast feedback, rejection swallowed), useConfirmableAction (open/confirm/
  cancel/reject), and the menu suite updated for the clone mutation + the
  two-step delete flow.

echo "--- done ---"
git log --oneline -1
echo "--- remaining ---" && git status --short
2026-06-15 16:46:29 +05:30
Abhi Kumar
f36c6ac87c refactor(dashboards-v2): capability- and role-gated panel actions menu
Rework the V2 panel actions menu so each action passes three orthogonal
gates, mirroring V1's WidgetHeader rules, and drop the prop-threading the
old menu relied on.

- Kind gate: PanelDefinition gains a required `actions`
  (PanelActionCapabilities: view/edit/download/createAlert) declared
  per-kind in each kinds/<Kind>/definition.ts — registering a new kind
  forces an explicit decision, like PanelInteractionMap does for gestures.
- Role gate: panelActionMeta maps each action id to a componentPermission
  key (absent = open to every role, V1 parity — edit/clone share
  edit_widget; delete uses delete_widget; move uses edit_dashboard).
- Context gate: dashboard editable (store) + layout config presence.
- usePanelActionItems resolves all three and composes items as groups
  with dividers between non-empty groups; PanelActionsMenu becomes purely
  presentational and renders nothing when no action survives its gates.
- The full V1 action set is present — View, Edit, Clone, Download as CSV,
  Create Alerts, Move to section, Delete — with not-yet-ported actions on
  a temporary placeholder handler.
- De-thread move/delete: the menu calls the store-backed useDeletePanel /
  useMovePanelToSection directly, so PanelActionsConfig is now pure data
  ({ currentLayoutIndex, sections }) and SectionList/Section/SectionGrid/
  SortableSection no longer forward callbacks. Fixes a latent free-flow
  bug where delete was wired to a noop.
- Edit opens the URL-driven overlay via the extracted useOpenPanelEditor.
- Tests: gate matrix (kind/role/context), divider grouping, and mutation
  wiring.

echo "--- commit B done ---"
git log --oneline -4
echo "--- remaining staged: $(git diff --cached --name-only | wc -l) ---"
2026-06-15 16:46:29 +05:30
Abhi Kumar
116a38b975 fix(dashboards-v2): surface V5 query warnings in panel status
Adapt the panel status helpers to the pure-V5 warning shape
(Querybuildertypesv5QueryWarnDataDTO) instead of the legacy hand-written
Warning type. V5 warnings carry no `code`, so:

- panelStatusFromWarning maps message/url/sub-messages only and filters
  out empty sub-messages; PanelStatusDetail.code becomes optional.
- PanelStatusContent renders the code heading only when present.

echo "--- commit C done ---"
git log --oneline -1
echo "--- remaining staged: $(git diff --cached --name-only | wc -l) ---"
2026-06-15 16:46:29 +05:30
Abhi Kumar
6d375d860b feat(dashboards-v2): panel editor overlay with live preview
Add the V2 panel editor as a full-screen overlay driven by the
`editPanelId` query param — the dashboard stays mounted underneath
(no separate route) and the overlay portals to <body> to escape the
app-content stacking context.

- DashboardContainer resolves the panel from the already-loaded
  dashboard (no extra fetch) and hosts the overlay.
- Resizable split (persisted via useDefaultLayout + scoped
  layoutStorage): left = live PreviewPane rendering the draft through
  the production renderer registry in DASHBOARD_EDIT mode + a query
  builder placeholder; right = ConfigPane (title/description).
- Draft-authoritative state (usePanelEditorDraft) + JSON-patch save of
  the panel display (usePanelEditorSave), invalidating + refetching the
  dashboard on success.
- Preview time is editor-local: PreviewTimePicker keeps fully-local
  state (no global Redux time, no URL writes) and resolves the selection
  to an absolute [startMs, endMs] window passed to usePanelQuery as an
  optional `time` override, floored to int64 ms — so changing it never
  touches or re-runs the dashboard behind the overlay.
- Unit tests for the draft, save patch, ConfigPane, and the query-time
  override (incl. fractional-ms flooring).

echo "--- commit A done ---"
git log --oneline -1
2026-06-15 16:46:26 +05:30
Abhi Kumar
5ed8dc78ca fix(dashboards-v2): prevent pie donut and labels from clipping
The donut used a flat radius = 0.35 * min(w,h) with leader labels anchored
at 1.3 * radius, leaving only ~0.045 * size between the label anchor and
the SVG edge — so on small panels the top/bottom labels (and a stray
container padding under border-box) clipped.

- getDonutGeometry now solves the radius back from the box half-extent
  minus a fixed label allowance, so the label anchor always lands a
  constant 22px inside the edge regardless of panel size — extracted as a
  pure, tested helper with a shared label-ratio constant (no drift with
  getArcGeometry) and a DonutGeometry type.
- Align the pie legend wrapper padding with ChartLayout's.
- Tests for getDonutGeometry (anchor stays in-box, derived radii, smaller
  axis wins, no negative radius).

echo "--- commit 2 done ---" && git log --oneline -3
echo "--- remaining ---" && git status --short
2026-06-15 16:40:41 +05:30
Abhi Kumar
4ccef6190d fix(dashboards-v2): cap bottom legend height at 30% of the panel
The bottom-legend reservation was capped only at min(2 rows, 80px) with
no relation to the container height, despite the helper's doc promising a
%-of-container cap. On short grid panels the legend kept its full slice
and the chart — the pie donut especially — collapsed to a sliver.

- calculateChartDimensions now also caps the bottom legend at 30% of the
  container height (mirroring the RIGHT-legend width cap).
- Drop the legend grid row-gap so the capped height isn't overrun.
- Add the previously-missing tests for calculateChartDimensions,
  including the short-container cap.

echo "--- commit 1 done ---" && git log --oneline -1
echo "--- remaining ---" && git status --short
2026-06-15 16:40:41 +05:30
Abhi Kumar
cd77cb7cb0 feat(dashboards-v2): number panel kind + registry entry 2026-06-15 16:40:41 +05:30
Abhi Kumar
e30f9725f6 feat(dashboards): support = and != threshold operators 2026-06-15 16:40:41 +05:30
Abhi Kumar
e20c71ef4d chore(dashboards-v2): dashboards-list-v2 query-param sort/order adjustments 2026-06-15 16:40:41 +05:30
Abhi Kumar
493c85a94e feat(dashboards-v2): panel chrome - header, body, interactions & grid wiring 2026-06-15 16:40:41 +05:30
Abhi Kumar
2d2e13ec3b feat(dashboards-v2): panel status popover (error/warning surfacing) 2026-06-15 16:40:41 +05:30
Abhi Kumar
e9b0089c8c feat(dashboards-v2): panel plugin registry 2026-06-15 16:40:41 +05:30
Abhi Kumar
afbbd644a7 feat(dashboards-v2): PieChartPanel renderer 2026-06-15 16:40:41 +05:30
Abhi Kumar
15740bd928 feat(dashboards-v2): HistogramPanel renderer 2026-06-15 16:40:41 +05:30
Abhi Kumar
98453ea4b4 feat(dashboards-v2): BarChartPanel renderer 2026-06-15 16:40:41 +05:30
Abhi Kumar
6ac2a398d8 feat(dashboards-v2): TimeSeriesPanel renderer 2026-06-15 16:40:41 +05:30
Abhi Kumar
90160054f6 feat(dashboards-v2): usePanelQuery data-fetching hook 2026-06-15 16:40:41 +05:30
Abhi Kumar
2064c08137 feat(dashboards-v2): panel type system & shared chart utilities 2026-06-15 16:40:41 +05:30
177 changed files with 12392 additions and 191 deletions

View File

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

View File

@@ -1,5 +1,6 @@
export enum QueryParams {
interval = 'interval',
editPanelId = 'editPanelId',
startTime = 'startTime',
endTime = 'endTime',
service = 'service',

View File

@@ -63,5 +63,6 @@
flex: 0 0 auto;
min-height: 0;
min-width: 0;
padding: 8px;
padding-left: 12px;
padding-bottom: 12px;
}

View File

@@ -16,7 +16,7 @@ import PieArc from './PieArc';
import PieCenterLabel from './PieCenterLabel';
import styles from './Pie.module.scss';
import { PieTooltipData } from './types';
import { getFillColor } from './utils';
import { getDonutGeometry, getFillColor } from './utils';
/**
* Donut chart rendered with @visx. Splits its area into chart + legend with the
@@ -78,16 +78,12 @@ export default function Pie({
[containerWidth, containerHeight, position, data],
);
// Donut geometry derived from the allocated chart box.
const { size, radius, innerRadius } = useMemo(() => {
const nextSize = Math.min(width, height);
const nextRadius = nextSize * 0.35;
return {
size: nextSize,
radius: nextRadius,
innerRadius: nextRadius * 0.6,
};
}, [width, height]);
// Donut geometry derived from the allocated chart box, sized to leave room
// for the external leader labels (see getDonutGeometry).
const { size, radius, innerRadius } = useMemo(
() => getDonutGeometry(width, height),
[width, height],
);
const totalValue = useMemo(
() => visibleData.reduce((sum, slice) => sum + slice.value, 0),

View File

@@ -1,11 +1,40 @@
import {
getArcGeometry,
getDonutGeometry,
getFillColor,
getScaledFontSize,
lightenColor,
} from '../utils';
describe('Pie utils', () => {
describe('getDonutGeometry', () => {
it('keeps the label anchor inside the box (reserves room for leader labels)', () => {
const { radius } = getDonutGeometry(400, 300);
const half = Math.min(400, 300) / 2; // 150
// The label anchor sits at radius * 1.3 and must stay within the box
// half-extent so labels are not clipped.
expect(radius * 1.3).toBeLessThanOrEqual(half);
// And it should use the available room (anchor = half - 22 allowance).
expect(radius * 1.3).toBeCloseTo(half - 22);
});
it('derives size and inner radius from the outer radius', () => {
const { size, radius, innerRadius } = getDonutGeometry(300, 300);
expect(size).toBeCloseTo(radius * 2);
expect(innerRadius).toBeCloseTo(radius * 0.6);
});
it('sizes off the smaller dimension so it fits both axes', () => {
expect(getDonutGeometry(1000, 200)).toStrictEqual(
getDonutGeometry(200, 1000),
);
});
it('never returns a negative radius for a box too small for labels', () => {
expect(getDonutGeometry(20, 20).radius).toBe(0);
});
});
describe('getScaledFontSize', () => {
it('returns the base size for empty text', () => {
expect(getScaledFontSize({ text: '', baseSize: 30, innerRadius: 100 })).toBe(

View File

@@ -10,6 +10,16 @@ export interface ScaledFontSizeArgs {
innerRadius: number;
}
/** Donut sizing for a given chart box: the outer/inner radii and the square it spans. */
export interface DonutGeometry {
/** Outer diameter — feeds the visx Pie width/height and the render guard. */
size: number;
/** Outer radius of the donut ring. */
radius: number;
/** Inner radius (the hole) — also bounds the centre-total font. */
innerRadius: number;
}
export interface ArcGeometry {
/** Outer point where the leader label sits. */
labelX: number;

View File

@@ -3,7 +3,37 @@
* so the renderer stays declarative (per the one-component-per-file rule).
*/
import { ArcGeometry, ParsedRgb, ScaledFontSizeArgs } from './types';
import {
ArcGeometry,
DonutGeometry,
ParsedRgb,
ScaledFontSizeArgs,
} from './types';
// Leader-line + two-line label/value drawn outside the donut. `getArcGeometry`
// anchors the label at `radius * LABEL_RADIUS_RATIO`; `LABEL_TEXT_ALLOWANCE` is
// the px reserved beyond that anchor for the (10px, two-line) text so it never
// clips against the SVG edge.
const LABEL_RADIUS_RATIO = 1.3;
const LABEL_TEXT_ALLOWANCE = 22;
const INNER_RADIUS_RATIO = 0.6;
/**
* Sizes the donut to fit inside a `width × height` box *with room for the
* external leader labels*. The label anchor sits at `radius * 1.3`, so we solve
* the outer radius back from the box's half-extent minus the text allowance —
* guaranteeing the labels stay inside the SVG instead of being clipped (V1 used
* a flat `0.35 * min(w,h)`, which left too little margin on small panels).
*/
export function getDonutGeometry(width: number, height: number): DonutGeometry {
const half = Math.min(width, height) / 2;
const radius = Math.max(0, (half - LABEL_TEXT_ALLOWANCE) / LABEL_RADIUS_RATIO);
return {
size: radius * 2,
radius,
innerRadius: radius * INNER_RADIUS_RATIO,
};
}
/**
* Shrinks the centre-total font as the text gets longer so it never overflows
@@ -37,7 +67,7 @@ export function getArcGeometry(
radius: number,
): ArcGeometry {
const angle = (startAngle + endAngle) / 2;
const labelRadius = radius * 1.3;
const labelRadius = radius * LABEL_RADIUS_RATIO;
const lineEndRadius = radius * 1.1;
return {
labelX: Math.sin(angle) * labelRadius,

View File

@@ -0,0 +1,79 @@
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { calculateChartDimensions } from '../utils';
const labels = (count: number, length = 20): string[] =>
Array.from({ length: count }, (_, i) =>
`label-${i}`.padEnd(length, 'x').slice(0, length),
);
describe('calculateChartDimensions', () => {
it('returns all zeros when the container has no space', () => {
expect(
calculateChartDimensions({
containerWidth: 0,
containerHeight: 300,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(3),
}),
).toStrictEqual({
width: 0,
height: 0,
legendWidth: 0,
legendHeight: 0,
averageLegendWidth: 0,
});
});
it('RIGHT: reserves a side column capped at 30% of the width and keeps full height', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
// 40-char labels approximate to 336px, capped at min(240, 30% of 1000).
expect(dims.legendWidth).toBe(240);
expect(dims.width).toBe(760);
expect(dims.height).toBe(400);
expect(dims.legendHeight).toBe(400);
});
it('BOTTOM: a single row of items reserves one legend row', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(3),
});
// One row = line height (28) + padding (12).
expect(dims.legendHeight).toBe(40);
expect(dims.height).toBe(460);
expect(dims.legendWidth).toBe(1000);
});
it('BOTTOM: many items cap at two rows on a tall container', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
expect(dims.legendHeight).toBe(68);
expect(dims.height).toBe(432);
});
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 160,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Without the height-relative cap the legend would take 68px of a 160px
// panel and the chart (pie especially) collapses to a sliver.
expect(dims.legendHeight).toBe(48); // 30% of 160
expect(dims.height).toBe(112);
});
});

View File

@@ -116,7 +116,15 @@ export function calculateChartDimensions({
? legendRowCount * legendRowHeight - LEGEND_PADDING
: legendRowHeight;
const maxAllowedLegendHeight = Math.min(2 * legendRowHeight, 80);
// Cap at two rows / 80px, and never more than 30% of the container height
// (the doc above always promised the %-cap; without it, short grid panels
// hand most of their area to the legend and the chart — the pie donut
// especially — collapses to a sliver). 30% mirrors the RIGHT-legend width cap.
const maxAllowedLegendHeight = Math.min(
2 * legendRowHeight,
80,
Math.floor(containerHeight * 0.3),
);
const bottomLegendHeight = Math.min(
idealBottomLegendHeight,

View File

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

View File

@@ -2,6 +2,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
createColumnsAndDataSource,
evaluateThresholdWithConvertedValue,
getQueryLegend,
sortFunction,
} from '../utils';
@@ -225,3 +226,30 @@ describe('Table Panel utils with QB v5 aggregations', () => {
).toBe(0);
});
});
// No units passed, so `convertUnit` is a no-op and the comparison runs against
// the raw value — exercising `evaluateCondition`'s operator switch directly.
describe('evaluateThresholdWithConvertedValue operators', () => {
it('handles ordering operators', () => {
expect(evaluateThresholdWithConvertedValue(5, 3, '>')).toBe(true);
expect(evaluateThresholdWithConvertedValue(2, 3, '>')).toBe(false);
expect(evaluateThresholdWithConvertedValue(2, 3, '<')).toBe(true);
expect(evaluateThresholdWithConvertedValue(3, 3, '>=')).toBe(true);
expect(evaluateThresholdWithConvertedValue(3, 3, '<=')).toBe(true);
});
it('treats = and == as equality', () => {
expect(evaluateThresholdWithConvertedValue(3, 3, '=')).toBe(true);
expect(evaluateThresholdWithConvertedValue(3, 3, '==')).toBe(true);
expect(evaluateThresholdWithConvertedValue(4, 3, '=')).toBe(false);
});
it('handles != as inequality', () => {
expect(evaluateThresholdWithConvertedValue(4, 3, '!=')).toBe(true);
expect(evaluateThresholdWithConvertedValue(3, 3, '!=')).toBe(false);
});
it('returns false for an unknown operator', () => {
expect(evaluateThresholdWithConvertedValue(3, 3, '~')).toBe(false);
});
});

View File

@@ -29,8 +29,11 @@ function evaluateCondition(
return value >= thresholdValue;
case '<=':
return value <= thresholdValue;
case '=':
case '==':
return value === thresholdValue;
case '!=':
return value !== thresholdValue;
default:
return false;
}

View File

@@ -2,7 +2,7 @@ import { Dispatch, ReactNode, SetStateAction } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ColumnUnit } from 'types/api/dashboard/getAll';
export type ThresholdOperators = '>' | '<' | '>=' | '<=' | '=';
export type ThresholdOperators = '>' | '<' | '>=' | '<=' | '=' | '!=';
export type ThresholdProps = {
index: string;

View File

@@ -6,6 +6,8 @@ export const operatorOptions: DefaultOptionType[] = [
{ value: '>=', label: '>=' },
{ value: '<', label: '<' },
{ value: '<=', label: '<=' },
{ value: '=', label: '=' },
{ value: '!=', label: '≠' },
];
export const showAsOptions: DefaultOptionType[] = [

View File

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

View File

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

View File

@@ -15,6 +15,10 @@
gap: 12px;
height: 100%;
width: 100%;
// Allow the flex children to shrink below their content height so the
// virtualized grid scrolls within the capped legend height instead of
// overflowing the wrapper (default min-height:auto would block the shrink).
min-height: 0;
&:has(.legend-item-focused) .legend-item {
opacity: 0.3;
@@ -33,6 +37,11 @@
}
.legend-virtuoso-container {
// flex:1 + min-height:0 pins the scroller to the space left after the
// search box (RIGHT legend) and lets it scroll instead of growing to fit
// every row — without this the grid overflows a BOTTOM legend's fixed height.
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
@@ -44,7 +53,6 @@
auto-fill,
minmax(var(--legend-average-width, 240px), 1fr)
);
row-gap: 4px;
column-gap: 12px;
}
@@ -109,6 +117,10 @@
align-items: center;
gap: 6px;
padding: 4px 8px;
// Include padding within the width so a full-width row (legend-item-right) fits its
// column instead of overflowing by the 16px horizontal padding — there is no global
// border-box reset, so the default content-box would make it overflow.
box-sizing: border-box;
max-width: 100%;
overflow: hidden;
border-radius: 4px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,211 @@
import { type ChangeEvent, useEffect, useState } from 'react';
import { Check, Pencil, Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type {
DashboardtypesComparisonOperatorDTO,
DashboardtypesComparisonThresholdDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { formatPanelValue } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/formatPanelValue';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ThresholdColorSelect from '../ThresholdsSection/ThresholdColorSelect';
import {
isThresholdUnitIncompatible,
thresholdUnitCategories,
} from '../ThresholdsSection/thresholdUnitCategories';
import {
FORMAT_OPTIONS,
OPERATOR_OPTIONS,
OPERATOR_SYMBOL,
} from './comparisonThresholdOptions';
import styles from '../ThresholdsSection/ThresholdsSection.module.scss';
interface ComparisonThresholdRowProps {
index: number;
threshold: DashboardtypesComparisonThresholdDTO;
/** Panel formatting unit — scopes the unit picker to its category (V1 parity). */
yAxisUnit?: string;
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesComparisonThresholdDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
/**
* One Number-panel comparison threshold ("If value > 80 → red") with V1-style
* view/edit modes. View mode is a compact summary (color · operator value+unit ·
* display); edit mode is a labelled form — condition (operator), value, unit (scoped to
* the y-axis unit's category), color, and display (text/background) — editing a local
* draft committed only on Save. Discard drops it.
*/
function ComparisonThresholdRow({
index,
threshold,
yAxisUnit,
isEditing,
onEdit,
onSave,
onDiscard,
onRemove,
}: ComparisonThresholdRowProps): JSX.Element {
const [draft, setDraft] = useState(threshold);
// Snapshot the saved threshold into the draft each time we (re)enter edit mode, so
// Discard simply drops the draft and the next edit starts clean.
useEffect(() => {
if (isEditing) {
setDraft(threshold);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- snapshot only on edit entry
}, [isEditing]);
if (!isEditing) {
const symbol = threshold.operator ? OPERATOR_SYMBOL[threshold.operator] : '';
return (
<div className={styles.viewRow}>
<span className={styles.dot} style={{ backgroundColor: threshold.color }} />
<span className={styles.viewValue}>
{symbol} {formatPanelValue(threshold.value, threshold.unit)}
</span>
<div className={styles.spacer} />
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
aria-label={`Edit threshold ${index + 1}`}
data-testid={`comparison-threshold-edit-${index}`}
onClick={onEdit}
>
<Pencil size={14} />
</Button>
<Button
type="button"
variant="ghost"
color="destructive"
size="icon"
aria-label={`Remove threshold ${index + 1}`}
data-testid={`comparison-threshold-remove-${index}`}
onClick={onRemove}
>
<Trash2 size={14} />
</Button>
</div>
);
}
const handleValue = (e: ChangeEvent<HTMLInputElement>): void => {
const next = Number(e.target.value);
setDraft((d) => ({ ...d, value: Number.isNaN(next) ? d.value : next }));
};
return (
<div className={styles.editRow}>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>If value is</Typography.Text>
<ConfigSelect
testId={`comparison-threshold-operator-${index}`}
placeholder="Select condition"
value={draft.operator}
items={OPERATOR_OPTIONS}
onChange={(operator): void =>
setDraft((d) => ({
...d,
operator: operator as DashboardtypesComparisonOperatorDTO,
}))
}
/>
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Value</Typography.Text>
<Input
data-testid={`comparison-threshold-value-${index}`}
type="number"
placeholder="Value"
value={draft.value}
onChange={handleValue}
/>
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Unit</Typography.Text>
<YAxisUnitSelector
containerClassName={styles.unitSelector}
data-testid={`comparison-threshold-unit-${index}`}
placeholder="Select unit"
source={YAxisSource.DASHBOARDS}
categoriesOverride={thresholdUnitCategories(yAxisUnit)}
value={draft.unit}
onChange={(unit): void => setDraft((d) => ({ ...d, unit }))}
/>
{isThresholdUnitIncompatible(draft.unit, yAxisUnit) && (
<Typography.Text
className={styles.invalidUnit}
data-testid={`comparison-threshold-unit-invalid-${index}`}
>
Threshold unit ({draft.unit}) is not valid with the y-axis unit (
{yAxisUnit})
</Typography.Text>
)}
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Color</Typography.Text>
<ThresholdColorSelect
value={draft.color}
testId={`comparison-threshold-color-${index}`}
onChange={(color): void => setDraft((d) => ({ ...d, color }))}
/>
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Display</Typography.Text>
<ConfigSelect
testId={`comparison-threshold-format-${index}`}
placeholder="Select display"
value={draft.format}
items={FORMAT_OPTIONS}
onChange={(format): void =>
setDraft((d) => ({
...d,
format: format as DashboardtypesThresholdFormatDTO,
}))
}
/>
</div>
<div className={styles.actions}>
<Button
type="button"
variant="outlined"
color="secondary"
prefix={<X size={14} />}
data-testid={`comparison-threshold-discard-${index}`}
onClick={onDiscard}
>
Discard
</Button>
<Button
type="button"
variant="solid"
color="primary"
prefix={<Check size={14} />}
data-testid={`comparison-threshold-save-${index}`}
onClick={(): void => onSave(draft)}
>
Save
</Button>
</div>
</div>
);
}
export default ComparisonThresholdRow;

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesComparisonThresholdDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ComparisonThresholdRow from './ComparisonThresholdRow';
import styles from '../ThresholdsSection/ThresholdsSection.module.scss';
// New thresholds default to red (the first palette preset); the user recolors per rule.
const DEFAULT_THRESHOLD_COLOR = '#F1575F';
type ComparisonThresholdsSectionProps =
SectionEditorProps<'comparisonThresholds'> & {
/** Panel formatting unit; scopes each row's unit picker to its category (V1 parity). */
yAxisUnit?: string;
};
/**
* Edits the Number panel's `thresholds` slice — a list of comparison rules (value
* crosses an operator → recolor the displayed number), each with V1-style view/edit
* modes. Only one row edits at a time; a freshly-added row opens in edit mode and is
* removed if discarded before saving. TimeSeries/Bar use the value+label `ThresholdsSection`.
*/
function ComparisonThresholdsSection({
value,
onChange,
yAxisUnit,
}: ComparisonThresholdsSectionProps): JSX.Element {
const thresholds = value ?? [];
// Which row is being edited, and whether it was just added (so Discard removes it).
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [unsavedIndex, setUnsavedIndex] = useState<number | null>(null);
const addThreshold = (): void => {
const nextIndex = thresholds.length;
onChange([
...thresholds,
{
value: 0,
color: DEFAULT_THRESHOLD_COLOR,
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.text,
},
]);
setEditingIndex(nextIndex);
setUnsavedIndex(nextIndex);
};
const saveAt =
(index: number) =>
(next: DashboardtypesComparisonThresholdDTO): void => {
onChange(thresholds.map((t, i) => (i === index ? next : t)));
setEditingIndex(null);
setUnsavedIndex(null);
};
const removeAt = (index: number): void => {
onChange(thresholds.filter((_, i) => i !== index));
setEditingIndex(null);
setUnsavedIndex(null);
};
const discardAt = (index: number) => (): void => {
// Discarding a row that was never saved removes it; otherwise just exit edit.
if (index === unsavedIndex) {
removeAt(index);
return;
}
setEditingIndex(null);
};
return (
<div className={styles.list}>
{thresholds.map((threshold, index) => (
<ComparisonThresholdRow
// Thresholds have no stable id on the wire; index is the row identity.
// eslint-disable-next-line react/no-array-index-key
key={index}
index={index}
threshold={threshold}
yAxisUnit={yAxisUnit}
isEditing={editingIndex === index}
onEdit={(): void => setEditingIndex(index)}
onSave={saveAt(index)}
onDiscard={discardAt(index)}
onRemove={(): void => removeAt(index)}
/>
))}
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
data-testid="panel-editor-v2-add-comparison-threshold"
onClick={addThreshold}
>
Add threshold
</Button>
</div>
);
}
export default ComparisonThresholdsSection;

View File

@@ -0,0 +1,180 @@
import { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesComparisonThresholdDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import ComparisonThresholdsSection from '../ComparisonThresholdsSection';
const THRESHOLDS: DashboardtypesComparisonThresholdDTO[] = [
{
value: 80,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.above,
unit: 'percent',
format: DashboardtypesThresholdFormatDTO.background,
},
];
// Stateful harness for flows that depend on the value updating (add/discard).
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
const [value, setValue] = useState<DashboardtypesComparisonThresholdDTO[]>([]);
return (
<ComparisonThresholdsSection
value={value}
onChange={setValue}
yAxisUnit={yAxisUnit}
/>
);
}
describe('ComparisonThresholdsSection', () => {
it('renders only the add button when there are no thresholds', () => {
render(
<ComparisonThresholdsSection value={undefined} onChange={jest.fn()} />,
);
expect(
screen.getByTestId('panel-editor-v2-add-comparison-threshold'),
).toBeInTheDocument();
expect(
screen.queryByTestId('comparison-threshold-edit-0'),
).not.toBeInTheDocument();
});
it('shows an existing threshold in view mode (no form until Edit)', () => {
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={jest.fn()} />,
);
expect(screen.getByTestId('comparison-threshold-edit-0')).toBeInTheDocument();
// Operator symbol + value render in the summary.
expect(screen.getByText(/> 80/)).toBeInTheDocument();
// The editable fields are hidden until the row is edited.
expect(
screen.queryByTestId('comparison-threshold-value-0'),
).not.toBeInTheDocument();
});
it('formats the view-mode value through its unit (e.g. currency symbol)', () => {
render(
<ComparisonThresholdsSection
value={[
{
value: 3100,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.below,
unit: 'currencyUSD',
},
]}
onChange={jest.fn()}
/>,
);
const row = screen.getByTestId('comparison-threshold-edit-0').closest('div');
// Unit-aware: shows the currency symbol, never the raw unit id.
expect(row).toHaveTextContent('$');
expect(row).not.toHaveTextContent('currencyUSD');
});
it('edits a threshold value and commits it on Save', () => {
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
fireEvent.click(screen.getByTestId('comparison-threshold-edit-0'));
expect(screen.getByTestId('comparison-threshold-value-0')).toHaveValue(80);
fireEvent.change(screen.getByTestId('comparison-threshold-value-0'), {
target: { value: '90' },
});
fireEvent.click(screen.getByTestId('comparison-threshold-save-0'));
expect(onChange).toHaveBeenCalledWith([
{
value: 90,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.above,
unit: 'percent',
format: DashboardtypesThresholdFormatDTO.background,
},
]);
});
it('does not commit edits when Discard is clicked', () => {
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
fireEvent.click(screen.getByTestId('comparison-threshold-edit-0'));
fireEvent.change(screen.getByTestId('comparison-threshold-value-0'), {
target: { value: '90' },
});
fireEvent.click(screen.getByTestId('comparison-threshold-discard-0'));
expect(onChange).not.toHaveBeenCalled();
// Back to view mode.
expect(
screen.queryByTestId('comparison-threshold-value-0'),
).not.toBeInTheDocument();
expect(screen.getByTestId('comparison-threshold-edit-0')).toBeInTheDocument();
});
it('removes a threshold from view mode', () => {
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
fireEvent.click(screen.getByTestId('comparison-threshold-remove-0'));
expect(onChange).toHaveBeenCalledWith([]);
});
it('adds a threshold that opens in edit mode, and discards it away', () => {
render(<Harness />);
fireEvent.click(
screen.getByTestId('panel-editor-v2-add-comparison-threshold'),
);
// New row opens in edit mode.
expect(
screen.getByTestId('comparison-threshold-value-0'),
).toBeInTheDocument();
fireEvent.click(screen.getByTestId('comparison-threshold-discard-0'));
// Discarding a never-saved row removes it entirely.
expect(
screen.queryByTestId('comparison-threshold-value-0'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('comparison-threshold-edit-0'),
).not.toBeInTheDocument();
});
it('flags a threshold unit in a different category than the y-axis unit', () => {
render(
<ComparisonThresholdsSection
value={[
{
value: 80,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.above,
unit: 'ms',
},
]}
yAxisUnit="bytes"
onChange={jest.fn()}
/>,
);
fireEvent.click(screen.getByTestId('comparison-threshold-edit-0'));
expect(
screen.getByTestId('comparison-threshold-unit-invalid-0'),
).toBeInTheDocument();
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.unitSelector {
:global(.ant-select) {
width: 100%;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,180 @@
import { type ChangeEvent, useEffect, useState } from 'react';
import { Check, Pencil, Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { formatPanelValue } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/formatPanelValue';
import ThresholdColorSelect from './ThresholdColorSelect';
import {
isThresholdUnitIncompatible,
thresholdUnitCategories,
} from './thresholdUnitCategories';
import styles from './ThresholdsSection.module.scss';
interface ThresholdRowProps {
index: number;
threshold: DashboardtypesThresholdWithLabelDTO;
/** Panel formatting unit — scopes the unit picker to its category (V1 parity). */
yAxisUnit?: string;
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesThresholdWithLabelDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
/**
* One threshold rule with V1-style view/edit modes. View mode shows a compact summary
* (color · value+unit · label) with Edit + Delete. Edit mode is a labelled form — color
* (preset/custom), value, unit (scoped to the y-axis unit's category), label — editing a
* local draft that's only committed on Save; Discard drops it.
*/
function ThresholdRow({
index,
threshold,
yAxisUnit,
isEditing,
onEdit,
onSave,
onDiscard,
onRemove,
}: ThresholdRowProps): JSX.Element {
const [draft, setDraft] = useState(threshold);
// Snapshot the saved threshold into the draft each time we (re)enter edit mode, so
// Discard simply drops the draft and the next edit starts clean.
useEffect(() => {
if (isEditing) {
setDraft(threshold);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- snapshot only on edit entry
}, [isEditing]);
if (!isEditing) {
return (
<div className={styles.viewRow}>
<span className={styles.dot} style={{ backgroundColor: threshold.color }} />
<span className={styles.viewValue}>
{formatPanelValue(threshold.value, threshold.unit)}
</span>
{threshold.label && (
<span className={styles.viewLabel}>{threshold.label}</span>
)}
<div className={styles.spacer} />
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
aria-label={`Edit threshold ${index + 1}`}
data-testid={`threshold-edit-${index}`}
onClick={onEdit}
>
<Pencil size={14} />
</Button>
<Button
type="button"
variant="ghost"
color="destructive"
size="icon"
aria-label={`Remove threshold ${index + 1}`}
data-testid={`threshold-remove-${index}`}
onClick={onRemove}
>
<Trash2 size={14} />
</Button>
</div>
);
}
const handleValue = (e: ChangeEvent<HTMLInputElement>): void => {
const next = Number(e.target.value);
setDraft((d) => ({ ...d, value: Number.isNaN(next) ? d.value : next }));
};
return (
<div className={styles.editRow}>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Color</Typography.Text>
<ThresholdColorSelect
value={draft.color}
testId={`threshold-color-${index}`}
onChange={(color): void => setDraft((d) => ({ ...d, color }))}
/>
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Value</Typography.Text>
<Input
data-testid={`threshold-value-${index}`}
type="number"
placeholder="Value"
value={draft.value}
onChange={handleValue}
/>
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Unit</Typography.Text>
<YAxisUnitSelector
containerClassName={styles.unitSelector}
data-testid={`threshold-unit-${index}`}
placeholder="Select unit"
source={YAxisSource.DASHBOARDS}
categoriesOverride={thresholdUnitCategories(yAxisUnit)}
value={draft.unit}
onChange={(unit): void => setDraft((d) => ({ ...d, unit }))}
/>
{isThresholdUnitIncompatible(draft.unit, yAxisUnit) && (
<Typography.Text
className={styles.invalidUnit}
data-testid={`threshold-unit-invalid-${index}`}
>
Threshold unit ({draft.unit}) is not valid with the y-axis unit (
{yAxisUnit})
</Typography.Text>
)}
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Label</Typography.Text>
<Input
data-testid={`threshold-label-${index}`}
placeholder="Optional"
value={draft.label ?? ''}
onChange={(e): void => setDraft((d) => ({ ...d, label: e.target.value }))}
/>
</div>
<div className={styles.actions}>
<Button
type="button"
variant="outlined"
color="secondary"
prefix={<X size={14} />}
data-testid={`threshold-discard-${index}`}
onClick={onDiscard}
>
Discard
</Button>
<Button
type="button"
variant="solid"
color="primary"
prefix={<Check size={14} />}
data-testid={`threshold-save-${index}`}
onClick={(): void => onSave(draft)}
>
Save
</Button>
</div>
</div>
);
}
export default ThresholdRow;

View File

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

View File

@@ -0,0 +1,100 @@
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ThresholdRow from './ThresholdRow';
import styles from './ThresholdsSection.module.scss';
// New thresholds default to red (the first palette preset); the user recolors per rule.
const DEFAULT_THRESHOLD_COLOR = '#F1575F';
type ThresholdsSectionProps = SectionEditorProps<'thresholds'> & {
/** Panel formatting unit; scopes each row's unit picker to its category (V1 parity). */
yAxisUnit?: string;
};
/**
* Edits the `thresholds` slice (TimeSeries / Bar) — a list of value + color + label
* rules drawn on the chart, each with V1-style view/edit modes. Only one row edits at a
* time; a freshly-added row opens in edit mode and is removed if discarded before saving.
* Number panels use a different (comparison-operator) threshold shape — separate editor.
*/
function ThresholdsSection({
value,
onChange,
yAxisUnit,
}: ThresholdsSectionProps): JSX.Element {
const thresholds = value ?? [];
// Which row is being edited, and whether it was just added (so Discard removes it).
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [unsavedIndex, setUnsavedIndex] = useState<number | null>(null);
const addThreshold = (): void => {
const nextIndex = thresholds.length;
onChange([
...thresholds,
{ value: 0, color: DEFAULT_THRESHOLD_COLOR, label: '' },
]);
setEditingIndex(nextIndex);
setUnsavedIndex(nextIndex);
};
const saveAt =
(index: number) =>
(next: DashboardtypesThresholdWithLabelDTO): void => {
onChange(thresholds.map((t, i) => (i === index ? next : t)));
setEditingIndex(null);
setUnsavedIndex(null);
};
const removeAt = (index: number): void => {
onChange(thresholds.filter((_, i) => i !== index));
setEditingIndex(null);
setUnsavedIndex(null);
};
const discardAt = (index: number) => (): void => {
// Discarding a row that was never saved removes it; otherwise just exit edit.
if (index === unsavedIndex) {
removeAt(index);
return;
}
setEditingIndex(null);
};
return (
<div className={styles.list}>
{thresholds.map((threshold, index) => (
<ThresholdRow
// Thresholds have no stable id on the wire; index is the row identity.
// eslint-disable-next-line react/no-array-index-key
key={index}
index={index}
threshold={threshold}
yAxisUnit={yAxisUnit}
isEditing={editingIndex === index}
onEdit={(): void => setEditingIndex(index)}
onSave={saveAt(index)}
onDiscard={discardAt(index)}
onRemove={(): void => removeAt(index)}
/>
))}
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
data-testid="panel-editor-v2-add-threshold"
onClick={addThreshold}
>
Add threshold
</Button>
</div>
);
}
export default ThresholdsSection;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import styles from './Header.module.scss';
interface HeaderProps {
isDirty: boolean;
isSaving: boolean;
onSave: () => void;
onClose: () => void;
}
function Header({
isDirty,
isSaving,
onSave,
onClose,
}: HeaderProps): JSX.Element {
return (
<div className={styles.header}>
<div className={styles.title}>
<Button
variant="ghost"
color="secondary"
size="icon"
suffix={<X size={14} />}
data-testid="panel-editor-v2-close"
onClick={onClose}
/>
<Divider type="vertical" />
<Typography.Text>Configure panel</Typography.Text>
</div>
<div className={styles.actions}>
<Button
variant="solid"
color="primary"
data-testid="panel-editor-v2-save"
disabled={!isDirty || isSaving}
loading={isSaving}
onClick={onSave}
>
Save changes
</Button>
</div>
</div>
);
}
export default Header;

View File

@@ -0,0 +1,21 @@
/**
* The panel editor renders as a full-screen overlay (`PanelEditor.module.scss` `.root`,
* z-index: 1000). The editor's own floating UI (Select dropdowns) is rendered inside the
* overlay (withPortal={false}), so it needs no z-index help.
*
* The ⌘K command palette, however, is a global component mounted at the app root that
* portals to <body> as a sibling of the overlay — so without a higher z-index its dialog
* paints behind. This rule lifts it above the overlay. It must be global (the dialog lives
* at the <body> root) but is gated on the `panel-editor-open` body class the editor toggles
* while mounted, and scoped via :has([cmdk-root]) so no other dialog is affected.
*/
body.panel-editor-open {
[data-slot='dialog-content']:has([cmdk-root]) {
z-index: 1100;
}
[data-slot='dialog-overlay']:has(+ [data-slot='dialog-content'] [cmdk-root]) {
z-index: 1099;
}
}

View File

@@ -0,0 +1,95 @@
@keyframes panel-editor-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes panel-editor-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes panel-editor-modal-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes panel-editor-modal-out {
from {
opacity: 1;
transform: none;
}
to {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
}
.root {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
// Inset the modal from every edge so the dimmed backdrop shows around it.
padding: 18px;
background: rgba(0, 0, 0, 0.55);
animation: panel-editor-backdrop-in 160ms ease-out;
}
// The modal surface: a bordered, rounded card that fills the padded backdrop.
.modal {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: var(--l1-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
animation: panel-editor-modal-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
// Closing: play the reverse keyframes and hold the end frame (`forwards`) so the
// overlay stays faded out until `onAnimationEnd` unmounts it.
.closing {
animation: panel-editor-backdrop-out 180ms ease-in forwards;
.modal {
animation: panel-editor-modal-out 180ms ease-in forwards;
}
}
// Respect reduced-motion: snap in/out with no transform or fade choreography.
@media (prefers-reduced-motion: reduce) {
.root,
.modal,
.closing,
.closing .modal {
animation: none;
}
}
.left {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.right {
display: flex;
}

View File

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

View File

@@ -0,0 +1,101 @@
import { Spin } from 'antd';
import { Loader, Spline } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { EQueryType } from 'types/common/dashboard';
import type {
PreviewTimeRange,
UsePreviewQueryResult,
} from '../usePreviewQuery';
import styles from './PreviewPane.module.scss';
interface PreviewPaneProps {
panelId: string;
panel: DashboardtypesPanelDTO;
/** Resolved definition for the panel kind; undefined when the kind is unsupported. */
panelDef: RenderablePanelDefinition | undefined;
data: PanelQueryData;
isLoading: boolean;
error: Error | null;
/** Editor-local time selection (never touches global Redux time or the URL). */
selectedInterval: UsePreviewQueryResult['selectedInterval'];
timeRange: PreviewTimeRange;
onTimeChange: UsePreviewQueryResult['onTimeChange'];
}
/**
* Live preview for the panel editor. Presentational: the draft panel renders through the
* same registry the dashboard grid uses (`panelDef.Renderer`), so the preview is the
* production renderer — only `panelMode` differs (DASHBOARD_EDIT). The query + editor-local
* time are owned by the editor root (`usePreviewQuery`) and passed in, so the same result
* is shared with the config pane.
*/
function PreviewPane({
panelId,
panel,
panelDef,
data,
isLoading,
error,
selectedInterval,
timeRange,
onTimeChange,
}: PreviewPaneProps): JSX.Element {
return (
<div className={styles.preview}>
<div className={styles.header}>
<div className={styles.queryType}>
<Spline size={14} />
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
</div>
{/* Shared time picker in modal mode: edits a local window via onTimeChange
and never touches global Redux time or the URL (disableUrlSync). */}
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
disableUrlSync
isModalTimeSelection
defaultRelativeTime={DEFAULT_TIME_RANGE}
onTimeChange={onTimeChange}
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startMs}
modalInitialEndTime={timeRange.endMs}
/>
</div>
<div className={styles.container}>
<div className={styles.surface}>
{/* eslint-disable-next-line no-nested-ternary -- 3-way branch on render state */}
{!panelDef ? (
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
This panel type is not yet supported in V2.
</div>
) : isLoading && !data.response ? (
<div className={styles.state} data-testid="panel-editor-v2-loading">
<Spin indicator={<Loader size={14} className="animate-spin" />} />
</div>
) : (
<panelDef.Renderer
panelId={panelId}
panel={panel}
data={data}
isLoading={isLoading}
error={error}
panelMode={PanelMode.DASHBOARD_EDIT}
enableDrillDown={false}
/>
)}
</div>
</div>
</div>
);
}
export default PreviewPane;

View File

@@ -0,0 +1,12 @@
.placeholder {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 30%;
margin: 0 16px 16px;
border: 1px dashed var(--l2-border);
border-radius: 4px;
color: var(--l2-foreground);
font-size: 13px;
}

View File

@@ -0,0 +1,23 @@
import { Terminal } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from './QueryBuilderPlaceholder.module.scss';
/**
* Placeholder for the query builder in the panel editor's left pane. Milestone 2
* replaces this with the shared `QueryBuilderV2`, wired through `fromPerses` /
* `toPerses` so query edits flow into the draft and re-fetch the preview.
*/
function QueryBuilderPlaceholder(): JSX.Element {
return (
<div
className={styles.placeholder}
data-testid="panel-editor-v2-query-placeholder"
>
<Terminal size={16} />
<Typography.Text>Query builder coming soon</Typography.Text>
</div>
);
}
export default QueryBuilderPlaceholder;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,171 @@
import { type AnimationEvent, useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import cx from 'classnames';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import layoutStorage from './layoutStorage';
import PreviewPane from './PreviewPane/PreviewPane';
import QueryBuilderPlaceholder from './QueryBuilderPlaceholder/QueryBuilderPlaceholder';
import { useLegendSeries } from './useLegendSeries';
import { usePanelEditorDraft } from './usePanelEditorDraft';
import { usePanelEditorSave } from './usePanelEditorSave';
import { usePreviewQuery } from './usePreviewQuery';
import './PanelEditor.globals.scss';
import styles from './PanelEditor.module.scss';
interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/** Dismiss the editor overlay (clears the `editPanelId` query param). */
onClose: () => void;
/** Called after a successful save so the dashboard can refetch. */
onSaved: () => void;
}
/**
* V2 panel editor rendered as a full-screen overlay on top of the dashboard
* view (the dashboard stays mounted underneath). A resizable split holds the
* live preview + query builder on the left and the configuration pane on the
* right. Owns the draft state and the save round-trip.
*/
function PanelEditorContainer({
dashboardId,
panelId,
panel,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
const { draft, spec, setSpec, isDirty } = usePanelEditorDraft(panel);
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
});
// One shared query result for the whole editor: the preview renders it and the config
// pane derives the panel's series from it (e.g. for the legend-colors control).
const panelDef = getPanelDefinition(draft.spec?.plugin?.kind);
const { data, isLoading, error, selectedInterval, timeRange, onTimeChange } =
usePreviewQuery({
panel: draft,
panelId,
enabled: !!panelDef,
});
const legendSeries = useLegendSeries(draft, data);
// Flags the document while the editor overlay is mounted so the global stylesheet can
// lift body-portaled floating UI (Select dropdowns, the ⌘K palette) above the overlay.
useEffect(() => {
document.body.classList.add('panel-editor-open');
return (): void => document.body.classList.remove('panel-editor-open');
}, []);
// Dismiss is deferred until the exit animation finishes: `requestClose` flips the
// overlay into its closing state (playing the reverse keyframes), and the modal's
// `onAnimationEnd` then calls the real `onClose`, which unmounts the editor.
const [closing, setClosing] = useState(false);
const requestClose = useCallback(() => setClosing(true), []);
const onExitAnimationEnd = useCallback(
(event: AnimationEvent<HTMLDivElement>): void => {
// Only the modal's own exit animation should unmount — ignore animations that
// bubble up from descendants (e.g. the loading spinner).
if (closing && event.target === event.currentTarget) {
onClose();
}
},
[closing, onClose],
);
// Safety net: `prefers-reduced-motion` disables the exit animation, so
// `onAnimationEnd` never fires. Fall back to a timer (slightly longer than the
// animation) so the editor always unmounts once closing. `onClose` is idempotent.
useEffect(() => {
if (!closing) {
return undefined;
}
const timer = setTimeout(onClose, 240);
return (): void => clearTimeout(timer);
}, [closing, onClose]);
const onSave = useCallback(async (): Promise<void> => {
try {
await save(draft.spec);
toast.success('Panel saved');
onSaved();
requestClose();
} catch {
toast.error('Failed to save panel');
}
}, [save, draft.spec, onSaved, requestClose]);
// Portal to <body> so the fixed overlay escapes the dashboard content's
// stacking context (AppLayout pins `.app-content` at `z-index: 0`, which
// would otherwise trap the overlay below the side nav).
return createPortal(
<div
className={cx(styles.root, { [styles.closing]: closing })}
data-testid="panel-editor-v2"
>
<div className={styles.modal} onAnimationEnd={onExitAnimationEnd}>
<Header
isDirty={isDirty}
isSaving={isSaving}
onSave={onSave}
onClose={requestClose}
/>
<ResizablePanelGroup
id="panel-editor-v2"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
<div className={styles.left}>
<PreviewPane
panelId={panelId}
panel={draft}
panelDef={panelDef}
data={data}
isLoading={isLoading}
error={error}
selectedInterval={selectedInterval}
timeRange={timeRange}
onTimeChange={onTimeChange}
/>
<QueryBuilderPlaceholder />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
minSize="20%"
maxSize="25%"
defaultSize="20%"
className={styles.right}
>
<ConfigPane
panelKind={draft.spec?.plugin?.kind}
spec={spec}
onChangeSpec={setSpec}
legendSeries={legendSeries}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>,
document.body,
);
}
export default PanelEditorContainer;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,120 @@
import { useCallback, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports -- seed initial time from global store; never written back
import { useSelector } from 'react-redux';
import type {
DashboardtypesPanelDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import GetMinMax from 'lib/getMinMax';
import { resolvePanelTimeWindow } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
import {
type UsePanelQueryResult,
usePanelQuery,
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
const NS_TO_MS = 1e6;
/** Editor-local time window in epoch milliseconds — what `DateTimeSelectionV2` seeds from. */
export interface PreviewTimeRange {
startMs: number;
endMs: number;
}
interface UsePreviewQueryArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
enabled: boolean;
}
export interface UsePreviewQueryResult extends UsePanelQueryResult {
/** Current relative interval (or `custom`) shown in the modal time picker. */
selectedInterval: Time;
/** Editor-local window (epoch ms); seeds the picker's custom range + duration pill. */
timeRange: PreviewTimeRange;
/** `DateTimeSelectionV2` modal callback: relative interval, or `custom` + [startMs, endMs]. */
onTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
}
/**
* Owns the panel editor's preview query and its editor-local time selection. Lifted out
* of `PreviewPane` so the editor root can share the single query result between the
* preview and the config pane (e.g. the legend-colors control needs the resolved series).
*
* Time is driven by `DateTimeSelectionV2` in modal mode (`isModalTimeSelection` +
* `disableUrlSync`), so the picker never reads or writes global Redux time or the URL —
* its selections arrive through `onTimeChange` and stay in local state. The selection is
* seeded once from the current global window so the preview opens matching the dashboard,
* then resolved to an absolute `[startMs, endMs]` handed to `usePanelQuery`. The panel's
* own time preference is folded in so editing it updates the preview live.
*/
export function usePreviewQuery({
panel,
panelId,
enabled,
}: UsePreviewQueryArgs): UsePreviewQueryResult {
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [selectedInterval, setSelectedInterval] = useState<Time>(
globalTime.selectedTime as Time,
);
const [timeRange, setTimeRange] = useState<PreviewTimeRange>(() => ({
startMs: Math.floor(globalTime.minTime / NS_TO_MS),
endMs: Math.floor(globalTime.maxTime / NS_TO_MS),
}));
const onTimeChange = useCallback(
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
setSelectedInterval(interval as Time);
if (interval === 'custom' && dateTimeRange) {
// DateTimeSelectionV2 emits custom ranges in epoch ms.
setTimeRange({
startMs: Math.floor(dateTimeRange[0]),
endMs: Math.floor(dateTimeRange[1]),
});
return;
}
// GetMinMax resolves a relative interval to a now-anchored window in ns.
const { minTime, maxTime } = GetMinMax(interval);
setTimeRange({
startMs: Math.floor(minTime / NS_TO_MS),
endMs: Math.floor(maxTime / NS_TO_MS),
});
},
[],
);
// The panel's saved time preference must drive the preview too, so editing it shows
// the effect live. `visualization` is common to every plugin-spec variant — localized
// cast reads it without narrowing on kind. A relative preset shrinks the picked window
// to that span; global_time/none leaves it untouched.
const timePreference = (
panel?.spec?.plugin?.spec as
| { visualization?: { timePreference?: DashboardtypesTimePreferenceDTO } }
| undefined
)?.visualization?.timePreference;
const time = useMemo(
() =>
resolvePanelTimeWindow({
timePreference,
globalStartMs: timeRange.startMs,
globalEndMs: timeRange.endMs,
}),
[timeRange, timePreference],
);
const result = usePanelQuery({ panel, panelId, enabled, time });
return { ...result, selectedInterval, timeRange, onTimeChange };
}

View File

@@ -0,0 +1,30 @@
import { useMemo } from 'react';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Builds a record keyed by builder-query name to that query's groupBy keys
* in the V1 `BaseAutocompleteData` shape — the shape `TimeSeries` and the
* tooltip plugin consume. Conversion from v5 `GroupByKey` lives at this one
* call site that needs the V1 shape; the rest of V2 panel code stays on
* v5 types.
*/
export function useGroupByPerQuery(
builderQueries: BuilderQuery[],
): Record<string, BaseAutocompleteData[]> {
return useMemo(() => {
const result: Record<string, BaseAutocompleteData[]> = {};
builderQueries.forEach((q) => {
if (!q.name) {
return;
}
result[q.name] = (q.groupBy ?? []).map((g) => ({
key: g.name,
dataType: g.fieldDataType as BaseAutocompleteData['dataType'],
type: (g.fieldContext as BaseAutocompleteData['type']) ?? '',
id: '',
}));
});
return result;
}, [builderQueries]);
}

View File

@@ -0,0 +1,48 @@
import { RefObject, useEffect, useRef, useState } from 'react';
const MIN_FONT_PX = 16;
const MAX_FONT_PX = 60;
// The value font is sized to a fraction of the container's smaller dimension so
// it scales with the panel without overflowing.
const FONT_SCALE_DIVISOR = 5;
/**
* Sizes a single large value to its container, recomputing on resize via a
* ResizeObserver. Returns the ref to attach to the container and the current
* font size (px) to apply to the value text.
*/
export function useResponsiveFontSize(): {
containerRef: RefObject<HTMLDivElement>;
fontSize: string;
} {
const containerRef = useRef<HTMLDivElement>(null);
const [fontSize, setFontSize] = useState('2.5vw');
useEffect(() => {
const updateFontSize = (): void => {
if (!containerRef.current) {
return;
}
const { width, height } = containerRef.current.getBoundingClientRect();
const minDimension = Math.min(width, height);
const newSize = Math.max(
Math.min(minDimension / FONT_SCALE_DIVISOR, MAX_FONT_PX),
MIN_FONT_PX,
);
setFontSize(`${newSize}px`);
};
updateFontSize();
const resizeObserver = new ResizeObserver(updateFontSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return (): void => {
resizeObserver.disconnect();
};
}, []);
return { containerRef, fontSize };
}

View File

@@ -0,0 +1,36 @@
import { definition as barChart } from './kinds/BarChartPanel/definition';
import { definition as histogram } from './kinds/HistogramPanel/definition';
import { definition as number } from './kinds/NumberPanel/definition';
import { definition as pieChart } from './kinds/PieChartPanel/definition';
import { definition as timeSeries } from './kinds/TimeSeriesPanel/definition';
import type {
PanelRegistry,
RenderablePanelDefinition,
} from './types/panelDefinition';
import type { PanelKind } from './types/panelKind';
// Pure assembly: each kind owns its own PanelDefinition (see
// `kinds/<Kind>/definition.ts`). Registering a new panel = add its folder and a
// single entry below — no other central file needs editing.
export const PANELS: PanelRegistry = {
[timeSeries.kind]: timeSeries,
[barChart.kind]: barChart,
[histogram.kind]: histogram,
[number.kind]: number,
[pieChart.kind]: pieChart,
};
export function getPanelDefinition(
kind: string | undefined,
): RenderablePanelDefinition | undefined {
if (!kind) {
return undefined;
}
// The registry is correlated by kind, so a string lookup yields a union over
// every kind's exactly-typed definition. The renderer cannot be validated
// against that union at the JSX boundary, so widen to the kind-agnostic
// surface here — the single, intentional cast for the whole panel system.
return PANELS[kind as PanelKind] as unknown as
| RenderablePanelDefinition
| undefined;
}

View File

@@ -0,0 +1,175 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import {
flattenTimeSeries,
getExecStats,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import { useTimezone } from 'providers/Timezone';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildBarChartConfig } from './buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function BarPanelRenderer({
panelId,
panel,
data,
onClick,
onDragSelect,
dashboardPreference,
panelMode,
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/BarChartPanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesBarChartPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
// X-scale clamps come from the request that produced the data (falls back
// to the global picker inside the helper). The generated request DTO is
// structurally the hand-written V5 request; the cast is the boundary.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
data?.requestPayload as unknown as QueryRangeRequestV5 | undefined,
);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data?.requestPayload]);
const groupByPerQuery = useGroupByPerQuery(builderQueries);
const flatSeries = useMemo(
() =>
flattenTimeSeries(
getTimeSeriesResults(data?.response),
data?.legendMap ?? {},
),
[data?.response, data?.legendMap],
);
const config = useMemo(
() =>
buildBarChartConfig({
panelId,
spec,
builderQueries,
series: flatSeries,
stepIntervals: getExecStats(data?.response)?.stepIntervals,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
panelId,
spec,
builderQueries,
flatSeries,
data?.response,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
dashboardPreference?.syncMode,
],
);
const chartData = useMemo(() => prepareAlignedData(flatSeries), [flatSeries]);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(() => {
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
),
[panelId],
);
// The uPlot key prop is the only way to force a full teardown and re-mount
// of the chart. Including syncMode/syncFilterMode in the key ensures changes
// to these preferences trigger a fresh chart instance, preventing stale
// sync wiring from being inherited.
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="bar-panel-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={key}
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default BarPanelRenderer;

View File

@@ -0,0 +1,138 @@
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { toClickPluginPayload } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { BuilderQuery } from 'types/api/v5/queryRange';
export interface BuildBarChartConfigArgs {
panelId: string;
spec: DashboardtypesBarChartPanelSpecDTO;
/**
* Flat list of builder queries on this panel (see `getBuilderQueries`).
* Powers per-query legend resolution; empty for non-builder panels.
*/
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
/** Per-query step intervals from the response exec stats. */
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Bar chart panel.
*
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
* in the Bar-specific concerns: optional stacking via uPlot bands, plus
* one bar series per result row.
*/
export function buildBarChartConfig({
panelId,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
timezone,
panelMode,
onDragSelect,
onClick,
minTimeScale,
maxTimeScale,
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.BAR,
isDarkMode,
timezone,
panelMode,
isLogScale: spec.axes?.isLogScale,
softMin: spec.axes?.softMin ?? undefined,
softMax: spec.axes?.softMax ?? undefined,
formatting: spec.formatting,
thresholds: spec.thresholds,
stepIntervals,
clickPayload: toClickPluginPayload(series),
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
});
addSeries({
builder,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
});
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesBarChartPanelSpecDTO;
builderQueries: BuilderQuery[];
series: PanelSeries[];
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
}
/**
* Adds one bar series per flattened V5 series, plus uPlot bands for stacking
* when `spec.visualization.stackedBarChart` is set. Each series receives its
* own per-query step interval so bar widths line up with the actual
* sampling cadence reported by the backend.
*
* Order must match `prepareAlignedData` — both iterate the same flat list.
*/
function addSeries({
builder,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
}: AddSeriesArgs): void {
const colorMapping = spec.legend?.customColors ?? {};
if (spec.visualization?.stackedBarChart) {
// uPlot uses 1-based series indices (index 0 is the timestamp axis);
// `+1` keeps the band targets aligned with the series we're about to add.
builder.setBands(getInitialStackedBands(series.length + 1));
}
series.forEach((s) => {
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
const stepInterval = s.queryName ? stepIntervals?.[s.queryName] : undefined;
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label,
colorMapping,
isDarkMode,
stepInterval,
metric: s.labels,
});
});
}

View File

@@ -0,0 +1,14 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: { view: true, edit: true, download: false, createAlert: true },
};

View File

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

View File

@@ -0,0 +1,139 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Histogram from 'container/DashboardContainer/visualization/charts/Histogram/Histogram';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import {
flattenTimeSeries,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { resolveLegendPosition } from '../../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildHistogramConfig } from './buildConfig';
import { prepareHistogramData } from './prepareData';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function HistogramPanelRenderer({
panelId,
panel,
data,
panelMode,
onClick,
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/HistogramPanel'`, so the cast is a
// documented boundary narrowing.
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
() =>
(panel?.spec?.plugin?.spec ?? {}) as DashboardtypesHistogramPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
const flatSeries = useMemo(
() =>
flattenTimeSeries(
getTimeSeriesResults(data?.response),
data?.legendMap ?? {},
),
[data?.response, data?.legendMap],
);
const config = useMemo(
() =>
buildHistogramConfig({
panelId,
spec,
builderQueries,
series: flatSeries,
isDarkMode,
timezone,
panelMode,
}),
[panelId, spec, builderQueries, flatSeries, isDarkMode, timezone, panelMode],
);
const chartData = useMemo(
() =>
prepareHistogramData({
series: flatSeries,
bucketWidth: spec.histogramBuckets?.bucketWidth ?? undefined,
bucketCount: spec.histogramBuckets?.bucketCount ?? undefined,
mergeAllActiveQueries: spec.histogramBuckets?.mergeAllActiveQueries,
}),
[
flatSeries,
spec.histogramBuckets?.bucketWidth,
spec.histogramBuckets?.bucketCount,
spec.histogramBuckets?.mergeAllActiveQueries,
],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter
id={panelId}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={false}
/>
),
[panelId],
);
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="histogram-panel-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<Histogram
key={panelId}
config={config}
data={chartData as uPlot.AlignedData}
legendConfig={{ position: legendPosition }}
canPinTooltip
isQueriesMerged={isQueriesMerged}
width={containerDimensions.width}
height={containerDimensions.height}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default HistogramPanelRenderer;

View File

@@ -0,0 +1,129 @@
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import getLabelName from 'lib/getLabelName';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { BuilderQuery } from 'types/api/v5/queryRange';
const POINT_SIZE = 5;
const BAR_WIDTH_FACTOR = 1;
// Merged-series colors mirror the V1 default — single histogram bin gets a
// fixed blue-ish pair so the merged view looks the same as before.
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
export interface BuildHistogramConfigArgs {
panelId: string;
spec: DashboardtypesHistogramPanelSpecDTO;
/** Builder queries on this panel — used to resolve per-series labels. */
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Histogram panel.
*
* Unlike time-axis panels, histograms have no time scale and no drag-to-zoom.
* We still reuse `buildBaseConfig` for the consistent scaffolding (thresholds,
* axes, click plugin) but then override the X/Y scales to be auto-linear
* (`time: false, auto: true`) and install a histogram-specific cursor that
* disables drag-pan and tightens focus proximity.
*/
export function buildHistogramConfig({
panelId,
spec,
builderQueries,
series,
isDarkMode,
timezone,
panelMode,
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
// Histograms have no time axis — no stepIntervals, and no click plugin
// (the renderer passes no onClick), so the base config needs no response.
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.HISTOGRAM,
isDarkMode,
timezone,
panelMode,
});
builder.setCursor({
drag: { x: false, y: false, setScale: true },
focus: { prox: 1e3 },
});
// Override the time-axis scales from `buildBaseConfig` — histograms are
// distribution plots, not time series.
builder.addScale({ scaleKey: 'x', time: false, auto: true });
builder.addScale({ scaleKey: 'y', time: false, auto: true, min: 0 });
addSeries({ builder, spec, builderQueries, series, isDarkMode });
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesHistogramPanelSpecDTO;
builderQueries: BuilderQuery[];
series: PanelSeries[];
isDarkMode: boolean;
}
/**
* Adds histogram bar series to the builder. When `mergeAllActiveQueries` is
* set, `prepareHistogramData` produces a single Y column, so we add exactly
* one series with the fixed merged-mode colors. Otherwise one series per
* result row, with labels resolved via the standard legend matrix.
*/
function addSeries({
builder,
spec,
builderQueries,
series,
isDarkMode,
}: AddSeriesArgs): void {
const colorMapping = spec.legend?.customColors ?? {};
const mergeAllActiveQueries =
spec.histogramBuckets?.mergeAllActiveQueries ?? false;
if (mergeAllActiveQueries) {
builder.addSeries({
scaleKey: 'y',
label: '',
drawStyle: DrawStyle.Histogram,
colorMapping,
barWidthFactor: BAR_WIDTH_FACTOR,
pointSize: POINT_SIZE,
lineColor: MERGED_SERIES_LINE_COLOR,
fillColor: MERGED_SERIES_FILL_COLOR,
isDarkMode,
});
return;
}
series.forEach((s) => {
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
builder.addSeries({
scaleKey: 'y',
label,
drawStyle: DrawStyle.Histogram,
colorMapping,
barWidthFactor: BAR_WIDTH_FACTOR,
pointSize: POINT_SIZE,
isDarkMode,
});
});
}

View File

@@ -0,0 +1,14 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: { view: true, edit: true, download: false, createAlert: true },
};

View File

@@ -0,0 +1,148 @@
import { histogramBucketSizes } from '@grafana/data';
import { DEFAULT_BUCKET_COUNT } from 'container/PanelWrapper/constants';
import {
buildHistogramBuckets,
mergeAlignedDataTables,
prependNullBinToFirstHistogramSeries,
replaceUndefinedWithNullInAlignedData,
} from 'container/DashboardContainer/visualization/panels/utils/histogram';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { AlignedData } from 'uplot';
import { incrRoundDn, roundDecimals } from 'utils/round';
export interface PrepareHistogramDataArgs {
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
bucketWidth?: number;
bucketCount?: number;
mergeAllActiveQueries?: boolean;
}
const BUCKET_OFFSET = 0;
const sortAscending = (a: number, b: number): number => a - b;
/**
* Bins raw series values into a uPlot-aligned histogram. Picks a bucket size
* either from `bucketWidth` (explicit override) or the smallest predefined
* Grafana bucket that fits the data's `range / bucketCount` target while
* staying ≥ the data's smallest non-zero delta (so we never sub-divide below
* the resolution of the input).
*
* Empty input → `[[]]` (a valid empty AlignedData uPlot accepts).
*/
export function prepareHistogramData({
series,
bucketWidth,
bucketCount = DEFAULT_BUCKET_COUNT,
mergeAllActiveQueries = false,
}: PrepareHistogramDataArgs): AlignedData {
const values = extractNumericValues(series);
if (values.length === 0) {
return [[]];
}
const sorted = [...values].sort(sortAscending);
const range = sorted[sorted.length - 1] - sorted[0];
const smallestDelta = computeSmallestDelta(sorted);
let bucketSize = selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride: bucketWidth,
});
if (bucketSize <= 0) {
bucketSize = range > 0 ? range / bucketCount : 1;
}
const getBucket = (v: number): number =>
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
const frames = buildFrames(series, mergeAllActiveQueries);
// Merged mode folds every query into frame 0 and leaves trailing empty
// frames — drop those. Per-query mode must keep one column per result row
// (even empty queries), or the data column count drifts below the series
// count `buildHistogramConfig` adds per row → uPlot renders nothing.
const histograms: AlignedData[] = frames
.filter((frame) => !mergeAllActiveQueries || frame.length > 0)
.map((frame) => buildHistogramBuckets(frame, getBucket, sortAscending));
if (histograms.length === 0) {
return [[]];
}
const merged = mergeAlignedDataTables(histograms);
replaceUndefinedWithNullInAlignedData(merged);
prependNullBinToFirstHistogramSeries(merged, bucketSize);
return merged;
}
// Non-finite samples degrade to 0 (legacy `parseFloat(...) || 0` parity).
function toBinnableValue(value: number): number {
return Number.isFinite(value) ? value : 0;
}
function extractNumericValues(series: PanelSeries[]): number[] {
const values: number[] = [];
for (const s of series) {
for (const point of s.values) {
values.push(toBinnableValue(point.value));
}
}
return values;
}
function computeSmallestDelta(sortedValues: number[]): number {
if (sortedValues.length <= 1) {
return 0;
}
let smallest = Infinity;
for (let i = 1; i < sortedValues.length; i++) {
const delta = sortedValues[i] - sortedValues[i - 1];
if (delta > 0) {
smallest = Math.min(smallest, delta);
}
}
return smallest === Infinity ? 0 : smallest;
}
function selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride,
}: {
range: number;
bucketCount: number;
smallestDelta: number;
bucketWidthOverride?: number;
}): number {
if (bucketWidthOverride != null && bucketWidthOverride > 0) {
return bucketWidthOverride;
}
const targetSize = range / bucketCount;
for (const candidate of histogramBucketSizes) {
if (targetSize < candidate && candidate >= smallestDelta) {
return candidate;
}
}
return 0;
}
// When merging is on, fold all frames into the first; the trailing empty
// frames stay in the array so downstream `.filter(length > 0)` drops them.
function buildFrames(
series: PanelSeries[],
mergeAllActiveQueries: boolean,
): number[][] {
const frames: number[][] = series.map((s) =>
s.values.map((point) => toBinnableValue(point.value)),
);
if (mergeAllActiveQueries && frames.length > 1) {
const first = frames[0];
for (let i = 1; i < frames.length; i++) {
first.push(...frames[i]);
frames[i] = [];
}
}
return frames;
}

View File

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

View File

@@ -0,0 +1,81 @@
import { useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesNumberPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { formatPanelValue } from '../../utils/formatPanelValue';
import { resolveDecimalPrecision } from '../../utils/chartAppearanceMappings';
import { prepareNumberData } from './prepareData';
import { mapNumberThresholds } from './utils';
import ValueDisplay from './components/ValueDisplay/ValueDisplay';
function NumberPanelRenderer({
panel,
data,
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/NumberPanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesNumberPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const value = useMemo(
() =>
prepareNumberData(
prepareScalarTables({
results: getScalarResults(data?.response),
legendMap: data?.legendMap ?? {},
requestPayload: data?.requestPayload,
}),
),
[data?.response, data?.legendMap, data?.requestPayload],
);
const thresholds = useMemo(
() => mapNumberThresholds(spec.thresholds),
[spec.thresholds],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const unit = spec.formatting?.unit;
// Precision is applied regardless of whether a unit is set (see
// `formatPanelValue`), so decimal-precision changes always take effect.
const formattedValue = useMemo(
() => (value === null ? '' : formatPanelValue(value, unit, decimalPrecision)),
[value, unit, decimalPrecision],
);
return (
<div
data-testid="number-panel-renderer"
className={PanelStyles.panelContainer}
>
{value === null ? (
<Typography.Text data-testid="number-panel-no-data">
No Data
</Typography.Text>
) : (
<ValueDisplay
value={formattedValue}
rawValue={value}
thresholds={thresholds}
unit={unit}
/>
)}
</div>
);
}
export default NumberPanelRenderer;

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