Compare commits

...

14 Commits

Author SHA1 Message Date
Ashwin Bhatkal
3cffe99d3c fix(dashboard-v2): word the orphaned-panel warning as any section 2026-07-06 21:21:44 +05:30
Ashwin Bhatkal
3cb50873bb fix(dashboard-v2): type panel-layout checks off the spec DTO + flag missing refs
Address review feedback:
- Type the panel/layout integrity check against DashboardtypesDashboardSpecDTO /
  DashboardtypesLayoutDTO instead of hand-rolled optional shapes.
- Also detect the reverse desync — layout items referencing a panel id that no
  longer exists in spec.panels — and surface it as its own warning.
- Reword the dangling warning to say the panels aren't present in any layout.
2026-07-06 21:21:06 +05:30
Ashwin Bhatkal
f118f78d9c fix(dashboard-v2): warn about orphaned panels + surface JSON save errors
- Editing the dashboard JSON can leave panels in spec.panels that no layout
  references (e.g. deleting a layout), silently orphaning them. Detect these and
  show a warning in the drawer footer before saving.
- The JSON save passed the raw generated error straight to showErrorModal cast as
  APIError, so a rejected save (e.g. a locked dashboard) rendered no message. Run
  it through toAPIError so the real error is shown.
2026-07-06 19:29:53 +05:30
Abhi kumar
379331ee77 feat(dashboards-v2): panel drilldown — View in Logs/Traces (#11895)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(dashboards-v2): add the panel View-modal state and hooks

URL-driven open state (useViewPanel off expandedWidgetId, V1 parity), a per-view
time window isolated from the dashboard (useViewPanelTimeWindow), and
useViewPanelEditor which layers the drilldown reset + type-selector signal/query
type on top of the shared usePanelEditSession.

* refactor(dashboards-v2): address View-mode PR review feedback

Naming/convention:
- UsePanelEditSessionApi -> UsePanelEditSessionReturn; fullKind -> panelKind
- useViewPanelEditor -> useViewPanelMode (hook, types, file, refs, test)
- useOpenPanelEditor `state` param -> `handoffState` (view -> edit handoff)

Types tightened:
- useViewPanelMode: panelDefinition is required (registry always resolves)
- signal is required: fold the default-signal fallback into the hook so the
  query builder + type selector always get a signal; drop the now-dead
  defaultSignal from the return and the `signal ?? defaultSignal` call site.
  Behavior-preserving: only List restricts signals and it's already gated by
  query type under PromQL/ClickHouse.

Misc:
- PanelEditorPage: drop redundant optional chaining on getPanelDefinition
- ViewPanelModal: Typography.Text title with ellipsis truncation
- ViewPanelModalHeader: clearer queryType JSDoc

* feat(dashboards-v2): add panel drilldown click-enrichment utils

* feat(dashboards-v2): declare drilldown as a per-kind panel capability

* feat(dashboards-v2): open a drilldown context menu with View in Logs/Traces

* feat(dashboards-v2): substitute dashboard variables in drilldown View-in-X

Clicking View in Logs/Traces from a panel drilldown now resolves the
panel query's $var/dashboard-variable references before building the
explorer URL (V1 parity with useBaseAggregateOptions). New
useResolvedDrilldownQuery hook fires a /substitute_vars round-trip
(useReplaceVariables + buildQueryRangeRequest) when the aggregate menu
opens and the dashboard has selections, mirroring the V2-native path in
useCreateAlertFromPanel. While resolving, both actions show a spinner
and are disabled; no-variable dashboards skip the round-trip.

* refactor(dashboards-v2): address drilldown review comments

- stepClickTimeRange takes a single stepInterval (defaulted in props);
  callers do the per-query lookup from exec-stats stepIntervals
- move DrilldownAggregateMenu static inline styles into a CSS module,
  keeping only the dynamic series color inline

* feat(dashboards-v2): drill down filter-by-value into the View modal (#11959)

* feat(dashboards-v2): drill down filter-by-value into the View modal

A table group-cell click opens the filter-by-value menu (Is this / Is
not this); picking an operator adds the filter to the panel query and
opens the refined result in the View modal, persisted in the URL (V1
parity). Reuses V1's read-only getGroupContextMenuConfig; no V1 files
modified.

* refactor(dashboards-v2): type the filter-by-value ClickedData without a double-cast

Address review: build a genuinely-typed ClickedData for the group-cell
filter menu instead of an `as unknown as ClickedData` escape hatch.
getGroupContextMenuConfig reads only column.dataIndex, so column carries
just that field and record gets a minimal valid RowData.

* refactor(dashboards-v2): render the filter-by-value menu from a component

Reshape useDrilldownFilter to return data (isGroupColumnClick + onFilter)
instead of JSX, and move the operator-menu rendering into a new
DrilldownFilterMenu component wired in useDrilldown. Keeps hooks to
logic/state and rendering in components (PR review preference).

* refactor(dashboards-v2): strongly-type the drilldown popover coordinates

Replace the drilldown's use of V1's loosely-typed useCoordinates (clickedData:
any) with a V2-local useDrilldownCoordinates hook generic over the click
payload, so DrilldownContext flows through without a cast. Extract the pure
viewport-clamp math into calculatePopoverPosition with unit tests.

* feat(dashboards-v2): panel drilldown — Breakout by .. (#11896)

* feat(dashboards-v2): gate the panel editor through the capability guard

Route the panel editor's query builder and visualization type switcher through
the capability guard instead of V1's PANEL_TYPE_TO_QUERY_TYPES (now no longer
imported by any V2 file):

- PanelEditorQueryBuilder is keyed on PanelKind; its query-type tabs and
  query-builder field visibility come from the guard.
- Switching the panel kind coerces the active query type via the guard.
- The visualization type switcher disables a kind when the active query type or
  datasource is incompatible with it (e.g. List under ClickHouse/PromQL, or List
  with a metrics query). The live query type is read from the query-builder
  provider so a not-yet-staged new panel still gates correctly, and a tooltip
  explains why a type is disabled. ConfigSelect gains opt-in per-option tooltips.

* feat(dashboards-v2): create alerts from a dashboard panel

Wire the panel actions menu's "Create Alerts" item to seed a new alert
from the panel's query. `buildCreateAlertUrl` translates the panel's V5
queries into the V1 compositeQuery the alert page reads (tagged with the
panel type, v5 version and a dashboards source), and
`useCreateAlertFromPanel` opens /alerts/new in a new tab and logs the
action. Available regardless of edit access (V1 parity: create-alert
works on locked dashboards too).

To reach the query, the full panel is threaded through
Panel -> PanelHeader -> PanelActionsMenu -> usePanelActionItems instead
of just its kind; the header now derives its name/description from the
panel as well.

* refactor(dashboards-v2): ready ChartManager for the standalone view

Prep the shared graph-manager so it renders cleanly inside the View
modal: swap the legacy useNotifications save toast for the sonner toast,
and let ChartLayout grow to its content height (auto) so the manager's
legend table isn't clipped under the chart.

* feat(dashboards-v2): add the panel View modal

The View modal UI — a compact, single-host drilldown editor matching V1's
FullView: a draft preview over the per-view window, a toolbar (Reset
Query, Switch to Edit Mode, panel-type selector, time picker, refresh),
and the query-builder-only drilldown editor (no ClickHouse/PromQL tabs,
per V1). Edits are temporary — they never touch the saved dashboard.

* feat(dashboards-v2): open a drilldown context menu with View in Logs/Traces

* feat(dashboards-v2): add the Breakout by .. drilldown submenu

* chore: pr review fixes

* refactor(dashboards-v2): render the Breakout by .. menu from a component

Reshape useDrilldownBreakout to return data (queryData + onBreakout) instead
of JSX, and move the picker rendering into a new DrilldownBreakoutMenu
component (with a CSS module) wired in useDrilldown. Keeps hooks to
logic/state and rendering in components (PR review preference).

* feat(dashboards-v2): panel drilldown — context links (#11897)

* feat(dashboards-v2): resolve panel context links in the drilldown menu

* feat(dashboards-v2): panel drilldown — dashboard variables + trace link (#11964)

* feat(dashboards-v2): complete drilldown V1 parity — dashboard variables + trace link

Add the last two V1 drilldown features to V2 panels:

- Dashboard Variables submenu: from a grouped click, the aggregate menu now
  offers, per group-by field, Set/Unset a matching dynamic variable to the
  clicked value, or Create a new dynamic variable from it. Set/Unset are
  runtime-only (store + URL, matching V2 selections); Create is the one
  persisted path (appends to spec.variables via the optimistic patch).
  New Panel/hooks/useDrilldownDashboardVariables.tsx mirrors V1's
  useDashboardVarConfig; wired into useDrilldown as a new submenu.

- "View Trace Details" data link: the aggregate menu shows a /trace/{id}
  link when the clicked point carries a trace_id (V1 getDataLinks parity).
  New Panels/utils/drilldown/getDataLinks.ts.

Exports variablesUrlParser from useVariableSelection so the submenu reuses the
runtime-selection URL contract without re-running its seeding effect.

* fix(dashboards-v2): restrict drilldown filters to group-by dimensions

The pure-V5 flattening backfills an ungrouped series' labels with
{queryName: queryName} for its legend; the chart/pie enrichers turned every
label into an equality filter, leaking a bogus `A=A` into the drilldown
context. That broke the "filters are the clicked point's group-by labels,
empty when ungrouped" contract — surfacing a spurious Dashboard Variables
entry and a corrupt View-in-Logs query that V1 never shows.

Add getGroupByFilters to intersect the clicked labels with the query's
groupBy, and use it in enrichChartClick/enrichPieClick.

* refactor(dashboards-v2): dashboard-variables drilldown review fixes

Address PR review on the Dashboard Variables submenu:
- render it from a DrilldownDashboardVariablesMenu component; the hook now
  returns data (hasFieldVariables + actions) instead of JSX, taking a required
  filters array + signal (rendering belongs in the component, not the hook)
- identify dynamic variables by plugin kind on the DTO, not by mapping every
  variable to a form model first
- drop the redundant useGetDashboardV2 enabled guard (the generated hook
  already gates on !!id)
- reference the DrilldownSubMenu enum instead of a raw string
- move inline styles to a CSS module

* refactor(dashboards-v2): read drilldown variables via useDashboardFetchRequired

Use the shared useDashboardFetchRequired in useDrilldownDashboardVariables
instead of calling useGetDashboardV2 directly, so it resolves through the one
dashboard cache entry and drops the `?? []` guards. Filter dynamic variables on
the form model's `type` (via dtoToFormModel) rather than casting the raw spec.

That hook throws on a missing dashboard id, and the id was seeded into the store
in a useEffect — undefined on the first Panel render. Seed it synchronously
during render in DashboardContainer instead, and make setEditContext idempotent
so the render-phase call is a safe no-op once seeded.

* chore: fix failing lint
2026-07-06 13:18:39 +00:00
Ashwin Bhatkal
4d079a8338 refactor(dashboard-v2): unify dashboard tag chips into one TagBadge component (#11984)
* fix(dashboard-v2): render tag chips with the sienna Badge component

The key:value tag editor rendered chips as a hand-rolled div + ghost buttons with
sienna colors mixed in by hand. Use the @signozhq Badge (color=sienna, closable)
so the chips match the design system and the rest of the dashboard tag styling.

* fix(dashboard-v2): keep key:key tags intact in the settings tag editor

tagsToStrings collapsed a tag whose key equals its value down to a bare key, so
the settings tag editor showed `env` for an `env:env` tag. Always render both
sides so key:key tags round-trip through the editor unchanged.

* refactor(dashboard-v2): single TagBadge component for all dashboard tags

The list rows, details header, and tag editors each rendered tag chips their own
way (two hand-rolled sienna variants + an inline Badge), so they drifted. Extract
one TagBadge (sienna Badge, optional closable) and use it everywhere, so tag
styling changes in one place. List-row chips were not even sienna before.

* fix(dashboard-v2): tag label inherits the Badge sienna color

The chip label button used --button-variant-ghost-color: inherit, but for a CSS
custom property inherit takes the parent's value of that same variable (unset),
not the parent color — so the label fell back to the muted ghost default. Use
currentColor so it picks up the Badge's sienna foreground.
2026-07-06 13:05:23 +00:00
Vinicius Lourenço
91938925ec feat(infra-monitoring): rework the category selection (#11957) 2026-07-06 12:04:07 +00:00
Abhi kumar
e2588e6fff feat(dashboards-v2): context-link editor with variable + URL-param support (list + modal) (#11950)
* feat(dashboards-v2): add context-link variable + URL-param building blocks

Reusable primitives for the V2 context-link editor, kept separate from the
UI wiring:

- types: VariableItem / UrlParam / VariableSource
- utils: query-string <-> key/value param parsing, cursor-aware {{variable}}
  insertion, and URL validation (http(s)://, /path, or {{var}}/)
- VariablesPopover: autocomplete that lists {{variables}} grouped by source
  and inserts the picked token at the cursor
- useContextLinkVariables: self-contained hook sourcing the offered variables
  (global timestamps, per-query groupBy fields as _<key>, dashboard variables)
  from the query-builder provider + dashboard store, no prop threading

Covered by utils + hook unit tests.

* feat(dashboards-v2): rework context-link editor into a list + modal

The section is now a list of saved links (label + URL with edit/delete),
with add/edit handled in a modal (@signozhq/ui DialogWrapper) instead of the
cramped inline rows.

The modal carries V1's full authoring UX:
- label + URL with {{variable}} autocomplete and inline validation
- a key/value URL-parameters editor synced to the URL query string
- an "open in new tab" toggle; Save is gated on a non-empty, valid URL

Saved links set renderVariables so consumers interpolate the URL at
click-time. The variables popover renders with withPortal=false so it stays
inside the modal (radix's modal disables pointer events on portalled content).

* chore: pr review fixes

* refactor(dashboards-v2): split dashboard fetch into fetch-only hook + required accessor

- useDashboardFetch (renamed from useDashboardV2) now exposes only the raw
  dashboard plus fetch lifecycle; spec derivation moves out of it.
- Add useDashboardFetchRequired: reads the active dashboardId from the store,
  reuses the shared react-query cache entry, throws when the dashboard is
  missing, and derives spec collections (variables). Also exposes refetch.
- Enforce the split with a new signoz/no-dashboard-fetch-outside-root lint rule,
  allowlisted for the two root pages and the required hook.
- Point the context-link variables hook at useDashboardFetchRequired and expand
  the VariableSource JSDoc.

* chore: fixed fmt
2026-07-06 09:46:24 +00:00
Naman Verma
832a6cc794 feat: v2 versions of public dashboard APIs (#11725)
* feat: add first draft of v2 public dashboard apis

* fix: remove duplicate call to GetDashboardByPublicIDV2 in GetPublicWidgetQueryRangeV2

* fix: fill fields that were in the data blob in v1

* chore: trim comments

* fix: remove fields that v1 also removes when redacting

* chore: rename method name

* test: unit tests for GetPanelQuery

* fix: add fill gaps to query

* fix: generate api specs

* test: add integration tests for new v2 public apis

* fix: add query validation and aggregation validation

* fix: remove unneeded tags db call from public query range api

* fix: redact variable queries as well

* fix: move regex out of method so that it is only compiled once per package load

* chore: remove empty line

* fix: use pointer to specs during redaction

* fix: move single expression validation to dashboard package, use chparser for it

* test: add integration test for variable query redaction

* test: add integration test for expression with many parens

* test: use valid query in integration test

* test: use realistic query in variable
2026-07-06 09:16:42 +00:00
Abhi kumar
5948b5b970 feat(dashboards-v2): view (expand) a dashboard panel (#11882)
* refactor(dashboards-v2): extract usePanelEditSession shared editing pipeline

Pull the panel-editing pipeline (draft + query + staged-query sync + kind
switch) out of the editor container into a shared usePanelEditSession hook so the
full-page editor and the View modal share one source of truth and can't drift. A
characterization test locks the container's forwarding behaviour.

* feat(dashboards-v2): open the panel editor on a handed-off spec

Let useOpenPanelEditor carry an optional spec via router location state so the
View modal can hand its drilldown edits to the full editor; the editor page opens
on the handed-off spec when present, falling back to the saved panel on
refresh/new-tab.

* feat(dashboards-v2): render the graph-manager legend in the standalone view

The time-series and bar renderers show V1's graph-manager legend (Filter Series +
per-series show/hide + Save) below the chart in STANDALONE_VIEW, threaded through
as onCloseStandaloneView. ChartManager moves to the sonner toast, and the shared
ChartLayout only drops its fill height when it has stacked layout children
(--with-layout-children) so the dashboard grid, alert preview, and other charts
keep filling their container.

* refactor(dashboards-v2): make the editor preview and panel-type selector reusable

Prepare the editor's building blocks so the View modal can reuse them instead of
duplicating them:
- PreviewPane takes panelMode/hideHeader/dashboardPreference/onCloseStandaloneView
  so it can render the standalone preview without its own time picker.
- Extract usePanelTypeSelectItems from PanelTypeSwitcher so the modal header and
  the editor build the same capabilities-guarded panel-type options.
- The shared query builder's run button reads "Run Query" (V1 FullView parity).

* feat(dashboards-v2): add the panel View-modal state and hooks

URL-driven open state (useViewPanel off expandedWidgetId, V1 parity), a per-view
time window isolated from the dashboard (useViewPanelTimeWindow), and
useViewPanelEditor which layers the drilldown reset + type-selector signal/query
type on top of the shared usePanelEditSession.

* feat(dashboards-v2): add the panel View modal

A full-screen, temporary drilldown editor mounted once per dashboard (URL host in
the layout). It reuses the editor's PreviewPane, tabbed query builder, and
panel-type selector over the isolated per-view window, with Reset Query and Switch
to Edit Mode. Edits stay in the builder/URL and the local draft, never the saved
dashboard.

* feat(dashboards-v2): wire up the panel View action

The panel actions menu's View item opens the View modal for the panel, replacing
the placeholder.

* fix: fixed failing test

* refactor(dashboards-v2): address View-mode PR review feedback

Naming/convention:
- UsePanelEditSessionApi -> UsePanelEditSessionReturn; fullKind -> panelKind
- useViewPanelEditor -> useViewPanelMode (hook, types, file, refs, test)
- useOpenPanelEditor `state` param -> `handoffState` (view -> edit handoff)

Types tightened:
- useViewPanelMode: panelDefinition is required (registry always resolves)
- signal is required: fold the default-signal fallback into the hook so the
  query builder + type selector always get a signal; drop the now-dead
  defaultSignal from the return and the `signal ?? defaultSignal` call site.
  Behavior-preserving: only List restricts signals and it's already gated by
  query type under PromQL/ClickHouse.

Misc:
- PanelEditorPage: drop redundant optional chaining on getPanelDefinition
- ViewPanelModal: Typography.Text title with ellipsis truncation
- ViewPanelModalHeader: clearer queryType JSDoc

* refactor(dashboards-v2): make panel-type selector queryType required

queryType originates as a required EQueryType in ConfigPane (from the QB
provider) and is always defined in the View modal too, so the optional
prop + silent `?? QUERY_BUILDER` fallback on the leaf selector were just
typing artifacts. Make queryType required on PanelTypeSwitcher and
usePanelTypeSelectItems and drop the fallback. The kind-erased
SectionEditorContext stays all-optional per its invariant, so the default
now lives at the VisualizationSection boundary that consumes it.

* chore: updated dashboard page title

* feat(dashboards-v2): persist the View modal's query edits across refresh (#11945)

* feat(dashboards-v2): persist the View modal's query edits across refresh

The View modal's in-modal query builder is URL-synced (`compositeQuery`, plus
`graphType` when present), but the modal seeded its draft from the saved panel on
mount — so a page refresh discarded any in-modal edits. Seed the draft from the URL
when a query is present (else the saved panel) via a mount-only `buildViewPanelSpec`,
and clear those params on a plain open so a stale query can't bleed into the modal.
URL-backed, shareable, and refresh-safe (V1 parity).

* refactor(dashboards-v2): drop redundant queries fallback in buildViewPanelSpec

DashboardtypesPanelSpecDTO.queries is a required array, so `spec.queries ?? []`
never falls back — pass spec.queries directly. Addresses PR review feedback.
2026-07-06 09:10:48 +00:00
Nityananda Gohain
84834b087b feat: add endpoint for fetching unpriced models (#11763)
* feat: add endpoint for fetching unpriced models

* fix: more updates

* fix: response model
2026-07-06 08:50:15 +00:00
Ashwin Bhatkal
d490126162 fix(dashboard-v2): list-page bug bash fixes (#11939)
* fix(dashboard-v2): refresh the list after deleting a dashboard

Delete invalidated invalidateListDashboardsV2, but the list renders from
useListDashboardsForUserV2 (as does pin/unpin), so the deleted row lingered until
a manual reload. Invalidate the for-user list instead.

* feat(dashboard-v2): show a lock icon on locked dashboards in the list

The list rows already carry `locked`; surface it with a LockKeyhole icon (with a
tooltip) next to the row actions, mirroring the detail-page header.

* fix(dashboard-v2): show pinned icon (not unpin) until the pin is hovered

A pinned row rendered the PinOff ("unpin") icon at rest, so it read as an action
rather than a state. Show the filled Pin by default and reveal PinOff only on
hover of the pin button.

* fix(dashboard-v2): break long unbroken dashboard names in the list

The list title clamps to 3 lines but a long unbroken string could still overflow
the row horizontally; add overflow-wrap so it wraps within the clamp. (The detail
header already single-line truncates with a tooltip.)

* fix(dashboard-v2): use sienna tags in the dashboard header

The create modal and settings tag inputs already render sienna chips (matching the
list rows); the detail-page header still showed amber/warning badges. Switch them
to sienna for consistency across create, configure and display.

* fix(dashboard-v2): use a columns icon for the list columns control

The columns/metadata popover trigger used the HdmiPort icon, which doesn't read as
'columns'. Swap it for the Columns3 icon.

* fix(dashboard-v2): close the new-dashboard modal after creating

Blank/Import/Template create flows navigated to the new dashboard but left the
modal mounted, so it lingered over the detail page. Call onClose() on success
(Import/Template now take the onClose prop the modal already passes to Blank).

* feat(dashboard-v2): open the public dashboard from the header globe

The header globe now reflects the real public state (via usePublicDashboardMeta,
a deduped read) and is clickable — it opens the public dashboard page in a new
tab, with the tooltip updated to say so alongside the existing text.

* fix(dashboard-v2): embed the V1 template gallery in the new-dashboard modal

The V2 templates tab rendered a mock gallery. Until the templates BE API lands,
show the existing V1 gallery instead: extract it into DashboardTemplatesContent
(shared by the V1 modal and the V2 tab, no modal-in-modal) and embed it inline in
the V2 'From a template' tab. The V1 templates are placeholders, so the action
creates a blank dashboard and closes the modal. Drops the now-unused mock
templatesData.

* feat(dashboard-v2): click-to-unlock header lock icon + keep header icons on long names

The header lock icon is now a click-to-unlock control for author/admin (routes to
the existing lock toggle; static for others). Give it flex-shrink:0 (like the
public globe and description icons) so the description/public/lock icons and the
tag overflow stay visible when the title is long and truncates.

* feat(dashboard-v2): add Rename and Lock/Unlock to the list row actions

Rename opens a small dialog that patches /spec/display/name and refreshes the
list (shown for unlocked dashboards). Lock/Unlock toggles via lock/unlockDashboardV2
and refreshes the list, gated to author/admin on non-integration dashboards
(mirroring the detail-page gate).

* fix(dashboard-v2): list row menu — lock casing, disabled Rename tooltip, stop nav on menu click

- Capitalize 'Lock/Unlock Dashboard'.
- Show Rename disabled (not hidden) on locked dashboards with a tooltip explaining
  why (matches Delete).
- Stop clicks inside the actions menu from bubbling to the row's navigate handler
  (disabled items were opening the dashboard).

* fix(dashboard-v2): single-line row title + bottom-anchored tooltip

Truncate the list row title to a single line with an ellipsis (was a 3-line clamp),
and show the full name in a tooltip anchored to the bottom (auto-flips to top, with
an arrow) instead of the left.

* feat(dashboard-v2): session-local lock toggle in the header

The header lock control now reflects a session-local state: it appears once the
dashboard is locked (on load or during the session) and stays as a lock/unlock
toggle for the rest of the page. A dashboard that loads unlocked shows no icon
until it's locked. Clicking flips state optimistically so it no longer depends on
the refetch to update.

* fix(dashboard-v2): add top margin above the public-dashboard variables hint

* feat(dashboard-v2): run search with a Run query button + Cmd/Ctrl+Enter

Show a 'Run query' affordance with the OS-aware ⌘/Ctrl ⏎ hint in the search bar
(matching the query builder) and run the search on Cmd/Ctrl+Enter.

* fix(dashboard-v2): make the filter Clear button prominent

Use the primary (outlined) style for the Clear-filters button so it stands out
once a filter is applied.

* revert(dashboard-v2): restore multi-line clamp for list row title

Single-line truncation broke the row's responsiveness; revert to the 3-line clamp.
Keeps the bottom-anchored tooltip for long names.

* fix(dashboard-v2): show the lock tooltip on disabled Rename/Delete items

antd tooltips don't fire on disabled buttons; wrap them in a span the tooltip can
attach to so the 'dashboard is locked' reason shows on hover.

* fix(dashboard-v2): header tooltips update when moving between icons

Set disableHoverableContent on the title/description/public/lock tooltips so
leaving a trigger closes its tooltip immediately, letting the next icon's tooltip
open in both directions (was sticking when moving public → lock).

* fix(dashboard-v2): tooltip on the pin button showing the click action

Replace the native title with an antd tooltip that reads 'Pin dashboard' /
'Unpin dashboard' based on the current state.

* fix(dashboard-v2): delete dashboards via the v2 endpoint from the list

The list-page delete action was calling the v1 delete endpoint; route it through
deleteDashboardV2 so v2-shape dashboards (incl. pinned) delete correctly.

* refactor(dashboard-v2): use signoz Button/Tooltip in list row & dashboard header

Address review feedback on #11939:
- DashboardRow: swap the antd Tooltip for @signozhq TooltipSimple and the native
  pin <button> for the @signozhq Button. Only wrap long titles in a tooltip so a
  short name no longer renders an empty hanging tooltip. Rename pinBtn -> pinButton.
- DashboardInfo: swap the native public-link and lock <button>s for @signozhq Button.
- Drop the now-orphaned .titleTooltipOverlay antd override.

* fix(dashboard-v2): show a loader and block double-click when deleting a dashboard

The list-row delete confirm fired the mutation on click and closed on settle,
so a quick second click could fire delete twice. Use a promise-based onOk so the
Delete button shows a loading spinner and rejects further clicks until the request
settles, then closes.
2026-07-06 05:42:31 +00:00
Shivam Gupta
526a7d1c46 fix: update stale docs links in frontend (#11319)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: update 47 stale docs links in frontend to current URLs

Update documentation links across 19 frontend files to match
current signoz.io docs structure after product module restructures.

Key changes:
- Instrumentation links updated to new OpenTelemetry-prefixed paths
- product-features/* links replaced with current locations
- Query builder links point to new querying module pages
- Alert notification channel links point to setup-alerts-notification
- SSO, infra monitoring, and version upgrade links corrected

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update 278 more stale docs links and broken anchors in frontend

- Onboarding md-docs: update instrumentation URLs to canonical paths
  (django/flask/fastapi/falcon → opentelemetry-python, express/nestjs →
  javascript/opentelemetry-nodejs, springboot → java/opentelemetry-java,
  tomcat → java/opentelemetry-tomcat, jboss → java/opentelemetry-jboss,
  golang → opentelemetry-golang, elixir → opentelemetry-elixir,
  reactjs → frontend-monitoring/sending-traces-with-opentelemetry)
- Onboarding md-docs: update tutorial/* → opentelemetry-collection-agents/*,
  userguide/hostmetrics → infrastructure-monitoring/hostmetrics,
  userguide/logs# → userguide/logs_query_builder#
- Query builder UI: fix broken anchors after query-builder-v5 page
  restructure (Having → result-manipulation, Order By → result-manipulation,
  Limit → result-manipulation, Legend → aggregation-grouping, Group By →
  aggregation-grouping, Formula → multi-query-analysis, Trace Matching →
  multi-query-analysis, Reduce → result-manipulation, Aggregation functions
  → aggregation-grouping, Time aggregation → temporal-aggregation)
- Fix Apdex link → alerts-management/apdex-alerts
- Fix missing spans link → traces-management/troubleshooting/faqs
- Fix cost meter, ClickHouse traces, k8s pod logs anchors
- Drop broken anchors where sections were removed from docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: format CreateAlertRule files after anchor removal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: revert OnboardingContainer changes (deprecated module)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Vishal Sharma <makeavish786@gmail.com>
Co-authored-by: Ubuntu <ubuntu@ip-172-31-24-8.eu-central-1.compute.internal>
2026-07-06 04:47:31 +00:00
Abhi kumar
9f72338c87 fix(dashboards-v2): list panel pagination, spanGaps clamp, discard dialog & create-alert fixes (#11974)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(dashboards-v2): show the list panel pager

A new/edited List panel never showed its server pager. The V1→V5 mapper
folds a list query's internal `pageSize` into the V5 `limit`
(`createBaseSpec`), so a seeded panel (pageSize 100) always looked like it
had a user limit — and `usePanelQuery` suppresses the pager when a limit is
present.

Strip the V1 explorer's `pageSize`/`offset` in `toPerses` before conversion
so `limit` reflects only a real user limit; List panels then page by default
while an explicit user limit still renders as a static, unpaged list.

* fix(dashboards-v2): give logs list requests a deterministic order

Offset paging over a non-total order can duplicate or drop rows sharing a
value (e.g. same-millisecond logs) across page boundaries. Enforce a total
order on every logs-list request (`withListOrderTiebreaker`): default the
primary to `timestamp desc` and always append `id` (logs-explorer parity).
Request-only, so it never lands in the saved panel spec; traces keep theirs.

* fix(dashboards-v2): list pagination runtime behaviour

- Drive `canNext` from the backend's `nextCursor` (set iff the page filled)
  instead of a client-side row-count guess.
- Keep the table + pager mounted on a page change (`keepPreviousData`); show
  the full-panel loader only on the first fetch, not on every refetch.
- Ignore a stale cross-type response kept across a panel-kind switch (match
  the response type to the request) so the list doesn't flash "No data".
- While the next page loads, swap the stale rows for horizontal skeleton rows
  (dashboard grid and editor preview).

* fix(dashboards-v2): seed timestamp order when switching to a list panel

V1's shared `handleQueryChange` clears orderBy when switching to a list, so
the builder's Order By opened empty after e.g. Time series → List. Re-seed
the fresh-list default (timestamp desc) in `usePanelTypeSwitch` so the query
matches a newly created list panel.

* fix(dashboards-v2): clamp time-series spanGaps to the step interval

A numeric spanGaps is a max-gap threshold (seconds). Floor it at the smallest
step interval so a sub-step value doesn't break the line at every normal
point. Boolean "span all" passes through unchanged.

* fix(dashboards-v2): flip discard-dialog buttons

"Keep editing" now sits on the right and is outlined; "Discard" stays left
(destructive). The ConfirmDialog preset can't reorder/restyle its footer, so
swap to DialogWrapper with a custom footer (same chrome, async confirm state
preserved via the button's loading flag).

* fix(dashboards-v2): floor the create-alert time window to integer ms

buildQueryRangeRequest's start/end are int64 ms, so passing a float from the
nanosecond division could break the call. Floor the ns→ms conversion and use
the named NANO_SECOND_MULTIPLIER constant instead of a magic 1e6.

* fix(dashboards-v2): clone patched values so optimistic apply can't corrupt outgoing ops

Creating the first panel on an empty dashboard failed with
"spec.layouts[0].spec.items[0] and items[1] overlap". createPanelOps
emits three ops (add empty section, add panel, add item into that
section), but useOptimisticPatch.onMutate runs applyJsonPatch before the
request is sent, and applyJsonPatch inserted each op.value by reference.
The cache's layouts[0].spec.items then aliased the empty items array held
inside the section-add op, so applying the item op mutated that op's
value too. react-query sent the mutated ops (section already holding the
item, plus the separate item add) → two overlapping items.

Clone the inserted value on add/replace so the applied document never
shares references with the input ops, restoring the purity the docstring
already promises. New-dashboard-only because that is the only batch that
adds a layout and an item into that same layout together.
2026-07-05 06:00:51 +00:00
Abhi kumar
e7ef4c6bdb fix(dashboard-v2): correct formatting carry on panel-kind switch + unify spec seeding (#11956)
* refactor(dashboard-v2): make ThresholdVariant a string enum

Replace the ThresholdVariant string-union with a string enum so panel
section configs reference named members (ThresholdVariant.LABEL/COMPARISON/
TABLE) instead of bare string literals, and update every call site.

* fix(dashboard-v2): correct formatting carry on panel-kind switch

Switching a panel's visualization kind carried formatting fields the target
kind's schema does not accept (e.g. `unit` into a Table, which only supports
`columnUnits` + `decimalPrecision`), so the save API rejected the spec.

Unify new-panel and kind-switch spec seeding into one per-section registry
(`buildPluginSpec`, SECTION_SEEDS): each section derives its plugin-spec slice
from the target kind's declared `controls` and optional context, so a carried
field is emitted only when the target kind actually supports it. Adding a
section now means one registry entry rather than editing two seeders.

Delete the redundant `buildDefaultPluginSpec` wrapper (it only forwarded to
`buildPluginSpec`); `getSwitchedPluginSpec` becomes a thin delegating wrapper.
2026-07-05 05:51:44 +00:00
235 changed files with 11373 additions and 1833 deletions

View File

@@ -2917,6 +2917,13 @@ components:
publicDashboard:
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
type: object
DashboardtypesGettablePublicDashboardDataV2:
properties:
dashboard:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
publicDashboard:
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
type: object
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -5224,6 +5231,16 @@ components:
- offset
- limit
type: object
LlmpricingruletypesGettableUnmappedModels:
properties:
items:
items:
$ref: '#/components/schemas/LlmpricingruletypesUnmappedModel'
nullable: true
type: array
required:
- items
type: object
LlmpricingruletypesLLMPricingCacheCosts:
properties:
mode:
@@ -5313,6 +5330,19 @@ components:
type: string
nullable: true
type: array
LlmpricingruletypesUnmappedModel:
properties:
modelName:
type: string
provider:
type: string
spanCount:
minimum: 0
type: integer
required:
- modelName
- spanCount
type: object
LlmpricingruletypesUpdatableLLMPricingRule:
properties:
enabled:
@@ -11190,6 +11220,60 @@ paths:
summary: Get a pricing rule
tags:
- llmpricingrules
/api/v1/llm_pricing_rules/unmapped_models:
get:
deprecated: false
description: Returns models seen in the last hour of trace data (gen_ai.request.model)
that no pricing rule pattern matches, so the user can add them to an existing
rule or create a new one.
operationId: ListUnmappedLLMModels
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/LlmpricingruletypesGettableUnmappedModels'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List unmapped models
tags:
- llmpricingrules
/api/v1/logs/promote_paths:
get:
deprecated: false
@@ -17441,6 +17525,138 @@ paths:
summary: Update my organization
tags:
- orgs
/api/v2/public/dashboards/{id}:
get:
deprecated: false
description: This endpoint returns the sanitized v2-shape dashboard data for
public access. Each panel query is reduced to a safe field subset, so filters
and raw query strings are not exposed.
operationId: GetPublicDashboardDataV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettablePublicDashboardDataV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- anonymous:
- public-dashboard:read
summary: Get public dashboard data (v2)
tags:
- dashboard
/api/v2/public/dashboards/{id}/panels/{key}/query_range:
get:
deprecated: false
description: This endpoint returns query range results for a panel of a v2-shape
public dashboard. The panel is addressed by its key in spec.panels.
operationId: GetPublicDashboardPanelQueryRangeV2
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: key
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- anonymous:
- public-dashboard:read
summary: Get query range result (v2)
tags:
- dashboard
/api/v2/readyz:
get:
operationId: Readyz

View File

@@ -29,6 +29,7 @@ type module struct {
settings factory.ScopedProviderSettings
querier querier.Querier
licensing licensing.Licensing
tagModule tag.Module
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
@@ -41,6 +42,7 @@ func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, an
settings: scopedProviderSettings,
querier: querier,
licensing: licensing,
tagModule: tagModule,
}
}
@@ -132,6 +134,55 @@ func (module *module) GetPublicWidgetQueryRange(ctx context.Context, id valuer.U
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
}
func (module *module) GetDashboardByPublicIDV2(ctx context.Context, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
if err != nil {
return nil, err
}
tags, err := module.tagModule.ListForResource(ctx, storableDashboard.OrgID, coretypes.KindDashboard, storableDashboard.ID)
if err != nil {
return nil, err
}
return storableDashboard.ToDashboardV2(tags)
}
func (module *module) GetPublicWidgetQueryRangeV2(ctx context.Context, id valuer.UUID, panelKey, startTimeRaw, endTimeRaw string) (*querybuildertypesv5.QueryRangeResponse, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "dashboard",
instrumentationtypes.CodeFunctionName: "GetPublicWidgetQueryRangeV2",
})
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
if err != nil {
return nil, err
}
// tags are not needed for query range.
dashboard, err := storableDashboard.ToDashboardV2(nil)
if err != nil {
return nil, err
}
publicDashboard, err := module.GetPublic(ctx, dashboard.OrgID, dashboard.ID)
if err != nil {
return nil, err
}
startTime, endTime, err := publicDashboard.ResolveTimeRange(startTimeRaw, endTimeRaw)
if err != nil {
return nil, err
}
query, err := dashboard.GetPanelQuery(startTime, endTime, panelKey)
if err != nil {
return nil, err
}
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
}
func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publicDashboard *dashboardtypes.PublicDashboard) error {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {

View File

@@ -293,6 +293,8 @@
// Forces subpath imports (@signozhq/ui/<component>) instead of the eagerly-loaded barrel
"signoz/no-css-module-bracket-access": "warn",
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
"signoz/no-dashboard-fetch-outside-root": "error",
// Forces useDashboardFetchRequired() outside the root V2 pages (allowlisted in overrides below)
"no-restricted-globals": [
"error",
{
@@ -558,6 +560,18 @@
"rules": {
"signoz/no-zustand-getstate-in-hooks": "off"
}
},
{
// Root V2 pages own the dashboard fetch lifecycle; useDashboardFetchRequired wraps it.
// Everywhere else must use useDashboardFetchRequired().
"files": [
"src/pages/DashboardPageV2/DashboardPageV2.tsx",
"src/pages/DashboardPageV2/PanelEditorPage/PanelEditorPage.tsx",
"src/pages/DashboardPageV2/DashboardContainer/hooks/useDashboardFetchRequired.ts"
],
"rules": {
"signoz/no-dashboard-fetch-outside-root": "off"
}
}
]
}

View File

@@ -0,0 +1,48 @@
/**
* Rule: no-dashboard-fetch-outside-root
*
* `useDashboardFetch` owns the V2 dashboard fetch and exposes its loading/error/refetch
* lifecycle. That lifecycle is a root-page concern: DashboardPageV2 and PanelEditorPage
* gate their subtree on a resolved dashboard before mounting anything below them.
*
* Every other component therefore renders inside an already-loaded subtree and must use
* `useDashboardFetchRequired`, which reuses the same react-query cache entry but guarantees
* a non-undefined dashboard (throwing otherwise) — so consumers don't re-handle states the
* root page already owns.
*
* This rule flags `useDashboardFetch` imports. It is disabled via an override in
* .oxlintrc.json for the two root pages and for useDashboardFetchRequired.ts (which wraps it).
*/
const FETCH_HOOK = 'useDashboardFetch';
export default {
meta: {
type: 'suggestion',
docs: {
description:
'Disallow useDashboardFetch outside the root dashboard pages; use useDashboardFetchRequired instead',
category: 'Dashboard V2',
},
schema: [],
messages: {
useRequired:
'Use useDashboardFetchRequired() instead of useDashboardFetch — the latter is reserved for the root pages (DashboardPageV2, PanelEditorPage) that own the fetch lifecycle. Components below them render inside a loaded subtree and should assume a guaranteed dashboard.',
},
},
create(context) {
return {
ImportDeclaration(node) {
node.specifiers.forEach((specifier) => {
if (
specifier.type === 'ImportSpecifier' &&
specifier.imported.name === FETCH_HOOK
) {
context.report({ node: specifier, messageId: 'useRequired' });
}
});
},
};
},
};

View File

@@ -12,6 +12,7 @@ import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
import noAntdComponents from './rules/no-antd-components.mjs';
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root.mjs';
export default {
meta: {
@@ -25,5 +26,6 @@ export default {
'no-antd-components': noAntdComponents,
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
'no-css-module-bracket-access': noCssModuleBracketAccess,
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
},
};

View File

@@ -38,6 +38,10 @@ import type {
GetPublicDashboard200,
GetPublicDashboardData200,
GetPublicDashboardDataPathParameters,
GetPublicDashboardDataV2200,
GetPublicDashboardDataV2PathParameters,
GetPublicDashboardPanelQueryRangeV2200,
GetPublicDashboardPanelQueryRangeV2PathParameters,
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
@@ -1799,6 +1803,217 @@ export const useLockDashboardV2 = <
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};
/**
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
* @summary Get public dashboard data (v2)
*/
export const getPublicDashboardDataV2 = (
{ id }: GetPublicDashboardDataV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetPublicDashboardDataV2200>({
url: `/api/v2/public/dashboards/${id}`,
method: 'GET',
signal,
});
};
export const getGetPublicDashboardDataV2QueryKey = ({
id,
}: GetPublicDashboardDataV2PathParameters) => {
return [`/api/v2/public/dashboards/${id}`] as const;
};
export const getGetPublicDashboardDataV2QueryOptions = <
TData = Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetPublicDashboardDataV2PathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetPublicDashboardDataV2QueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getPublicDashboardDataV2>>
> = ({ signal }) => getPublicDashboardDataV2({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetPublicDashboardDataV2QueryResult = NonNullable<
Awaited<ReturnType<typeof getPublicDashboardDataV2>>
>;
export type GetPublicDashboardDataV2QueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get public dashboard data (v2)
*/
export function useGetPublicDashboardDataV2<
TData = Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetPublicDashboardDataV2PathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetPublicDashboardDataV2QueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get public dashboard data (v2)
*/
export const invalidateGetPublicDashboardDataV2 = async (
queryClient: QueryClient,
{ id }: GetPublicDashboardDataV2PathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetPublicDashboardDataV2QueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.
* @summary Get query range result (v2)
*/
export const getPublicDashboardPanelQueryRangeV2 = (
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetPublicDashboardPanelQueryRangeV2200>({
url: `/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
method: 'GET',
signal,
});
};
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = ({
id,
key,
}: GetPublicDashboardPanelQueryRangeV2PathParameters) => {
return [`/api/v2/public/dashboards/${id}/panels/${key}/query_range`] as const;
};
export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
TData = Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
> = ({ signal }) => getPublicDashboardPanelQueryRangeV2({ id, key }, signal);
return {
queryKey,
queryFn,
enabled: !!(id && key),
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetPublicDashboardPanelQueryRangeV2QueryResult = NonNullable<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
>;
export type GetPublicDashboardPanelQueryRangeV2QueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get query range result (v2)
*/
export function useGetPublicDashboardPanelQueryRangeV2<
TData = Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetPublicDashboardPanelQueryRangeV2QueryOptions(
{ id, key },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get query range result (v2)
*/
export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
queryClient: QueryClient,
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }) },
options,
);
return queryClient;
};
/**
* Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.
* @summary List dashboards for the current user (v2)

View File

@@ -23,6 +23,7 @@ import type {
GetLLMPricingRulePathParameters,
ListLLMPricingRules200,
ListLLMPricingRulesParams,
ListUnmappedLLMModels200,
LlmpricingruletypesUpdatableLLMPricingRulesDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -393,3 +394,87 @@ export const invalidateGetLLMPricingRule = async (
return queryClient;
};
/**
* Returns models seen in the last hour of trace data (gen_ai.request.model) that no pricing rule pattern matches, so the user can add them to an existing rule or create a new one.
* @summary List unmapped models
*/
export const listUnmappedLLMModels = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListUnmappedLLMModels200>({
url: `/api/v1/llm_pricing_rules/unmapped_models`,
method: 'GET',
signal,
});
};
export const getListUnmappedLLMModelsQueryKey = () => {
return [`/api/v1/llm_pricing_rules/unmapped_models`] as const;
};
export const getListUnmappedLLMModelsQueryOptions = <
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListUnmappedLLMModelsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listUnmappedLLMModels>>
> = ({ signal }) => listUnmappedLLMModels(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListUnmappedLLMModelsQueryResult = NonNullable<
Awaited<ReturnType<typeof listUnmappedLLMModels>>
>;
export type ListUnmappedLLMModelsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List unmapped models
*/
export function useListUnmappedLLMModels<
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListUnmappedLLMModelsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List unmapped models
*/
export const invalidateListUnmappedLLMModels = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListUnmappedLLMModelsQueryKey() },
options,
);
return queryClient;
};

View File

@@ -4941,6 +4941,11 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
}
export interface DashboardtypesGettablePublicDashboardDataV2DTO {
dashboard?: DashboardtypesGettableDashboardV2DTO;
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
}
export enum DashboardtypesPatchOpDTO {
add = 'add',
remove = 'remove',
@@ -6818,6 +6823,29 @@ export interface LlmpricingruletypesGettablePricingRulesDTO {
total: number;
}
export interface LlmpricingruletypesUnmappedModelDTO {
/**
* @type string
*/
modelName: string;
/**
* @type string
*/
provider?: string;
/**
* @type integer
* @minimum 0
*/
spanCount: number;
}
export interface LlmpricingruletypesGettableUnmappedModelsDTO {
/**
* @type array,null
*/
items: LlmpricingruletypesUnmappedModelDTO[] | null;
}
export interface LlmpricingruletypesUpdatableLLMPricingRuleDTO {
/**
* @type boolean
@@ -10168,6 +10196,14 @@ export type GetLLMPricingRule200 = {
status: string;
};
export type ListUnmappedLLMModels200 = {
data: LlmpricingruletypesGettableUnmappedModelsDTO;
/**
* @type string
*/
status: string;
};
export type ListPromotedAndIndexedPaths200 = {
/**
* @type array,null
@@ -11161,6 +11197,29 @@ export type GetMyOrganization200 = {
status: string;
};
export type GetPublicDashboardDataV2PathParameters = {
id: string;
};
export type GetPublicDashboardDataV2200 = {
data: DashboardtypesGettablePublicDashboardDataV2DTO;
/**
* @type string
*/
status: string;
};
export type GetPublicDashboardPanelQueryRangeV2PathParameters = {
id: string;
key: string;
};
export type GetPublicDashboardPanelQueryRangeV2200 = {
data: Querybuildertypesv5QueryRangeResponseDTO;
/**
* @type string
*/
status: string;
};
export type Readyz200 = {
data: FactoryResponseDTO;
/**

View File

@@ -1,7 +1,7 @@
import { QueryParams } from 'constants/query';
export const ExploreHeaderToolTip = {
url: 'https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=new-query-builder',
url: 'https://signoz.io/docs/querying/overview/?utm_source=product&utm_medium=new-query-builder',
text: 'More details on how to use query builder',
};

View File

@@ -131,7 +131,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}
@@ -254,7 +254,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}

View File

@@ -52,7 +52,7 @@ const ADD_ONS = [
key: ADD_ONS_KEYS.GROUP_BY,
description:
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/#grouping',
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
},
{
icon: <ScrollText size={14} />,
@@ -61,7 +61,7 @@ const ADD_ONS = [
description:
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having',
'https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having',
},
{
icon: <ScrollText size={14} />,
@@ -70,7 +70,7 @@ const ADD_ONS = [
description:
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
'https://signoz.io/docs/querying/result-manipulation/#sorting--limiting',
},
{
icon: <ScrollText size={14} />,
@@ -79,7 +79,7 @@ const ADD_ONS = [
description:
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
'https://signoz.io/docs/querying/result-manipulation/#how-limit-works-for-time-series',
},
{
icon: <ScrollText size={14} />,
@@ -88,7 +88,7 @@ const ADD_ONS = [
description:
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#legend-formatting',
'https://signoz.io/docs/querying/aggregation-grouping/#legend-formatting',
},
];
@@ -99,7 +99,7 @@ const REDUCE_TO = {
description:
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations',
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
};
const hasValue = (value: unknown): boolean =>
@@ -349,7 +349,7 @@ function QueryAddOns({
<TooltipContent
label="Group By"
description="Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments."
docLink="https://signoz.io/docs/userguide/query-builder-v5/#grouping"
docLink="https://signoz.io/docs/querying/aggregation-grouping/#grouping"
/>
}
placement="top"
@@ -385,7 +385,7 @@ function QueryAddOns({
<TooltipContent
label="Having"
description="Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500"
docLink="https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having"
docLink="https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having"
/>
}
placement="top"
@@ -434,7 +434,7 @@ function QueryAddOns({
<TooltipContent
label="Order By"
description="Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers."
docLink="https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting"
docLink="https://signoz.io/docs/querying/result-manipulation/#sorting--limiting"
/>
}
placement="top"
@@ -473,7 +473,7 @@ function QueryAddOns({
<TooltipContent
label="Reduce to"
description="Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value."
docLink="https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations"
docLink="https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation"
/>
}
placement="top"

View File

@@ -65,7 +65,7 @@ function QueryAggregationOptions({
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}

View File

@@ -676,7 +676,7 @@ function QueryAggregationSelect({
</span>
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#core-aggregation-functions"
href="https://signoz.io/docs/querying/aggregation-grouping/#core-aggregation-functions-logs--traces"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}

View File

@@ -44,7 +44,7 @@ function TraceOperatorSection({
<div style={{ textAlign: 'center' }}>
Add Trace Matching
<Typography.Link
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-trace-operators"
href="https://signoz.io/docs/querying/multi-query-analysis/#trace-matching"
target="_blank"
style={{ textDecoration: 'underline' }}
>
@@ -106,7 +106,7 @@ export default function QueryFooter({
<div style={{ textAlign: 'center' }}>
Add New Formula
<Typography.Link
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-advanced-comparisons"
href="https://signoz.io/docs/querying/multi-query-analysis/#advanced-comparisons"
target="_blank"
style={{ textDecoration: 'underline' }}
>

View File

@@ -0,0 +1,34 @@
import { type MouseEvent, type ReactNode } from 'react';
import { Badge } from '@signozhq/ui/badge';
interface TagBadgeProps {
children: ReactNode;
// Show a remove button (editable contexts: create modal, settings drawer).
closable?: boolean;
onClose?: (event: MouseEvent<HTMLButtonElement>) => void;
className?: string;
}
// The single sienna tag chip used everywhere dashboards render tags — list rows,
// the details header, and the tag editors. Kept as one component so the tag
// styling stays identical across all of them.
function TagBadge({
children,
closable,
onClose,
className,
}: TagBadgeProps): JSX.Element {
return (
<Badge
color="sienna"
variant="outline"
className={className}
closable={closable}
onClose={onClose}
>
{children}
</Badge>
);
}
export default TagBadge;

View File

@@ -15,59 +15,28 @@
border: 1px solid var(--l2-border);
}
// Sienna chip — matches the dashboard list-row tag badge.
// Sienna chip via the @signozhq Badge; this only constrains its width.
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 240px;
height: 24px;
padding: 2px 4px 2px 8px;
border-radius: 50px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
font-size: 13px;
font-weight: var(--font-weight-normal);
line-height: 20px;
cursor: text;
}
// The tag label is a bare, chrome-less button inside the Badge (double-click to
// edit); strip its Button styling and let it ellipsize.
.tagLabel {
--button-height: auto;
--button-padding: 0;
--button-gap: 0;
--button-variant-ghost-background-color: transparent;
--button-variant-ghost-hover-background-color: transparent;
--button-variant-ghost-color: inherit;
--button-variant-ghost-hover-color: inherit;
--button-variant-ghost-color: currentColor;
--button-variant-ghost-hover-color: currentColor;
overflow: hidden;
max-width: 200px;
font-size: 13px;
font-weight: var(--font-weight-normal);
text-overflow: ellipsis;
white-space: nowrap;
cursor: text;
}
.remove {
// Size overrides to fit the chip, plus a sienna-tinted hover — the Button's
// default ghost hover is a grey that clashes with the chip. Resting color is
// left at the Button default.
--button-height: 16px;
--button-padding: 0;
--button-border-radius: 50%;
--button-variant-ghost-hover-background-color: color-mix(
in srgb,
var(--bg-sienna-500) 22%,
transparent
);
--button-variant-ghost-hover-color: var(--bg-sienna-400);
width: 16px;
min-width: 16px;
flex: none;
}
.input {
flex: 1;
min-width: 120px;

View File

@@ -2,9 +2,9 @@ import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import cx from 'classnames';
import TagBadge from '../TagBadge/TagBadge';
import { parseKeyValueTag } from './utils';
import styles from './TagKeyValueInput.module.scss';
@@ -120,27 +120,23 @@ function TagKeyValueInput({
onBlur={commitEdit}
/>
) : (
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
<TagBadge
key={tag}
className={styles.tag}
closable
onClose={(): void => removeTag(tag)}
>
<Button
variant="ghost"
color="secondary"
className={styles.tagLabel}
title="Double-click to edit"
testId={`${testId}-chip`}
onDoubleClick={(): void => startEdit(index)}
>
{tag}
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.remove}
aria-label={`Remove ${tag}`}
onClick={(): void => removeTag(tag)}
>
<X size={12} />
</Button>
</div>
</TagBadge>
),
)}
<Input

View File

@@ -1,5 +1,5 @@
export const apDexToolTipText =
"Apdex is a way to measure your users' satisfaction with the response time of your web service. It's represented as a score from 0-1.";
export const apDexToolTipUrl =
'https://signoz.io/docs/userguide/metrics/#apdex?utm_source=product&utm_medium=frontend&utm_campaign=apdex';
'https://signoz.io/docs/alerts-management/apdex-alerts/?utm_source=product&utm_medium=frontend&utm_campaign=apdex';
export const apDexToolTipUrlText = 'Learn more about Apdex.';

View File

@@ -68,7 +68,7 @@ function AlertChannels(): JSX.Element {
<RightActionContainer>
<TextToolTip
text={t('tooltip_notification_channels')}
url="https://signoz.io/docs/userguide/alerts-management/#setting-notification-channel"
url="https://signoz.io/docs/setup-alerts-notification/"
/>
<Tooltip

View File

@@ -29,8 +29,7 @@ function SelectAlertType({ onSelect }: SelectAlertTypeProps): JSX.Element {
let url = '';
switch (option) {
case AlertTypes.ANOMALY_BASED_ALERT:
url =
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples';
url = 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/';
break;
case AlertTypes.METRICS_BASED_ALERT:
url =

View File

@@ -31,8 +31,7 @@ export const ALERT_TYPE_URL_MAP: Record<
'https://signoz.io/docs/alerts-management/exceptions-based-alerts/?utm_source=product&utm_medium=alert-creation-page',
},
[AlertTypes.ANOMALY_BASED_ALERT]: {
selection:
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples',
selection: 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/',
creation:
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-creation-page',
},

View File

@@ -1,3 +1,4 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
@@ -27,7 +28,7 @@ interface PieArcProps {
fill: string;
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
onLeave: () => void;
onClick?: (slice: PieSlice) => void;
onClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
}
/**
@@ -72,7 +73,7 @@ export default function PieArc({
<g
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
onMouseLeave={onLeave}
onClick={(): void => onClick?.(slice)}
onClick={(event): void => onClick?.(slice, event)}
>
<path d={arcPath} fill={fill} />
{shouldShowLabel && (

View File

@@ -80,6 +80,7 @@ describe('PieArc', () => {
expect(onLeave).toHaveBeenCalledTimes(1);
fireEvent.click(g);
expect(onClick).toHaveBeenCalledWith(SLICE);
// onClick now also receives the DOM event (for drill-down popover positioning).
expect(onClick).toHaveBeenCalledWith(SLICE, expect.anything());
});
});

View File

@@ -1,3 +1,4 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import {
@@ -79,6 +80,10 @@ export interface PieSlice {
label: string;
value: number;
color: string;
/** Source query of the slice's value column — the drill-down target (present for V2 panels). */
queryName?: string;
/** Group-by key→value of the slice's source row, used to build drill-down filters. */
labels?: Record<string, string>;
}
/**
@@ -99,7 +104,7 @@ export interface PieChartProps {
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
*/
id?: string;
/** Fired when a slice (or its legend entry) is clicked. */
onSliceClick?: (slice: PieSlice) => void;
/** Fired when a slice's arc is clicked; carries the DOM event for popover positioning. */
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
'data-testid'?: string;
}

View File

@@ -3,7 +3,6 @@ import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { ResizeTable } from 'components/ResizeTable';
import { useNotifications } from 'hooks/useNotifications';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
@@ -11,6 +10,7 @@ import {
selectIsDashboardLocked,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { toast } from '@signozhq/ui/sonner';
import { getChartManagerColumns } from './getChartMangerColumns';
import { ExtendedChartDataset, getDefaultTableDataSet } from './utils';
@@ -44,7 +44,6 @@ export default function ChartManager({
decimalPrecision = PrecisionOptionsEnum.TWO,
onCancel,
}: ChartManagerProps): JSX.Element {
const { notifications } = useNotifications();
const { legendItemsMap } = useLegendsSync({
config,
subscribeToFocusChange: false,
@@ -136,11 +135,9 @@ export default function ChartManager({
const handleSave = useCallback((): void => {
syncSeriesVisibilityToLocalStorage();
notifications.success({
message: 'The updated graphs & legends are saved',
});
toast.success('The updated graphs & legends are saved');
onCancel?.();
}, [syncSeriesVisibilityToLocalStorage, notifications, onCancel]);
}, [syncSeriesVisibilityToLocalStorage, onCancel]);
return (
<div className="chart-manager-container">

View File

@@ -5,7 +5,7 @@ import { render, screen } from 'tests/test-utils';
import ChartManager from '../ChartManager';
const mockSyncSeriesVisibilityToLocalStorage = jest.fn();
const mockNotificationsSuccess = jest.fn();
const mockToastSuccess = jest.fn();
jest.mock('lib/uPlotV2/context/PlotContext', () => ({
usePlotContext: (): {
@@ -46,12 +46,11 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
}): boolean => s.dashboardData?.locked ?? false,
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): { notifications: { success: jest.Mock } } => ({
notifications: {
success: mockNotificationsSuccess,
},
}),
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
},
}));
jest.mock('components/ResizeTable', () => {
@@ -160,7 +159,7 @@ describe('ChartManager', () => {
expect(screen.queryByTestId('row-2')).not.toBeInTheDocument();
});
it('calls syncSeriesVisibilityToLocalStorage, notifications.success, and onCancel when Save is clicked', async () => {
it('calls syncSeriesVisibilityToLocalStorage, toast.success, and onCancel when Save is clicked', async () => {
render(
<ChartManager
config={createMockConfig() as UPlotConfigBuilder}
@@ -172,9 +171,9 @@ describe('ChartManager', () => {
await userEvent.click(screen.getByRole('button', { name: /Save/ }));
expect(mockSyncSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
expect(mockNotificationsSuccess).toHaveBeenCalledWith({
message: 'The updated graphs & legends are saved',
});
expect(mockToastSuccess).toHaveBeenCalledWith(
'The updated graphs & legends are saved',
);
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
});

View File

@@ -2,9 +2,17 @@
position: relative;
display: flex;
width: 100%;
height: 100%;
height: auto;
flex-direction: column;
// Stacked children (the FullView / standalone graph-manager) sit below the chart
// in the same container; size the chart region to its content so they aren't
// pushed out. Only this case opts out of filling the height — the dashboard grid,
// alert preview, and other charts keep 100% so they fill their container.
&--with-layout-children {
height: auto;
}
&--legend-right {
flex-direction: row;
}

View File

@@ -63,6 +63,7 @@ export default function ChartLayout({
className={cx('chart-layout', {
'chart-layout--legend-right':
legendConfig.position === LegendPosition.RIGHT,
'chart-layout--with-layout-children': !!layoutChildren,
})}
>
<div className="chart-layout__content">

View File

@@ -717,13 +717,13 @@ function ExplorerOptions({
const infoIconLink = useMemo(() => {
if (isLogsExplorer) {
return 'https://signoz.io/docs/product-features/logs-explorer/?utm_source=product&utm_medium=logs-explorer-toolbar';
return 'https://signoz.io/docs/userguide/logs_query_builder/?utm_source=product&utm_medium=logs-explorer-toolbar';
}
// TODO: Add metrics explorer info icon link
if (isMetricsExplorer) {
return '';
}
return 'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=trace-explorer-toolbar';
return 'https://signoz.io/docs/userguide/traces/?utm_source=product&utm_medium=trace-explorer-toolbar';
}, [isLogsExplorer, isMetricsExplorer]);
const getQueryName = (query: Query): string => {

View File

@@ -201,7 +201,7 @@ export default function SavedViews({
});
window.open(
'https://signoz.io/docs/product-features/saved-view/',
'https://signoz.io/docs/metrics-management/metrics-explorer/#saved-views-in-metrics-explorer',
'_blank',
'noopener noreferrer',
);

View File

@@ -29,12 +29,12 @@ export const checkListStepToPreferenceKeyMap = {
export const DOCS_LINKS = {
ADD_DATA_SOURCE: 'https://signoz.io/docs/instrumentation/overview/',
SEND_LOGS: 'https://signoz.io/docs/userguide/logs/',
SEND_LOGS: 'https://signoz.io/docs/userguide/logs_query_builder/',
SEND_TRACES: 'https://signoz.io/docs/userguide/traces/',
SEND_METRICS: 'https://signoz.io/docs/metrics-management/metrics-explorer/',
SETUP_ALERTS: 'https://signoz.io/docs/userguide/alerts-management/',
SETUP_ALERTS: 'https://signoz.io/docs/alerts/',
SETUP_SAVED_VIEWS:
'https://signoz.io/docs/product-features/saved-view/#step-2-save-your-view',
'https://signoz.io/docs/metrics-management/metrics-explorer/#saved-views-in-metrics-explorer',
SETUP_DASHBOARDS: 'https://signoz.io/docs/userguide/manage-dashboards/',
};

View File

@@ -82,7 +82,7 @@ export function K8sEmptyState({
<span className={styles.message}>
Please refer to{' '}
<a
href="https://signoz.io/docs/userguide/hostmetrics/"
href="https://signoz.io/docs/infrastructure-monitoring/hostmetrics/"
target="_blank"
rel="noreferrer"
>

View File

@@ -611,7 +611,7 @@ describe('K8sBaseList', () => {
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
'https://signoz.io/docs/userguide/hostmetrics/',
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/',
);
});
});

View File

@@ -66,20 +66,6 @@
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.ant-collapse-header) {
border-bottom: 1px solid var(--l1-border);
padding: 12px 8px;
}
:global(.ant-collapse-content-box) {
padding: 0 !important;
padding-block: 0 !important;
:global(.quick-filters .checkbox-filter) {
padding-left: 18px;
}
}
:global(.quick-filters) {
overflow-y: auto;
overflow-x: hidden;
@@ -111,22 +97,6 @@
justify-content: space-between;
}
.quickFiltersCategoryLabel {
display: flex;
align-items: center;
gap: 4px;
}
.quickFiltersCategoryLabelIcon {
margin-right: 8px;
}
.quickFiltersCategoryLabelContainer {
display: flex;
align-items: center;
gap: 4px;
}
.listContainer {
flex: 1;
min-width: 0;
@@ -160,3 +130,106 @@
.listContainerFiltersVisible {
max-width: calc(100% - 280px);
}
.categorySelectorSection {
padding: var(--spacing-4);
}
.sectionHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
margin-bottom: var(--spacing-4);
&[data-type='filter'] {
padding: 0 var(--spacing-4);
}
}
.sectionLabel {
font-size: var(--periscope-font-size-small);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l3-foreground);
white-space: nowrap;
}
.sectionLine {
flex: 1;
height: 1px;
background: var(--l1-border);
}
.categoryCard {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--spacing-3);
padding: var(--spacing-2);
}
.categoryList {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.categoryItem {
display: flex;
align-items: center;
gap: var(--spacing-5);
padding: var(--spacing-3) var(--spacing-4);
border: none;
border-radius: var(--spacing-2);
background: transparent;
cursor: pointer;
width: 100%;
text-align: left;
transition:
background-color 0.2s ease,
color 0.2s ease;
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
--typography-color: var(--l1-foreground);
&:hover:not(.categoryItemSelected) {
background: var(--l2-background-hover);
}
svg {
color: var(--l3-foreground);
flex-shrink: 0;
transition: color 0.2s ease;
}
&.categoryItemSelected {
background: var(--accent-primary);
color: var(--l1-background);
font-weight: 600;
&:hover {
background: var(--accent-primary-hover, var(--accent-primary));
}
svg {
color: var(--l1-foreground);
}
}
}
.quickFiltersSection {
flex: 1;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
}

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as Sentry from '@sentry/react';
import { Button, CollapseProps } from 'antd';
import { Collapse, Tooltip } from 'antd';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
@@ -113,192 +112,74 @@ export default function InfraMonitoringK8s(): JSX.Element {
});
};
const renderCategoryLabel = (
icon: JSX.Element,
label: string,
): JSX.Element => (
<div className={styles.quickFiltersCategoryLabel}>
<div className={styles.quickFiltersCategoryLabelContainer}>
{icon}
<Typography.Text>{label}</Typography.Text>
</div>
</div>
const categories = useMemo(
() => [
{
key: K8sCategories.PODS,
label: 'Pods',
icon: <Container size={14} />,
config: GetPodsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.NODES,
label: 'Nodes',
icon: <Workflow size={14} />,
config: GetNodesQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.NAMESPACES,
label: 'Namespaces',
icon: <FilePenLine size={14} />,
config: GetNamespaceQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.CLUSTERS,
label: 'Clusters',
icon: <Boxes size={14} />,
config: GetClustersQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.DEPLOYMENTS,
label: 'Deployments',
icon: <Computer size={14} />,
config: GetDeploymentsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.JOBS,
label: 'Jobs',
icon: <Bolt size={14} />,
config: GetJobsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.DAEMONSETS,
label: 'DaemonSets',
icon: <Group size={14} />,
config: GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.STATEFULSETS,
label: 'StatefulSets',
icon: <ArrowUpDown size={14} />,
config: GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.VOLUMES,
label: 'Volumes',
icon: <HardDrive size={14} />,
config: GetVolumesQuickFiltersConfig(dotMetricsEnabled),
},
],
[dotMetricsEnabled],
);
const items: CollapseProps['items'] = [
{
label: renderCategoryLabel(
<Container size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Pods',
),
key: K8sCategories.PODS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetPodsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Workflow size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Nodes',
),
key: K8sCategories.NODES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetNodesQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<FilePenLine size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Namespaces',
),
key: K8sCategories.NAMESPACES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetNamespaceQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Boxes size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Clusters',
),
key: K8sCategories.CLUSTERS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetClustersQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Computer size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Deployments',
),
key: K8sCategories.DEPLOYMENTS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetDeploymentsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Bolt size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Jobs',
),
key: K8sCategories.JOBS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetJobsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Group size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'DaemonSets',
),
key: K8sCategories.DAEMONSETS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<ArrowUpDown size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'StatefulSets',
),
key: K8sCategories.STATEFULSETS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<HardDrive size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Volumes',
),
key: K8sCategories.VOLUMES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetVolumesQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
// TODO: Enable once we have implemented containers.
// {
// label: (
// <div className="k8s-quick-filters-category-label">
// <div className="k8s-quick-filters-category-label-container">
// <PackageOpen
// size={14}
// className="k8s-quick-filters-category-label-icon"
// />
// <Typography.Text>Containers</Typography.Text>
// </div>
// </div>
// ),
// key: K8sCategories.CONTAINERS,
// showArrow: false,
// children: (
// <QuickFilters
// source={QuickFiltersSource.INFRA_MONITORING}
// config={ContainersQuickFiltersConfig}
// handleFilterVisibilityChange={handleFilterVisibilityChange}
// onFilterChange={handleFilterChange}
// />
// ),
// },
];
const selectedCategoryConfig = useMemo(
() => categories.find((cat) => cat.key === selectedCategory)?.config,
[categories, selectedCategory],
);
const handleCategoryChange = (key: string | string[]): void => {
if (Array.isArray(key) && key.length > 0) {
setSelectedCategory(key[0] as string);
const handleCategorySelect = (key: string): void => {
if (key !== selectedCategory) {
setSelectedCategory(key);
// Reset filters
setUrlFilters(null);
setOrderBy(null);
@@ -332,26 +213,58 @@ export default function InfraMonitoringK8s(): JSX.Element {
<div className={styles.infraContentRow}>
{showFilters && (
<div className={styles.quickFiltersContainer}>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
Viewing · Resource
</Typography.Text>
<div className={styles.sectionLine} />
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
</div>
<div className={styles.categoryCard}>
<div className={styles.categoryList}>
{categories.map((category) => (
<button
key={category.key}
type="button"
className={`${styles.categoryItem} ${
selectedCategory === category.key
? styles.categoryItemSelected
: ''
}`}
onClick={(): void => handleCategorySelect(category.key)}
data-testid={`category-${category.key}`}
>
{category.icon}
<Typography.Text>{category.label}</Typography.Text>
</button>
))}
</div>
</div>
</div>
<div className={styles.quickFiltersSection}>
<div className={styles.sectionHeader} data-type="filter">
<Typography.Text className={styles.sectionLabel}>
Filter by
</Typography.Text>
<div className={styles.sectionLine} />
</div>
{selectedCategoryConfig && (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={selectedCategoryConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
)}
</div>
<Collapse
onChange={handleCategoryChange}
items={items}
defaultActiveKey={[selectedCategory]}
activeKey={[selectedCategory]}
accordion
bordered={false}
ghost
/>
</div>
)}

View File

@@ -0,0 +1,228 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { ChangeEvent, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
import DockerIcon from 'assets/CustomIcons/DockerIcon';
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
import NginxIcon from 'assets/CustomIcons/NginxIcon';
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
import RedisIcon from 'assets/CustomIcons/RedisIcon';
import cx from 'classnames';
import {
ConciergeBell,
DraftingCompass,
Drill,
Plus,
X,
} from '@signozhq/icons';
import { DashboardTemplate } from 'types/api/dashboard/getAll';
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
import { filterTemplates } from '../utils';
import './DashboardTemplatesModal.styles.scss';
const templatesList: DashboardTemplate[] = [
{
name: 'Blank dashboard',
icon: <Drill />,
id: 'blank',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Alert Manager',
icon: <ConciergeBell />,
id: 'alertManager',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Apache',
icon: <ApacheIcon />,
id: 'apache',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Docker',
icon: <DockerIcon />,
id: 'docker',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Elasticsearch',
icon: <ElasticSearchIcon />,
id: 'elasticSearch',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MongoDB',
icon: <MongoDBIcon />,
id: 'mongoDB',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Heroku',
icon: <HerokuIcon />,
id: 'heroku',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Nginx',
icon: <NginxIcon />,
id: 'nginx',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Kubernetes',
icon: <KubernetesIcon />,
id: 'kubernetes',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MySQL',
icon: <MySQLIcon />,
id: 'mySQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'PostgreSQL',
icon: <PostgreSQLIcon />,
id: 'postgreSQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Redis',
icon: <RedisIcon />,
id: 'redis',
description: 'Create a custom dashboard from scratch.',
previewImage: redisTemplatePreviewUrl,
},
{
name: 'AWS',
icon: <DraftingCompass size={14} />,
id: 'aws',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
];
interface DashboardTemplatesContentProps {
onCreateNewDashboard: () => void;
/** When provided, renders the modal-style header with a close affordance. Omitted for inline use. */
onCancel?: () => void;
}
// The template gallery (search + list + preview + create), extracted from the
// modal so it can be embedded inline (e.g. the V2 new-dashboard modal's template
// tab) as well as inside DashboardTemplatesModal. Styles live under the global
// `.new-dashboard-templates-modal` scope, so inline callers wrap it in that class.
export default function DashboardTemplatesContent({
onCreateNewDashboard,
onCancel,
}: DashboardTemplatesContentProps): JSX.Element {
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
templatesList[0],
);
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
const handleDashboardTemplateSearch = (
event: ChangeEvent<HTMLInputElement>,
) => {
const searchText = event.target.value;
const filteredTemplates = filterTemplates(searchText, templatesList);
setDashboardTemplates(filteredTemplates);
};
return (
<div className="new-dashboard-templates-content-container">
{onCancel && (
<div className="new-dashboard-templates-content-header">
<Typography.Text>New Dashboard</Typography.Text>
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
</div>
)}
<div className="new-dashboard-templates-content">
<div className="new-dashboard-templates-list">
<Input
className="new-dashboard-templates-search"
placeholder="🔍 Search..."
onChange={handleDashboardTemplateSearch}
/>
<div className="templates-list">
{dashboardTemplates.map((template) => (
<div
className={cx(
'template-list-item',
selectedDashboardTemplate.id === template.id ? 'active' : '',
)}
key={template.name}
onClick={() => setSelectedDashboardTemplate(template)}
>
<div className="template-icon">{template.icon}</div>
<div className="template-name">{template.name}</div>
</div>
))}
</div>
</div>
<div className="new-dashboard-template-preview">
<div className="template-preview-header">
<div className="template-preview-title">
<div className="template-preview-icon">
{selectedDashboardTemplate.icon}
</div>
<div className="template-info">
<div className="template-name">{selectedDashboardTemplate.name}</div>
<div className="template-description">
{selectedDashboardTemplate.description}
</div>
</div>
</div>
<div className="create-dashboard-btn">
<Button
type="primary"
className="periscope-btn primary"
icon={<Plus size={14} />}
onClick={onCreateNewDashboard}
>
New dashboard
</Button>
</div>
</div>
<div className="template-preview-image">
<img
src={selectedDashboardTemplate.previewImage}
alt={`${selectedDashboardTemplate.name}-preview`}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,129 +1,9 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { ChangeEvent, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
import DockerIcon from 'assets/CustomIcons/DockerIcon';
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
import NginxIcon from 'assets/CustomIcons/NginxIcon';
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
import RedisIcon from 'assets/CustomIcons/RedisIcon';
import cx from 'classnames';
import {
ConciergeBell,
DraftingCompass,
Drill,
Plus,
X,
} from '@signozhq/icons';
import { DashboardTemplate } from 'types/api/dashboard/getAll';
import { Modal } from 'antd';
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
import { filterTemplates } from '../utils';
import DashboardTemplatesContent from './DashboardTemplatesContent';
import './DashboardTemplatesModal.styles.scss';
const templatesList: DashboardTemplate[] = [
{
name: 'Blank dashboard',
icon: <Drill />,
id: 'blank',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Alert Manager',
icon: <ConciergeBell />,
id: 'alertManager',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Apache',
icon: <ApacheIcon />,
id: 'apache',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Docker',
icon: <DockerIcon />,
id: 'docker',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Elasticsearch',
icon: <ElasticSearchIcon />,
id: 'elasticSearch',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MongoDB',
icon: <MongoDBIcon />,
id: 'mongoDB',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Heroku',
icon: <HerokuIcon />,
id: 'heroku',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Nginx',
icon: <NginxIcon />,
id: 'nginx',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Kubernetes',
icon: <KubernetesIcon />,
id: 'kubernetes',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MySQL',
icon: <MySQLIcon />,
id: 'mySQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'PostgreSQL',
icon: <PostgreSQLIcon />,
id: 'postgreSQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Redis',
icon: <RedisIcon />,
id: 'redis',
description: 'Create a custom dashboard from scratch.',
previewImage: redisTemplatePreviewUrl,
},
{
name: 'AWS',
icon: <DraftingCompass size={14} />,
id: 'aws',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
];
interface DashboardTemplatesModalProps {
showNewDashboardTemplatesModal: boolean;
onCreateNewDashboard: () => void;
@@ -135,20 +15,6 @@ export default function DashboardTemplatesModal({
onCreateNewDashboard,
onCancel,
}: DashboardTemplatesModalProps): JSX.Element {
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
templatesList[0],
);
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
const handleDashboardTemplateSearch = (
event: ChangeEvent<HTMLInputElement>,
) => {
const searchText = event.target.value;
const filteredTemplates = filterTemplates(searchText, templatesList);
setDashboardTemplates(filteredTemplates);
};
return (
<Modal
wrapClassName="new-dashboard-templates-modal"
@@ -159,75 +25,10 @@ export default function DashboardTemplatesModal({
destroyOnClose
width="60vw"
>
<div className="new-dashboard-templates-content-container">
<div className="new-dashboard-templates-content-header">
<Typography.Text>New Dashboard</Typography.Text>
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
</div>
<div className="new-dashboard-templates-content">
<div className="new-dashboard-templates-list">
<Input
className="new-dashboard-templates-search"
placeholder="🔍 Search..."
onChange={handleDashboardTemplateSearch}
/>
<div className="templates-list">
{dashboardTemplates.map((template) => (
<div
className={cx(
'template-list-item',
selectedDashboardTemplate.id === template.id ? 'active' : '',
)}
key={template.name}
onClick={() => setSelectedDashboardTemplate(template)}
>
<div className="template-icon">{template.icon}</div>
<div className="template-name">{template.name}</div>
</div>
))}
</div>
</div>
<div className="new-dashboard-template-preview">
<div className="template-preview-header">
<div className="template-preview-title">
<div className="template-preview-icon">
{selectedDashboardTemplate.icon}
</div>
<div className="template-info">
<div className="template-name">{selectedDashboardTemplate.name}</div>
<div className="template-description">
{selectedDashboardTemplate.description}
</div>
</div>
</div>
<div className="create-dashboard-btn">
<Button
type="primary"
className="periscope-btn primary"
icon={<Plus size={14} />}
onClick={onCreateNewDashboard}
>
New dashboard
</Button>
</div>
</div>
<div className="template-preview-image">
<img
src={selectedDashboardTemplate.previewImage}
alt={`${selectedDashboardTemplate.name}-preview`}
/>
</div>
</div>
</div>
</div>
<DashboardTemplatesContent
onCreateNewDashboard={onCreateNewDashboard}
onCancel={onCancel}
/>
</Modal>
);
}

View File

@@ -164,7 +164,7 @@ function BreakDown(): JSX.Element {
Meter metrics data is aggregated over 1 hour period. Please select time
range accordingly.&nbsp;
<a
href="https://signoz.io/docs/cost-meter/overview/#accessing-cost-meter"
href="https://signoz.io/docs/cost-meter/overview/#get-started"
rel="noopener noreferrer"
target="_blank"
style={{ textDecoration: 'underline' }}

View File

@@ -197,7 +197,7 @@ function TopOperationsTable({
const entryPointSpanInfo = {
text: 'Shows the spans where requests enter new services for the first time',
url: 'https://signoz.io/docs/traces-management/guides/entry-point-spans-service-overview/',
url: 'https://signoz.io/docs/apm-and-distributed-tracing/application-details/',
urlText: 'Learn more about Entrypoint Spans.',
};

View File

@@ -64,7 +64,7 @@ function ConfigureGoogleAuthAuthnProvider({
Enter OAuth 2.0 credentials obtained from the Google API Console below.
Read the{' '}
<a
href="https://signoz.io/docs/userguide/sso-authentication"
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
target="_blank"
rel="noreferrer"
>

View File

@@ -38,7 +38,7 @@ function ConfigureOIDCAuthnProvider({
Configure OpenID Connect Single Sign-On with your Identity Provider. Read
the{' '}
<a
href="https://signoz.io/docs/userguide/sso-authentication"
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
target="_blank"
rel="noreferrer"
>

View File

@@ -37,7 +37,7 @@ function ConfigureSAMLAuthnProvider({
<p className="authn-provider__description">
Configure SAML 2.0 Single Sign-On with your Identity Provider. Read the{' '}
<a
href="https://signoz.io/docs/userguide/sso-authentication"
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
target="_blank"
rel="noreferrer"
>

View File

@@ -216,7 +216,7 @@ export default function QueryFunctions({
Add new function
<Typography.Link
style={{ textDecoration: 'underline' }}
href="https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=query-builder#functions-for-extended-data-analysis"
href="https://signoz.io/docs/querying/functions-extended-analysis/?utm_source=product&utm_medium=query-builder"
target="_blank"
>
{' '}

View File

@@ -100,7 +100,7 @@ function Version(): JSX.Element {
{!isError && !isLatestVersion && (
<div className="version-page-upgrade-container">
<Button
href="https://signoz.io/docs/operate/docker-standalone/#upgrade"
href="https://signoz.io/docs/opentelemetry-collection-agents/docker/overview/"
target="_blank"
type="primary"
className="periscope-btn primary"

View File

@@ -32,6 +32,34 @@
cursor: help;
}
.publicLink {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.lockButton {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.lockButton:disabled {
cursor: default;
}
.divider {
flex-shrink: 0;
width: 1px;

View File

@@ -1,12 +1,20 @@
import { KeyboardEvent } from 'react';
import { Check, Globe, LockKeyhole, SolidInfoCircle, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import {
Check,
Globe,
LockKeyhole,
LockKeyholeOpen,
SolidInfoCircle,
X,
} from '@signozhq/icons';
import TagBadge from 'components/TagBadge/TagBadge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { isEmpty } from 'lodash-es';
import { openInNewTab } from 'utils/navigation';
import styles from './DashboardInfo.module.scss';
import { useVisibleTagCount } from './useVisibleTagCount';
@@ -18,7 +26,13 @@ interface DashboardInfoProps {
tags: string[];
description: string;
isPublicDashboard: boolean;
/** Absolute URL of the public dashboard page; opened when the globe is clicked. */
publicUrl: string;
isDashboardLocked: boolean;
/** Whether to render the lock toggle at all (hidden for never-locked dashboards). */
showLockToggle: boolean;
/** When provided, the lock icon toggles lock/unlock (author/admin only). */
onToggleLock?: () => void;
isEditing: boolean;
draft: string;
onDraftChange: (value: string) => void;
@@ -33,7 +47,10 @@ function DashboardInfo({
tags,
description,
isPublicDashboard,
publicUrl,
isDashboardLocked,
showLockToggle,
onToggleLock,
isEditing,
draft,
onDraftChange,
@@ -51,6 +68,17 @@ function DashboardInfo({
const visibleTags = needsOverflow ? tags.slice(0, visibleCount) : tags;
const remainingTags = needsOverflow ? tags.slice(visibleCount) : [];
let lockTooltip: string;
if (onToggleLock) {
lockTooltip = isDashboardLocked
? 'Locked — click to unlock'
: 'Unlocked — click to lock';
} else {
lockTooltip = isDashboardLocked
? 'This dashboard is locked'
: 'This dashboard is unlocked';
}
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
event.preventDefault();
@@ -101,7 +129,7 @@ function DashboardInfo({
</Button>
</div>
) : (
<TooltipSimple title={title}>
<TooltipSimple title={title} disableHoverableContent>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
@@ -115,7 +143,7 @@ function DashboardInfo({
)}
{hasDescription && (
<TooltipSimple title={description}>
<TooltipSimple title={description} disableHoverableContent>
<SolidInfoCircle
className={styles.descriptionIcon}
size={14}
@@ -125,14 +153,44 @@ function DashboardInfo({
)}
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} />
<TooltipSimple
title="This dashboard is publicly accessible. Click to open the public page."
disableHoverableContent
>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.publicLink}
aria-label="Open public dashboard"
testId="dashboard-public-link"
onClick={(): void => openInNewTab(publicUrl)}
>
<Globe size={14} />
</Button>
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
{showLockToggle && (
<TooltipSimple title={lockTooltip} disableHoverableContent>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.lockButton}
aria-label={isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard'}
testId="dashboard-lock"
disabled={!onToggleLock}
onClick={onToggleLock}
>
{isDashboardLocked ? (
<LockKeyhole size={14} />
) : (
<LockKeyholeOpen size={14} />
)}
</Button>
</TooltipSimple>
)}
@@ -145,19 +203,13 @@ function DashboardInfo({
data-testid="dashboard-tags"
>
{visibleTags.map((tag) => (
<Badge key={tag} color="warning" variant="outline">
{tag}
</Badge>
<TagBadge key={tag}>{tag}</TagBadge>
))}
{remainingTags.length > 0 && (
<TooltipSimple title={remainingTags.join(', ')}>
<Badge
color="warning"
variant="outline"
data-testid="dashboard-tags-overflow"
>
+{remainingTags.length}
</Badge>
<span data-testid="dashboard-tags-overflow">
<TagBadge>+{remainingTags.length}</TagBadge>
</span>
</TooltipSimple>
)}
</div>

View File

@@ -48,6 +48,13 @@
gap: 12px;
}
.footerStatus {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.validation {
min-width: 0;
overflow: hidden;
@@ -57,6 +64,15 @@
white-space: nowrap;
}
.danglingWarning {
min-width: 0;
overflow: hidden;
color: var(--bg-amber-400);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.validationValid {
color: var(--bg-forest-400);
}

View File

@@ -26,8 +26,18 @@ function JsonEditorDrawer({
}: JsonEditorDrawerProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const { draft, setDraft, validity, isDirty, isSaving, format, reset, apply } =
useJsonEditor({ dashboard, isOpen, onApplied: onClose });
const {
draft,
setDraft,
validity,
isDirty,
isSaving,
danglingPanelIds,
missingPanelRefs,
format,
reset,
apply,
} = useJsonEditor({ dashboard, isOpen, onApplied: onClose });
const onCopy = useCallback((): void => {
copyToClipboard(draft);
@@ -60,6 +70,19 @@ function JsonEditorDrawer({
const validationText = validity.valid
? `Valid JSON · ${validity.lineCount} lines`
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
const plural = (n: number): string => (n === 1 ? '' : 's');
const danglingWarning =
danglingPanelIds.length > 0
? `${danglingPanelIds.length} panel${plural(
danglingPanelIds.length,
)} not present in any section — they won't be shown after saving.`
: null;
const missingRefWarning =
missingPanelRefs.length > 0
? `${missingPanelRefs.length} layout item${plural(
missingPanelRefs.length,
)} reference a panel that no longer exists.`
: null;
return (
<Drawer
@@ -71,15 +94,33 @@ function JsonEditorDrawer({
rootClassName={styles.root}
footer={
<div className={styles.footer}>
<Typography.Text
className={cx(styles.validation, {
[styles.validationValid]: validity.valid,
[styles.validationInvalid]: !validity.valid,
})}
data-testid="json-editor-validation"
>
{validationText}
</Typography.Text>
<div className={styles.footerStatus}>
<Typography.Text
className={cx(styles.validation, {
[styles.validationValid]: validity.valid,
[styles.validationInvalid]: !validity.valid,
})}
data-testid="json-editor-validation"
>
{validationText}
</Typography.Text>
{danglingWarning && (
<Typography.Text
className={styles.danglingWarning}
data-testid="json-editor-dangling-warning"
>
{danglingWarning}
</Typography.Text>
)}
{missingRefWarning && (
<Typography.Text
className={styles.danglingWarning}
data-testid="json-editor-missing-ref-warning"
>
{missingRefWarning}
</Typography.Text>
)}
</div>
<div className={styles.footerActions}>
<Button
variant="outlined"

View File

@@ -48,11 +48,13 @@ function hookValue(
validity: { valid: true, lineCount: 3 },
isDirty: true,
isSaving: false,
danglingPanelIds: [],
missingPanelRefs: [],
format: jest.fn(),
reset: jest.fn(),
apply: jest.fn().mockResolvedValue(undefined),
...overrides,
};
} as ReturnType<typeof useJsonEditor>;
}
describe('JsonEditorDrawer', () => {
@@ -81,6 +83,34 @@ describe('JsonEditorDrawer', () => {
);
});
it('warns about dangling panels, and hides the warning when there are none', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({ danglingPanelIds: ['p1', 'p2'] }),
);
const { rerender } = render(
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
);
expect(screen.getByTestId('json-editor-dangling-warning')).toHaveTextContent(
'2 panels not present in any section',
);
mockUseJsonEditor.mockReturnValue(hookValue({ danglingPanelIds: [] }));
rerender(
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
);
expect(
screen.queryByTestId('json-editor-dangling-warning'),
).not.toBeInTheDocument();
});
it('warns about layout refs to missing panels', () => {
mockUseJsonEditor.mockReturnValue(hookValue({ missingPanelRefs: ['ghost'] }));
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
expect(
screen.getByTestId('json-editor-missing-ref-warning'),
).toHaveTextContent('1 layout item reference a panel that no longer exists');
});
it('shows the error line and message when invalid', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({

View File

@@ -0,0 +1,69 @@
import type { DashboardtypesDashboardSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { findPanelLayoutIssues } from '../danglingPanels';
const grid = (refs: string[]): unknown => ({
kind: 'Grid',
spec: { items: refs.map((r) => ({ content: { $ref: r } })) },
});
// Cast a loose fixture to the spec type — the helper is defensive against the
// untrusted, hand-edited JSON it runs on.
const spec = (value: unknown): DashboardtypesDashboardSpecDTO =>
value as DashboardtypesDashboardSpecDTO;
describe('findPanelLayoutIssues', () => {
it('flags nothing when panels and layouts agree', () => {
expect(
findPanelLayoutIssues(
spec({
panels: { a: {}, b: {} },
layouts: [grid(['#/spec/panels/a', '#/spec/panels/b'])],
}),
),
).toStrictEqual({ danglingPanelIds: [], missingPanelRefs: [] });
});
it('lists panels placed in no layout as dangling', () => {
const result = findPanelLayoutIssues(
spec({
panels: { a: {}, b: {}, c: {} },
layouts: [grid(['#/spec/panels/a'])],
}),
);
expect(result.danglingPanelIds.sort()).toStrictEqual(['b', 'c']);
expect(result.missingPanelRefs).toStrictEqual([]);
});
it('treats a removed/empty layout as orphaning every panel', () => {
expect(
findPanelLayoutIssues(
spec({ panels: { a: {}, b: {} }, layouts: [] }),
).danglingPanelIds.sort(),
).toStrictEqual(['a', 'b']);
});
it('lists layout refs to a panel that no longer exists as missing', () => {
const result = findPanelLayoutIssues(
spec({
panels: { a: {} },
layouts: [grid(['#/spec/panels/a', '#/spec/panels/ghost'])],
}),
);
expect(result.danglingPanelIds).toStrictEqual([]);
expect(result.missingPanelRefs).toStrictEqual(['ghost']);
});
it('handles empty / malformed specs', () => {
expect(
findPanelLayoutIssues(spec({ panels: {}, layouts: [] })),
).toStrictEqual({
danglingPanelIds: [],
missingPanelRefs: [],
});
expect(findPanelLayoutIssues(undefined)).toStrictEqual({
danglingPanelIds: [],
missingPanelRefs: [],
});
});
});

View File

@@ -203,4 +203,47 @@ describe('useJsonEditor', () => {
rerender({ isOpen: true });
expect(result.current.draft).toBe(serialized);
});
it('reports panels not placed in any layout as dangling', () => {
const withDangling = {
...dashboard,
spec: { ...dashboard.spec, panels: { p1: {} }, layouts: [] },
} as unknown as DashboardtypesGettableDashboardV2DTO;
const { result } = renderHook(() =>
useJsonEditor({
dashboard: withDangling,
isOpen: true,
onApplied: jest.fn(),
}),
);
expect(result.current.danglingPanelIds).toStrictEqual(['p1']);
expect(result.current.missingPanelRefs).toStrictEqual([]);
});
it('reports layout refs to panels that no longer exist as missing', () => {
const withMissing = {
...dashboard,
spec: {
...dashboard.spec,
panels: {},
layouts: [
{
kind: 'Grid',
spec: { items: [{ content: { $ref: '#/spec/panels/ghost' } }] },
},
],
},
} as unknown as DashboardtypesGettableDashboardV2DTO;
const { result } = renderHook(() =>
useJsonEditor({
dashboard: withMissing,
isOpen: true,
onApplied: jest.fn(),
}),
);
expect(result.current.missingPanelRefs).toStrictEqual(['ghost']);
expect(result.current.danglingPanelIds).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,47 @@
import type {
DashboardtypesDashboardSpecDTO,
DashboardtypesLayoutDTO,
} from 'api/generated/services/sigNoz.schemas';
import { extractPanelIdFromRef } from '../../utils';
export interface PanelLayoutIssues {
// Panels defined in `spec.panels` that no layout places — they render nowhere.
danglingPanelIds: string[];
// Panel ids a layout item references that no longer exist in `spec.panels`.
missingPanelRefs: string[];
}
const referencedPanelIds = (
layouts: DashboardtypesLayoutDTO[],
): Set<string> => {
const referenced = new Set<string>();
layouts.forEach((layout) => {
if (layout?.kind !== 'Grid') {
return;
}
(layout.spec?.items ?? []).forEach((item) => {
const id = extractPanelIdFromRef(item?.content?.$ref);
if (id) {
referenced.add(id);
}
});
});
return referenced;
};
// The two ways a hand-edited spec can desync panels and layouts: a panel with no
// layout slot (renders nowhere), or a layout slot pointing at a panel that was
// removed (broken reference). Guarded for untrusted, user-edited JSON.
export function findPanelLayoutIssues(
spec: DashboardtypesDashboardSpecDTO | undefined,
): PanelLayoutIssues {
const panels = spec?.panels ?? {};
const panelIds = Object.keys(panels);
const referenced = referencedPanelIds(spec?.layouts ?? []);
return {
danglingPanelIds: panelIds.filter((id) => !referenced.has(id)),
missingPanelRefs: [...referenced].filter((id) => !(id in panels)),
};
}

View File

@@ -1,11 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { updateDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesDashboardSpecDTO,
DashboardtypesGettableDashboardV2DTO,
} from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import { dashboardToUpdatable } from './dashboardToUpdatable';
import { findPanelLayoutIssues } from './danglingPanels';
import { useDashboardStore } from '../../store/useDashboardStore';
export interface JsonValidity {
@@ -28,6 +32,10 @@ interface Result {
validity: JsonValidity;
isDirty: boolean;
isSaving: boolean;
// Panel ids in the draft's `spec.panels` referenced by no layout — orphaned.
danglingPanelIds: string[];
// Panel ids a layout references that are missing from the draft's `spec.panels`.
missingPanelRefs: string[];
format: () => void;
reset: () => void;
apply: () => Promise<void>;
@@ -109,6 +117,23 @@ export function useJsonEditor({
const isDirty = draft !== appliedText;
const { danglingPanelIds, missingPanelRefs } = useMemo<{
danglingPanelIds: string[];
missingPanelRefs: string[];
}>(() => {
if (!validity.valid) {
return { danglingPanelIds: [], missingPanelRefs: [] };
}
try {
const parsed = JSON.parse(draft) as {
spec?: DashboardtypesDashboardSpecDTO;
};
return findPanelLayoutIssues(parsed.spec);
} catch {
return { danglingPanelIds: [], missingPanelRefs: [] };
}
}, [draft, validity.valid]);
const format = useCallback((): void => {
try {
setDraft(JSON.stringify(JSON.parse(draft), null, 2));
@@ -138,7 +163,7 @@ export function useJsonEditor({
refetch();
onApplied();
} catch (error) {
showErrorModal(error as APIError);
showErrorModal(toAPIError(error as Parameters<typeof toAPIError>[0]));
} finally {
setIsSaving(false);
}
@@ -159,6 +184,8 @@ export function useJsonEditor({
validity,
isDirty,
isSaving,
danglingPanelIds,
missingPanelRefs,
format,
reset,
apply,

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
@@ -15,9 +15,12 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { getAbsoluteUrl } from 'utils/basePath';
import { useCreatePanel } from '../hooks/useCreatePanel';
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
@@ -36,7 +39,15 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { dashboard, handle, refetch } = props;
const id = dashboard.id;
const isDashboardLocked = !!dashboard.locked;
// Session-local lock state: the toggle appears once locked and persists for the page.
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
const [showLockToggle, setShowLockToggle] = useState(!!dashboard.locked);
useEffect(() => {
setIsDashboardLocked(!!dashboard.locked);
setShowLockToggle(!!dashboard.locked);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.id]);
const title = dashboard.spec.display.name;
const description = dashboard.spec.display.description ?? '';
@@ -58,20 +69,36 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
// Author/admin can lock-unlock (mirrors the Actions menu gate); integration-owned
// dashboards are never toggleable.
const canToggleLock =
(isAuthor || user.role === USER_ROLES.ADMIN) &&
dashboard.createdBy !== 'integration';
// Public-sharing meta (deduped react-query read); drives the header globe.
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
if (!id) {
return;
}
const next = !isDashboardLocked;
setIsDashboardLocked(next);
if (next) {
setShowLockToggle(true);
}
try {
if (isDashboardLocked) {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
} else {
if (next) {
await lockDashboardV2({ id });
toast.success('Dashboard locked');
} else {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
}
refetch();
} catch (error) {
setIsDashboardLocked(!next);
showErrorModal(error as APIError);
}
}, [id, isDashboardLocked, refetch, showErrorModal]);
@@ -119,8 +146,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
image={image}
tags={tags}
description={description}
isPublicDashboard={false}
isPublicDashboard={isPublic}
publicUrl={publicUrl}
isDashboardLocked={isDashboardLocked}
showLockToggle={showLockToggle}
onToggleLock={canToggleLock ? handleLockDashboardToggle : undefined}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}

View File

@@ -3,10 +3,10 @@ import type { TagtypesPostableTagDTO } from 'api/generated/services/sigNoz.schem
export { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
export { parseKeyValueTag } from 'components/TagKeyValueInput/utils';
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
// collapsed back to just `x` for display.
// The tag editor is strictly key:value, so always render both sides — a
// `key:key` tag stays `key:key` rather than collapsing to a bare `key`.
export function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
return tags.map((t) => `${t.key}:${t.value}`);
}
export function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {

View File

@@ -2,6 +2,7 @@
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 12px;
padding-top: 2px;
color: var(--l3-foreground);
}

View File

@@ -1,19 +1,18 @@
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import type { EQueryType } from 'types/common/dashboard';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import styles from './PanelTypeSwitcher.module.scss';
import { getPanelTypeDisabledReason } from './utils';
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
panelKind: PanelKind;
/** Active query type — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse disable it). Defaults to Query Builder. */
queryType?: EQueryType;
/** Active query type — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse disable it). */
queryType: EQueryType;
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
onChange: (kind: PanelKind) => void;
@@ -31,22 +30,7 @@ function PanelTypeSwitcher({
signal,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = PANEL_TYPES.map(({ panelKind, label, Icon }) => {
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
const disabledReason = getPanelTypeDisabledReason({
kind: panelKind,
queryType: queryType ?? EQueryType.QUERY_BUILDER,
signal,
label,
});
return {
value: panelKind,
label,
icon: <Icon size={14} />,
disabled: !!disabledReason,
tooltip: disabledReason,
};
});
const items = usePanelTypeSelectItems({ queryType, signal });
return (
<div className={styles.field}>

View File

@@ -49,7 +49,11 @@ describe('PanelTypeSwitcher', () => {
it('fires onChange with the chosen plugin kind', () => {
const onChange = jest.fn();
render(
<PanelTypeSwitcher panelKind="signoz/TimeSeriesPanel" onChange={onChange} />,
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={onChange}
/>,
);
openDropdown();
@@ -62,6 +66,7 @@ describe('PanelTypeSwitcher', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
signal={TelemetrytypesSignalDTO.metrics}
onChange={jest.fn()}
/>,
@@ -77,6 +82,7 @@ describe('PanelTypeSwitcher', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={jest.fn()}
/>,
);

View File

@@ -0,0 +1,48 @@
import { useMemo } from 'react';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
import { getPanelTypeDisabledReason } from './utils';
interface UsePanelTypeSelectItemsArgs {
/** Active query type — a kind that can't be authored in it is disabled. */
queryType: EQueryType;
/** Current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
}
/**
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
* tooltip) when the active query type or signal is incompatible — resolved through
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
* modal's header so the two selectors apply the same rule and can't drift.
*/
export function usePanelTypeSelectItems({
queryType,
signal,
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
return useMemo(
() =>
PANEL_TYPES.map(({ panelKind, label, Icon }) => {
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
const disabledReason = getPanelTypeDisabledReason({
kind: panelKind,
queryType,
signal,
label,
});
return {
value: panelKind,
label,
icon: <Icon size={14} />,
disabled: !!disabledReason,
tooltip: disabledReason,
};
}),
[queryType, signal],
);
}

View File

@@ -0,0 +1,259 @@
import { useEffect, useState } from 'react';
import { Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
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 { UrlParam, VariableItem } from './types';
import {
getUrlParams,
insertVariableAtCursor,
isValidContextLinkUrl,
updateUrlWithParams,
} from './utils';
import VariablesPopover from './VariablesPopover';
import styles from './ContextLinksSection.module.scss';
interface ContextLinkDialogProps {
open: boolean;
/** The link being edited, or null when adding a new one. */
initialLink: DashboardLinkDTO | null;
variables: VariableItem[];
onOpenChange: (open: boolean) => void;
onSave: (link: DashboardLinkDTO) => void;
}
const URL_ERROR = 'URL must start with http(s)://, /, or {{variable}}/';
const cursorOf = (e: { target: EventTarget }): number =>
(e.target as HTMLInputElement).selectionStart ?? 0;
/**
* Modal editor for a single context link (V1 parity): label + URL with `{{variable}}`
* autocomplete + validation, a key/value URL-parameters editor, and an "open in new tab"
* toggle. The URL is the source of truth; parameter rows are its query string projected
* out (blank-key rows live only in local state until they get a key). Save is gated on a
* non-empty, well-formed URL. `renderVariables` is set so consumers interpolate the URL.
*/
function ContextLinkDialog({
open,
initialLink,
variables,
onOpenChange,
onSave,
}: ContextLinkDialogProps): JSX.Element {
const [name, setName] = useState('');
const [url, setUrlState] = useState('');
const [targetBlank, setTargetBlank] = useState(true);
const [params, setParams] = useState<UrlParam[]>([]);
// Seed the draft each time the dialog opens for a (possibly different) link.
useEffect(() => {
if (open) {
setName(initialLink?.name ?? '');
setUrlState(initialLink?.url ?? '');
setTargetBlank(initialLink?.targetBlank ?? true);
setParams(getUrlParams(initialLink?.url ?? ''));
}
}, [open, initialLink]);
const setUrl = (next: string): void => {
setUrlState(next);
setParams(getUrlParams(next));
};
const applyParams = (next: UrlParam[]): void => {
setParams(next);
setUrlState(updateUrlWithParams(url, next));
};
const patchParam = (index: number, patch: Partial<UrlParam>): void =>
applyParams(params.map((p, i) => (i === index ? { ...p, ...patch } : p)));
const addParam = (): void => {
const last = params[params.length - 1];
if (!last || last.key.trim() || last.value.trim()) {
setParams([...params, { key: '', value: '' }]);
}
};
const urlInvalid = !isValidContextLinkUrl(url);
const canSave = !!url.trim() && !urlInvalid;
const handleSave = (): void => {
if (canSave) {
onSave({ name, url, targetBlank, renderVariables: true });
}
};
return (
<DialogWrapper
open={open}
onOpenChange={onOpenChange}
title={initialLink ? 'Edit context link' : 'Add a context link'}
width="wide"
testId="context-link-dialog"
footer={
<>
<Button
type="button"
variant="outlined"
color="secondary"
data-testid="context-link-cancel"
onClick={(): void => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
variant="solid"
color="primary"
disabled={!canSave}
data-testid="context-link-save"
onClick={handleSave}
>
Save
</Button>
</>
}
>
<div className={styles.form}>
<div className={styles.formField}>
<Typography.Text className={styles.formLabel}>Label</Typography.Text>
<Input
data-testid="context-link-label"
placeholder="View trace details: {{_traceId}}"
value={name}
onChange={(e): void => setName(e.target.value)}
/>
</div>
<div className={styles.formField}>
<Typography.Text className={styles.formLabel}>
URL <span className={styles.required}>*</span>
</Typography.Text>
<VariablesPopover
variables={variables}
onVariableSelect={(token, cursor): void =>
setUrl(insertVariableAtCursor(url, token, cursor))
}
>
{({ setIsOpen, setCursorPosition }): JSX.Element => (
<Input
data-testid="context-link-url"
placeholder="https://… or /path?var={{variable}}"
value={url}
status={urlInvalid ? 'error' : undefined}
autoComplete="off"
onChange={(e): void => {
setCursorPosition(e.target.selectionStart ?? 0);
setUrl(e.target.value);
}}
onFocus={(): void => setIsOpen(true)}
onClick={(e): void => setCursorPosition(cursorOf(e))}
onKeyUp={(e): void => setCursorPosition(cursorOf(e))}
/>
)}
</VariablesPopover>
{urlInvalid && (
<Typography.Text
className={styles.urlError}
data-testid="context-link-url-error"
>
{URL_ERROR}
</Typography.Text>
)}
</div>
{params.length > 0 && (
<div className={styles.formField}>
<Typography.Text className={styles.formLabel}>
URL parameters
</Typography.Text>
<div className={styles.params}>
{params.map((param, index) => (
<div
className={styles.paramRow}
// Parameter rows have no stable id; index is the row identity here.
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<Input
data-testid={`context-link-param-key-${index}`}
placeholder="Key"
value={param.key}
onChange={(e): void => patchParam(index, { key: e.target.value })}
/>
<VariablesPopover
variables={variables}
onVariableSelect={(token, cursor): void =>
patchParam(index, {
value: insertVariableAtCursor(param.value, token, cursor),
})
}
>
{({ setIsOpen, setCursorPosition }): JSX.Element => (
<Input
data-testid={`context-link-param-value-${index}`}
placeholder="Value"
value={param.value}
onChange={(e): void => {
setCursorPosition(e.target.selectionStart ?? 0);
patchParam(index, { value: e.target.value });
}}
onFocus={(): void => setIsOpen(true)}
onClick={(e): void => setCursorPosition(cursorOf(e))}
onKeyUp={(e): void => setCursorPosition(cursorOf(e))}
/>
)}
</VariablesPopover>
<Button
type="button"
variant="ghost"
color="destructive"
size="icon"
aria-label={`Remove parameter ${index + 1}`}
data-testid={`context-link-param-remove-${index}`}
onClick={(): void =>
applyParams(params.filter((_, i) => i !== index))
}
>
<Trash2 size={14} />
</Button>
</div>
))}
</div>
</div>
)}
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size={12} />}
data-testid="context-link-add-param"
onClick={addParam}
>
Add URL parameter
</Button>
<div className={styles.newTab}>
<Switch
testId="context-link-newtab"
value={targetBlank}
onChange={setTargetBlank}
/>
<Typography.Text className={styles.newTabLabel}>
Open in new tab
</Typography.Text>
</div>
</div>
</DialogWrapper>
);
}
export default ContextLinkDialog;

View File

@@ -0,0 +1,62 @@
import { Pencil, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './ContextLinksSection.module.scss';
interface ContextLinkListItemProps {
link: DashboardLinkDTO;
index: number;
onEdit: () => void;
onRemove: () => void;
}
/** A saved context link in the section list: its label + URL, with edit / delete actions. */
function ContextLinkListItem({
link,
index,
onEdit,
onRemove,
}: ContextLinkListItemProps): JSX.Element {
const label = link.name?.trim() || link.url || 'Untitled link';
return (
<div className={styles.listItem} data-testid={`context-link-item-${index}`}>
<div className={styles.listItemText}>
<Typography.Text className={styles.listItemLabel}>{label}</Typography.Text>
{!!link.url && (
<Typography.Text className={styles.listItemUrl}>
{link.url}
</Typography.Text>
)}
</div>
<div className={styles.listItemActions}>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
aria-label={`Edit link ${index + 1}`}
data-testid={`context-link-edit-${index}`}
onClick={onEdit}
>
<Pencil size={14} />
</Button>
<Button
type="button"
variant="ghost"
color="destructive"
size="icon"
aria-label={`Remove link ${index + 1}`}
data-testid={`context-link-remove-${index}`}
onClick={onRemove}
>
<Trash2 size={14} />
</Button>
</div>
</div>
);
}
export default ContextLinkListItem;

View File

@@ -1,32 +1,99 @@
.list {
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
}
.row {
/* --- Saved-link list item --- */
.listItem {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px;
padding: 8px 10px;
border: 1px solid var(--l2-border);
border-radius: 6px;
}
.rowFooter {
.listItemText {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.listItemLabel,
.listItemUrl {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.listItemLabel {
font-size: 13px;
color: var(--text-vanilla-100);
}
.listItemUrl {
font-size: 11px;
color: var(--text-vanilla-400);
}
.listItemActions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 2px;
flex-shrink: 0;
}
/* --- Dialog form --- */
.form {
display: flex;
flex-direction: column;
gap: 14px;
}
.formField {
display: flex;
flex-direction: column;
gap: 6px;
}
.formLabel {
font-size: 12px;
color: var(--l2-foreground);
}
.required {
color: var(--bg-cherry-400);
}
.urlError {
font-size: 11px;
color: var(--bg-cherry-400);
}
.params {
display: flex;
flex-direction: column;
gap: 8px;
}
.paramRow {
display: grid;
grid-template-columns: 1fr 1fr auto;
align-items: center;
gap: 6px;
}
.newTab {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.newTabLabel {
font-size: 12px;
color: var(--text-vanilla-400);
color: var(--l2-foreground);
}

View File

@@ -1,83 +1,63 @@
import { Plus, Trash2 } from '@signozhq/icons';
import { useState } from 'react';
import { Plus } 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,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ContextLinkDialog from './ContextLinkDialog';
import ContextLinkListItem from './ContextLinkListItem';
import { useContextLinkVariables } from './useContextLinkVariables';
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.
* Edits the panel's context links (`spec.links`) as a list of saved links, each added or
* edited through a modal ({@link ContextLinkDialog}) that carries V1's full authoring UX
* (variable autocomplete, URL-parameters editor, validation).
*/
function ContextLinksSection({
value,
onChange,
}: SectionEditorProps<SectionKind.ContextLinks>): JSX.Element {
const links = value ?? [];
const variables = useContextLinkVariables();
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 }]);
// `index === null` while adding a new link; a number while editing an existing one.
const [dialog, setDialog] = useState<{ open: boolean; index: number | null }>({
open: false,
index: null,
});
const removeAt = (index: number): void =>
onChange(links.filter((_, i) => i !== index));
const handleSave = (link: DashboardLinkDTO): void => {
onChange(
dialog.index === null
? [...links, link]
: links.map((existing, i) => (i === dialog.index ? link : existing)),
);
setDialog((d) => ({ ...d, open: false }));
};
const editingLink =
dialog.index !== null ? (links[dialog.index] ?? null) : null;
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>
<ContextLinkListItem
// Links have no stable id on the wire; index is the row identity here.
// eslint-disable-next-line react/no-array-index-key
key={index}
link={link}
index={index}
onEdit={(): void => setDialog({ open: true, index })}
onRemove={(): void => removeAt(index)}
/>
))}
<Button
@@ -86,10 +66,18 @@ function ContextLinksSection({
color="secondary"
prefix={<Plus size={14} />}
data-testid="panel-editor-v2-add-link"
onClick={addLink}
onClick={(): void => setDialog({ open: true, index: null })}
>
Add link
Add Context Link
</Button>
<ContextLinkDialog
open={dialog.open}
initialLink={editingLink}
variables={variables}
onOpenChange={(open): void => setDialog((d) => ({ ...d, open }))}
onSave={handleSave}
/>
</div>
);
}

View File

@@ -0,0 +1,64 @@
.container {
width: 100%;
}
.anchor {
width: 100%;
}
.content {
z-index: 1100;
padding: 4px 0;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
min-width: var(--radix-popover-trigger-width);
}
.empty {
padding: 8px 12px;
color: var(--text-vanilla-400);
font-size: 12px;
font-style: italic;
}
.item {
--button-display: flex;
--button-justify-content: flex-start;
--button-height: auto;
--button-padding: 8px 12px;
--button-border-radius: 0;
--button-font-size: 13px;
--button-line-height: 1.4;
width: 100%;
overflow: hidden;
}
.row {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.name,
.source {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.name {
flex: 1 1 auto;
text-align: start;
}
.source {
color: var(--l3-foreground);
font-size: 12px;
flex: 0 1 auto;
}

View File

@@ -0,0 +1,117 @@
// Uses Popover (not DropdownMenu): DropdownMenuTrigger preventDefaults pointerdown,
// which steals input focus and dismisses on every keystroke. PopoverAnchor is a passive
// positioning element that leaves the wrapped input fully interactive.
import { ReactNode, useRef, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
import { Typography } from '@signozhq/ui/typography';
import type { VariableItem } from './types';
import styles from './VariablesPopover.module.scss';
interface VariablesPopoverRenderProps {
setIsOpen: (open: boolean) => void;
setCursorPosition: (position: number | null) => void;
}
interface VariablesPopoverProps {
variables: VariableItem[];
/** Called with the braces-wrapped token (e.g. `{{env}}`) and the tracked cursor offset. */
onVariableSelect: (token: string, cursorPosition?: number) => void;
children: (props: VariablesPopoverRenderProps) => ReactNode;
}
/**
* Autocomplete for context-link variables. Wraps an input (via the render-prop) and
* shows the available `{{variables}}` grouped by source; picking one inserts it at the
* tracked cursor. The consumer drives `setIsOpen` (on focus) and `setCursorPosition`.
*/
function VariablesPopover({
variables,
onVariableSelect,
children,
}: VariablesPopoverProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
const anchorRef = useRef<HTMLDivElement>(null);
const handleOpenChange = (open: boolean): void => {
// Accept "close" events (outside-click, Esc) but ignore opens — opening is driven
// by the input's onFocus in the consumer.
if (!open) {
setIsOpen(false);
}
};
// Keep the popover open while the pointer/focus is inside the anchored input.
const keepOpenIfInsideAnchor = (event: {
target: EventTarget | null;
}): void => {
const target = event.target as Node | null;
if (
target &&
anchorRef.current?.contains(target) &&
'preventDefault' in event
) {
(event as Event).preventDefault();
}
};
return (
<div className={styles.container}>
<Popover open={isOpen} onOpenChange={handleOpenChange} modal={false}>
<PopoverAnchor asChild>
<div className={styles.anchor} ref={anchorRef}>
{children({ setIsOpen, setCursorPosition })}
</div>
</PopoverAnchor>
<PopoverContent
align="start"
sideOffset={4}
// Render inside the anchor (not a body portal): the editor lives in a modal
// DialogWrapper, and radix's modal sets pointer-events:none on everything
// portalled outside it, which would make the suggestions unclickable.
withPortal={false}
className={styles.content}
onOpenAutoFocus={(e): void => e.preventDefault()}
onCloseAutoFocus={(e): void => e.preventDefault()}
onInteractOutside={keepOpenIfInsideAnchor}
onFocusOutside={keepOpenIfInsideAnchor}
>
{variables.length === 0 ? (
<div className={styles.empty}>No variables available</div>
) : (
variables.map((v) => (
<Button
key={`${v.source}-${v.name}`}
type="button"
variant="ghost"
color="secondary"
size="md"
className={styles.item}
aria-label={`Insert {{${v.name}}}`}
testId={`context-link-variable-${v.name}`}
// Prevent the input from losing focus when clicking an item.
onMouseDown={(e): void => e.preventDefault()}
onClick={(): void => {
onVariableSelect(`{{${v.name}}}`, cursorPosition ?? undefined);
setIsOpen(false);
}}
>
<div className={styles.row}>
<Typography.Text
className={styles.name}
>{`{{${v.name}}}`}</Typography.Text>
<Typography.Text className={styles.source}>{v.source}</Typography.Text>
</div>
</Button>
))
)}
</PopoverContent>
</Popover>
</div>
);
}
export default VariablesPopover;

View File

@@ -3,47 +3,92 @@ import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import ContextLinksSection from '../ContextLinksSection';
// The variable-source hook reads the query-builder provider + store; stub it so the
// editor can be exercised in isolation (its own suite covers the sourcing logic).
jest.mock('../useContextLinkVariables', () => ({
useContextLinkVariables: (): unknown => [
{ name: 'timestamp_start', source: 'Global timestamp' },
{ name: 'env', source: 'Dashboard variable' },
],
}));
const LINKS: DashboardLinkDTO[] = [
{ name: 'Docs', url: 'https://signoz.io', targetBlank: true },
];
const lastCall = (fn: jest.Mock): DashboardLinkDTO[] =>
fn.mock.calls[fn.mock.calls.length - 1][0];
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();
expect(screen.queryByTestId('context-link-item-0')).not.toBeInTheDocument();
});
it('appends a blank link (open-in-new-tab on) when Add link is clicked', () => {
it('renders existing links as list items showing the label', () => {
render(<ContextLinksSection value={LINKS} onChange={jest.fn()} />);
expect(screen.getByTestId('context-link-item-0')).toHaveTextContent('Docs');
// The editor is a modal — no inline fields until it's opened.
expect(screen.queryByTestId('context-link-url')).not.toBeInTheDocument();
});
it('adds a link through the dialog (Save gated on a valid URL)', async () => {
const onChange = jest.fn();
render(<ContextLinksSection value={[]} onChange={onChange} />);
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
const save = await screen.findByTestId('context-link-save');
expect(save).toBeDisabled();
fireEvent.change(screen.getByTestId('context-link-url'), {
target: { value: 'https://signoz.io' },
});
fireEvent.change(screen.getByTestId('context-link-label'), {
target: { value: 'Docs' },
});
expect(save).not.toBeDisabled();
fireEvent.click(save);
expect(onChange).toHaveBeenCalledWith([
{ name: '', url: '', targetBlank: true },
{
name: 'Docs',
url: 'https://signoz.io',
targetBlank: true,
renderVariables: true,
},
]);
});
it('renders existing links and edits a label through onChange', () => {
it('edits an existing link through the dialog', async () => {
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(
fireEvent.click(screen.getByTestId('context-link-edit-0'));
const label = await screen.findByTestId('context-link-label');
expect(label).toHaveValue('Docs');
expect(screen.getByTestId('context-link-url')).toHaveValue(
'https://signoz.io',
);
fireEvent.change(screen.getByTestId('context-link-label-0'), {
target: { value: 'Runbook' },
});
fireEvent.change(label, { target: { value: 'Runbook' } });
fireEvent.click(screen.getByTestId('context-link-save'));
expect(onChange).toHaveBeenCalledWith([
{ name: 'Runbook', url: 'https://signoz.io', targetBlank: true },
{
name: 'Runbook',
url: 'https://signoz.io',
targetBlank: true,
renderVariables: true,
},
]);
});
it('removes a link through onChange', () => {
it('removes a link from the list', () => {
const onChange = jest.fn();
render(<ContextLinksSection value={LINKS} onChange={onChange} />);
@@ -51,4 +96,52 @@ describe('ContextLinksSection', () => {
expect(onChange).toHaveBeenCalledWith([]);
});
it('shows a validation error only for a malformed URL', async () => {
render(<ContextLinksSection value={[]} onChange={jest.fn()} />);
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
const urlInput = await screen.findByTestId('context-link-url');
fireEvent.change(urlInput, { target: { value: 'not-a-url' } });
expect(screen.getByTestId('context-link-url-error')).toBeInTheDocument();
expect(screen.getByTestId('context-link-save')).toBeDisabled();
fireEvent.change(urlInput, { target: { value: '/valid/path' } });
expect(
screen.queryByTestId('context-link-url-error'),
).not.toBeInTheDocument();
});
it('adds a URL parameter and writes it into the URL query string', async () => {
const onChange = jest.fn();
render(<ContextLinksSection value={[]} onChange={onChange} />);
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
fireEvent.change(await screen.findByTestId('context-link-url'), {
target: { value: '/logs' },
});
fireEvent.click(screen.getByTestId('context-link-add-param'));
fireEvent.change(screen.getByTestId('context-link-param-key-0'), {
target: { value: 'env' },
});
fireEvent.click(screen.getByTestId('context-link-save'));
expect(lastCall(onChange)).toStrictEqual([
{ name: '', url: '/logs?env=', targetBlank: true, renderVariables: true },
]);
});
it('inserts a {{variable}} into the URL from the autocomplete popover', async () => {
render(<ContextLinksSection value={[]} onChange={jest.fn()} />);
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
const urlInput = await screen.findByTestId('context-link-url');
fireEvent.focus(urlInput);
fireEvent.click(
await screen.findByTestId('context-link-variable-timestamp_start'),
);
expect(urlInput).toHaveValue('{{timestamp_start}}');
});
});

View File

@@ -0,0 +1,83 @@
import { renderHook } from '@testing-library/react';
import { useContextLinkVariables } from '../useContextLinkVariables';
const mockUseGetDashboardV2 = jest.fn();
const mockUseQueryBuilder = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (
selector: (s: { dashboardId: string }) => unknown,
): unknown => selector({ dashboardId: 'dash-1' }),
}),
);
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => mockUseQueryBuilder(),
}));
jest.mock('api/generated/services/dashboard', () => ({
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
}));
// dtoToFormModel is exercised by its own suite; here we only need it to surface a name.
jest.mock(
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters',
() => ({
dtoToFormModel: (dto: { name: string }): { name: string } => ({
name: dto.name,
}),
}),
);
describe('useContextLinkVariables', () => {
beforeEach(() => {
mockUseQueryBuilder.mockReturnValue({
currentQuery: {
builder: {
queryData: [
{ groupBy: [{ key: 'service.name' }, { key: 'env' }] },
// Duplicate across queries — must dedupe.
{ groupBy: [{ key: 'service.name' }] },
],
},
},
});
mockUseGetDashboardV2.mockReturnValue({
data: {
data: { spec: { variables: [{ name: 'region' }, { name: 'tier' }] } },
},
});
});
it('orders globals, then _prefixed query fields (deduped), then dashboard vars', () => {
const { result } = renderHook(() => useContextLinkVariables());
expect(result.current).toStrictEqual([
{ name: 'timestamp_start', source: 'Global timestamp' },
{ name: 'timestamp_end', source: 'Global timestamp' },
{ name: '_service.name', source: 'Query variable' },
{ name: '_env', source: 'Query variable' },
{ name: 'region', source: 'Dashboard variable' },
{ name: 'tier', source: 'Dashboard variable' },
]);
});
it('still returns the global timestamps when there are no queries or variables', () => {
mockUseQueryBuilder.mockReturnValue({
currentQuery: { builder: { queryData: [] } },
});
// Loaded dashboard with no variables — useDashboardFetchRequired guarantees the
// dashboard is present, so the empty case is an empty spec, not `undefined`.
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
const { result } = renderHook(() => useContextLinkVariables());
expect(result.current).toStrictEqual([
{ name: 'timestamp_start', source: 'Global timestamp' },
{ name: 'timestamp_end', source: 'Global timestamp' },
]);
});
});

View File

@@ -0,0 +1,68 @@
import {
getUrlParams,
insertVariableAtCursor,
isValidContextLinkUrl,
updateUrlWithParams,
} from '../utils';
describe('ContextLinksSection utils', () => {
describe('isValidContextLinkUrl', () => {
it.each([
['', true],
['https://signoz.io', true],
['http://localhost:3301/trace', true],
['/trace/{{_traceId}}', true],
['{{host}}/trace', true],
['trace/{{_traceId}}', false],
['ftp://signoz.io', false],
])('validates %p as %p', (url, expected) => {
expect(isValidContextLinkUrl(url)).toBe(expected);
});
});
describe('insertVariableAtCursor', () => {
it('inserts at the cursor position', () => {
expect(insertVariableAtCursor('/trace/', '{{id}}', 7)).toBe('/trace/{{id}}');
expect(insertVariableAtCursor('ab', '{{x}}', 1)).toBe('a{{x}}b');
});
it('appends when no cursor position is given', () => {
expect(insertVariableAtCursor('/trace/', '{{id}}')).toBe('/trace/{{id}}');
});
});
describe('getUrlParams', () => {
it('returns [] when there is no query string', () => {
expect(getUrlParams('/trace/{{id}}')).toStrictEqual([]);
});
it('parses and decodes key/value pairs', () => {
expect(getUrlParams('/logs?service={{svc}}&env=prod')).toStrictEqual([
{ key: 'service', value: '{{svc}}' },
{ key: 'env', value: 'prod' },
]);
});
it('double-decodes over-encoded values', () => {
// %257B%257B == encodeURIComponent('%7B%7B') == encodeURIComponent('{{')
expect(getUrlParams('/logs?q=%257B%257Bx%257D%257D')).toStrictEqual([
{ key: 'q', value: '{{x}}' },
]);
});
});
describe('updateUrlWithParams', () => {
it('rebuilds the query string and drops empty keys', () => {
expect(
updateUrlWithParams('/logs?old=1', [
{ key: 'service', value: '{{svc}}' },
{ key: '', value: 'ignored' },
]),
).toBe('/logs?service=%7B%7Bsvc%7D%7D');
});
it('drops the query string entirely when no valid params remain', () => {
expect(updateUrlWithParams('/logs?a=b', [])).toBe('/logs');
});
});
});

View File

@@ -0,0 +1,29 @@
/**
* Where a context-link variable comes from — shown as the right-hand label in the popover.
*
* - `Global timestamp` — the dashboard's selected time range: `timestamp_start` /
* `timestamp_end`. Always available, independent of the panel or its queries.
* - `Query variable` — a `groupBy` field from the panel's own queries, `_`-prefixed
* (e.g. `_service_name`). Resolved at click time from the data point the user clicked,
* so it reflects the current query definition and changes as the queries are edited.
* - `Dashboard variable` — a user-defined dashboard variable from Dashboard Settings
* (e.g. `env`, `region`). Shared across every panel and driven by the dashboard's
* variable selectors rather than by an individual query.
*/
export type VariableSource =
| 'Global timestamp'
| 'Query variable'
| 'Dashboard variable';
/** One entry in the context-link variable autocomplete. */
export interface VariableItem {
/** Bare variable name without braces, e.g. `timestamp_start`, `_service_name`, `env`. */
name: string;
source: VariableSource;
}
/** A single decoded key/value pair parsed from (or written back to) a URL query string. */
export interface UrlParam {
key: string;
value: string;
}

View File

@@ -0,0 +1,60 @@
import { useMemo } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { dtoToFormModel } from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters';
import { useDashboardFetchRequired } from 'pages/DashboardPageV2/DashboardContainer/hooks/useDashboardFetchRequired';
import type { VariableItem } from './types';
// Global time-range variables, always available (V1 parity: `timestamp_start` / `_end`).
const GLOBAL_TIMESTAMP_VARIABLES: VariableItem[] = [
{ name: 'timestamp_start', source: 'Global timestamp' },
{ name: 'timestamp_end', source: 'Global timestamp' },
];
/**
* Variables offered by the context-link autocomplete, ordered as in V1: global
* timestamps, then per-query `groupBy` fields (prefixed `_`), then dashboard variables.
*
* Self-contained — the editor renders inside the query-builder provider and the
* DashboardContainer, so every source is read here rather than threaded through the
* section registry. The dashboard fetch dedupes against the editor page's own query.
*/
export function useContextLinkVariables(): VariableItem[] {
const { currentQuery } = useQueryBuilder();
const { variables: variableDtos } = useDashboardFetchRequired();
const dashboardVariableNames = useMemo(
() =>
variableDtos
.map((dto) => dtoToFormModel(dto).name)
.filter((name): name is string => !!name),
[variableDtos],
);
// `_`-prefixed to match V1 and avoid colliding with dashboard-variable names.
const fieldVariableNames = useMemo(() => {
const names = new Set<string>();
(currentQuery?.builder?.queryData ?? []).forEach((query) => {
(query.groupBy ?? []).forEach((field) => {
if (field.key) {
names.add(`_${field.key}`);
}
});
});
return Array.from(names);
}, [currentQuery?.builder?.queryData]);
return useMemo(
() => [
...GLOBAL_TIMESTAMP_VARIABLES,
...fieldVariableNames.map(
(name): VariableItem => ({ name, source: 'Query variable' }),
),
...dashboardVariableNames.map(
(name): VariableItem => ({ name, source: 'Dashboard variable' }),
),
],
[fieldVariableNames, dashboardVariableNames],
);
}

View File

@@ -0,0 +1,78 @@
import type { UrlParam } from './types';
// A link URL must be absolute (http/https), root-relative (`/path`), or start with a
// leading `{{var}}/` segment (a variable that resolves to a host/path at click-time).
const CONTEXT_LINK_URL_PATTERN = /^(https?:\/\/|\/|{{.*}}\/)/;
/**
* Whether the URL is well-formed for a context link. Empty is treated as valid so the
* editor doesn't nag while a row is still being filled in — emptiness is a separate concern.
*/
export function isValidContextLinkUrl(url: string): boolean {
if (!url) {
return true;
}
return CONTEXT_LINK_URL_PATTERN.test(url);
}
/** Inserts `variable` into `current` at `cursorPosition`, or appends when it's unknown. */
export function insertVariableAtCursor(
current: string,
variable: string,
cursorPosition?: number,
): string {
if (cursorPosition === undefined) {
return current + variable;
}
return (
current.slice(0, cursorPosition) + variable + current.slice(cursorPosition)
);
}
// Values may be double-encoded on the wire; decode a second time only when it changes
// the string, so already-single-encoded values are left intact.
function decodeForDisplay(value: string): string {
const decoded = decodeURIComponent(value);
try {
const doubleDecoded = decodeURIComponent(decoded);
return doubleDecoded !== decoded ? doubleDecoded : decoded;
} catch {
return decoded;
}
}
/** Parses the `?a=b&c=d` query string of a URL into decoded key/value rows. */
export function getUrlParams(url: string): UrlParam[] {
const [, queryString] = url.split('?');
if (!queryString) {
return [];
}
const params: UrlParam[] = [];
queryString.split('&').forEach((pair) => {
const [key, value] = pair.split('=');
if (key) {
params.push({
key: decodeURIComponent(key),
value: decodeForDisplay(value || ''),
});
}
});
return params;
}
/** Rewrites `url`'s query string from `params`, dropping rows with an empty key. */
export function updateUrlWithParams(url: string, params: UrlParam[]): string {
const [baseUrl] = url.split('?');
const queryString = params
.filter((param) => param.key.trim() !== '')
.map(
(param) =>
`${encodeURIComponent(param.key.trim())}=${encodeURIComponent(
param.value,
)}`,
)
.join('&');
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
}

View File

@@ -8,7 +8,7 @@ import {
DashboardtypesThresholdFormatDTO,
type DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
import {
AnyThreshold,
ThresholdVariant,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
@@ -77,7 +77,7 @@ function ThresholdsSection({
yAxisUnit,
tableColumns = [],
}: ThresholdsSectionProps): JSX.Element {
const variant = controls?.variant ?? 'label';
const variant = controls?.variant ?? ThresholdVariant.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);

View File

@@ -4,7 +4,10 @@ import {
type DashboardtypesComparisonThresholdDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import {
ThresholdVariant,
type AnyThreshold,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import UnifiedThresholdsSection from '../ThresholdsSection';
@@ -21,7 +24,7 @@ function ComparisonThresholdsSection(props: {
value={props.value}
onChange={props.onChange as (next: AnyThreshold[]) => void}
yAxisUnit={props.yAxisUnit}
controls={{ variant: 'comparison' }}
controls={{ variant: ThresholdVariant.COMPARISON }}
/>
);
}

View File

@@ -3,6 +3,7 @@ import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { EQueryType } from 'types/common/dashboard';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
@@ -38,7 +39,9 @@ function VisualizationSection({
{controls.switchPanelKind && panelKind && onChangePanelKind && (
<PanelTypeSwitcher
panelKind={panelKind}
queryType={queryType}
// queryType is optional on the kind-erased section context, but always
// supplied in practice; default to Query Builder at this boundary.
queryType={queryType ?? EQueryType.QUERY_BUILDER}
signal={signal}
onChange={onChangePanelKind}
/>

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { SolidAlertTriangle, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
@@ -61,24 +61,42 @@ function Header({
</Button>
</div>
<ConfirmDialog
<DialogWrapper
open={discard.open}
onOpenChange={(next): void => {
onOpenChange={(next: boolean): void => {
if (!next) {
discard.cancel();
}
}}
title="Discard changes?"
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
confirmText="Discard"
confirmColor="destructive"
cancelText="Keep editing"
onConfirm={discard.confirm}
onCancel={discard.cancel}
data-testid="panel-editor-v2-discard-modal"
testId="panel-editor-v2-discard-modal"
footer={
<>
<Button
type="button"
variant="solid"
color="destructive"
data-testid="panel-editor-v2-discard-confirm"
loading={discard.isPending}
onClick={discard.confirm}
>
Discard
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
data-testid="panel-editor-v2-discard-cancel"
onClick={discard.cancel}
>
Keep editing
</Button>
</>
}
>
<Typography>Your unsaved edits to this panel will be lost.</Typography>
</ConfirmDialog>
</DialogWrapper>
</div>
);
}

View File

@@ -164,7 +164,7 @@ function PanelEditorQueryBuilder({
<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"
label="Run Query"
onStageRunQuery={onStageRunQuery}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={onCancelQuery}

View File

@@ -49,6 +49,13 @@
background: var(--l2-background);
}
// Standalone View stacks the graph-manager below the chart inside the surface (it
// must stay within the chart's PlotContext). Let it flow out of the surface so the
// modal body scrolls as a whole, instead of clipping it or scrolling the panel.
.surfaceStacked {
overflow: visible;
}
.state {
flex: 1;
display: flex;

View File

@@ -1,11 +1,13 @@
import { useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import type {
PanelPagination,
@@ -23,6 +25,8 @@ interface PreviewPaneProps {
data: PanelQueryData;
/** Any fetch in flight — drives the header spinner and the body's loading state. */
isFetching: boolean;
/** Showing a prior page's data while the next loads; forwarded so the list shows skeleton rows. */
isPreviousData?: boolean;
error: Error | null;
/** Re-run the query (drives PanelBody's error-state retry). */
refetch: () => void;
@@ -30,6 +34,14 @@ interface PreviewPaneProps {
onDragSelect: (start: number, end: number) => void;
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
pagination?: PanelPagination;
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
panelMode?: PanelMode;
/** Hide the preview's top row entirely (query-type badge + time picker) — the View modal has its own header. */
hideHeader?: boolean;
/** Dashboard-wide preferences (cursor sync, …) forwarded to the body; the modal isolates cursor-sync. */
dashboardPreference?: DashboardPreference;
/** Close the standalone View modal — forwarded to the time-series/bar graph manager. */
onCloseStandaloneView?: () => void;
}
/**
@@ -43,10 +55,15 @@ function PreviewPane({
panelDefinition,
data,
isFetching,
isPreviousData,
error,
refetch,
onDragSelect,
pagination,
panelMode = PanelMode.DASHBOARD_EDIT,
hideHeader = false,
dashboardPreference,
onCloseStandaloneView,
}: PreviewPaneProps): JSX.Element {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const queryType = getPanelQueryType(panel);
@@ -58,18 +75,24 @@ function PreviewPane({
return (
<div className={styles.preview}>
<div className={styles.header}>
<PlotTag
queryType={queryType}
panelType={panelType}
className={styles.queryType}
/>
<div className={styles.dateTimeSelector}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
{!hideHeader && (
<div className={styles.header}>
<PlotTag
queryType={queryType}
panelType={panelType}
className={styles.queryType}
/>
<div className={styles.dateTimeSelector}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
</div>
</div>
)}
<div className={styles.container}>
<div className={styles.surface}>
<div
className={cx(styles.surface, {
[styles.surfaceStacked]: panelMode === PanelMode.STANDALONE_VIEW,
})}
>
<PanelHeader
panelId={panelId}
panel={panel}
@@ -87,12 +110,15 @@ function PreviewPane({
panelId={panelId}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
panelMode={PanelMode.DASHBOARD_EDIT}
panelMode={panelMode}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onCloseStandaloneView={onCloseStandaloneView}
/>
</div>
</div>

View File

@@ -0,0 +1,274 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import PanelEditorContainer from '../index';
/**
* Characterization test for the editor's composition: which derived values and
* options it forwards to the draft/query/query-sync/type-switch hooks and to its
* children. The leaf hooks are mocked as arg-capturing spies so this pins the
* wiring; it stays valid (and guards behavior) after that wiring is pulled into a
* shared edit-session hook, since the mocks intercept the leaf hooks either way.
*/
const mockSetSpec = jest.fn();
const mockRefetch = jest.fn();
const mockCancelQuery = jest.fn();
const mockBuildSaveSpec = jest.fn((spec: unknown) => spec);
const mockOnChangePanelKind = jest.fn();
const mockSave = jest.fn().mockResolvedValue(undefined);
const mockUseDraft = jest.fn();
jest.mock('../hooks/usePanelEditorDraft', () => ({
usePanelEditorDraft: (panel: unknown): unknown => mockUseDraft(panel),
}));
const mockUseQuery = jest.fn();
jest.mock('../../hooks/usePanelQuery', () => ({
usePanelQuery: (args: unknown): unknown => mockUseQuery(args),
}));
const mockUseQuerySync = jest.fn();
jest.mock('../hooks/usePanelEditorQuerySync', () => ({
usePanelEditorQuerySync: (args: unknown): unknown => mockUseQuerySync(args),
}));
const mockUseTypeSwitch = jest.fn();
jest.mock('../hooks/usePanelTypeSwitch', () => ({
usePanelTypeSwitch: (args: unknown): unknown => mockUseTypeSwitch(args),
}));
jest.mock('../hooks/usePanelEditorSave', () => ({
usePanelEditorSave: (): unknown => ({ save: mockSave, isSaving: false }),
}));
jest.mock('../hooks/useSwitchColumnsOnSignalChange', () => ({
useSwitchColumnsOnSignalChange: jest.fn(),
}));
jest.mock('../hooks/useSeedNewListColumns', () => ({
useSeedNewListColumns: jest.fn(),
}));
jest.mock('../hooks/useLegendSeries', () => ({
useLegendSeries: (): [] => [],
}));
jest.mock('../hooks/useTableColumns', () => ({
useTableColumns: (): [] => [],
}));
jest.mock('../hooks/useMetricYAxisUnit', () => ({
useMetricYAxisUnit: (): unknown => ({
metricUnit: undefined,
isLoading: false,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({ currentQuery: { queryType: 'builder' } }),
}));
jest.mock(
'../../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions',
() => ({
usePanelInteractions: (): unknown => ({
onDragSelect: jest.fn(),
dashboardPreference: {},
}),
}),
);
jest.mock('@signozhq/ui/resizable', () => ({
__esModule: true,
ResizablePanelGroup: ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <div>{children}</div>,
ResizablePanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
ResizableHandle: (): null => null,
useDefaultLayout: (): unknown => ({
defaultLayout: undefined,
onLayoutChanged: jest.fn(),
}),
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
// Children mocked to capture props (and expose a Save trigger / footer slot).
const mockHeaderProps = jest.fn();
jest.mock('../Header/Header', () => ({
__esModule: true,
default: (props: { onSave: () => void }): JSX.Element => {
mockHeaderProps(props);
return (
<button type="button" data-testid="editor-save" onClick={props.onSave}>
save
</button>
);
},
}));
const mockPreviewProps = jest.fn();
jest.mock('../PreviewPane/PreviewPane', () => ({
__esModule: true,
default: (props: unknown): JSX.Element => {
mockPreviewProps(props);
return <div data-testid="preview" />;
},
}));
const mockQbProps = jest.fn();
jest.mock('../PanelEditorQueryBuilder/PanelEditorQueryBuilder', () => ({
__esModule: true,
default: (props: { footer?: React.ReactNode }): JSX.Element => {
mockQbProps(props);
return <div data-testid="qb">{props.footer}</div>;
},
}));
const mockConfigProps = jest.fn();
jest.mock('../ConfigPane/ConfigPane', () => ({
__esModule: true,
default: (props: unknown): JSX.Element => {
mockConfigProps(props);
return <div data-testid="config" />;
},
}));
jest.mock('../ListColumnsEditor/ListColumnsEditor', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="list-columns" />,
}));
function makePanel(kind: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind, spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
const baseProps = {
dashboardId: 'dash-1',
panelId: 'panel-1',
onClose: jest.fn(),
onSaved: jest.fn(),
};
function setup(
panel: DashboardtypesPanelDTO,
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
): void {
mockUseDraft.mockReturnValue({
draft: panel,
spec: panel.spec,
setSpec: mockSetSpec,
isSpecDirty: false,
});
mockUseQuery.mockReturnValue({
data: { response: undefined },
isFetching: false,
error: null,
cancelQuery: mockCancelQuery,
refetch: mockRefetch,
pagination: undefined,
});
mockUseQuerySync.mockReturnValue({
runQuery: jest.fn(),
isQueryDirty: false,
buildSaveSpec: mockBuildSaveSpec,
});
mockUseTypeSwitch.mockReturnValue({
onChangePanelKind: mockOnChangePanelKind,
});
render(<PanelEditorContainer {...baseProps} panel={panel} {...overrides} />);
}
describe('PanelEditorContainer composition', () => {
beforeEach(() => jest.clearAllMocks());
it('renders the editor shell with preview, query builder, and config pane', () => {
const panel = makePanel('signoz/TimeSeriesPanel');
setup(panel);
expect(screen.getByTestId('panel-editor-v2')).toBeInTheDocument();
expect(screen.getByTestId('preview')).toBeInTheDocument();
expect(screen.getByTestId('qb')).toBeInTheDocument();
expect(screen.getByTestId('config')).toBeInTheDocument();
expect(mockPreviewProps).toHaveBeenCalledWith(
expect.objectContaining({
panel,
panelDefinition: getPanelDefinition('signoz/TimeSeriesPanel'),
}),
);
expect(mockQbProps).toHaveBeenCalledWith(
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
);
expect(mockConfigProps).toHaveBeenCalledWith(
expect.objectContaining({
panel,
spec: panel.spec,
onChangePanelKind: mockOnChangePanelKind,
}),
);
});
it('forwards the derived panel type + query-sync options to the leaf hooks', () => {
const panel = makePanel('signoz/TimeSeriesPanel');
setup(panel);
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({ panel, panelId: 'panel-1', enabled: true }),
);
expect(mockUseQuerySync).toHaveBeenCalledWith(
expect.objectContaining({
panelType: PANEL_TYPES.TIME_SERIES,
setSpec: mockSetSpec,
refetch: mockRefetch,
alwaysSerializeQuery: false,
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
}),
);
expect(mockUseTypeSwitch).toHaveBeenCalledWith(
expect.objectContaining({
panelType: PANEL_TYPES.TIME_SERIES,
spec: panel.spec,
setSpec: mockSetSpec,
}),
);
});
it('marks a new panel dirty and always serializes its query', () => {
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
expect(mockUseQuerySync).toHaveBeenCalledWith(
expect.objectContaining({ alwaysSerializeQuery: true }),
);
expect(mockHeaderProps).toHaveBeenCalledWith(
expect.objectContaining({ isDirty: true }),
);
});
it('bakes the live query into the spec on save, then notifies', async () => {
const panel = makePanel('signoz/TimeSeriesPanel');
setup(panel, { onSaved: baseProps.onSaved });
await userEvent.click(screen.getByTestId('editor-save'));
await waitFor(() => expect(baseProps.onSaved).toHaveBeenCalled());
expect(mockBuildSaveSpec).toHaveBeenCalledWith(panel.spec);
expect(mockSave).toHaveBeenCalledWith(panel.spec);
});
it('renders the list-columns editor only for list panels', () => {
setup(makePanel('signoz/ListPanel'));
expect(screen.getByTestId('list-columns')).toBeInTheDocument();
});
it('omits the list-columns editor for non-list panels', () => {
setup(makePanel('signoz/TimeSeriesPanel'));
expect(screen.queryByTestId('list-columns')).not.toBeInTheDocument();
});
});

View File

@@ -1,7 +1,5 @@
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
@@ -28,20 +26,21 @@ function specWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
} as unknown as DashboardtypesPanelSpecDTO;
}
// Thin wrapper — only prove delegation; seeding rules are covered in buildPluginSpec.test.ts.
describe('getSwitchedPluginSpec', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDefaultColumnsForSignal.mockReturnValue([]);
});
it('carries only unit + decimalPrecision when the new kind has a formatting section', () => {
it("resolves the target kind's sections and carries the old spec through them", () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'formatting', controls: { unit: true, decimals: true } }],
});
const old = specWith({
formatting: { unit: 'ms', decimalPrecision: 2, columnUnits: { A: 'bytes' } },
axes: { logScale: true },
sections: [
{ kind: 'legend', controls: { position: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
],
});
const old = specWith({ formatting: { unit: 'ms', decimalPrecision: 2 } });
const result = getSwitchedPluginSpec(
old,
@@ -49,25 +48,12 @@ describe('getSwitchedPluginSpec', () => {
TelemetrytypesSignalDTO.logs,
);
expect(mockGetPanelDefinition).toHaveBeenCalledWith('signoz/TimeSeriesPanel');
expect(result.legend?.position).toBe('bottom');
expect(result.formatting).toStrictEqual({ unit: 'ms', decimalPrecision: 2 });
// Type-specific config from the old kind is dropped.
expect((result as { axes?: unknown }).axes).toBeUndefined();
});
it('does not carry formatting when the new kind has no formatting section', () => {
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const old = specWith({ formatting: { unit: 'ms' } });
const result = getSwitchedPluginSpec(
old,
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.formatting).toBeUndefined();
});
it('seeds List columns from the signal when switching into a List', () => {
it('forwards the signal to seed List columns', () => {
const columns = [{ name: 'body' }];
mockDefaultColumnsForSignal.mockReturnValue(columns);
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
@@ -83,155 +69,4 @@ describe('getSwitchedPluginSpec', () => {
);
expect(result.selectFields).toBe(columns);
});
it('includes the kind section defaults (e.g. legend position)', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'legend', controls: { position: true } }],
});
const result = getSwitchedPluginSpec(
specWith({}),
'signoz/PieChartPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.legend?.position).toBe('bottom');
});
describe('thresholds', () => {
it('does not carry thresholds when the new kind has no thresholds section', () => {
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toBeUndefined();
});
it('carries thresholds verbatim within the label variant (color/value/unit/label)', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/BarChartPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' },
]);
});
it('remaps label thresholds into the comparison variant, defaulting operator + format', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'comparison' } }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/NumberPanel',
TelemetrytypesSignalDTO.logs,
);
// The label is dropped; operator/format are seeded so the threshold can match.
expect(result.thresholds).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.text,
},
]);
});
it('remaps comparison thresholds into the table variant, keeping operator/format and seeding a column', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'table' } }],
});
const old = specWith({
thresholds: [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
},
],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TablePanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
columnName: '',
},
]);
});
it('drops the table-only columnName when remapping into the label variant', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
});
const old = specWith({
thresholds: [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.background,
columnName: 'p99',
},
],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([{ value: 80, color: '#F1575F' }]);
});
it('defaults the variant to label when the thresholds section omits controls', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: {} }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{ value: 80, color: '#F1575F', label: 'warn' },
]);
});
});
});

View File

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

View File

@@ -49,6 +49,7 @@ const LIST_QUERIES = [{ id: 'list-q' }] as unknown as NonNullable<
const TRANSFORMED = {
id: 'transformed',
queryType: 'builder',
builder: { queryData: [{ orderBy: [] }] },
} as unknown as Query;
const CONVERTED = [{ id: 'converted' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
@@ -131,7 +132,39 @@ describe('usePanelTypeSwitch', () => {
expect(next.plugin.kind).toBe('signoz/ListPanel');
expect(next.plugin.spec).toBe(SWITCHED_SPEC);
expect(next.queries).toBe(CONVERTED);
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(TRANSFORMED);
const redirected = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(redirected.builder.queryData[0].orderBy).toStrictEqual([
{ columnName: 'timestamp', order: 'desc' },
]);
});
it('seeds timestamp-desc Order By on every query when switching to a List panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
queryType: 'builder',
builder: { queryData: [{ orderBy: [] }, { orderBy: undefined }] },
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
const [persisted] = mockToPerses.mock.calls[0] as [Query];
persisted.builder.queryData.forEach((qd) => {
expect(qd.orderBy).toStrictEqual([
{ columnName: 'timestamp', order: 'desc' },
]);
});
});
it('coerces the query type when the new kind disallows it (promql → List)', () => {

View File

@@ -0,0 +1,119 @@
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import {
usePanelQuery,
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import { usePanelEditorDraft } from './usePanelEditorDraft';
import { usePanelEditorQuerySync } from './usePanelEditorQuerySync';
import { usePanelTypeSwitch } from './usePanelTypeSwitch';
interface UsePanelEditSessionArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/** Per-view time window (epoch ms); omit to follow the dashboard's global window. */
time?: PanelQueryTimeOverride;
/** Serialize the live builder query into the spec on save even if unchanged (new panels). */
alwaysSerializeQuery?: boolean;
/** Seed an empty builder with the kind's default signal (new panels) — off for drilldown. */
seedQuerySignal?: boolean;
}
export interface UsePanelEditSessionReturn {
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
draft: DashboardtypesPanelDTO;
spec: DashboardtypesPanelSpecDTO;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
isSpecDirty: boolean;
/** Restore the draft to the originally-loaded panel. */
reset: () => void;
/** Draft kind → V1 panel type (drives the query builder + preview). */
panelType: PANEL_TYPES;
panelDefinition: RenderablePanelDefinition;
/** The kind's first supported signal — seeds new queries/columns. */
defaultSignal: TelemetrytypesSignalDTO;
/** Shared query result for the draft over the resolved time window. */
query: UsePanelQueryResult;
/** Stage & run the live builder query into the draft. */
runQuery: () => void;
isQueryDirty: boolean;
/** Bake the live (possibly un-run) query into a spec — for save / editor handoff. */
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
/** Switch the draft's visualization kind in place (reversible per session). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* The panel-editing pipeline shared by the full-page editor and the View modal's
* drilldown editor: a local draft, its query result over the resolved time window,
* the staged-query sync, and the visualization-kind switch. Each consumer layers its
* own concerns on top (the editor adds save + list seeding; the modal adds per-view
* time isolation + reset). Keeping the wiring here stops the two from drifting.
*/
export function usePanelEditSession({
panel,
panelId,
time,
alwaysSerializeQuery = false,
seedQuerySignal = false,
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
const { draft, spec, setSpec, isSpecDirty, reset } =
usePanelEditorDraft(panel);
const panelKind = draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const defaultSignal = panelDefinition.supportedSignals[0];
const query = usePanelQuery({
panel: draft,
panelId,
time,
enabled: !!panelDefinition,
});
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch: query.refetch,
alwaysSerializeQuery,
signal: seedQuerySignal ? defaultSignal : undefined,
});
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draft.spec,
panelType,
setSpec,
});
return {
draft,
spec,
setSpec,
isSpecDirty,
reset,
panelType,
panelDefinition,
defaultSignal,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
};
}

View File

@@ -11,7 +11,10 @@ import {
type PartialPanelTypes,
} from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import type {
OrderByPayload,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../Panels/capabilities';
import {
@@ -25,6 +28,25 @@ import {
type SwitchedPluginSpec,
} from '../getSwitchedPluginSpec';
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
{ columnName: 'timestamp', order: 'desc' },
];
function withDefaultListOrder(query: Query): Query {
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((qd) =>
qd.orderBy && qd.orderBy.length > 0
? qd
: { ...qd, orderBy: DEFAULT_LIST_ORDER_BY },
),
},
};
}
/** What a kind looks like when you leave it; restored verbatim if you return. */
interface KindState {
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
@@ -116,16 +138,21 @@ export function usePanelTypeSwitch({
{ ...query, queryType },
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
const nextQuery =
newPanelType === PANEL_TYPES.LIST
? withDefaultListOrder(transformed)
: transformed;
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(
buildSpec(
getSwitchedPluginSpec(currentSpec, newKind, signal),
toPerses(transformed, newPanelType),
toPerses(nextQuery, newPanelType),
),
);
redirectWithQueryBuilderData(transformed);
redirectWithQueryBuilderData(nextQuery);
},
[setSpec, redirectWithQueryBuilderData],
);

View File

@@ -12,13 +12,7 @@ import {
type DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { getExecStats } from '../queryV5/v5ResponseData';
@@ -29,12 +23,9 @@ 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 { useMetricYAxisUnit } from './hooks/useMetricYAxisUnit';
import { usePanelEditSession } from './hooks/usePanelEditSession';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useTableColumns } from './hooks/useTableColumns';
@@ -70,7 +61,36 @@ function PanelEditorContainer({
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
const { draft, spec, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
// panel always serializes its seed query and seeds the builder's default signal.
const {
draft,
spec,
setSpec,
isSpecDirty,
panelDefinition,
defaultSignal,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
} = usePanelEditSession({
panel,
panelId,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
});
const {
data,
isFetching,
isPreviousData,
error,
cancelQuery,
refetch,
pagination,
} = query;
// Live query type (the selected tab) — the type switcher disables kinds that can't be
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
// query until staged, so the spec would lag the tab.
@@ -94,37 +114,7 @@ function PanelEditorContainer({
storage: layoutStorage,
});
// Panel kind → V1 panel type, which drives the query builder and 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.
const panelDefinition = getPanelDefinition(draft.spec.plugin.kind);
const { data, isFetching, error, cancelQuery, refetch, pagination } =
usePanelQuery({
panel: draft,
panelId,
enabled: !!panelDefinition,
});
// A new panel's default signal (its kind's first supported) — seeds the query and columns.
const defaultSignal = panelDefinition.supportedSignals[0];
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch,
// New panel's seed query is the builder default, not a real saved query —
// always serialize it on save.
alwaysSerializeQuery: isNew,
signal: defaultSignal,
});
// Switch the panel's visualization kind in place (reversible per session).
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
const panelKind = draft.spec.plugin.kind;
// At editor level, not the collapsible FormattingSection, so seeding runs while closed.
const formattingUnit = (
@@ -156,7 +146,7 @@ function PanelEditorContainer({
// Spec and query dirtiness are tracked independently so query re-serialization
// never false-dirties. A new panel is always savable (you're creating it).
const isDirty = isNew || isSpecDirty || isQueryDirty;
const isListPanel = fullKind === 'signoz/ListPanel';
const isListPanel = panelKind === 'signoz/ListPanel';
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
// values; cast at this boundary (as ConfigPane does) so the columns editor's
// field-key lookup is typed.
@@ -235,6 +225,7 @@ function PanelEditorContainer({
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
@@ -245,7 +236,7 @@ function PanelEditorContainer({
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<PanelEditorQueryBuilder
panelKind={fullKind}
panelKind={panelKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}

View File

@@ -0,0 +1,10 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
/**
* Router location state for opening the panel editor pre-loaded with edits instead of
* the saved panel. The View modal sets this so "Switch to Edit Mode" carries its
* drilldown-edited spec (queries/plugin) into the editor.
*/
export interface PanelEditorHandoffState {
editSpec?: DashboardtypesPanelSpecDTO;
}

View File

@@ -1,7 +1,9 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
@@ -12,8 +14,6 @@ import {
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import { useTimezone } from 'providers/Timezone';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
import NoData from '../../components/NoData/NoData';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
@@ -23,7 +23,10 @@ import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { buildBarChartConfig } from './utils/buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
@@ -37,6 +40,8 @@ function BarPanelRenderer({
onDragSelect,
dashboardPreference,
panelMode,
onCloseStandaloneView,
enableDrillDown,
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
@@ -53,12 +58,10 @@ function BarPanelRenderer({
[panel.spec.queries],
);
// X-scale clamps come from the request that produced the data. The generated
// request DTO is structurally the V5 request; the cast is the boundary.
// X-scale clamps come from the request that produced the data, so each panel
// pins to the window it fetched.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
);
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data.requestPayload]);
@@ -114,6 +117,32 @@ function BarPanelRenderer({
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
// The standalone View modal shows V1's graph-manager legend below the chart:
// Filter Series + per-series show/hide + Save. Series visibility auto-persists to
// localStorage (STANDALONE_VIEW selection prefs), keyed by panelId.
const layoutChildren = useMemo(
() =>
panelMode === PanelMode.STANDALONE_VIEW ? (
<div className={PanelStyles.chartManagerContainer}>
<ChartManager
config={config}
alignedData={chartData}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
onCancel={onCloseStandaloneView}
/>
</div>
) : null,
[
panelMode,
config,
chartData,
spec.formatting?.unit,
decimalPrecision,
onCloseStandaloneView,
],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
@@ -126,10 +155,29 @@ function BarPanelRenderer({
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
(args: ChartClickData): void => {
if (!onClick) {
return;
}
const payload = enrichChartClick({
clickData: args,
series: flatSeries,
builderQueries,
});
if (!payload) {
return;
}
const timeRange = stepClickTimeRange({
clickedDataTimestamp: args.clickedDataTimestamp,
queryName: payload.context.queryName,
builderQueries,
stepInterval: getExecStats(data.response)?.stepIntervals?.[
payload.context.queryName
],
});
onClick({ ...payload, context: { ...payload.context, timeRange } });
},
[onClick],
[onClick, flatSeries, builderQueries, data.response],
);
return (
@@ -147,6 +195,7 @@ function BarPanelRenderer({
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
layoutChildren={layoutChildren}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}
@@ -158,7 +207,7 @@ function BarPanelRenderer({
syncFilterMode={dashboardPreference?.syncFilterMode}
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
onClick={enableDrillDown ? handleChartClick : undefined}
/>
)}
</div>

View File

@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
download: false,
createAlert: true,
search: false,
drilldown: true,
},
};

View File

@@ -1,4 +1,8 @@
import { SectionKind, type SectionConfig } from '../../types/sections';
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
// Bar stacking lives in `visualization.stackedBarChart`, so it's a `visualization`
// control, not `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
@@ -10,6 +14,9 @@ export const sections: SectionConfig[] = [
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{ kind: SectionKind.Legend, controls: { position: true } },
{ kind: SectionKind.Thresholds, controls: { variant: 'label' } },
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },
},
{ kind: SectionKind.ContextLinks },
];

View File

@@ -20,7 +20,6 @@ import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildHistogramConfig } from './utils/buildConfig';
import { prepareHistogramData } from './prepareData';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function HistogramPanelRenderer({
panelId,
@@ -28,7 +27,6 @@ function HistogramPanelRenderer({
data,
refetch,
panelMode,
onClick,
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
@@ -100,13 +98,6 @@ function HistogramPanelRenderer({
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
@@ -127,7 +118,6 @@ function HistogramPanelRenderer({
width={containerDimensions.width}
height={containerDimensions.height}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>

View File

@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
download: false,
createAlert: true,
search: false,
drilldown: false,
},
};

View File

@@ -1,5 +1,5 @@
import { useMemo, useRef } from 'react';
import { Select, Table } from 'antd';
import { Select, Skeleton, Table } from 'antd';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { ChevronLeft, ChevronRight } from '@signozhq/icons';
@@ -36,6 +36,7 @@ function ListPanelRenderer({
refetch,
searchTerm = '',
pagination,
isPreviousData = false,
}: PanelRendererProps<'signoz/ListPanel'>): JSX.Element {
// Pin the header while the body scrolls (shared with the Table kind).
const containerRef = useRef<HTMLDivElement>(null);
@@ -115,6 +116,25 @@ function ListPanelRenderer({
// Show the footer whenever the panel pages server-side, so the page-size picker stays reachable (V1 parity).
const showPager = !!pagination;
// While the next page loads, swap the stale rows (held by keepPreviousData) for skeleton bars,
// keeping the header + pager. Row count mirrors the page being left.
const skeletonRowCount = dataSource.length || pagination?.pageSize || 10;
const skeletonColumns = useMemo(
() =>
resizableColumns.map((col) => ({
...col,
render: (): JSX.Element => <Skeleton.Input active block size="small" />,
})),
[resizableColumns],
);
const skeletonRows = useMemo(
() =>
Array.from({ length: skeletonRowCount }, (_, index) => ({
key: `skeleton-${index}`,
})) as unknown as typeof filteredDataSource,
[skeletonRowCount],
);
return (
<div
ref={containerRef}
@@ -134,13 +154,13 @@ function ListPanelRenderer({
<Table
size="small"
tableLayout="fixed"
columns={resizableColumns}
columns={isPreviousData ? skeletonColumns : resizableColumns}
components={components}
dataSource={filteredDataSource}
dataSource={isPreviousData ? skeletonRows : filteredDataSource}
pagination={false}
// Vertical scroll only; `x: 'max-content'` forced a content-width min that pushed columns off-screen.
scroll={{ y: scrollY }}
onRow={onRow}
onRow={isPreviousData ? undefined : onRow}
/>
</div>
{showPager && pagination && (

View File

@@ -162,4 +162,18 @@ describe('ListPanelRenderer', () => {
expect(queryByTestId('list-panel-pager')).not.toBeInTheDocument();
});
it('swaps rows for skeletons while the next page loads (isPreviousData), keeping header + pager', () => {
const { getByText, queryByText, getByTestId, container } = renderPanel({
data: dataWith([{ data: { body: 'stale row' } }]),
pagination: makePagination({ canNext: true }),
isPreviousData: true,
});
expect(getByText('body')).toBeInTheDocument();
expect(getByTestId('list-panel-pager')).toBeInTheDocument();
// Stale page content is replaced by skeleton bars.
expect(queryByText('stale row')).not.toBeInTheDocument();
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});

View File

@@ -37,5 +37,6 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
download: false,
createAlert: false,
search: true,
drilldown: false,
},
};

View File

@@ -1,4 +1,9 @@
import { useMemo } from 'react';
import {
useCallback,
useMemo,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from 'react';
import type { DashboardtypesNumberPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
@@ -8,6 +13,9 @@ import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { formatPanelValue } from '../../utils/formatPanelValue';
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
import { enrichNumberClick } from '../../utils/drilldown/enrichNumberClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { prepareNumberData } from './prepareData';
import { mapNumberThresholds } from './utils';
@@ -17,24 +25,31 @@ function NumberPanelRenderer({
panel,
data,
refetch,
onClick,
enableDrillDown,
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
const value = useMemo(
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
const tables = useMemo(
() =>
prepareNumberData(
prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap ?? {},
requestPayload: data.requestPayload,
}),
),
prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap ?? {},
requestPayload: data.requestPayload,
}),
[data.response, data.legendMap, data.requestPayload],
);
const value = useMemo(() => prepareNumberData(tables), [tables]);
const thresholds = useMemo(
() => mapNumberThresholds(spec.thresholds),
[spec.thresholds],
@@ -54,10 +69,60 @@ function NumberPanelRenderer({
[value, unit, decimalPrecision],
);
const openDrilldown = useCallback(
(coordinates: { x: number; y: number }): void => {
if (!onClick) {
return;
}
const payload = enrichNumberClick({
tables,
builderQueries,
coordinates,
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {
onClick(payload);
}
},
[onClick, tables, data.requestPayload, builderQueries],
);
const handleClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>): void =>
openDrilldown({ x: event.clientX, y: event.clientY }),
[openDrilldown],
);
const handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
openDrilldown({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
}
},
[openDrilldown],
);
// The whole panel is the value, so the container itself is the drill-down target.
const isClickable = enableDrillDown && !!onClick && value !== null;
return (
<div
data-testid="number-panel-renderer"
className={PanelStyles.panelContainer}
{...(isClickable
? {
role: 'button',
tabIndex: 0,
onClick: handleClick,
onKeyDown: handleKeyDown,
style: { cursor: 'pointer' },
}
: {})}
>
{value === null ? (
<NoData data-testid="number-panel-no-data" onRetry={refetch} />

View File

@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
download: false,
createAlert: true,
search: false,
drilldown: true,
},
};

View File

@@ -1,4 +1,8 @@
import { SectionKind, type SectionConfig } from '../../types/sections';
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
export const sections: SectionConfig[] = [
{
@@ -6,6 +10,9 @@ export const sections: SectionConfig[] = [
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Thresholds, controls: { variant: 'comparison' } },
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.COMPARISON },
},
{ kind: SectionKind.ContextLinks },
];

View File

@@ -1,4 +1,8 @@
import { useCallback, useMemo } from 'react';
import {
useCallback,
useMemo,
type MouseEvent as ReactMouseEvent,
} from 'react';
import type { DashboardtypesPieChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Pie from 'container/DashboardContainer/visualization/charts/Pie/Pie';
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
@@ -13,6 +17,9 @@ import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import { enrichPieClick } from '../../utils/drilldown/enrichPieClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { preparePieData } from './prepareData';
@@ -22,6 +29,7 @@ function PiePanelRenderer({
data,
refetch,
onClick,
enableDrillDown,
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
const isDarkMode = useIsDarkMode();
@@ -30,6 +38,11 @@ function PiePanelRenderer({
[panel.spec.plugin.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
const slices = useMemo(
() =>
preparePieData({
@@ -61,10 +74,21 @@ function PiePanelRenderer({
);
const handleSliceClick = useCallback(
(slice: PieSlice) => {
onClick?.({ label: slice.label, value: slice.value });
(slice: PieSlice, event: ReactMouseEvent): void => {
if (!onClick) {
return;
}
const payload = enrichPieClick({
slice,
builderQueries,
coordinates: { x: event.clientX, y: event.clientY },
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {
onClick(payload);
}
},
[onClick],
[onClick, builderQueries, data.requestPayload],
);
return (
@@ -79,7 +103,7 @@ function PiePanelRenderer({
isDarkMode={isDarkMode}
position={legendPosition}
id={panelId}
onSliceClick={handleSliceClick}
onSliceClick={enableDrillDown ? handleSliceClick : undefined}
data-testid="pie-chart"
/>
)}

View File

@@ -23,5 +23,6 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
download: false,
createAlert: false,
search: false,
drilldown: true,
},
};

View File

@@ -1,4 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { Table } from 'antd';
import type { DashboardtypesTablePanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -8,6 +15,9 @@ import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/query
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
import { enrichTableClick } from '../../utils/drilldown/enrichTableClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { useResizableColumns } from '../../hooks/useResizableColumns';
import NoData from '../../components/NoData/NoData';
@@ -27,6 +37,8 @@ function TablePanelRenderer({
data,
refetch,
searchTerm = '',
onClick,
enableDrillDown,
}: PanelRendererProps<'signoz/TablePanel'>): JSX.Element {
// Measure the panel so each page roughly fills it (min 10 rows) with a pinned header.
const containerRef = useRef<HTMLDivElement>(null);
@@ -42,6 +54,11 @@ function TablePanelRenderer({
[panel.spec.plugin.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
// V5 joins every query into a single scalar result, so the first non-empty
// table is the whole panel.
const table = useMemo(
@@ -64,6 +81,34 @@ function TablePanelRenderer({
[spec.thresholds],
);
const handleCellClick = useCallback(
({
columnId,
record,
event,
}: {
columnId: string;
record: TableRowData;
event: ReactMouseEvent<HTMLElement>;
}): void => {
if (!onClick || !table) {
return;
}
const payload = enrichTableClick({
record,
columnId,
table,
builderQueries,
coordinates: { x: event.clientX, y: event.clientY },
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {
onClick(payload);
}
},
[onClick, table, builderQueries, data.requestPayload],
);
const columns = useMemo(
() =>
table
@@ -72,9 +117,17 @@ function TablePanelRenderer({
columnUnits: spec.formatting?.columnUnits ?? {},
decimalPrecision,
thresholdsByColumn,
onCellClick: enableDrillDown ? handleCellClick : undefined,
})
: [],
[table, spec.formatting?.columnUnits, decimalPrecision, thresholdsByColumn],
[
table,
spec.formatting?.columnUnits,
decimalPrecision,
thresholdsByColumn,
enableDrillDown,
handleCellClick,
],
);
// User-resizable columns, persisted per panel to localStorage.

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