Compare commits

...

35 Commits

Author SHA1 Message Date
Abhi Kumar
d53df5e805 refactor(dashboards-v2): type panelKind as PanelKind, not string
Tighten `panelKind` from `string | undefined` to the `PanelKind` union across
the editor config pane and the panel actions menu, sourcing the type from the
local `Panels/types/panelKind` rather than the raw generated DTO. Drops the
optional/undefined surface now that the render boundary always resolves a kind,
and updates the call-site tests to cast their literal kinds accordingly.
2026-06-17 16:59:01 +05:30
Abhi Kumar
8dd60afb52 feat(dashboards-v2): table panel header search
Add a collapsible search box to the panel header that filters table rows
client-side (V1 parity), wired through V2's capability-driven panel
architecture rather than a kind check in the header.

- Declare headerControls.search per kind in PanelDefinition (required, so
  each kind makes an explicit choice); only TablePanel opts in.
- Panel owns the searchTerm and feeds both the header (input) and the body.
- searchTerm flows via the kind-agnostic BaseRendererProps; only the table
  renderer consumes it, filtering rows with filterTableRows and resetting
  pagination to page 1 on each new term.
- New PanelHeaderSearch component renders the collapsible icon/input.

Any future kind enables search by setting the flag and reading searchTerm —
no shell changes.
2026-06-17 16:59:01 +05:30
Abhi Kumar
f03aefdbdc feat(dashboards-v2): editor UX — discard guard, time pill, drag-zoom, query sync
- close guard: clicking the editor's close (X) on a dirty panel opens a
  "Discard changes?" confirm (signozhq ConfigDialog); pristine closes
  straight away.
- back navigation: returning to the dashboard carries only `variables`,
  dropping editor-only URL state (compositeQuery) — mirrors V1.
- query sync: switching query type commits the active query so the
  preview matches the selected tab; dirty detection compares against the
  provider's normalized baseline (first stagedQuery) via getIsQueryModified,
  so opening / re-running an unedited query no longer marks the panel dirty.
- preview: drag-to-zoom on the chart updates the URL-synced time window
  (onDragSelect wired through from usePanelInteractions).
- header: show a per-panel time-preference pill (e.g. `6h`) beside the
  actions menu when the panel overrides the dashboard time window.
- tests: exhaustive coverage for usePanelEditorQuerySync, useTableColumns,
  useLegendSeries, and the header time pill.

echo "=== EXIT $? ==="
2026-06-17 16:59:01 +05:30
Abhi Kumar
fd64574c9f feat(dashboards-v2): panel editor as a route + query builder integration
Move the V2 panel editor from a modal overlay to a dedicated, chromeless
page route, and wire the real query builder into it.

Editor → page route:
- new DASHBOARD_PANEL_EDITOR route (/dashboard/:dashboardId/panel/:panelId)
  + PanelEditorPage (fetches the dashboard, resolves the panel, navigates
  back on close/save); registered in AppRoutes + permission map.
- useOpenPanelEditor navigates to the route (was an editPanelId query
  param); removed the modal mount + editPanelId from DashboardContainer.
- PanelEditor de-modal'd (no portal/overlay/exit-animation/body-class);
  AppLayout renders the route full-screen (no side nav / top nav).

Query builder:
- persesQueryAdapters (fromPerses/toPerses) bridge the perses panel
  queries and the V1 Query the shared QueryBuilderV2 + global
  QueryBuilderProvider operate on (URL compositeQuery mode), reusing the
  existing V1<->V5 mappers; covers builder/promql/clickhouse/formula.
- PanelEditorQueryBuilder renders the queryType tabs + Stage & Run;
  usePanelEditorQuerySync seeds the builder from the draft and, on Stage
  & Run / Cmd+Enter, writes the query back into the draft (preview
  re-fetches) or forces a refetch when unchanged.
- Stage & Run shows loading + cancel via usePanelQuery.cancelQuery.

Preview:
- use the dashboard view's URL-synced DateTimeSelectionV2 (the editor is
  its own page now); drop the editor-local time window (usePreviewQuery
  removed — the editor calls usePanelQuery directly).

echo "=== EXIT $? ==="; git log --oneline -1; echo "--- tree ---"; git status --short
2026-06-17 16:59:01 +05:30
Abhi Kumar
ae13acbc63 feat(dashboards-v2): shared scrollbar mixin + align table/editor with refactor
- styles: add a shared `custom-scrollbar` mixin (always-visible thin
  scrollbar — transparent track, l3 thumb) and use it in the chart
  Legend and the TablePanel (wrapper + antd `.ant-table-body` /
  `.ant-table-content`), so the table scrollbar matches the legend.
- table/editor: point getPanelDefinition imports at `Panels/registry`,
  the chartAppearance resolver at its new path, drop the now-required
  `panel?.`/`data?.` optional chaining, and render the shared `NoData`
  empty-state; update the renderer test accordingly.

echo "=== log ==="; git log --oneline -2; echo "=== tree ==="; git status --short
2026-06-17 16:59:01 +05:30
Abhi Kumar
e2e45c57f4 refactor(dashboards-v2): extract shared threshold-row chrome and fields
The label / comparison / table threshold rows shared ~80% of their code. Pull
the common parts into rows/shared/ and reduce each variant to a thin composition:

- useThresholdDraft: the draft state + snapshot-on-edit-entry + numeric value setter.
- ThresholdRowShell: the view/edit chrome (color dot, summary slot, Edit/Delete, and
  the edit form's Discard/Save), owning the shared markup and test ids.
- Field primitives: ThresholdValueField, ThresholdColorField, ThresholdUnitField
  (unit scoping + mismatch message parameterized), ThresholdSelectField (the labelled
  ConfigSelect reused for operator / display / column).

Each variant now declares only its summary and its field set + order. All test ids,
aria-labels, and behaviour are unchanged (~280 fewer lines across the three rows).
2026-06-17 16:59:01 +05:30
Abhi Kumar
c8b9371580 feat(dashboards-v2): table panel with column units + per-column thresholds
Add the V2 Table panel and the editor surfaces it needs, plus the threshold and
PanelEditor restructure they grew into. The pieces are interdependent, so they
land together.

Table panel:
- New signoz/TablePanel kind: renders the V5 scalar result (backend-joined into a
  single column set) as an antd Table with per-column unit/precision formatting,
  threshold cell coloring, sortable columns, a sticky header, and a page size that
  fits the panel height (min 10 rows).

Config editors:
- Formatting gains a per-column units editor; a new per-column thresholds editor.
  Both key by the query identifier (queryName / queryName.expression) to match the
  rendered dataIndex (V1 parity), not the display name.
- Column options and each column's configured unit are derived from the preview
  data and threaded to the editors, so the table-threshold unit picker scopes to
  the selected column's unit category.

Threshold editor unification:
- Collapse the label / comparison / table threshold sections into one
  variant-driven ThresholdsSection with a single registry entry; each kind selects
  its variant. Share the comparison operator/format mapping via
  utils/mapComparisonThreshold.

PanelEditor file structure:
- Consolidate the three threshold folders into ThresholdsSection/ (rows/ + shared
  helpers/options), move the state hooks into hooks/, and co-locate ConfigPane's
  test with the component.
2026-06-17 16:59:01 +05:30
Abhi Kumar
94617b6b24 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-17 16:59:01 +05:30
Abhi Kumar
69f536a366 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-17 16:59:01 +05:30
Abhi Kumar
c1036a9dc4 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-17 16:59:01 +05:30
Abhi Kumar
a091024f4e 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-17 16:59:01 +05:30
Abhi Kumar
be1b7ce66b 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-17 16:59:01 +05:30
Abhi Kumar
966828da19 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-17 16:59:01 +05:30
Abhi Kumar
1dfeafdd75 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-17 16:59:01 +05:30
Abhi Kumar
420fac001d 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-17 16:59:00 +05:30
Abhi Kumar
7b9948811d 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-17 16:59:00 +05:30
Abhi Kumar
4b397ded6e 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-17 16:59:00 +05:30
Abhi Kumar
0427520bf3 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-17 16:59:00 +05:30
Abhi Kumar
010ec74063 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-17 16:59:00 +05:30
Abhi Kumar
6094adb377 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-17 16:59:00 +05:30
Abhi Kumar
e2ac217fda 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-17 16:59:00 +05:30
Abhi Kumar
28cac3e4b7 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-17 16:59:00 +05:30
Abhi Kumar
141673e9f4 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-17 16:59:00 +05:30
Abhi Kumar
940c772991 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-17 16:59:00 +05:30
Abhi Kumar
3ecc67d246 refactor(dashboards-v2): derive PanelKind from generated contract
Type panel kinds off DashboardtypesPanelPluginKindDTO instead of a
hand-written union, keeping the generated API contract as the single
source of truth. getPanelDefinition and PANEL_KIND_TO_PANEL_TYPE now take
PanelKind directly, dropping the `as PanelKind` casts at their call sites.

Renderers drop the defensive optional chaining / `?? {}` fallbacks the
render boundary already guarantees, and panelDef is renamed to
panelDefinition for clarity.
2026-06-17 16:59:00 +05:30
Abhi Kumar
9ca8c42ad8 refactor(dashboards-v2): address PR review feedback
Tightening + structure changes from review of #11639:

- types: make rendererProps `panel`/`data` required and `syncMode`
  non-optional (None is the off-state); `Panel.panel` required with
  orphan layout items guarded in SectionGrid; key the panel interaction
  map by PanelKind; require `requestPayload` in useGetQueryRangeV5.
- panel chrome: split the unknown-kind path into UnsupportedPanelBody so
  PanelBody only runs with a resolved renderer; rename chartBody ->
  chartContainer; move PanelHeader/PanelBody into their own folders with
  dedicated scss (Panel.module.scss trimmed to .panel).
- panel status: normalize query failures via convertToApiError (the
  generated client rejects with a raw AxiosError, so the old isAPIError
  guard never matched and dropped the backend code/message); drop an
  unnecessary useMemo; docs link -> Typography.Link.
- registry: PascalCase aliases; Panels/index.ts -> registry.ts.
- utils: extract selectionPreferences out of baseConfigBuilder; split
  chartAppearanceMappings into chartAppearance/{enumMaps,resolvers};
  move each kind's buildConfig under utils/.
- renderers: render NoData on an empty result set.
- dashboards-list: drop redundant sort/order casts.

echo "=== result ==="; git log --oneline -3; echo "--- working tree ---"; git status --short
2026-06-17 16:58:58 +05:30
Abhi Kumar
3cc4246e96 feat(dashboards-v2): number panel kind + registry entry 2026-06-17 16:57:35 +05:30
Abhi Kumar
58a64543f4 chore(dashboards-v2): dashboards-list-v2 query-param sort/order adjustments 2026-06-17 16:57:11 +05:30
Abhi Kumar
5bc42eb8a6 feat(dashboards-v2): panel chrome - header, body, interactions & grid wiring 2026-06-17 16:57:09 +05:30
Abhi Kumar
007a7e4ec2 feat(dashboards-v2): panel plugin registry 2026-06-17 16:54:22 +05:30
Abhi Kumar
5eb419f79a feat(dashboards-v2): HistogramPanel renderer 2026-06-17 16:53:51 +05:30
Abhi Kumar
3bed5687e7 feat(dashboards-v2): BarChartPanel renderer 2026-06-17 16:53:25 +05:30
Abhi Kumar
7274ba0ab2 feat(dashboards-v2): TimeSeriesPanel renderer 2026-06-17 16:52:58 +05:30
Abhi Kumar
ca46fc45a0 feat(dashboards-v2): panel type system & shared chart utilities 2026-06-17 16:51:56 +05:30
Nikhil Soni
b50933d622 chore(trace-details): remove flamegraph v2 API (#11629)
* chore: remove flamegraph v2 since we've moved to v4 now

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove orphaned helpers after v2 waterfall and flamegraph removal

Drop GetSpansForTrace, cacheForTraceDetail, fluxIntervalForTraceDetail from
clickhouseReader — all were only used by the deleted v2 handlers. Also remove
the orphaned model.Span and model.Event types, and clean up the
cacheForTraceDetail initialization from server.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: update ee/query-service NewReader call after v2 cleanup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: format whitespacing

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 10:24:24 +00:00
158 changed files with 8630 additions and 941 deletions

View File

@@ -10,7 +10,6 @@ import (
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
"go.opentelemetry.io/otel/propagation"
"github.com/SigNoz/signoz/pkg/cache/memorycache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/gorilla/handlers"
@@ -20,7 +19,6 @@ import (
"github.com/SigNoz/signoz/ee/query-service/app/api"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/web"
@@ -59,25 +57,12 @@ type Server struct {
// NewServer creates and initializes Server
func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
cacheForTraceDetail, err := memorycache.New(context.TODO(), signoz.Instrumentation.ToProviderSettings(), cache.Config{
Provider: "memory",
Memory: cache.Memory{
NumCounters: 10 * 10000,
MaxCost: 1 << 27, // 128 MB
},
})
if err != nil {
return nil, err
}
reader := clickhouseReader.NewReader(
signoz.Instrumentation.Logger(),
signoz.SQLStore,
signoz.TelemetryStore,
signoz.Prometheus,
signoz.TelemetryStore.Cluster(),
config.Querier.FluxInterval,
cacheForTraceDetail,
signoz.Cache,
nil,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

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

View File

@@ -1,9 +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, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { stacked: true } },
{ kind: 'axes', controls: { minMax: true, logScale: true } },
{ kind: 'legend', controls: { position: true } },
{ kind: 'thresholds', controls: { variant: 'label' } },
{ kind: 'contextLinks' },
];

View File

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

View File

@@ -1,6 +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, mode: true } },
{ kind: 'buckets', controls: { count: true } },
{
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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