Compare commits

..

43 Commits

Author SHA1 Message Date
Vinícius Lourenço
c275aae9b8 Revert "feat(infra-monitoring): enable quick filters v2"
This reverts commit 8902a157c1.
2026-07-08 16:46:00 -03:00
Vinícius Lourenço
15de8650f4 Revert "fix(infra-monitoring): change the prefix of metric namespaces"
This reverts commit 176dc6d3bc.
2026-07-08 16:45:55 -03:00
Vinícius Lourenço
8059687bc8 fix(quick-filters): render related/all values when searching 2026-07-07 13:58:13 -03:00
Vinícius Lourenço
50031ac2f8 fix(quick-filters): move from flat list to sections 2026-07-06 17:22:28 -03:00
Vinícius Lourenço
5a806583eb fix(checkbox-v2): update to improve ux 2026-07-06 09:52:35 -03:00
Vinícius Lourenço
176dc6d3bc fix(infra-monitoring): change the prefix of metric namespaces 2026-07-06 09:52:35 -03:00
Vinícius Lourenço
8902a157c1 feat(infra-monitoring): enable quick filters v2 2026-07-06 09:52:34 -03:00
Vinícius Lourenço
a3349c3885 feat(quick-filters): add flag to enable v2 2026-07-06 09:52:22 -03:00
Vinícius Lourenço
dce786b0c7 test(quick-filters): add tests for checkbox filter v2 2026-07-06 09:52:22 -03:00
Vinícius Lourenço
18cdd8c56f feat(quick-filters): add checkbox filter v2 2026-07-06 09:52:22 -03:00
Vinícius Lourenço
d1545f3136 feat(quick-filters): add hook to get existingQuery for field values 2026-07-06 09:52:21 -03:00
Vinícius Lourenço
d372b92c48 feat(quick-filters): add sectioned values hook to correctly calculate checkboxes to render 2026-07-06 09:52:21 -03:00
Vinícius Lourenço
6f579bef35 feat(quick-filters): add checkbox row v2 2026-07-06 09:52:21 -03:00
Vinícius Lourenço
81e3e930bd feat(quick-filters): add item rules to represent the states of checkbox 2026-07-06 09:52:21 -03:00
Vinícius Lourenço
af984faf7a feat(quick-filters): add checkbox header v2 2026-07-06 09:52:21 -03:00
Vinícius Lourenço
885f25f670 feat(quick-filters): add support to handle indeterminate state of checkbox 2026-07-06 09:52:21 -03:00
Vinícius Lourenço
1b9d55b324 feat(quick-filters): add field values hook 2026-07-06 09:52:20 -03: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
Vinicius Lourenço
ef9b8eec8a perf(tsconfig): improve type check time (#11927)
Some checks failed
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (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
* chore(tsconfig): explicit path mappings and cleaned includes

* perf(tsconfig): enable incremental & fix large type resolution
2026-07-03 14:29:16 +00:00
Vinicius Lourenço
94a5e568fd refactor(web-settings): move more settings to runtime & disabled by default (#11917)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (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
Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2026-07-03 11:56:15 +00:00
Vinicius Lourenço
25cd3fc705 chore(authz): move components/hooks to lib (#11932) 2026-07-03 11:52:56 +00:00
Vinicius Lourenço
5c3fdb64ed feat(entity-metrics): use new uplot API for charts (#11954) 2026-07-03 11:37:35 +00:00
Vikrant Gupta
bac68a6707 fix(types): guarantee both letter cases in generated factor passwords (#11961)
password.Generate draws letters from both cases without guaranteeing
either, so roughly 1 in 1000 generated passwords contained no lowercase
letter and failed IsPasswordValid, panicking MustGenerateFactorPassword.
The existing "Z" suffix only covered the uppercase requirement; append
"zZ" to guarantee both cases while keeping the generated part mixed-case
for entropy.
2026-07-03 10:20:41 +00:00
Naman Verma
766a05e24b chore: fix issues found in bug bash (#11949)
* fix: return list of all tags sorted alphabetically

* chore: return reserved keys in list api response for easy filtering

* fix: add length limit to dashboard display name

* test: check error message as well

* chore: increase the length limits

* chore: add copy suffix on cloning dashboards

* fix: increase limit to 64 for dashboard view name

* fix: send user friendly err message on length check fail

* fix: add path to error message

* fix: include path in main error message directly

* fix: move regex out so that it only compiles once per init

* fix: format integration test properly
2026-07-03 08:10:37 +00:00
Ashwin Bhatkal
1a84503f4b feat(dashboards): add auto-refresh to public dashboards (#11938)
* feat(dashboards): add auto-refresh to public dashboards

Add a self-contained auto-refresh interval selector to the public dashboard
header, shown alongside the time picker when the publisher enabled the time
range. On each tick it recomputes the rolling window from the selected
relative range and updates local state, so panels refetch with an advanced
window. Paused/disabled for fixed 'custom' ranges.

Kept independent of Redux global time (which the public viewer doesn't use);
reuses the shared refreshIntervalOptions.

* refactor(dashboards): dedup public auto-refresh tick, use nano constant, userEvent

Route the auto-refresh interval through handleTimeChange instead of

duplicating the GetMinMax/setSelectedTimeRange logic, replace the 1e9

magic number with NANO_SECOND_MULTIPLIER, and switch the AutoRefresh

test to userEvent.
2026-07-03 06:44:35 +00:00
Ashwin Bhatkal
9454427b47 feat(dashboard-v2): JSON drawer redaction, JSON action cleanup & breadcrumb SPA nav (#11952)
* feat(dashboard-v2): redact JSON editor to tags/spec, preserve other fields on save

The dashboard JSON drawer now exposes only the user-editable `tags` and `spec`;
server-owned/derived keys (id, orgId, name, timestamps, locked, schemaVersion,
image, …) are redacted from the view, copy, and export. On save, the edited draft
is overlaid on the current dashboard so those redacted keys are preserved.

* feat(dashboard-v2): drop redundant JSON actions, rename Edit-as-JSON to JSON

Remove the Actions-menu Data group (Export JSON / Copy as JSON) — these are
redundant with the JSON drawer's own Copy/Download. Rename the 'Edit as JSON'
toolbar button to 'JSON' (keeping the Braces icon).

* feat(dashboard-v2): client-side nav on the Dashboard breadcrumb

Intercept a plain click on the 'Dashboard' breadcrumb for instant SPA navigation
to the list page instead of a full-page reload; the href is kept so middle/
modifier-click still opens in a new tab.
2026-07-03 06:33:15 +00:00
Ashwin Bhatkal
124d76068c fix(dashboard-v2): reconcile optimistic patches from the PATCH response (#11947)
Panel delete/move flickered A -> B -> A -> B: onSettled invalidateQueries fired an
immediate GET that read pre-mutation (stale) data before settling. patchDashboardV2
already returns the updated dashboard, so reconcile the cache from that response
(onSuccess) instead of refetching. Sequence is now A -> B -> B; rollback on error
is unchanged.
2026-07-03 06:33:06 +00:00
Vinicius Lourenço
704b8bf917 chore(frontend-readme): clean the readme for frontend (#11955)
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
2026-07-02 21:18:44 +00:00
Vinicius Lourenço
3e6339019e fix(meter-explorer): not persisting query after page refresh (#11948)
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
2026-07-02 14:57:44 +00:00
Vinicius Lourenço
372d304a6f feat(meter): migrate to new uplot API & use bar stacked chart (#11902) 2026-07-02 12:23:30 +00:00
Naman Verma
dbb4eb9574 chore: validate layout size and positioning in backend (#11921)
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
* chore: validate layout size and positioning in backend

* test: add integratino test for layout validation

* test: use assert instead of require for non blocking check

* test: use assert instead of require for non blocking check

* fix: move require to assert for actual test checks
2026-07-02 11:07:58 +00:00
Abhi kumar
c36226050e feat(dashboards-v2): substitute dashboard variables when creating an alert from a panel (#11929)
Wire the `/substitute_vars` round-trip into the panel create-alert flow so
`$var` / dynamic variable references resolve to the values selected in the
variable bar before the alert is seeded — V1 parity with `useCreateAlerts`,
which the previous V2 path skipped (it shipped variable refs verbatim).

When the dashboard has resolved variables, `useCreateAlertFromPanel` builds a
V5 query-range request (panel queries + resolved variables) and POSTs it through
the generated `useReplaceVariables` hook; on success the substituted envelopes
are translated to the V1 `Query` the alert page reads. With no variables to
substitute the round-trip is a no-op, so we keep seeding synchronously and the
new tab stays tied to the click.

- persesQueryAdapters: extract `envelopesToQuery` from `fromPerses` (the
  substitute response hands back envelopes, not panel-query wrappers)
- buildCreateAlertUrl: export `buildAlertUrl` + `readPanelUnit` so the sync and
  substituted paths share URL assembly
2026-07-02 08:43:48 +00:00
Ashwin Bhatkal
a72484f12c feat(dashboard-v2): optimistic updates for dashboard spec mutations (#11936)
* feat(dashboard-v2): add lenient RFC-6902 JSON-Patch applier

Add applyJsonPatch, a pure applier for the add/replace/remove ops our patchOps
builders emit. Deep-clones and returns a new document (never mutates input), and
is deliberately lenient like the backend apply (remove/replace on a missing path
is a no-op, add creates missing object parents). This lets a dashboard edit be
reflected in the react-query cache optimistically, with the mutation's settle
refetch as the reconcile safety net.

* feat(dashboard-v2): add useOptimisticPatch central mutation hook

A single react-query mutation over patchDashboardV2 that applies the ops to the
cached dashboard on onMutate (via applyJsonPatch, patching the envelope's .data),
snapshots for rollback on onError, and invalidates on settle to reconcile. Reads
dashboardId from the edit-context store (with an optional override for the panel
editor, which receives its id as a prop) and exposes error; rethrows so call sites
keep their own error handling.

* feat(dashboard-v2): route section/layout edits through optimistic patch

Migrate the section and layout mutations (rename, add, delete, reorder, resize/
move persist, first-section migration) off the 'await patchDashboardV2(...);
refetch()' pattern onto useOptimisticPatch, so section edits render instantly and
reconcile in the background. The explicit refetch is dropped (onSettled invalidates)
and each hook keeps its own toast/error handling.

* feat(dashboard-v2): route panel add/move/delete through optimistic patch

Migrate the grid-item panel mutations (delete, move-between-sections, clone) onto
useOptimisticPatch so they render instantly and reconcile on settle. useClonePanel
keeps its toast.promise UX over the patch promise. Update the clone test to assert
against the ops passed to patchAsync.

* feat(dashboard-v2): route panel editor save through optimistic patch

Migrate usePanelEditorSave off usePatchDashboardV2 + invalidateQueries onto the
central useOptimisticPatch (passing the editor's explicit dashboardId). The isNew
branch still reads cached layouts to resolve the target section.

* feat(dashboard-v2): route settings/toolbar edits through optimistic patch

Migrate the remaining patchDashboardV2 spec edits — variable-definition save,
Overview metadata (title/description/image/tags), and toolbar rename — onto
useOptimisticPatch. The toolbar keeps its refetch prop for the lock/unlock toggle
(a non-patch API), and the edit-context refetch stays for the JSON editor's
full-document save; both are outside this PR's patch-op scope.

* chore(dashboard-v2): ban direct patchDashboardV2 via oxlint

Add a no-restricted-imports rule forbidding patchDashboardV2 / usePatchDashboardV2
from api/generated/services/dashboard, directing callers to useOptimisticPatch().
patchAsync so every spec edit goes through the optimistic cache path. The hook
itself (the one sanctioned caller) and its test carry a scoped inline exception.
2026-07-02 07:28:50 +00:00
Ashwin Bhatkal
71eabac1e7 fix(dashboards): stop query cache collisions on public dashboards (#11935)
The public payload redacts each widget's query (filters/limit/orderBy
stripped), so panels differing only by their filter arrive with identical
query bodies. The react-query key was built from that query body, so those
panels hashed to the same key and were deduped into one request — its data
filled every colliding panel while other indices were never fetched.

Key each panel on what determines its response — widget id + index + time —
instead of the redacted query body.

Fixes SigNoz/engineering-pod#5503
2026-07-02 06:07:21 +00:00
Abhi kumar
fea3be7c51 feat(dashboards-v2): panel editor — threshold carry, span-gaps fixes, metric unit defaults, live thresholds and pie multi-column fix (#11918)
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): carry thresholds across a panel visualization-type switch

When switching a panel's visualization kind mid-edit, thresholds now carry
over if the target kind supports them, remapped to the target's variant
(label/comparison/table) — keeping the shared core (color, value, unit) and
seeding any variant-required fields (operator, format, column) with sensible
defaults so the carried threshold stays functional. Kinds without a Thresholds
section drop them. The carry is a first-visit seed; reversible round-trips
still restore from the per-kind session cache.

* fix(dashboards-v2): span-gaps Disconnect Values reactivity + fillOnlyBelow flag

Fixes three issues in the chart-appearance span-gaps control and makes the
fillOnlyBelow flag authoritative end-to-end:

- Threshold default now seeds from the live query step interval (which arrives
  async) instead of being captured once at mount, so it no longer falls back
  to 1m.
- The threshold input no longer re-commits an unchanged value on blur, so
  clicking "Never" reliably switches mode (the blur/toggle race is gone).
- Invalid input is validated live as the user types, surfacing the error
  immediately rather than only on blur.
- Selecting Threshold writes fillOnlyBelow: true (+ duration); Never writes
  fillOnlyBelow: false and drops the duration. The selected mode is derived
  from fillOnlyBelow, and the renderer honors it (explicit false spans every
  gap), with back-compat for panels saved before the flag existed.

* feat(dashboards-v2): auto-seed panel unit from the metric with a mismatch warning

V1 parity for the formatting unit selector: when the selected metric carries a
unit, a new panel auto-initializes its unit from it, and choosing a different
unit shows the "Unit mismatch" warning. Reuses the shared useGetYAxisUnit hook
and YAxisUnitSelector read-only.

The resolution + auto-seed runs at the editor level (not inside the collapsible
FormattingSection) so it applies even while that section is closed; the
resolved metric unit is threaded down via context purely to drive the warning.
Seeding is gated to new panels — editing never overwrites a saved unit.

* feat(dashboards-v2): reactive threshold editing with live preview

Threshold edits now stream to the spec as the user types, so the panel preview
reflects them before Save. The per-row draft is mirrored into the spec via an
onLiveChange effect in useThresholdDraft; because edits reach the spec live,
ThresholdsSection snapshots the saved value on edit entry and restores it on
Discard. Save keeps the value, Discard rolls back, and add-then-discard still
removes the row.

* fix(dashboard): apply pie multi-column scalar fix to v2 panels

Mirror the V1 pie fix in the V2 PieChartPanel. preparePieData already reads
the scalar table (via prepareScalarTables) but used
columns.find(isValueColumn), so only the first value column was plotted — a
ClickHouse `count() AS col1, sum() AS col2` collapsed to a single slice.

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

Per the V1/V2 split, this duplicates the behaviour into V2 land rather than
sharing the V1 helper.

* chore: pr review fixes
2026-07-02 00:45:26 +00:00
409 changed files with 16432 additions and 5944 deletions

5
.github/CODEOWNERS vendored
View File

@@ -109,10 +109,7 @@ go.mod @therealpandey
/pkg/modules/role/ @therealpandey
/pkg/types/coretypes/ @therealpandey @vikrantgupta25
/frontend/src/hooks/useAuthZ/ @H4ad
/frontend/src/components/GuardAuthZ/ @H4ad
/frontend/src/components/AuthZTooltip/ @H4ad
/frontend/src/components/createGuardedRoute/ @H4ad
/frontend/src/lib/authz/ @H4ad
/frontend/src/container/RolesSettings/ @H4ad
/frontend/src/components/RolesSelect/ @H4ad
/frontend/src/pages/MembersSettings/ @H4ad

View File

@@ -61,13 +61,6 @@ jobs:
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env
echo 'VITE_TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> frontend/.env
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> frontend/.env
echo 'VITE_POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> frontend/.env
echo 'VITE_PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> frontend/.env
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> frontend/.env
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> frontend/.env
echo 'VITE_ENVIRONMENT="production"' >> frontend/.env
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env

View File

@@ -67,12 +67,6 @@ jobs:
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env
echo 'VITE_TUNNEL_URL="${{ secrets.NP_TUNNEL_URL }}"' >> frontend/.env
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.NP_TUNNEL_DOMAIN }}"' >> frontend/.env
echo 'VITE_PYLON_APP_ID="${{ secrets.NP_PYLON_APP_ID }}"' >> frontend/.env
echo 'VITE_APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.NP_PYLON_IDENTITY_SECRET }}"' >> frontend/.env
echo 'VITE_DOCS_BASE_URL="https://staging.signoz.io"' >> frontend/.env
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env

View File

@@ -27,13 +27,6 @@ jobs:
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> .env
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> .env
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> .env
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> .env
echo 'VITE_TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> .env
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> .env
echo 'VITE_POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> .env
echo 'VITE_PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> .env
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> .env
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> .env
echo 'VITE_ENVIRONMENT="production"' >> .env
echo 'VITE_VERSION="${{ github.ref_name }}"' >> .env

View File

@@ -12,7 +12,7 @@ import (
"github.com/spf13/cobra"
)
const permissionsTypePath = "frontend/src/hooks/useAuthZ/permissions.type.ts"
const permissionsTypePath = "frontend/src/lib/authz/hooks/useAuthZ/permissions.type.ts"
var permissionsTypeTemplate = template.Must(template.New("permissions").Parse(
`// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY cmd/enterprise/*.go generate authz

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:
@@ -3071,6 +3078,10 @@ components:
items:
$ref: '#/components/schemas/DashboardtypesListedDashboardForUserV2'
type: array
reservedKeywords:
items:
type: string
type: array
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
@@ -3082,6 +3093,7 @@ components:
- dashboards
- total
- tags
- reservedKeywords
type: object
DashboardtypesListableDashboardV2:
properties:
@@ -3089,6 +3101,10 @@ components:
items:
$ref: '#/components/schemas/DashboardtypesListedDashboardV2'
type: array
reservedKeywords:
items:
type: string
type: array
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
@@ -3100,6 +3116,7 @@ components:
- dashboards
- total
- tags
- reservedKeywords
type: object
DashboardtypesListableDashboardView:
properties:
@@ -5214,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:
@@ -5303,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:
@@ -11180,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
@@ -17431,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",
{
@@ -328,6 +330,11 @@
{
"name": "immer",
"message": "[State mgmt] Direct immer usage is deprecated. Use Zustand (which integrates immer via the immer middleware) instead."
},
{
"name": "api/generated/services/dashboard",
"importNames": ["patchDashboardV2", "usePatchDashboardV2"],
"message": "[dashboard-v2] Don't call patchDashboardV2/usePatchDashboardV2 directly — use useOptimisticPatch().patchAsync so spec edits update the react-query cache optimistically and reconcile on settle."
}
]
}
@@ -553,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

@@ -1,105 +1,116 @@
# Configuring Over Local
1. Docker
1. Without Docker
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="../docs/readme-assets/signoz-hero-dark.png" width="700">
<source media="(prefers-color-scheme: light)" srcset="../docs/readme-assets/signoz-hero-light.png" width="700">
<img alt="SigNoz - Observability on Your Terms" src="../docs/readme-assets/signoz-hero-light.png" width="700">
</picture>
</p>
## With Docker
<p align="center">
<a href="https://github.com/SigNoz/signoz/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/SigNoz/signoz"></a>
<a href="https://signoz.io/slack"><img alt="Slack community" src="https://img.shields.io/badge/slack-community-4A154B?logo=slack&logoColor=white"></a>
</p>
**Building image**
# SigNoz Frontend
``docker compose up`
/ This will also run
React-based web interface for [SigNoz](https://signoz.io), the open-source observability platform.
or
`docker build . -t tagname`
## Tech Stack
**Tag to remote url- Introduce versioning later on**
- **Framework:** React 18 + TypeScript
- **Build:** Vite
- **State:** React Query, Zustand, Redux Toolkit (legacy)
- **Styling:** CSS Modules, Ant Design (legacy)
- **Charts:** uPlot
- **Testing:** Jest
```
docker tag signoz/frontend:latest 7296823551/signoz:latest
## Local Development Setup
1. Run SigNoz backend locally — see [Self-Host Docs](https://signoz.io/docs/install/self-host/)
2. Configure environment:
```bash
cp example.env .env
```
Key variables in `.env`:
```bash
# Backend API endpoint (required)
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
# Enable bundle analyzer (optional)
BUNDLE_ANALYSER="true"
```
3. Install and run:
```bash
pnpm install
pnpm dev
```
## Development
```bash
pnpm dev
```
```
docker compose up
Opens [http://localhost:3301](http://localhost:3301).
## Build
```bash
pnpm build
```
## Without Docker
Follow the steps below
Output in `build/` folder.
1. ```git clone https://github.com/SigNoz/signoz.git && cd signoz/frontend```
1. change baseURL to ```<test environment URL>``` in file ```src/constants/env.ts```
## Bundle Size Analysis
1. ```pnpm install```
1. ```pnpm dev```
Set in `.env`:
```bash
BUNDLE_ANALYSER="true"
```
```Note: Please ping us in #contributing channel in our slack community and we will DM you with <test environment URL>```
Then run build:
```bash
pnpm build
```
# Getting Started with Create React App
Opens bundle analyzer visualization automatically.
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Testing
## Available Scripts
```bash
# Unit tests
pnpm test
In the project directory, you can run:
# Type checking
pnpm tsgo --noEmit
```
### `pnpm start`
## Linting
Runs the app in the development mode.\
Open [http://localhost:3301](http://localhost:3301) to view it in the browser.
```bash
# Run all linters (oxlint + stylelint)
pnpm lint
```
The page will reload if you make edits.\
You will also see any lint errors in the console.
## Project Structure
### `pnpm test`
```
src/
├── api/ # API clients and react-query hooks
├── components/ # Shared UI components
├── container/ # Page-level containers
├── hooks/ # Custom React hooks
├── pages/ # Route pages
├── providers/ # React context providers
├── store/ # Redux store
└── types/ # TypeScript definitions
```
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
## Contributing
### `pnpm build`
See [CONTRIBUTING.md](../CONTRIBUTING.md) in the root repo.
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `pnpm eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `pnpm build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
Questions? Join our [Slack community](https://signoz.io/slack).

View File

@@ -1,8 +1,38 @@
NODE_ENV="development"
BUNDLE_ANALYSER="true"
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
VITE_PYLON_APP_ID="pylon-app-id"
VITE_APPCUES_APP_ID="appcess-app-id"
VITE_PYLON_IDENTITY_SECRET="pylon-identity-secret"
CI="1"
# API
VITE_BASE_PATH=""
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
VITE_WEBSOCKET_API_ENDPOINT=""
# Pylon
VITE_PYLON_ENABLED="false"
VITE_PYLON_APP_ID=""
VITE_PYLON_IDENTITY_SECRET=""
# Appcues
VITE_APPCUES_ENABLED="false"
VITE_APPCUES_APP_ID=""
# PostHog
VITE_POSTHOG_ENABLED="false"
VITE_POSTHOG_API_HOST=""
VITE_POSTHOG_KEY=""
VITE_POSTHOG_UI_HOST=""
# Sentry
VITE_SENTRY_ENABLED="false"
VITE_SENTRY_AUTH_TOKEN=""
VITE_SENTRY_ORG=""
VITE_SENTRY_PROJECT_ID=""
VITE_SENTRY_TUNNEL=""
VITE_SENTRY_DSN=""
# Docs
VITE_DOCS_BASE_URL="https://signoz.io"
# Build info
VITE_ENVIRONMENT="development"
VITE_VERSION=""

View File

@@ -111,11 +111,10 @@
<div id="root"></div>
<script>
var PYLON_APP_ID = '<%- PYLON_APP_ID %>';
var pylonSettings =
((window.signozBootData || {}).settings || {}).pylon || {};
var pylonEnabled = pylonSettings.enabled !== false;
if (PYLON_APP_ID && pylonEnabled) {
var pylonEnabled = pylonSettings.enabled === true;
if (pylonSettings.appId && pylonEnabled) {
(function () {
var e = window;
var t = document;
@@ -133,7 +132,7 @@
e.setAttribute('async', 'true');
e.setAttribute(
'src',
'https://widget.usepylon.com/widget/' + PYLON_APP_ID,
'https://widget.usepylon.com/widget/' + pylonSettings.appId,
);
var n = t.getElementsByTagName('script')[0];
n.parentNode.insertBefore(e, n);
@@ -150,15 +149,14 @@
window.AppcuesSettings = { enableURLDetection: true };
</script>
<script>
var APPCUES_APP_ID = '<%- APPCUES_APP_ID %>';
var appcuesSettings =
((window.signozBootData || {}).settings || {}).appcues || {};
var appcuesEnabled = appcuesSettings.enabled !== false;
if (APPCUES_APP_ID && appcuesEnabled) {
var appcuesEnabled = appcuesSettings.enabled === true;
if (appcuesSettings.appId && appcuesEnabled) {
(function (d, t) {
var a = d.createElement(t);
a.async = 1;
a.src = '//fast.appcues.com/' + APPCUES_APP_ID + '.js';
a.src = '//fast.appcues.com/' + appcuesSettings.appId + '.js';
var s = d.getElementsByTagName(t)[0];
s.parentNode.insertBefore(a, s);
})(document, 'script');

View File

@@ -3,7 +3,6 @@
"project": ["src/**/*.ts", "src/**/*.tsx"],
"ignore": ["src/api/generated/**/*.ts", "src/typings/*.ts"],
"ignoreDependencies": [
"http-proxy-middleware",
"@typescript/native-preview"
]
}

View File

@@ -79,7 +79,6 @@
"event-source-polyfill": "1.0.31",
"eventemitter3": "5.0.1",
"history": "4.10.1",
"http-proxy-middleware": "4.1.1",
"http-status-codes": "2.3.0",
"i18next": "^21.6.12",
"i18next-browser-languagedetector": "^6.1.3",

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

@@ -164,9 +164,6 @@ importers:
history:
specifier: 4.10.1
version: 4.10.1
http-proxy-middleware:
specifier: 4.1.1
version: 4.1.1
http-status-codes:
specifier: 2.3.0
version: 2.3.0
@@ -5461,10 +5458,6 @@ packages:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
engines: {node: '>= 6'}
http-proxy-middleware@4.1.1:
resolution: {integrity: sha512-KX5ZofGXLFXqFAkQoOWZ+rTtaLTut7m0gyL+QzJrdejtIZ+F4bPPDoe7reISg2+v0CAz5OfVwEJEhty7X+e57g==}
engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0}
http-status-codes@2.3.0:
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
@@ -5472,9 +5465,6 @@ packages:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
httpxy@0.5.3:
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@@ -14515,16 +14505,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
http-proxy-middleware@4.1.1:
dependencies:
debug: 4.3.4(supports-color@5.5.0)
httpxy: 0.5.3
is-glob: 4.0.3
is-plain-obj: 4.1.0
micromatch: 4.0.8
transitivePeerDependencies:
- supports-color
http-status-codes@2.3.0: {}
https-proxy-agent@5.0.1:
@@ -14534,8 +14514,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
httpxy@0.5.3: {}
human-signals@2.1.0: {}
human-signals@8.0.1: {}

View File

@@ -292,10 +292,10 @@ function App(): JSX.Element {
isChatSupportEnabled &&
!showAddCreditCardModal &&
(isCloudUser || isEnterpriseSelfHostedUser) &&
(window.signozBootData?.settings?.pylon.enabled ?? true)
window.signozBootData?.settings?.pylon?.enabled
) {
const email = user.email || '';
const secret = process.env.PYLON_IDENTITY_SECRET || '';
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
let emailHash = '';
if (email && secret) {
@@ -304,7 +304,7 @@ function App(): JSX.Element {
window.pylon = {
chat_settings: {
app_id: process.env.PYLON_APP_ID,
app_id: window.signozBootData?.settings?.pylon?.appId,
email: user.email,
name: user.displayName || user.email,
email_hash: emailHash,
@@ -335,22 +335,23 @@ function App(): JSX.Element {
useEffect(() => {
if (isCloudUser || isEnterpriseSelfHostedUser) {
if (
(window.signozBootData?.settings?.posthog.enabled ?? true) &&
process.env.POSTHOG_KEY
window.signozBootData?.settings?.posthog?.enabled &&
window.signozBootData?.settings?.posthog?.key
) {
posthog.init(process.env.POSTHOG_KEY, {
api_host: 'https://us.i.posthog.com',
posthog.init(window.signozBootData.settings.posthog.key, {
api_host: window.signozBootData.settings.posthog.apiHost,
ui_host: window.signozBootData.settings.posthog.uiHost,
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
});
}
if (
!isSentryInitialized &&
(window.signozBootData?.settings?.sentry.enabled ?? true)
window.signozBootData?.settings?.sentry?.enabled
) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
tunnel: process.env.TUNNEL_URL,
dsn: window.signozBootData.settings.sentry.dsn,
tunnel: window.signozBootData.settings.sentry.tunnel,
environment: process.env.ENVIRONMENT,
release: process.env.VERSION,
integrations: [

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',
@@ -5031,6 +5036,10 @@ export interface DashboardtypesListableDashboardForUserV2DTO {
* @type array
*/
dashboards: DashboardtypesListedDashboardForUserV2DTO[];
/**
* @type array
*/
reservedKeywords: string[];
/**
* @type array
*/
@@ -5098,6 +5107,10 @@ export interface DashboardtypesListableDashboardV2DTO {
* @type array
*/
dashboards: DashboardtypesListedDashboardV2DTO[];
/**
* @type array
*/
reservedKeywords: string[];
/**
* @type array
*/
@@ -6810,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
@@ -10160,6 +10196,14 @@ export type GetLLMPricingRule200 = {
status: string;
};
export type ListUnmappedLLMModels200 = {
data: LlmpricingruletypesGettableUnmappedModelsDTO;
/**
* @type string
*/
status: string;
};
export type ListPromotedAndIndexedPaths200 = {
/**
* @type array,null
@@ -11153,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

@@ -2,8 +2,8 @@ import { Controller, useForm } from 'react-hook-form';
import { useQueryClient } from 'react-query';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { SACreatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';

View File

@@ -11,7 +11,7 @@ import {
import CreateServiceAccountModal from '../CreateServiceAccountModal';
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,

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

@@ -1,5 +1,14 @@
import { MutableRefObject } from 'react';
import { Chart, ChartConfiguration, ChartData, Color } from 'chart.js';
import {
ActiveElement,
Chart,
ChartConfiguration,
ChartData,
ChartEvent,
ChartType,
Color,
TooltipItem,
} from 'chart.js';
import * as chartjsAdapter from 'chartjs-adapter-date-fns';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -60,184 +69,189 @@ export const getGraphOptions = (
minTime?: number,
maxTime?: number,
// eslint-disable-next-line sonarjs/cognitive-complexity
): CustomChartOptions => ({
animation: {
duration: animate ? 200 : 0,
},
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: true,
},
plugins: {
...(staticLine
? {
annotation: {
annotations: [
{
type: 'line',
yMin: staticLine.yMin,
yMax: staticLine.yMax,
borderColor: staticLine.borderColor,
borderWidth: staticLine.borderWidth,
label: {
content: staticLine.lineText,
enabled: true,
font: {
size: 10,
): CustomChartOptions =>
({
animation: {
duration: animate ? 200 : 0,
},
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: true,
},
plugins: {
...(staticLine
? {
annotation: {
annotations: [
{
type: 'line',
yMin: staticLine.yMin,
yMax: staticLine.yMax,
borderColor: staticLine.borderColor,
borderWidth: staticLine.borderWidth,
label: {
content: staticLine.lineText,
enabled: true,
font: {
size: 10,
},
borderWidth: 0,
position: 'start',
backgroundColor: 'transparent',
color: staticLine.textColor,
},
borderWidth: 0,
position: 'start',
backgroundColor: 'transparent',
color: staticLine.textColor,
},
},
],
],
},
}
: {}),
title: {
display: title !== undefined,
text: title,
},
legend: {
display: false,
},
tooltip: {
callbacks: {
title(context: TooltipItem<'line'>[]): string | string[] {
const date = dayjs(context[0].parsed.x);
return date
.tz(timezone.value)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_FULL_SECONDS);
},
label(context: TooltipItem<'line'>): string | string[] {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += getToolTipValue(context.parsed.y.toString(), yAxisUnit);
}
return label;
},
labelTextColor(labelData: TooltipItem<'line'>): Color {
if (labelData.datasetIndex === nearestDatasetIndex.current) {
return 'rgba(255, 255, 255, 1)';
}
return 'rgba(255, 255, 255, 0.75)';
},
}
: {}),
title: {
display: title !== undefined,
text: title,
},
legend: {
display: false,
},
tooltip: {
callbacks: {
title(context): string | string[] {
const date = dayjs(context[0].parsed.x);
return date
.tz(timezone.value)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_FULL_SECONDS);
},
label(context): string | string[] {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += getToolTipValue(context.parsed.y.toString(), yAxisUnit);
}
return label;
},
labelTextColor(labelData): Color {
if (labelData.datasetIndex === nearestDatasetIndex.current) {
return 'rgba(255, 255, 255, 1)';
}
return 'rgba(255, 255, 255, 0.75)';
position: 'custom',
itemSort(item1: TooltipItem<'line'>, item2: TooltipItem<'line'>): number {
return item2.parsed.y - item1.parsed.y;
},
},
position: 'custom',
itemSort(item1, item2): number {
return item2.parsed.y - item1.parsed.y;
},
[dragSelectPluginId]: createDragSelectPluginOptions(
!!onDragSelect,
onDragSelect,
dragSelectColor,
),
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
!!onDragSelect,
currentTheme === 'dark' ? 'white' : 'black',
),
},
[dragSelectPluginId]: createDragSelectPluginOptions(
!!onDragSelect,
onDragSelect,
dragSelectColor,
),
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
!!onDragSelect,
currentTheme === 'dark' ? 'white' : 'black',
),
},
layout: {
padding: 0,
},
scales: {
x: {
stacked: isStacked,
offset: false,
grid: {
layout: {
padding: 0,
},
scales: {
x: {
stacked: isStacked,
offset: false,
grid: {
display: true,
color: getGridColor(),
drawTicks: true,
},
adapters: {
date: chartjsAdapter,
},
time: {
unit: xAxisTimeUnit?.unitName || 'minute',
stepSize: xAxisTimeUnit?.stepSize || 1,
displayFormats: {
millisecond: DATE_TIME_FORMATS.TIME_SECONDS,
second: DATE_TIME_FORMATS.TIME_SECONDS,
minute: DATE_TIME_FORMATS.TIME,
hour: DATE_TIME_FORMATS.SLASH_SHORT,
day: DATE_TIME_FORMATS.DATE_SHORT,
week: DATE_TIME_FORMATS.DATE_SHORT,
month: DATE_TIME_FORMATS.YEAR_MONTH,
year: DATE_TIME_FORMATS.YEAR_SHORT,
},
},
type: 'time',
ticks: { color: getAxisLabelColor(currentTheme) },
...(minTime && {
min: dayjs(minTime).tz(timezone.value).format(),
}),
...(maxTime && {
max: dayjs(maxTime).tz(timezone.value).format(),
}),
},
y: {
stacked: isStacked,
display: true,
color: getGridColor(),
drawTicks: true,
},
adapters: {
date: chartjsAdapter,
},
time: {
unit: xAxisTimeUnit?.unitName || 'minute',
stepSize: xAxisTimeUnit?.stepSize || 1,
displayFormats: {
millisecond: DATE_TIME_FORMATS.TIME_SECONDS,
second: DATE_TIME_FORMATS.TIME_SECONDS,
minute: DATE_TIME_FORMATS.TIME,
hour: DATE_TIME_FORMATS.SLASH_SHORT,
day: DATE_TIME_FORMATS.DATE_SHORT,
week: DATE_TIME_FORMATS.DATE_SHORT,
month: DATE_TIME_FORMATS.YEAR_MONTH,
year: DATE_TIME_FORMATS.YEAR_SHORT,
grid: {
display: true,
color: getGridColor(),
},
},
type: 'time',
ticks: { color: getAxisLabelColor(currentTheme) },
...(minTime && {
min: dayjs(minTime).tz(timezone.value).format(),
}),
...(maxTime && {
max: dayjs(maxTime).tz(timezone.value).format(),
}),
},
y: {
stacked: isStacked,
display: true,
grid: {
display: true,
color: getGridColor(),
},
ticks: {
color: getAxisLabelColor(currentTheme),
// Include a dollar sign in the ticks
callback(value): string {
return getYAxisFormattedValue(value.toString(), yAxisUnit);
ticks: {
color: getAxisLabelColor(currentTheme),
// Include a dollar sign in the ticks
callback(value: number | string): string {
return getYAxisFormattedValue(value.toString(), yAxisUnit);
},
},
},
},
},
elements: {
line: {
tension: 0,
cubicInterpolationMode: 'monotone',
},
point: {
hoverBackgroundColor: (ctx: any): string => {
if (ctx?.element?.options?.borderColor) {
return ctx.element.options.borderColor;
}
return 'rgba(0,0,0,0.1)';
elements: {
line: {
tension: 0,
cubicInterpolationMode: 'monotone',
},
hoverRadius: 5,
},
},
onClick: (event, element, chart): void => {
if (onClickHandler) {
onClickHandler(event, element, chart, data);
}
},
onHover: (event, _, chart): void => {
if (event.native) {
const interactions = chart.getElementsAtEventForMode(
event.native,
'nearest',
{
intersect: false,
point: {
hoverBackgroundColor: (ctx: any): string => {
if (ctx?.element?.options?.borderColor) {
return ctx.element.options.borderColor;
}
return 'rgba(0,0,0,0.1)';
},
true,
);
if (interactions[0]) {
nearestDatasetIndex.current = interactions[0].datasetIndex;
hoverRadius: 5,
},
},
onClick: (
event: ChartEvent,
element: ActiveElement[],
chart: Chart,
): void => {
if (onClickHandler) {
onClickHandler(event, element, chart, data);
}
}
},
});
},
onHover: (event: ChartEvent, _: ActiveElement[], chart: Chart): void => {
if (event.native) {
const interactions = chart.getElementsAtEventForMode(
event.native,
'nearest',
{
intersect: false,
},
true,
);
if (interactions[0]) {
nearestDatasetIndex.current = interactions[0].datasetIndex;
}
}
},
}) as CustomChartOptions;
declare module 'chart.js' {
interface TooltipPositionerMap {

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

@@ -11,6 +11,8 @@ import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { isKeyMatch } from './utils';
import { CheckedState } from '../../types';
import { SectionType } from './v2/itemRules';
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
@@ -148,6 +150,8 @@ export function applyCheckboxToggle({
value,
checked,
isOnlyOrAllClicked,
previousState,
sectionType,
}: {
currentQuery: Query;
activeQueryIndex: number;
@@ -157,6 +161,8 @@ export function applyCheckboxToggle({
value: string;
checked: boolean;
isOnlyOrAllClicked: boolean;
previousState?: CheckedState;
sectionType?: SectionType;
}): Query {
const activeItems =
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
@@ -216,6 +222,7 @@ export function applyCheckboxToggle({
);
if (currentFilter) {
const runningOperator = currentFilter?.op;
switch (runningOperator) {
case 'in':
if (checked) {
@@ -246,9 +253,29 @@ export function applyCheckboxToggle({
});
}
} else if (!checked) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
if (isArray(currentFilter.value)) {
// Related section: clicking to exclude creates NOT_IN for just this value
if (sectionType === SectionType.RELATED) {
const newFilter: TagFilterItem = {
id: uuid(),
op: getNotInOperator(source),
key: filter.attributeKey,
value,
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else if (isArray(currentFilter.value)) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
@@ -275,11 +302,38 @@ export function applyCheckboxToggle({
}
break;
case 'nin':
case 'not in':
// if the current running operator is NIN then when unchecking the value it gets
// added to the clause like key NIN [value1 , currentUnselectedValue]
if (!checked) {
// in case of array add the currentUnselectedValue to the list.
case 'not in': {
// NOT IN means "exclude these values"
// Check if value is currently in the exclusion list
const isValueInFilter = isArray(currentFilter.value)
? currentFilter.value.includes(value)
: currentFilter.value === value;
// When clicking unchecked "Other" item, user wants to SELECT it
// Replace NOT IN filter with IN [value]
if (previousState === 'unchecked' && checked) {
const newFilter: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.IN),
key: filter.attributeKey,
value,
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else if (!checked || !isValueInFilter) {
// Add to NOT IN when:
// - checked=false (user explicitly unchecked to exclude)
// - checked=true but value not in filter (clicking "other" value to exclude)
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
@@ -292,7 +346,6 @@ export function applyCheckboxToggle({
return item;
});
} else {
// in case of not an array make it one!
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
@@ -304,8 +357,9 @@ export function applyCheckboxToggle({
return item;
});
}
} else if (checked) {
// opposite of above!
} else {
// Remove from NOT IN when value IS in filter and checked=true
// (user wants to include this value back)
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
@@ -346,6 +400,7 @@ export function applyCheckboxToggle({
}
}
break;
}
case '=':
if (checked) {
const newFilter = {
@@ -389,10 +444,11 @@ export function applyCheckboxToggle({
}
}
} else {
// case - when there is no filter for the current key that means all are selected right now.
// No filter for this key - all are visually selected.
// checked=true → user wants to select (IN), checked=false → exclude (NOT IN)
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getNotInOperator(source),
op: checked ? getOperatorValue(OPERATORS.IN) : getNotInOperator(source),
key: filter.attributeKey,
value,
};

View File

@@ -10,6 +10,8 @@ import {
applyCheckboxToggle,
clearFilterFromQuery,
} from './checkboxFilterQuery';
import { CheckedState } from '../../types';
import { SectionType } from './v2/itemRules';
interface UseCheckboxFilterActionsProps {
filter: IQuickFiltersConfig;
@@ -24,6 +26,8 @@ interface UseCheckboxFilterActionsReturn {
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
previousState?: CheckedState,
sectionType?: SectionType,
) => void;
onClear: () => void;
}
@@ -53,6 +57,8 @@ function useCheckboxFilterActions({
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
previousState?: CheckedState,
sectionType?: SectionType,
): void => {
dispatch(
applyCheckboxToggle({
@@ -64,6 +70,8 @@ function useCheckboxFilterActions({
value,
checked,
isOnlyOrAllClicked,
previousState,
sectionType,
}),
);
};

View File

@@ -0,0 +1,302 @@
import { screen } from '@testing-library/react';
import { server, rest } from 'mocks-server/server';
import { render } from 'tests/test-utils';
import { QuickFiltersSource } from '../../../types';
import CheckboxFilterV2 from './CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
setupServer,
} from './CheckboxFilterV2.testUtils';
const USE_FIELD_APIS_AUTO_DERIVE = {
...DEFAULT_USE_FIELD_APIS,
existingQuery: undefined,
};
setupServer();
describe('CheckboxFilterV2 - existingQuery calculation', () => {
const captureExistingQuery = (): Promise<string | null> =>
new Promise((resolve) => {
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
const existingQuery = req.url.searchParams.get('existingQuery');
resolve(existingQuery);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: [],
stringValues: ['test'],
numberValues: [],
},
},
}),
);
}),
);
});
describe('useFieldApis.existingQuery takes precedence', () => {
it('uses useFieldApis.existingQuery when provided', async () => {
const queryPromise = captureExistingQuery();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'custom.query = "value"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: 'should.be.ignored = "yes"' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-test');
const capturedQuery = await queryPromise;
expect(capturedQuery).toBe('custom.query = "value"');
});
it('returns undefined when useFieldApis.existingQuery is null', async () => {
const queryPromise = captureExistingQuery();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: null,
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: 'should.be.ignored = "yes"' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-test');
const capturedQuery = await queryPromise;
expect(capturedQuery).toBeNull();
});
});
describe('V5 filter.expression preferred over V3 filters.items', () => {
it('uses V5 filter.expression when both exist', async () => {
const queryPromise = captureExistingQuery();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'service.name', dataType: 'string', type: 'tag' },
op: '=',
value: 'from-v3-items',
},
],
op: 'AND',
},
filter: { expression: 'v5.expression = "preferred"' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-test');
const capturedQuery = await queryPromise;
expect(capturedQuery).toBe('v5.expression = "preferred"');
});
it('uses V5 filter.expression when no V3 items exist', async () => {
const queryPromise = captureExistingQuery();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: 'only.v5 = "expression"' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-test');
const capturedQuery = await queryPromise;
expect(capturedQuery).toBe('only.v5 = "expression"');
});
});
describe('V3 filters.items fallback', () => {
it('converts V3 filters.items to expression when no V5 expression exists', async () => {
const queryPromise = captureExistingQuery();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'service.name', dataType: 'string', type: 'tag' },
op: '=',
value: 'api-service',
},
],
op: 'AND',
},
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-test');
const capturedQuery = await queryPromise;
expect(capturedQuery).toBe("service.name = 'api-service'");
});
it('converts multiple V3 filters.items with AND operator', async () => {
const queryPromise = captureExistingQuery();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'service.name', dataType: 'string', type: 'tag' },
op: '=',
value: 'api',
},
{
key: { key: 'env', dataType: 'string', type: 'tag' },
op: '=',
value: 'prod',
},
],
op: 'AND',
},
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-test');
const capturedQuery = await queryPromise;
expect(capturedQuery).toBe("service.name = 'api' AND env = 'prod'");
});
it('returns undefined when no filters exist', async () => {
const queryPromise = captureExistingQuery();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-test');
const capturedQuery = await queryPromise;
expect(capturedQuery).toBeNull();
});
});
});

View File

@@ -0,0 +1,768 @@
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { server, rest } from 'mocks-server/server';
import { render } from 'tests/test-utils';
import { QuickFiltersSource } from '../../../types';
import CheckboxFilterV2 from './CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
getFilterFromCall,
mockFieldsValuesAPI,
renderWithFilter,
setupServer,
} from './CheckboxFilterV2.testUtils';
setupServer();
describe('CheckboxFilterV2 - interactions', () => {
describe('search functionality', () => {
it('filters values based on search text', async () => {
const user = userEvent.setup();
let searchTextReceived = '';
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
searchTextReceived = req.url.searchParams.get('searchText') || '';
const values =
searchTextReceived === ''
? ['production', 'staging', 'development']
: ['production'];
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: [],
stringValues: values,
numberValues: [],
},
},
}),
);
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-production');
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
await waitFor(() => {
expect(searchTextReceived).toBe('prod');
});
await waitFor(() => {
expect(
screen.queryByTestId('checkbox-value-row-staging'),
).not.toBeInTheDocument();
});
});
it('filters values via search while preserving existingQuery context', async () => {
const user = userEvent.setup();
let requestCount = 0;
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
requestCount += 1;
const searchText = req.url.searchParams.get('searchText') || '';
if (requestCount === 1) {
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: ['production'],
stringValues: ['staging', 'development'],
numberValues: [],
},
},
}),
);
}
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: searchText === 'prod' ? ['production'] : [],
stringValues: searchText === 'prod' ? ['production'] : ['staging'],
numberValues: [],
},
},
}),
);
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-production');
// Related values now appear in "Related" section (no badge, uses divider instead)
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
await waitFor(() => {
expect(
screen.queryByTestId('checkbox-value-row-staging'),
).not.toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-production'),
).toBeInTheDocument();
});
});
it('shows filtered results in all_values section when searching with existingQuery', async () => {
const user = userEvent.setup();
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
const searchText = req.url.searchParams.get('searchText') || '';
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: [],
stringValues: searchText === '' ? ['prod', 'staging'] : ['prod-match'],
numberValues: [],
},
},
}),
);
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
);
await screen.findByTestId('checkbox-value-row-prod');
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
await waitFor(() => {
expect(screen.getByTestId('section-all_values')).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-prod-match'),
).toBeInTheDocument();
});
});
it('shows empty search results message when no matches found', async () => {
const user = userEvent.setup();
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
const searchText = req.url.searchParams.get('searchText') || '';
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: [],
stringValues: searchText === '' ? ['prod', 'staging'] : [],
numberValues: [],
},
},
}),
);
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-prod');
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'xyz-no-match');
await waitFor(() => {
expect(
screen.getByTestId('checkbox-filter-no-search-results'),
).toBeInTheDocument();
});
});
it('shows both RELATED and ALL_VALUES sections with non-overlapping values', async () => {
// This tests the bug fix where different pod names in relatedValues vs stringValues
// caused all items to go to ALL_VALUES instead of showing RELATED section
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
// Non-overlapping: different pod instances
relatedValues: ['pod-a-instance-1', 'pod-b-instance-1'],
stringValues: ['pod-a-instance-2', 'pod-b-instance-2'],
numberValues: [],
},
},
}),
),
),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
);
// Wait for values to load
await screen.findByTestId('checkbox-value-row-pod-a-instance-1');
// RELATED section should exist with relatedValues
expect(screen.getByTestId('section-related')).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-pod-a-instance-1'),
).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-pod-b-instance-1'),
).toBeInTheDocument();
// ALL_VALUES section should exist with stringValues
expect(screen.getByTestId('section-all_values')).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-pod-a-instance-2'),
).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-pod-b-instance-2'),
).toBeInTheDocument();
});
it('shows both sections during search with filtered non-overlapping values', async () => {
const user = userEvent.setup();
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
const searchText = req.url.searchParams.get('searchText') || '';
// Simulate API filtering - both arrays filtered but remain non-overlapping
const relatedValues =
searchText === '' ? ['pod-a-v1', 'pod-b-v1', 'pod-c-v1'] : ['pod-a-v1']; // filtered to match 'pod-a'
const stringValues =
searchText === '' ? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2'] : ['pod-a-v2']; // filtered to match 'pod-a'
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues,
stringValues,
numberValues: [],
},
},
}),
);
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
);
await screen.findByTestId('checkbox-value-row-pod-a-v1');
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'pod-a');
// After search, both sections should still appear with filtered results
await waitFor(() => {
// RELATED section with filtered relatedValues
expect(screen.getByTestId('section-related')).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-pod-a-v1'),
).toBeInTheDocument();
// ALL_VALUES section with filtered stringValues
expect(screen.getByTestId('section-all_values')).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-pod-a-v2'),
).toBeInTheDocument();
// Other values should be filtered out
expect(
screen.queryByTestId('checkbox-value-row-pod-b-v1'),
).not.toBeInTheDocument();
});
});
});
describe('header interactions', () => {
it('collapses when header clicked on open filter', async () => {
const user = userEvent.setup();
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-production');
const header = screen.getByTestId('checkbox-filter-header');
expect(header).toHaveAttribute('data-state', 'open');
await user.click(header);
expect(header).toHaveAttribute('data-state', 'closed');
expect(
screen.queryByTestId('checkbox-value-row-production'),
).not.toBeInTheDocument();
});
it('expands when header clicked on closed filter', async () => {
const user = userEvent.setup();
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
const header = screen.getByTestId('checkbox-filter-header');
expect(header).toHaveAttribute('data-state', 'closed');
await user.click(header);
expect(header).toHaveAttribute('data-state', 'open');
await screen.findByTestId('checkbox-value-row-production');
});
});
describe('show more functionality', () => {
it('shows "Show More..." when more than 10 values', async () => {
const values = Array.from(
{ length: 15 },
(_, i) => `value-${String(i).padStart(2, '0')}`,
);
mockFieldsValuesAPI({ stringValues: values });
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-value-00');
expect(screen.getByTestId('checkbox-filter-show-more')).toBeInTheDocument();
expect(
screen.queryByTestId('checkbox-value-row-value-10'),
).not.toBeInTheDocument();
});
it('loads more values when "Show More..." clicked', async () => {
const user = userEvent.setup();
const values = Array.from(
{ length: 15 },
(_, i) => `value-${String(i).padStart(2, '0')}`,
);
mockFieldsValuesAPI({ stringValues: values });
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-value-00');
await user.click(screen.getByTestId('checkbox-filter-show-more'));
await screen.findByTestId('checkbox-value-row-value-10');
expect(
screen.getByTestId('checkbox-value-row-value-14'),
).toBeInTheDocument();
});
});
describe('clear functionality', () => {
it('shows clear button when filter is open and has filter applied', async () => {
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'in',
value: ['production'],
},
],
op: 'AND',
},
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-production');
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
it('hides clear button when no filter applied for attribute', async () => {
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-production');
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
});
it('calls onFilterChange when clear clicked', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
onFilterChange={onFilterChange}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'in',
value: ['production'],
},
],
op: 'AND',
},
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-production');
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
expect(onFilterChange).toHaveBeenCalled();
});
});
describe('value row interactions', () => {
it('calls onFilterChange when checkbox value clicked', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
stringValues: ['production', 'staging'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
onFilterChange={onFilterChange}
/>,
);
const productionRow = await screen.findByTestId(
'checkbox-value-row-production',
);
await user.click(within(productionRow).getByText('production'));
expect(onFilterChange).toHaveBeenCalled();
});
it('creates NOT IN filter when unchecking related item with no existing filter', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
relatedValues: ['valueA'],
stringValues: ['valueB'],
});
// Start with no filter, related items show as checked
// Clicking unchecks them → creates NOT IN filter to exclude
renderWithFilter(onFilterChange);
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
expect(rowA).toHaveAttribute('data-state', 'checked');
await user.click(within(rowA).getByRole('checkbox'));
expect(onFilterChange).toHaveBeenCalledTimes(1);
const filter = getFilterFromCall(onFilterChange);
expect(filter?.op).toBe('not in');
expect(filter?.value).toBe('valueA');
});
it('converts NOT IN to IN when toggling unchecked (other) item', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
relatedValues: ['valueA'],
stringValues: ['valueB'],
});
// Clicking unchecked "Other" item with NOT IN filter should convert to IN [B]
renderWithFilter(onFilterChange, { op: 'not in', value: ['valueA'] });
const rowB = await screen.findByTestId('checkbox-value-row-valueB');
expect(rowB).toHaveAttribute('data-state', 'unchecked');
await user.click(within(rowB).getByRole('checkbox'));
expect(onFilterChange).toHaveBeenCalledTimes(1);
const filter = getFilterFromCall(onFilterChange);
expect(filter?.op).toBe('in');
expect(filter?.value).toBe('valueB');
});
it('accumulates both values in IN when toggling checked (related) then unchecked (other)', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
relatedValues: ['valueA'],
stringValues: ['valueB'],
});
// Start with IN filter for valueA
renderWithFilter(onFilterChange, { op: 'in', value: ['valueA'] });
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
expect(rowA).toHaveAttribute('data-state', 'checked');
const rowB = screen.getByTestId('checkbox-value-row-valueB');
expect(rowB).toHaveAttribute('data-state', 'unchecked');
// Toggle B (unchecked -> should add to IN)
await user.click(within(rowB).getByRole('checkbox'));
expect(onFilterChange).toHaveBeenCalledTimes(1);
const filter = getFilterFromCall(onFilterChange);
expect(filter?.op).toBe('in');
expect(filter?.value).toStrictEqual(['valueA', 'valueB']);
});
it('adds to NOT IN when toggling checked (related) with existing NOT IN filter', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
relatedValues: ['valueA'],
stringValues: ['valueB'],
});
// Start with NOT IN filter for valueB
renderWithFilter(onFilterChange, { op: 'not in', value: ['valueB'] });
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
// Related values show as checked
expect(rowA).toHaveAttribute('data-state', 'checked');
// Toggle A (checked -> should add to NOT IN)
await user.click(within(rowA).getByRole('checkbox'));
expect(onFilterChange).toHaveBeenCalledTimes(1);
const filter = getFilterFromCall(onFilterChange);
expect(filter?.op).toBe('not in');
expect(filter?.value).toStrictEqual(['valueB', 'valueA']);
});
it('creates NOT IN for single value when toggling related item with existing IN filter', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
relatedValues: ['relatedValue'],
stringValues: ['otherValue'],
});
// Start with IN filter for otherValue (Selected section)
// relatedValue is in Related section (checked visually)
renderWithFilter(onFilterChange, { op: 'in', value: ['otherValue'] });
const selectedRow = await screen.findByTestId(
'checkbox-value-row-otherValue',
);
expect(selectedRow).toHaveAttribute('data-state', 'checked');
const relatedRow = screen.getByTestId('checkbox-value-row-relatedValue');
expect(relatedRow).toHaveAttribute('data-state', 'checked');
// Toggle related item (checked -> should create NOT IN with just this value)
await user.click(within(relatedRow).getByRole('checkbox'));
expect(onFilterChange).toHaveBeenCalledTimes(1);
const filter = getFilterFromCall(onFilterChange);
expect(filter?.op).toBe('not in');
expect(filter?.value).toBe('relatedValue');
});
});
describe('custom renderer', () => {
it('uses customRendererForValue when provided', async () => {
mockFieldsValuesAPI({
stringValues: ['production'],
});
const customRenderer = (value: string): JSX.Element => (
<span data-testid="custom-rendered">{`ENV: ${value}`}</span>
);
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, customRendererForValue: customRenderer }}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('custom-rendered');
expect(screen.getByText('ENV: production')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,499 @@
import { screen, within } from '@testing-library/react';
import { render } from 'tests/test-utils';
import { QuickFiltersSource } from '../../../types';
import CheckboxFilterV2 from './CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
mockFieldsValuesAPI,
setupServer,
} from './CheckboxFilterV2.testUtils';
setupServer();
describe('CheckboxFilterV2 - item rules', () => {
describe('no existing query', () => {
it('all values show as checked with no badge when no query exists', async () => {
mockFieldsValuesAPI({
stringValues: ['production', 'staging'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
const productionRow = await screen.findByTestId(
'checkbox-value-row-production',
);
expect(within(productionRow).getByText('production')).toBeInTheDocument();
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
expect(productionRow).toHaveAttribute('data-state', 'checked');
expect(stagingRow).toHaveAttribute('data-state', 'checked');
// No section dividers when no existing query (all in "selected" section)
expect(
screen.queryByTestId('section-divider-related'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('section-divider-all-values'),
).not.toBeInTheDocument();
});
});
describe('with existing query (related values)', () => {
it('shows related values in "Related" section with checked state', async () => {
mockFieldsValuesAPI({
relatedValues: ['production'],
stringValues: ['staging', 'development'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
const productionRow = await screen.findByTestId(
'checkbox-value-row-production',
);
expect(within(productionRow).getByText('production')).toBeInTheDocument();
// Related values show as checked in "Related" section
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
expect(productionRow).toHaveAttribute('data-state', 'checked');
// Other values show as unchecked in "All values" section
expect(screen.getByTestId('section-divider-all-values')).toBeInTheDocument();
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
});
it('shows no badge for values not in relatedValues (unchecked state in All values section)', async () => {
mockFieldsValuesAPI({
relatedValues: ['production'],
stringValues: ['staging'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
const stagingRow = await screen.findByTestId('checkbox-value-row-staging');
expect(within(stagingRow).getByText('staging')).toBeInTheDocument();
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
expect(within(stagingRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
});
it('shows related values as checked when hasFilterForThisKey=true and isInRelatedValues=true', async () => {
mockFieldsValuesAPI({
relatedValues: ['production', 'staging'],
stringValues: ['development'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'in',
value: ['production'],
},
],
op: 'AND',
},
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
// production is selected - in selected section
const productionRow = await screen.findByTestId(
'checkbox-value-row-production',
);
expect(productionRow).toHaveAttribute('data-state', 'checked');
// staging is related - in related section with checked state
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
expect(stagingRow).toHaveAttribute('data-state', 'checked');
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
});
});
describe('selected values with IN operator', () => {
it('shows checked state with no badge for IN-selected values', async () => {
mockFieldsValuesAPI({
stringValues: ['production', 'staging'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'in',
value: ['production'],
},
],
op: 'AND',
},
},
],
},
},
} as never,
},
);
const productionRow = await screen.findByTestId(
'checkbox-value-row-production',
);
expect(productionRow).toHaveAttribute('data-state', 'checked');
expect(
within(productionRow).queryByTestId(/^badge-/),
).not.toBeInTheDocument();
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
});
});
describe('selected values with NOT IN operator', () => {
it('shows unchecked state with no badge for NOT_IN-selected values', async () => {
mockFieldsValuesAPI({
stringValues: ['production', 'staging'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'not in',
value: ['production'],
},
],
op: 'AND',
},
},
],
},
},
} as never,
},
);
const productionRow = await screen.findByTestId(
'checkbox-value-row-production',
);
expect(productionRow).toHaveAttribute('data-state', 'unchecked');
expect(
within(productionRow).queryByTestId(/^badge-/),
).not.toBeInTheDocument();
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
expect(within(stagingRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
});
});
describe('section ordering', () => {
it('orders selected section before related section before other section', async () => {
mockFieldsValuesAPI({
relatedValues: ['related-value'],
stringValues: ['other-value', 'selected-value'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'in',
value: ['selected-value'],
},
],
op: 'AND',
},
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-selected-value');
const allRows = screen.getAllByTestId(/^checkbox-value-row-/);
const values = allRows.map((row) =>
row.getAttribute('data-testid')?.replace('checkbox-value-row-', ''),
);
expect(values[0]).toBe('selected-value');
expect(values[1]).toBe('related-value');
expect(values[2]).toBe('other-value');
});
it('sorts alphabetically within same section', async () => {
mockFieldsValuesAPI({
relatedValues: ['zebra', 'alpha', 'mike'],
stringValues: [],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-alpha');
const allRows = screen.getAllByTestId(/^checkbox-value-row-/);
const values = allRows.map((row) =>
row.getAttribute('data-testid')?.replace('checkbox-value-row-', ''),
);
expect(values).toStrictEqual(['alpha', 'mike', 'zebra']);
});
});
describe('mixed state scenarios', () => {
it('handles mixed state: IN-selected + related + other in same list', async () => {
mockFieldsValuesAPI({
relatedValues: ['related-env'],
stringValues: ['other-env', 'selected-env'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'in',
value: ['selected-env'],
},
],
op: 'AND',
},
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
const selectedRow = await screen.findByTestId(
'checkbox-value-row-selected-env',
);
expect(selectedRow).toHaveAttribute('data-state', 'checked');
expect(within(selectedRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
// Related values now show as checked
const relatedRow = screen.getByTestId('checkbox-value-row-related-env');
expect(relatedRow).toHaveAttribute('data-state', 'checked');
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
const otherRow = screen.getByTestId('checkbox-value-row-other-env');
expect(otherRow).toHaveAttribute('data-state', 'unchecked');
expect(within(otherRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
});
it('handles NOT_IN-selected alongside related values', async () => {
mockFieldsValuesAPI({
relatedValues: ['related-env'],
stringValues: ['other-env', 'excluded-env'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'not in',
value: ['excluded-env'],
},
],
op: 'AND',
},
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
const excludedRow = await screen.findByTestId(
'checkbox-value-row-excluded-env',
);
expect(excludedRow).toHaveAttribute('data-state', 'unchecked');
expect(within(excludedRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
// Related values show as checked
const relatedRow = screen.getByTestId('checkbox-value-row-related-env');
expect(relatedRow).toHaveAttribute('data-state', 'checked');
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,103 @@
.checkboxFilter {
display: flex;
flex-direction: column;
padding: var(--spacing-6);
gap: var(--spacing-6);
border-bottom: 1px solid var(--l1-border);
}
.search {
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-focus-border-color: var(--l2-border);
}
.searchSpinner {
color: var(--l2-foreground);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.values {
display: flex;
flex-direction: column;
}
.sectionValues {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.loadingValues {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-6) 0;
}
.loadingMore {
align-self: center;
}
.noData {
align-self: center;
}
.showMore {
display: flex;
align-items: center;
justify-content: center;
}
.showMoreText {
color: var(--accent-primary);
cursor: pointer;
}
.goToDocs {
display: flex;
flex-direction: column;
gap: 44px;
}
.goToDocsContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
}
.goToDocsMessage {
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
.goToDocsButton {
display: flex;
align-items: center;
gap: var(--spacing-2);
cursor: pointer;
margin: 0 0 var(--spacing-2);
padding: 0;
}
.goToDocsButtonText {
color: var(--bg-robin-400);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}

View File

@@ -0,0 +1,207 @@
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { server, rest } from 'mocks-server/server';
import { render } from 'tests/test-utils';
import { QuickFiltersSource } from '../../../types';
import CheckboxFilterV2 from './CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
mockFieldsValuesAPI,
mockFieldsValuesAPILoading,
setupServer,
} from './CheckboxFilterV2.testUtils';
setupServer();
describe('CheckboxFilterV2 - states', () => {
describe('loading states', () => {
it('shows skeleton while loading initial data', async () => {
mockFieldsValuesAPILoading();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
expect(screen.getByTestId('checkbox-filter-v2')).toBeInTheDocument();
await waitFor(() => {
expect(
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
).toBeInTheDocument();
});
});
it('shows skeleton when initially closed filter is opened for the first time', async () => {
const user = userEvent.setup();
mockFieldsValuesAPILoading();
const closedFilter = { ...DEFAULT_FILTER, defaultOpen: false };
render(
<CheckboxFilterV2
filter={closedFilter}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
// Filter starts closed - no skeleton, no content
expect(
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('checkbox-filter-empty'),
).not.toBeInTheDocument();
// Click header to open
const header = screen.getByTestId('checkbox-filter-header');
await user.click(header);
// Should show skeleton while loading, NOT "No values found"
await waitFor(() => {
expect(
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
).toBeInTheDocument();
});
expect(
screen.queryByTestId('checkbox-filter-empty'),
).not.toBeInTheDocument();
});
it('shows search spinner when fetching after initial load', async () => {
const user = userEvent.setup();
let requestCount = 0;
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
requestCount += 1;
if (requestCount === 1) {
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: [],
stringValues: ['production', 'staging'],
numberValues: [],
},
},
}),
);
}
return res(ctx.delay(10000));
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-production');
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
await waitFor(() => {
expect(
screen.getByTestId('checkbox-filter-search-loading'),
).toBeInTheDocument();
});
});
});
describe('empty states', () => {
it('shows "No values found" when API returns empty arrays', async () => {
mockFieldsValuesAPI({
relatedValues: [],
stringValues: [],
numberValues: [],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
const emptySection = await screen.findByTestId('checkbox-filter-empty');
expect(emptySection).toBeInTheDocument();
});
});
describe('value rendering', () => {
it('renders values from API response', async () => {
mockFieldsValuesAPI({
stringValues: ['production', 'staging', 'development'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-production');
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-development'),
).toBeInTheDocument();
});
it('renders number values converted to strings', async () => {
mockFieldsValuesAPI({
numberValues: [200, 404, 500],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
const row200 = await screen.findByTestId('checkbox-value-row-200');
expect(within(row200).getByText('200')).toBeInTheDocument();
expect(
within(screen.getByTestId('checkbox-value-row-404')).getByText('404'),
).toBeInTheDocument();
expect(
within(screen.getByTestId('checkbox-value-row-500')).getByText('500'),
).toBeInTheDocument();
});
it('filters null/undefined values from response', async () => {
mockFieldsValuesAPI({
stringValues: ['valid', null, '', undefined as unknown as string],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
const validRow = await screen.findByTestId('checkbox-value-row-valid');
expect(within(validRow).getByText('valid')).toBeInTheDocument();
expect(screen.queryAllByTestId(/^checkbox-value-row-/)).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1,126 @@
import { render, RenderResult } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import {
FiltersType,
IQuickFiltersConfig,
QuickFilterCheckboxUseFieldApis,
QuickFiltersSource,
} from '../../../types';
import CheckboxFilterV2 from './CheckboxFilterV2';
export const DEFAULT_FILTER: IQuickFiltersConfig = {
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: 'deployment.environment',
dataType: DataTypes.String,
type: 'tag',
},
dataSource: DataSource.TRACES,
defaultOpen: true,
};
export const DEFAULT_USE_FIELD_APIS: QuickFilterCheckboxUseFieldApis = {
startUnixMilli: 1700000000000,
endUnixMilli: 1700003600000,
existingQuery: null,
};
export function mockFieldsValuesAPI(response: {
relatedValues?: (string | null)[];
stringValues?: (string | null)[];
numberValues?: (number | null)[];
}): void {
server.use(
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: response.relatedValues ?? [],
stringValues: response.stringValues ?? [],
numberValues: response.numberValues ?? [],
},
},
}),
),
),
);
}
export function mockFieldsValuesAPILoading(): void {
server.use(
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
res(ctx.delay(10000)),
),
);
}
export function setupServer(): void {
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
}
export interface FilterItemConfig {
op: string;
value: string | string[];
}
export function renderWithFilter(
onFilterChange: jest.Mock,
filterItem?: FilterItemConfig,
): RenderResult {
const items: TagFilterItem[] = filterItem
? [
{
key: { key: 'deployment.environment' },
op: filterItem.op,
value: filterItem.value,
} as TagFilterItem,
]
: [];
return render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
onFilterChange={onFilterChange}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items, op: 'AND' },
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
}
export function getFilterFromCall(
onFilterChange: jest.Mock,
callIndex = 0,
): TagFilterItem | undefined {
const query = onFilterChange.mock.calls[callIndex]?.[0] as Query | undefined;
return query?.builder.queryData[0]?.filters?.items?.find(
(item) => item.key?.key === 'deployment.environment',
);
}

View File

@@ -0,0 +1,221 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LoaderCircle } from '@signozhq/icons';
import {
IQuickFiltersConfig,
QuickFilterCheckboxUseFieldApis,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
import useActiveQueryIndex from '../useActiveQueryIndex';
import useCheckboxDisclosure from '../useCheckboxDisclosure';
import useCheckboxFilterActions from '../useCheckboxFilterActions';
import useCheckboxFilterState from '../useCheckboxFilterState';
import { useFieldValues } from './useFieldValues';
import { useExistingQuery } from './useExistingQuery';
import { isKeyMatch } from '../utils';
import { CheckboxFilterV2Header } from './CheckboxFilterV2Header';
import { CheckboxFilterV2Section } from './CheckboxFilterV2Section';
import { useSectionedValues } from './useSectionedValues';
import styles from './CheckboxFilterV2.module.scss';
interface CheckboxFilterV2Props {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
useFieldApis: QuickFilterCheckboxUseFieldApis;
}
export default function CheckboxFilterV2(
props: CheckboxFilterV2Props,
): JSX.Element {
const { source, filter, onFilterChange, useFieldApis } = props;
const [searchText, setSearchText] = useState<string>('');
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const { currentQuery } = useQueryBuilder();
const activeQueryIndex = useActiveQueryIndex(source);
const {
isOpen,
isSomeFilterPresentForCurrentAttribute,
visibleItemsCount,
onToggleOpen,
onShowMore,
} = useCheckboxDisclosure({ filter, activeQueryIndex });
// Auto-preserve open state when filter is present
useEffect(() => {
if (isSomeFilterPresentForCurrentAttribute && userToggleState === null) {
setUserToggleState(true);
}
}, [isSomeFilterPresentForCurrentAttribute, userToggleState]);
const { existingQuery, hasExistingQuery } = useExistingQuery({
useFieldApis,
activeQueryIndex,
});
const { relatedValues, allValues, isLoading, isFetching } = useFieldValues({
filter,
searchText,
existingQuery,
metricNamespace: useFieldApis.metricNamespace,
startUnixMilli: useFieldApis.startUnixMilli,
endUnixMilli: useFieldApis.endUnixMilli,
enabled: isOpen,
});
// Track if initial load completed (don't show skeleton after first load)
// Must track if loading ever started, otherwise hasLoadedOnce gets set
// immediately on first render when query is disabled (isLoading=false)
const hasLoadedOnce = useRef(false);
const wasLoading = useRef(false);
if (isLoading) {
wasLoading.current = true;
}
if (!isLoading && wasLoading.current && !hasLoadedOnce.current) {
hasLoadedOnce.current = true;
}
// Combine for state derivation
const attributeValues = useMemo(() => {
const combined = [...relatedValues, ...allValues];
return [...new Set(combined)];
}, [relatedValues, allValues]);
const { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
const { onChange, onClear } = useCheckboxFilterActions({
filter,
source,
attributeValues,
activeQueryIndex,
onFilterChange,
});
const setSearchTextDebounced = useDebouncedFn((...args) => {
setSearchText(args[0] as string);
}, DEBOUNCE_DELAY);
const currentFilterOp = useMemo(() => {
const filterSync = currentQuery?.builder.queryData?.[
activeQueryIndex
]?.filters?.items.find((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
);
return filterSync?.op;
}, [
currentQuery?.builder.queryData,
activeQueryIndex,
filter.attributeKey.key,
]);
const isNotInOperator = NON_SELECTED_OPERATORS.includes(currentFilterOp || '');
const { sections, totalCount } = useSectionedValues({
relatedValues,
allValues,
currentFilterState,
isSomeFilterPresentForCurrentAttribute,
isNotInOperator,
hasExistingQuery,
visibleItemsCount,
});
return (
<div className={styles.checkboxFilter} data-testid="checkbox-filter-v2">
<CheckboxFilterV2Header
title={filter.title}
isOpen={isOpen}
showClearAll={!!attributeValues.length}
onToggleOpen={onToggleOpen}
onClear={onClear}
isSomeFilterPresentForCurrentAttribute={
isSomeFilterPresentForCurrentAttribute
}
/>
{isOpen && isLoading && !hasLoadedOnce.current && (
<section>
<Skeleton paragraph={{ rows: 4 }} />
</section>
)}
{isOpen && (!isLoading || hasLoadedOnce.current) && (
<>
<section className={styles.search}>
<Input
placeholder="Filter values"
onChange={(e): void => setSearchTextDebounced(e.target.value)}
disabled={isFilterDisabled}
data-testid="checkbox-filter-search"
suffix={
isFetching ? (
<LoaderCircle
size={14}
className={styles.searchSpinner}
data-testid="checkbox-filter-search-loading"
/>
) : null
}
/>
</section>
{(totalCount > 0 || searchText) && (
<section className={styles.values}>
{sections.map((section, index) => (
<CheckboxFilterV2Section
key={section.type}
section={section}
index={index}
isFilterDisabled={isFilterDisabled}
filter={filter}
isSomeFilterPresentForCurrentAttribute={
isSomeFilterPresentForCurrentAttribute
}
isMultipleValuesTrueForTheKey={isMultipleValuesTrueForTheKey}
onChange={onChange}
/>
))}
</section>
)}
{totalCount === 0 && hasLoadedOnce.current && !isFetching && (
<section
className={styles.noData}
data-testid={
searchText
? 'checkbox-filter-no-search-results'
: 'checkbox-filter-empty'
}
>
<Typography.Text>No values found</Typography.Text>
</section>
)}
{visibleItemsCount < totalCount &&
!(searchText && (isLoading || isFetching)) && (
<section className={styles.showMore}>
<Typography.Text
className={styles.showMoreText}
onClick={onShowMore}
data-testid="checkbox-filter-show-more"
>
Show More...
</Typography.Text>
</section>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.leftAction {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.title {
color: var(--l2-foreground);
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
text-transform: capitalize;
}
.rightAction {
display: flex;
align-items: center;
min-width: 48px;
}
.clearAll {
font-size: 12px;
color: var(--accent-primary);
cursor: pointer;
}

View File

@@ -0,0 +1,156 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CheckboxFilterV2Header } from './CheckboxFilterV2Header';
describe('CheckboxFilterV2Header', () => {
const defaultProps = {
title: 'Environment',
isOpen: false,
showClearAll: true,
isSomeFilterPresentForCurrentAttribute: true,
onToggleOpen: jest.fn(),
onClear: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('collapsed state', () => {
it('renders title', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
expect(screen.getByText('Environment')).toBeInTheDocument();
});
it('sets data-state="closed" when collapsed', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
const header = screen.getByTestId('checkbox-filter-header');
expect(header).toHaveAttribute('data-state', 'closed');
});
it('does not show clear button when collapsed', () => {
render(
<CheckboxFilterV2Header {...defaultProps} isOpen={false} showClearAll />,
);
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
});
});
describe('expanded state', () => {
it('sets data-state="open" when expanded', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
const header = screen.getByTestId('checkbox-filter-header');
expect(header).toHaveAttribute('data-state', 'open');
});
it('shows clear button when expanded + showClearAll=true', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll />);
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
expect(screen.getByText('Clear')).toBeInTheDocument();
});
it('hides clear button when showClearAll=false', () => {
render(
<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll={false} />,
);
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
});
it('hides clear button when no filter present for attribute', () => {
render(
<CheckboxFilterV2Header
{...defaultProps}
isOpen
showClearAll
isSomeFilterPresentForCurrentAttribute={false}
/>,
);
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
});
});
describe('interactions', () => {
it('calls onToggleOpen on header click', async () => {
const user = userEvent.setup();
const onToggleOpen = jest.fn();
render(
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
);
await user.click(screen.getByTestId('checkbox-filter-header'));
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
it('calls onToggleOpen on Enter key', async () => {
const user = userEvent.setup();
const onToggleOpen = jest.fn();
render(
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
);
screen.getByTestId('checkbox-filter-header').focus();
await user.keyboard('{Enter}');
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
it('calls onToggleOpen on Space key', async () => {
const user = userEvent.setup();
const onToggleOpen = jest.fn();
render(
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
);
screen.getByTestId('checkbox-filter-header').focus();
await user.keyboard(' ');
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
it('calls onClear on clear button click', async () => {
const user = userEvent.setup();
const onClear = jest.fn();
render(
<CheckboxFilterV2Header {...defaultProps} isOpen onClear={onClear} />,
);
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
expect(onClear).toHaveBeenCalledTimes(1);
});
it('clear button click does not trigger onToggleOpen', async () => {
const user = userEvent.setup();
const onToggleOpen = jest.fn();
const onClear = jest.fn();
render(
<CheckboxFilterV2Header
{...defaultProps}
isOpen
onToggleOpen={onToggleOpen}
onClear={onClear}
/>,
);
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
expect(onClear).toHaveBeenCalledTimes(1);
expect(onToggleOpen).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,62 @@
import { Typography } from '@signozhq/ui/typography';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import styles from './CheckboxFilterV2Header.module.scss';
interface CheckboxFilterHeaderProps {
title: string;
isOpen: boolean;
showClearAll: boolean;
onToggleOpen: () => void;
onClear: () => void;
isSomeFilterPresentForCurrentAttribute: boolean;
}
export function CheckboxFilterV2Header({
title,
isOpen,
showClearAll,
onToggleOpen,
onClear,
isSomeFilterPresentForCurrentAttribute,
}: CheckboxFilterHeaderProps): JSX.Element {
return (
<section
role="button"
tabIndex={0}
className={styles.header}
onClick={onToggleOpen}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
onToggleOpen();
}
}}
data-testid="checkbox-filter-header"
data-state={isOpen ? 'open' : 'closed'}
>
<section className={styles.leftAction}>
{isOpen ? (
<ChevronDown size={13} cursor="pointer" />
) : (
<ChevronRight size={13} cursor="pointer" />
)}
<Typography.Text className={styles.title}>{title}</Typography.Text>
</section>
<section className={styles.rightAction}>
{isOpen && showClearAll && isSomeFilterPresentForCurrentAttribute && (
<Typography.Text
className={styles.clearAll}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
onClear();
}}
data-testid="checkbox-filter-clear-all"
>
Clear
</Typography.Text>
)}
</section>
</section>
);
}

View File

@@ -0,0 +1,106 @@
import {
IQuickFiltersConfig,
CheckedState,
} from 'components/QuickFilters/types';
import { CheckboxFilterV2ValueRow } from './CheckboxFilterV2ValueRow';
import { SectionDivider } from './SectionDivider';
import { Section } from './useSectionedValues';
import { SectionType } from './itemRules';
import styles from './CheckboxFilterV2.module.scss';
interface SectionConfig {
label: string;
tooltip?: string;
}
function getSectionConfig(type: SectionType): SectionConfig | null {
switch (type) {
case SectionType.SELECTED:
return { label: 'Selected' };
case SectionType.RELATED:
return {
label: 'Related',
tooltip: 'Values that are filtered by your current selection.',
};
case SectionType.ALL_VALUES:
return { label: 'All values' };
default:
return null;
}
}
interface CheckboxFilterV2SectionProps {
section: Section;
index: number;
isFilterDisabled: boolean;
filter: IQuickFiltersConfig;
isSomeFilterPresentForCurrentAttribute: boolean;
isMultipleValuesTrueForTheKey: boolean;
onChange: (
value: string,
checked: boolean,
isOnly: boolean,
previousState?: CheckedState,
sectionType?: SectionType,
) => void;
}
export function CheckboxFilterV2Section(
props: CheckboxFilterV2SectionProps,
): JSX.Element | null {
const {
section,
index,
isFilterDisabled,
filter,
isSomeFilterPresentForCurrentAttribute,
isMultipleValuesTrueForTheKey,
onChange,
} = props;
if (section.items.length === 0) {
return null;
}
const config = getSectionConfig(section.type);
// Show divider for all sections except first SELECTED section
const showDivider =
config !== null && (index > 0 || section.type !== SectionType.SELECTED);
return (
<div data-testid={`section-${section.type}`} className={styles.sectionValues}>
{showDivider && config && (
<SectionDivider label={config.label} tooltip={config.tooltip} />
)}
{section.items.map(({ value, badge, checkedState }) => {
const isChecked = checkedState === 'checked';
return (
<CheckboxFilterV2ValueRow
key={value}
value={value}
checkedState={checkedState}
disabled={isFilterDisabled}
title={filter.title}
badge={badge}
onlyButtonLabel={
isSomeFilterPresentForCurrentAttribute
? isChecked && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'
}
customRendererForValue={filter.customRendererForValue}
onCheckboxChange={(checked, previousState): void =>
onChange(value, checked, false, previousState, section.type)
}
onOnlyOrAllClick={(): void => onChange(value, isChecked, true)}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,166 @@
.valueRow {
display: flex;
align-items: center;
gap: var(--spacing-4);
min-height: 24px;
}
.checkbox {
display: inline-flex;
align-items: center;
}
.valueButton {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--spacing-2);
width: calc(100% - 24px);
cursor: pointer;
}
.content {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
}
.valueLabel {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.actions {
display: grid;
align-items: center;
justify-items: end;
// Stack badge / only / toggle in a single cell so the crossfade overlaps
// instead of laying them side-by-side mid-transition.
> * {
grid-area: 1 / 1;
}
}
.badge {
display: inline-flex;
align-items: center;
opacity: 1;
transition:
opacity 0.16s ease,
display 0.16s allow-discrete;
}
.onlyButton {
display: none;
align-items: center;
justify-content: center;
opacity: 0;
transform: translateX(4px);
transition:
opacity 0.16s ease,
transform 0.16s ease,
display 0.16s allow-discrete;
--button-height: 21px;
--button-padding: var(--spacing-5);
&:hover {
background-color: unset;
}
}
.toggleButton {
display: none;
align-items: center;
justify-content: center;
opacity: 0;
transform: translateX(4px);
transition:
opacity 0.16s ease,
transform 0.16s ease,
display 0.16s allow-discrete;
--button-height: 21px;
--button-padding: var(--spacing-5);
&:hover {
background-color: unset;
}
}
.isDisabled {
cursor: not-allowed;
.valueLabel {
color: var(--l3-foreground);
}
.onlyButton {
cursor: not-allowed;
color: var(--l3-foreground);
}
.toggleButton {
cursor: not-allowed;
color: var(--l3-foreground);
}
}
.valueButton:hover {
.onlyButton {
display: flex;
opacity: 1;
transform: translateX(0);
@starting-style {
opacity: 0;
transform: translateX(4px);
}
}
.badge {
display: none;
opacity: 0;
}
}
.checkbox:hover ~ .valueButton {
.toggleButton {
display: flex;
opacity: 1;
transform: translateX(0);
@starting-style {
opacity: 0;
transform: translateX(4px);
}
}
.badge {
display: none;
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.badge,
.onlyButton,
.toggleButton {
transition: none;
}
}
.indicatorFalse {
width: 2px;
height: 11px;
border-radius: 2px;
background: var(--danger-background);
}
.indicatorTrue {
width: 2px;
height: 11px;
border-radius: 2px;
background: var(--bg-forest-500);
}

View File

@@ -0,0 +1,297 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BadgeConfig } from './itemRules';
import { CheckedState } from '../../../types';
import { CheckboxFilterV2ValueRow } from './CheckboxFilterV2ValueRow';
describe('CheckboxFilterV2ValueRow', () => {
const defaultProps = {
value: 'production',
checkedState: 'unchecked' as CheckedState,
disabled: false,
title: 'Environment',
onlyButtonLabel: 'Only',
onCheckboxChange: jest.fn(),
onOnlyOrAllClick: jest.fn(),
badge: null as BadgeConfig | null,
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('checked states', () => {
it('sets data-state="unchecked" for unchecked state', () => {
render(
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="unchecked" />,
);
const row = screen.getByTestId('checkbox-value-row-production');
expect(row).toHaveAttribute('data-state', 'unchecked');
});
it('sets data-state="checked" for checked state', () => {
render(
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="checked" />,
);
const row = screen.getByTestId('checkbox-value-row-production');
expect(row).toHaveAttribute('data-state', 'checked');
});
});
describe('badge variations', () => {
it('renders no badge when badge=null', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} badge={null} />);
expect(screen.queryByTestId(/^badge-/)).not.toBeInTheDocument();
});
it('renders "Not in" warning badge', () => {
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
badge={{ key: 'not_in', label: 'Not in', color: 'warning' }}
/>,
);
expect(screen.getByTestId('badge-not_in')).toBeInTheDocument();
expect(screen.getByText('Not in')).toBeInTheDocument();
});
it('renders "Related" robin badge', () => {
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
badge={{ key: 'related', label: 'Related', color: 'robin' }}
/>,
);
expect(screen.getByTestId('badge-related')).toBeInTheDocument();
expect(screen.getByText('Related')).toBeInTheDocument();
});
it('renders "Other" secondary badge', () => {
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
badge={{ key: 'other', label: 'Other', color: 'secondary' }}
/>,
);
expect(screen.getByTestId('badge-other')).toBeInTheDocument();
expect(screen.getByText('Other')).toBeInTheDocument();
});
});
describe('only/all button label', () => {
it('shows "Only" label by default', () => {
render(
<CheckboxFilterV2ValueRow {...defaultProps} onlyButtonLabel="Only" />,
);
expect(screen.getByText('Only')).toBeInTheDocument();
});
it('shows "All" label when appropriate', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} onlyButtonLabel="All" />);
expect(screen.getByText('All')).toBeInTheDocument();
});
});
describe('disabled state', () => {
it('sets data-disabled=true when disabled', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} disabled />);
const row = screen.getByTestId('checkbox-value-row-production');
expect(row).toHaveAttribute('data-disabled', 'true');
});
it('does not call onOnlyOrAllClick when disabled + clicked', async () => {
const user = userEvent.setup();
const onOnlyOrAllClick = jest.fn();
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
disabled
onOnlyOrAllClick={onOnlyOrAllClick}
/>,
);
await user.click(screen.getByText('production'));
expect(onOnlyOrAllClick).not.toHaveBeenCalled();
});
it('does not call onOnlyOrAllClick on keydown when disabled', async () => {
const user = userEvent.setup();
const onOnlyOrAllClick = jest.fn();
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
disabled
onOnlyOrAllClick={onOnlyOrAllClick}
/>,
);
screen.getByText('production').focus();
await user.keyboard('{Enter}');
expect(onOnlyOrAllClick).not.toHaveBeenCalled();
});
});
describe('special value indicators', () => {
it('renders row for "true" value', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} value="true" />);
expect(screen.getByTestId('checkbox-value-row-true')).toBeInTheDocument();
expect(screen.getByText('true')).toBeInTheDocument();
});
it('renders row for "false" value', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} value="false" />);
expect(screen.getByTestId('checkbox-value-row-false')).toBeInTheDocument();
expect(screen.getByText('false')).toBeInTheDocument();
});
it('renders row for regular values', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} value="production" />);
expect(
screen.getByTestId('checkbox-value-row-production'),
).toBeInTheDocument();
expect(screen.getByText('production')).toBeInTheDocument();
});
});
describe('interactions', () => {
it('renders checkbox with correct testId', () => {
render(
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="unchecked" />,
);
expect(
screen.getByTestId('checkbox-Environment-production'),
).toBeInTheDocument();
});
it('calls onOnlyOrAllClick on value text click', async () => {
const user = userEvent.setup();
const onOnlyOrAllClick = jest.fn();
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
onOnlyOrAllClick={onOnlyOrAllClick}
/>,
);
await user.click(screen.getByText('production'));
expect(onOnlyOrAllClick).toHaveBeenCalledTimes(1);
});
it('calls onOnlyOrAllClick on Enter key', async () => {
const user = userEvent.setup();
const onOnlyOrAllClick = jest.fn();
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
onOnlyOrAllClick={onOnlyOrAllClick}
/>,
);
const valueButton = screen
.getByText('production')
.closest('[role="button"]');
await user.tab();
await user.tab();
if (valueButton && document.activeElement === valueButton) {
await user.keyboard('{Enter}');
}
expect(onOnlyOrAllClick).toHaveBeenCalled();
});
it('calls onOnlyOrAllClick on Space key', async () => {
const user = userEvent.setup();
const onOnlyOrAllClick = jest.fn();
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
onOnlyOrAllClick={onOnlyOrAllClick}
/>,
);
const valueButton = screen
.getByText('production')
.closest('[role="button"]');
await user.tab();
await user.tab();
if (valueButton && document.activeElement === valueButton) {
await user.keyboard(' ');
}
expect(onOnlyOrAllClick).toHaveBeenCalled();
});
it('shows Toggle button', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} />);
expect(screen.getByText('Toggle')).toBeInTheDocument();
});
});
describe('custom renderer', () => {
it('uses customRendererForValue when provided', () => {
const customRenderer = (value: string): JSX.Element => (
<span data-testid="custom-render">{`Custom: ${value}`}</span>
);
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
customRendererForValue={customRenderer}
/>,
);
expect(screen.getByTestId('custom-render')).toBeInTheDocument();
expect(screen.getByText('Custom: production')).toBeInTheDocument();
});
it('shows default value text when no custom renderer', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} />);
expect(screen.getByText('production')).toBeInTheDocument();
});
});
describe('state combinations', () => {
it('checked + not_in badge', () => {
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
checkedState="unchecked"
badge={{ key: 'not_in', label: 'Not in', color: 'warning' }}
/>,
);
expect(screen.getByTestId('badge-not_in')).toBeInTheDocument();
});
it('disabled + badge still shows badge', () => {
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
disabled
badge={{ key: 'other', label: 'Other', color: 'secondary' }}
/>,
);
expect(screen.getByTestId('badge-other')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,118 @@
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { BadgeConfig } from './itemRules';
import { CheckedState } from '../../../types';
import styles from './CheckboxFilterV2ValueRow.module.scss';
interface ValueRowProps {
value: string;
checkedState: CheckedState;
disabled: boolean;
title: string;
onlyButtonLabel: string;
customRendererForValue?: (value: string) => JSX.Element;
onCheckboxChange: (checked: boolean, previousState: CheckedState) => void;
onOnlyOrAllClick: () => void;
badge: BadgeConfig | null;
}
function toCheckboxValue(state: CheckedState): boolean | 'indeterminate' {
if (state === 'indeterminate') {
return 'indeterminate';
}
return state === 'checked';
}
const INDICATOR_CLASS_MAP = {
false: styles.indicatorFalse,
true: styles.indicatorTrue,
} as Record<string, string>;
export function CheckboxFilterV2ValueRow({
value,
checkedState,
disabled,
title,
onlyButtonLabel,
customRendererForValue,
onCheckboxChange,
onOnlyOrAllClick,
badge,
}: ValueRowProps): JSX.Element {
const indicatorClass = INDICATOR_CLASS_MAP[value];
return (
<div
className={styles.valueRow}
data-testid={`checkbox-value-row-${value}`}
data-state={checkedState}
data-disabled={disabled}
>
<div className={styles.checkbox}>
<Checkbox
onChange={(isChecked): void =>
onCheckboxChange(isChecked === true, checkedState)
}
value={toCheckboxValue(checkedState)}
disabled={disabled}
color="primary"
testId={`checkbox-${title}-${value}`}
/>
</div>
<div
role="button"
tabIndex={disabled ? -1 : 0}
className={cx(styles.valueButton, disabled && styles.isDisabled)}
onClick={(): void => {
if (disabled) {
return;
}
onOnlyOrAllClick();
}}
onKeyDown={(e): void => {
if (disabled) {
return;
}
if (e.key === 'Enter' || e.key === ' ') {
onOnlyOrAllClick();
}
}}
>
<div className={styles.content}>
{indicatorClass && <div className={indicatorClass} />}
{customRendererForValue ? (
customRendererForValue(value)
) : (
<Typography.Text title={value} className={styles.valueLabel}>
{value}
</Typography.Text>
)}
</div>
<div className={styles.actions}>
{badge && (
<Badge
variant="outline"
color={badge.color}
className={styles.badge}
testId={`badge-${badge.key}`}
>
{badge.label}
</Badge>
)}
<Button variant="ghost" color="secondary" className={styles.onlyButton}>
{onlyButtonLabel}
</Button>
<Button variant="ghost" color="secondary" className={styles.toggleButton}>
Toggle
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
.divider {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-2) 0;
margin-bottom: calc(-1 * var(--spacing-4));
}
.line {
flex: 1;
height: 1px;
background: var(--l1-border);
}
.label {
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;
}
.infoIcon {
color: var(--l3-foreground);
cursor: help;
flex-shrink: 0;
}

View File

@@ -0,0 +1,33 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Info } from '@signozhq/icons';
import styles from './SectionDivider.module.scss';
interface SectionDividerProps {
label: string;
tooltip?: string;
}
export function SectionDivider({
label,
tooltip,
}: SectionDividerProps): JSX.Element {
const testId = label.toLowerCase().replace(/\s+/g, '-');
return (
<div className={styles.divider} data-testid={`section-divider-${testId}`}>
<div className={styles.line} />
<Typography.Text className={styles.label}>{label}</Typography.Text>
{tooltip && (
<Tooltip title={tooltip}>
<Info
size={12}
className={styles.infoIcon}
data-testid="section-divider-info"
/>
</Tooltip>
)}
<div className={styles.line} />
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { deriveItemConfig, ItemContext, SectionType } from './itemRules';
describe('itemRules', () => {
describe('deriveItemConfig', () => {
it('no query at all → section selected, no badge', () => {
const ctx: ItemContext = {
isSelectedOnFilter: false,
isInRelatedValues: true,
isNotInOperator: false,
hasExistingQuery: false,
hasFilterForThisKey: false,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.SELECTED);
expect(result.badge).toBeNull();
});
it('selected + IN operator → section selected, no badge', () => {
const ctx: ItemContext = {
isSelectedOnFilter: true,
isInRelatedValues: true,
isNotInOperator: false,
hasExistingQuery: true,
hasFilterForThisKey: true,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.SELECTED);
expect(result.badge).toBeNull();
});
it('selected + NOT IN operator → section selected, no badge, unchecked', () => {
const ctx: ItemContext = {
isSelectedOnFilter: true,
isInRelatedValues: false,
isNotInOperator: true,
hasExistingQuery: true,
hasFilterForThisKey: true,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.SELECTED);
expect(result.badge).toBeNull();
expect(result.checkedState).toBe('unchecked');
});
it('has query, not selected, in related → section related, checked', () => {
const ctx: ItemContext = {
isSelectedOnFilter: false,
isInRelatedValues: true,
isNotInOperator: false,
hasExistingQuery: true,
hasFilterForThisKey: false,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.RELATED);
expect(result.badge).toBeNull();
expect(result.checkedState).toBe('checked');
});
it('has query, has filter for this key, in related → section related, checked', () => {
const ctx: ItemContext = {
isSelectedOnFilter: false,
isInRelatedValues: true,
isNotInOperator: false,
hasExistingQuery: true,
hasFilterForThisKey: true,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.RELATED);
expect(result.badge).toBeNull();
expect(result.checkedState).toBe('checked');
});
it('has query, not in related → section all_values, unchecked', () => {
const ctx: ItemContext = {
isSelectedOnFilter: false,
isInRelatedValues: false,
isNotInOperator: false,
hasExistingQuery: true,
hasFilterForThisKey: false,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.ALL_VALUES);
expect(result.badge).toBeNull();
expect(result.checkedState).toBe('unchecked');
});
it('has query + filter for key, not selected, not in related → section all_values, unchecked', () => {
const ctx: ItemContext = {
isSelectedOnFilter: false,
isInRelatedValues: false,
isNotInOperator: false,
hasExistingQuery: true,
hasFilterForThisKey: true,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.ALL_VALUES);
expect(result.badge).toBeNull();
expect(result.checkedState).toBe('unchecked');
});
it('no query but has filter for key, not selected → fallback to checked (DEFAULT_CONFIG)', () => {
const ctx: ItemContext = {
isSelectedOnFilter: false,
isInRelatedValues: false,
isNotInOperator: false,
hasExistingQuery: false,
hasFilterForThisKey: true,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.SELECTED);
expect(result.badge).toBeNull();
expect(result.checkedState).toBe('checked');
});
});
});

View File

@@ -0,0 +1,118 @@
import { CheckedState } from '../../../types';
export enum SectionType {
SELECTED = 'selected',
RELATED = 'related',
ALL_VALUES = 'all_values',
}
export interface BadgeConfig {
key: string;
label: string;
color: 'robin' | 'warning' | 'secondary';
}
export interface ItemConfig {
section: SectionType;
badge: BadgeConfig | null;
checkedState: CheckedState;
}
export interface ItemContext {
isSelectedOnFilter: boolean;
isInRelatedValues: boolean;
isNotInOperator: boolean;
hasExistingQuery: boolean;
hasFilterForThisKey: boolean;
}
export interface DerivedItem extends ItemConfig {
value: string;
}
interface ItemRule {
condition: (ctx: ItemContext) => boolean;
config: ItemConfig;
}
const ITEM_RULES: ItemRule[] = [
// No existing query and no filter → all checked (selected section)
{
condition: (ctx): boolean =>
!ctx.hasExistingQuery && !ctx.hasFilterForThisKey,
config: {
section: SectionType.SELECTED,
badge: null,
checkedState: 'checked',
},
},
// Selected with NOT IN operator → unchecked, no badge
{
condition: (ctx): boolean => ctx.isSelectedOnFilter && ctx.isNotInOperator,
config: {
section: SectionType.SELECTED,
badge: null,
checkedState: 'unchecked',
},
},
// Selected with IN operator → checked
{
condition: (ctx): boolean => ctx.isSelectedOnFilter && !ctx.isNotInOperator,
config: {
section: SectionType.SELECTED,
badge: null,
checkedState: 'checked',
},
},
// Related values (from existing query) → checked
{
condition: (ctx): boolean => ctx.hasExistingQuery && ctx.isInRelatedValues,
config: {
section: SectionType.RELATED,
badge: null,
checkedState: 'checked',
},
},
// All values (has existing query but not related) → unchecked
{
condition: (ctx): boolean => ctx.hasExistingQuery,
config: {
section: SectionType.ALL_VALUES,
badge: null,
checkedState: 'unchecked',
},
},
];
// Fallback when no rule matches
const DEFAULT_CONFIG: ItemConfig = {
section: SectionType.SELECTED,
badge: null,
checkedState: 'checked',
};
export function deriveItemConfig(ctx: ItemContext): ItemConfig {
for (const rule of ITEM_RULES) {
if (rule.condition(ctx)) {
return rule.config;
}
}
return DEFAULT_CONFIG;
}
export function deriveItems(
values: string[],
relatedSet: Set<string>,
selectedOnFilterSet: Set<string>,
ctx: Omit<ItemContext, 'isSelectedOnFilter' | 'isInRelatedValues'>,
): DerivedItem[] {
return values.map((value) => {
const itemCtx: ItemContext = {
...ctx,
isSelectedOnFilter: selectedOnFilterSet.has(value),
isInRelatedValues: relatedSet.has(value),
};
const config = deriveItemConfig(itemCtx);
return { value, ...config };
});
}

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { QuickFilterCheckboxUseFieldApis } from 'components/QuickFilters/types';
interface UseExistingQueryParams {
useFieldApis: QuickFilterCheckboxUseFieldApis;
activeQueryIndex: number;
}
interface UseExistingQueryResult {
existingQuery: string | undefined;
hasExistingQuery: boolean;
}
export function useExistingQuery({
useFieldApis,
activeQueryIndex,
}: UseExistingQueryParams): UseExistingQueryResult {
const { currentQuery } = useQueryBuilder();
const existingQuery = useMemo(() => {
if (useFieldApis.existingQuery === null) {
return undefined;
}
if (useFieldApis.existingQuery) {
return useFieldApis.existingQuery;
}
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
// Prefer V5 filter.expression
if (queryData?.filter?.expression) {
return queryData.filter.expression;
}
// Fall back to V3 filters.items
if (queryData?.filters?.items?.length) {
return convertFiltersToExpression(queryData.filters).expression;
}
return undefined;
}, [
useFieldApis.existingQuery,
currentQuery.builder.queryData,
activeQueryIndex,
]);
// Check if ANY filters exist in query (V3 items or V5 expression)
// This is separate from existingQuery because existingQuery can be explicitly
// disabled (null) while filters still exist in the query for UI purposes
const hasExistingQuery = useMemo(() => {
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
const hasV3Items = (queryData?.filters?.items?.length ?? 0) > 0;
const hasV5Expression = !!queryData?.filter?.expression;
return hasV3Items || hasV5Expression || !!existingQuery;
}, [currentQuery.builder.queryData, activeQueryIndex, existingQuery]);
return { existingQuery, hasExistingQuery };
}

View File

@@ -0,0 +1,97 @@
import { useMemo } from 'react';
import { useGetFieldsValues } from 'api/generated/services/fields';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { DataSource } from 'types/common/queryBuilder';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
interface UseFieldValuesProps {
filter: IQuickFiltersConfig;
searchText: string;
existingQuery?: string;
metricNamespace?: string;
startUnixMilli?: number;
endUnixMilli?: number;
enabled: boolean;
}
interface UseFieldValuesReturn {
relatedValues: string[];
allValues: string[];
isLoading: boolean;
isFetching: boolean;
}
const DATA_SOURCE_TO_SIGNAL: Record<DataSource, TelemetrytypesSignalDTO> = {
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
};
export function useFieldValues({
filter,
searchText,
existingQuery,
metricNamespace,
startUnixMilli,
endUnixMilli,
enabled,
}: UseFieldValuesProps): UseFieldValuesReturn {
const { data, isLoading, isFetching } = useGetFieldsValues(
{
signal: filter.dataSource
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
: undefined,
name: filter.attributeKey.key,
searchText,
existingQuery,
metricNamespace,
startUnixMilli,
// This field does not affect the backend but I wanted to keep it here
// in case we add the support in the future
endUnixMilli,
},
{
query: {
enabled,
cacheTime: FIELD_API_CACHE_TIME,
keepPreviousData: true,
},
},
);
const relatedValues: string[] = useMemo(() => {
const values = data?.data?.values;
if (!values) {
return [];
}
return (
values.relatedValues?.filter(
(value): value is string =>
value !== null && value !== undefined && value !== '',
) || []
);
}, [data]);
const allValues: string[] = useMemo(() => {
const values = data?.data?.values;
if (!values) {
return [];
}
const stringValues =
values.stringValues?.filter(
(value): value is string =>
value !== null && value !== undefined && value !== '',
) || [];
const numberValues =
values.numberValues
?.filter((value): value is number => value !== null && value !== undefined)
.map((value) => value.toString()) || [];
return [...stringValues, ...numberValues];
}, [data]);
return { relatedValues, allValues, isLoading, isFetching };
}

View File

@@ -0,0 +1,281 @@
import { renderHook } from '@testing-library/react';
import { SectionType } from './itemRules';
import { useSectionedValues, SectionedItem } from './useSectionedValues';
function flattenSections(
sections: { type: SectionType; items: SectionedItem[] }[],
): SectionedItem[] {
return sections.flatMap((s) => s.items);
}
describe('useSectionedValues', () => {
const baseInput = {
relatedValues: ['val1', 'val2'],
allValues: ['val1', 'val2', 'val3'],
currentFilterState: {},
isSomeFilterPresentForCurrentAttribute: false,
isNotInOperator: false,
hasExistingQuery: false,
visibleItemsCount: 10,
};
it('no query at all → all items in selected section, no badges', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
hasExistingQuery: false,
isSomeFilterPresentForCurrentAttribute: false,
}),
);
expect(result.current.sections).toHaveLength(1);
expect(result.current.sections[0].type).toBe(SectionType.SELECTED);
expect(result.current.sections[0].items).toHaveLength(3);
result.current.sections[0].items.forEach((item) => {
expect(item.badge).toBeNull();
});
});
it('has query, no filter for key → related and all_values sections', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: false,
}),
);
const allItems = flattenSections(result.current.sections);
const relatedItems = allItems.filter(
(item) => item.value === 'val1' || item.value === 'val2',
);
const otherItems = allItems.filter((item) => item.value === 'val3');
// Related values should be in related section, checked
relatedItems.forEach((item) => {
expect(item.section).toBe(SectionType.RELATED);
expect(item.checkedState).toBe('checked');
});
// Other values should be in all_values section, unchecked
otherItems.forEach((item) => {
expect(item.section).toBe(SectionType.ALL_VALUES);
expect(item.checkedState).toBe('unchecked');
});
});
it('has query + filter for key, selected value → in selected section', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: true,
currentFilterState: { val1: true, val2: false, val3: false },
}),
);
const allItems = flattenSections(result.current.sections);
const selectedItem = allItems.find((item) => item.value === 'val1');
expect(selectedItem?.section).toBe(SectionType.SELECTED);
expect(selectedItem?.badge).toBeNull();
});
it('has query + filter for key, NOT IN operator → excluded values in selected section, no badge', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: true,
isNotInOperator: true,
currentFilterState: { val1: false, val2: true, val3: true },
}),
);
const allItems = flattenSections(result.current.sections);
// val1 is unchecked + NOT IN = excluded
const excludedItem = allItems.find((item) => item.value === 'val1');
expect(excludedItem?.section).toBe(SectionType.SELECTED);
expect(excludedItem?.badge).toBeNull();
expect(excludedItem?.checkedState).toBe('unchecked');
});
it('items within same section sorted alphabetically', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
relatedValues: ['zebra', 'apple', 'mango'],
allValues: ['zebra', 'apple', 'mango'],
hasExistingQuery: false,
isSomeFilterPresentForCurrentAttribute: false,
}),
);
// All items have section selected, should be sorted alphabetically
const allItems = flattenSections(result.current.sections);
const values = allItems.map((item) => item.value);
expect(values).toStrictEqual(['apple', 'mango', 'zebra']);
});
describe('filtered results from API', () => {
it('keeps items in their natural sections with filtered API results', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: true,
currentFilterState: { val1: true },
}),
);
const sectionTypes = result.current.sections.map((s) => s.type);
expect(sectionTypes).toContain(SectionType.SELECTED);
expect(sectionTypes).toContain(SectionType.RELATED);
});
it('returns empty sections array when no values', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
relatedValues: [],
allValues: [],
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: false,
currentFilterState: {},
}),
);
expect(result.current.sections).toHaveLength(0);
expect(result.current.totalCount).toBe(0);
});
it('non-related items go to ALL_VALUES section', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
relatedValues: [],
allValues: ['other1', 'other2', 'other3'],
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: false,
}),
);
const allValuesSection = result.current.sections.find(
(s) => s.type === SectionType.ALL_VALUES,
);
expect(allValuesSection?.items).toHaveLength(3);
});
it('handles non-overlapping relatedValues and allValues correctly', () => {
// This tests the bug where different pod names in relatedValues vs allValues
// caused all items to go to ALL_VALUES instead of RELATED
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
relatedValues: ['pod-a-1', 'pod-b-1', 'pod-c-1'],
allValues: ['pod-a-2', 'pod-b-2', 'pod-c-2'],
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: false,
}),
);
const sectionTypes = result.current.sections.map((s) => s.type);
// RELATED section should exist with relatedValues items
expect(sectionTypes).toContain(SectionType.RELATED);
const relatedSection = result.current.sections.find(
(s) => s.type === SectionType.RELATED,
);
expect(relatedSection?.items).toHaveLength(3);
expect(relatedSection?.items.map((i) => i.value)).toStrictEqual(
expect.arrayContaining(['pod-a-1', 'pod-b-1', 'pod-c-1']),
);
// ALL_VALUES section should exist with allValues items
expect(sectionTypes).toContain(SectionType.ALL_VALUES);
const allValuesSection = result.current.sections.find(
(s) => s.type === SectionType.ALL_VALUES,
);
expect(allValuesSection?.items).toHaveLength(3);
expect(allValuesSection?.items.map((i) => i.value)).toStrictEqual(
expect.arrayContaining(['pod-a-2', 'pod-b-2', 'pod-c-2']),
);
});
});
describe('section ordering', () => {
it('sections appear in order: SELECTED → RELATED → ALL_VALUES', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
relatedValues: ['related1'],
allValues: ['all1'],
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: true,
currentFilterState: { selected1: true },
}),
);
const sectionTypes = result.current.sections.map((s) => s.type);
// Verify order
const selectedIdx = sectionTypes.indexOf(SectionType.SELECTED);
const relatedIdx = sectionTypes.indexOf(SectionType.RELATED);
const allValuesIdx = sectionTypes.indexOf(SectionType.ALL_VALUES);
expect(selectedIdx).toBeLessThan(relatedIdx);
expect(relatedIdx).toBeLessThan(allValuesIdx);
});
it('RELATED section appears before ALL_VALUES even with many items', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
relatedValues: ['r1', 'r2', 'r3', 'r4', 'r5'],
allValues: ['a1', 'a2', 'a3', 'a4', 'a5'],
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: false,
visibleItemsCount: 100,
}),
);
const sectionTypes = result.current.sections.map((s) => s.type);
expect(sectionTypes[0]).toBe(SectionType.RELATED);
expect(sectionTypes[1]).toBe(SectionType.ALL_VALUES);
});
it('visibleItemsCount limits total items across sections', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
relatedValues: ['r1', 'r2', 'r3'],
allValues: ['a1', 'a2', 'a3'],
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: false,
visibleItemsCount: 4,
}),
);
const totalItems = result.current.sections.reduce(
(sum, s) => sum + s.items.length,
0,
);
expect(totalItems).toBe(4);
// RELATED section should get priority (3 items)
const relatedSection = result.current.sections.find(
(s) => s.type === SectionType.RELATED,
);
expect(relatedSection?.items).toHaveLength(3);
// ALL_VALUES gets remaining (1 item)
const allValuesSection = result.current.sections.find(
(s) => s.type === SectionType.ALL_VALUES,
);
expect(allValuesSection?.items).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1,142 @@
import { useMemo } from 'react';
import { BadgeConfig, deriveItems, SectionType } from './itemRules';
import { CheckedState } from '../../../types';
interface SectionedValuesInput {
relatedValues: string[];
allValues: string[];
currentFilterState: Record<string, boolean>;
isSomeFilterPresentForCurrentAttribute: boolean;
isNotInOperator: boolean;
hasExistingQuery: boolean;
visibleItemsCount: number;
}
export interface SectionedItem {
value: string;
section: SectionType;
badge: BadgeConfig | null;
checkedState: CheckedState;
}
export interface Section {
type: SectionType;
items: SectionedItem[];
}
interface SectionedValuesOutput {
sections: Section[];
totalCount: number;
}
const SECTION_ORDER: SectionType[] = [
SectionType.SELECTED,
SectionType.RELATED,
SectionType.ALL_VALUES,
];
function buildSelectedSet(
currentFilterState: Record<string, boolean>,
isSomeFilterPresentForCurrentAttribute: boolean,
isNotInOperator: boolean,
): Set<string> {
const selectedSet = new Set<string>();
if (!isSomeFilterPresentForCurrentAttribute) {
return selectedSet;
}
for (const [val, isChecked] of Object.entries(currentFilterState)) {
// NOT IN: unchecked = explicitly excluded
// IN: checked = explicitly selected
const shouldAdd = isNotInOperator ? !isChecked : isChecked;
if (shouldAdd) {
selectedSet.add(val);
}
}
return selectedSet;
}
export function useSectionedValues({
relatedValues,
allValues,
currentFilterState,
isSomeFilterPresentForCurrentAttribute,
isNotInOperator,
hasExistingQuery,
visibleItemsCount,
}: SectionedValuesInput): SectionedValuesOutput {
const items = useMemo(() => {
const selectedSet = buildSelectedSet(
currentFilterState,
isSomeFilterPresentForCurrentAttribute,
isNotInOperator,
);
// Combine all values - API already filters both arrays by searchText
const allUniqueValues = Array.from(new Set([...relatedValues, ...allValues]));
// Include selected values at top - may not be in API response
const finalValues = [
...new Set([...Array.from(selectedSet), ...allUniqueValues]),
];
const relatedSet = new Set(relatedValues);
return deriveItems(finalValues, relatedSet, selectedSet, {
isNotInOperator,
hasExistingQuery,
hasFilterForThisKey: isSomeFilterPresentForCurrentAttribute,
});
}, [
relatedValues,
allValues,
currentFilterState,
isSomeFilterPresentForCurrentAttribute,
isNotInOperator,
hasExistingQuery,
]);
const sections = useMemo(() => {
// Group items by section
const sectionMap = new Map<SectionType, SectionedItem[]>();
for (const sectionType of SECTION_ORDER) {
sectionMap.set(sectionType, []);
}
for (const item of items) {
sectionMap.get(item.section)?.push(item);
}
// Sort items within each section alphabetically
for (const sectionItems of sectionMap.values()) {
sectionItems.sort((a, b) => a.value.localeCompare(b.value));
}
// Apply visibleItemsCount across all sections
let remaining = visibleItemsCount;
const result: Section[] = [];
for (const sectionType of SECTION_ORDER) {
const sectionItems = sectionMap.get(sectionType) || [];
if (sectionItems.length === 0) {
continue;
}
const itemsToTake = Math.min(sectionItems.length, remaining);
if (itemsToTake === 0) {
break;
}
result.push({
type: sectionType,
items: sectionItems.slice(0, itemsToTake),
});
remaining -= itemsToTake;
}
return result;
}, [items, visibleItemsCount]);
return { sections, totalCount: items.length };
}

View File

@@ -32,6 +32,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { USER_ROLES } from 'types/roles';
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
import Duration from './FilterRenderers/Duration/Duration';
import Slider from './FilterRenderers/Slider/Slider';
import useFilterConfig from './hooks/useFilterConfig';
@@ -51,6 +52,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
signal,
showFilterCollapse = true,
showQueryName = true,
useFieldApis,
} = props;
const { user } = useAppContext();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
@@ -297,21 +299,45 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
{filterConfig.map((filter) => {
switch (filter.type) {
case FiltersType.CHECKBOX:
return (
return useFieldApis ? (
<CheckboxV2
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
useFieldApis={useFieldApis}
/>
) : (
<Checkbox
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
);
case FiltersType.DURATION:
return <Duration filter={filter} onFilterChange={onFilterChange} />;
return (
<Duration
key={filter.attributeKey.key}
filter={filter}
onFilterChange={onFilterChange}
/>
);
case FiltersType.SLIDER:
return <Slider />;
return <Slider key={filter.attributeKey.key} />;
// eslint-disable-next-line sonarjs/no-duplicated-branches
default:
return (
return useFieldApis ? (
<CheckboxV2
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
useFieldApis={useFieldApis}
/>
) : (
<Checkbox
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
@@ -381,4 +407,5 @@ QuickFilters.defaultProps = {
config: [],
showFilterCollapse: true,
showQueryName: true,
useFieldApis: undefined,
};

View File

@@ -26,6 +26,11 @@ export enum SignalType {
METER_EXPLORER = 'meter',
}
/**
* Missing export from signozhq/ui/checkbox, TODO(H4ad): Add and remove this type definition
*/
export type CheckedState = 'checked' | 'unchecked' | 'indeterminate';
export interface IQuickFiltersConfig {
type: FiltersType;
title: string;
@@ -46,6 +51,7 @@ export interface IQuickFiltersProps {
className?: string;
showFilterCollapse?: boolean;
showQueryName?: boolean;
useFieldApis?: QuickFilterCheckboxUseFieldApis;
}
export enum QuickFiltersSource {
@@ -56,3 +62,19 @@ export enum QuickFiltersSource {
EXCEPTIONS = 'exceptions',
METER_EXPLORER = 'meter',
}
/**
* Opt-in: fetch values from the /v1/fields/values API instead of /v3/autocomplete/attribute_values
*/
export type QuickFilterCheckboxUseFieldApis = {
startUnixMilli: number;
endUnixMilli: number;
/**
* If you didn't specify a string, we automatically try to extract this from the currentQuery,
* from the filter.expression or filter.items.
*
* Use null to ignore/disable this behavior.
*/
existingQuery?: string | null;
metricNamespace?: string;
};

View File

@@ -4,11 +4,11 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import {
APIKeyCreatePermission,
buildSAAttachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { popupContainer } from 'utils/selectPopupContainer';
import { disabledDate } from '../utils';

View File

@@ -1,8 +1,8 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { buildSADeletePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { buildSADeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';

View File

@@ -7,12 +7,12 @@ import { Input } from '@signozhq/ui/input';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import {
buildAPIKeyDeletePermission,
buildAPIKeyUpdatePermission,
buildSADetachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { popupContainer } from 'utils/selectPopupContainer';
import { disabledDate, formatLastObservedAt } from '../utils';

View File

@@ -16,8 +16,8 @@ import type {
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import dayjs from 'dayjs';
import { buildAPIKeyUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { buildAPIKeyUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';

View File

@@ -4,13 +4,13 @@ import { Button } from '@signozhq/ui/button';
import { Skeleton, Table, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import {
APIKeyCreatePermission,
buildAPIKeyDeletePermission,
buildSAAttachPermission,
buildSADetachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs';

View File

@@ -5,11 +5,11 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { useCopyToClipboard } from 'react-use';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import RolesSelect from 'components/RolesSelect';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { buildSAUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { buildSAUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';

View File

@@ -1,11 +1,11 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import {
buildAPIKeyDeletePermission,
buildSADetachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';

View File

@@ -16,7 +16,7 @@ import {
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedCallout from 'components/PermissionDeniedCallout/PermissionDeniedCallout';
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
import { useRoles } from 'components/RolesSelect';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import {
@@ -35,8 +35,8 @@ import {
buildSADeletePermission,
buildSAReadPermission,
buildSAUpdatePermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
parseAsBoolean,
parseAsInteger,
@@ -47,7 +47,7 @@ import {
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import AddKeyModal from './AddKeyModal';
import DeleteAccountModal from './DeleteAccountModal';
import KeysTab from './KeysTab';

View File

@@ -6,7 +6,7 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import EditKeyModal from '../EditKeyModal';
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,

View File

@@ -6,7 +6,7 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import KeysTab from '../KeysTab';
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,

View File

@@ -7,11 +7,11 @@ import {
setupAuthzAdmin,
setupAuthzDeny,
setupAuthzDenyAll,
} from 'tests/authz-test-utils';
} from 'lib/authz/utils/authz-test-utils';
import {
APIKeyListPermission,
buildSADeletePermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import ServiceAccountDrawer from '../ServiceAccountDrawer';

View File

@@ -3,7 +3,7 @@ import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { setupAuthzAdmin } from 'tests/authz-test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import ServiceAccountDrawer from '../ServiceAccountDrawer';

View File

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

View File

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

View File

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

View File

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

@@ -1,3 +1,5 @@
export const DASHBOARD_CACHE_TIME = 30_000;
// keep it low or zero, otherwise, when enabled auto-refresh, this causes OOM due to accumulated queries in cache
export const DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED = 0;
export const FIELD_API_CACHE_TIME = 60_000;

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

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

@@ -5,6 +5,14 @@
height: 100%;
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

@@ -2,8 +2,9 @@ import { useCallback, useMemo, useRef } from 'react';
import { UseQueryResult } from 'react-query';
import { Skeleton } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { PANEL_TYPES } from 'constants/queryBuilder';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
@@ -13,13 +14,13 @@ import {
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { AlignedData, Options } from 'uplot';
import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver';
import { buildEntityMetricsChartConfig } from './configBuilder';
import { useEntityMetrics } from './hooks';
import { isKeyNotFoundError } from '../utils';
@@ -70,7 +71,7 @@ function EntityMetrics<T>({
{ threshold: 0.1 },
);
const { queries, chartData, queryPayloads } = useEntityMetrics({
const { queries, chartData, tableData, queryPayloads } = useEntityMetrics({
queryKey,
timeRange,
entity,
@@ -80,16 +81,10 @@ function EntityMetrics<T>({
});
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const { currentQuery } = useQueryBuilder();
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const onDragSelect = useCallback(
(start: number, end: number): void => {
@@ -101,43 +96,39 @@ function EntityMetrics<T>({
[handleTimeChange],
);
const options = useMemo(
const configs = useMemo(
() =>
queries.map(({ data }, idx) => {
const panelType = queryPayloads[idx]?.graphType;
if (panelType === PANEL_TYPES.TABLE) {
return null;
}
return getUPlotChartOptions({
apiResponse: data?.payload,
const widgetTitle = entityWidgetInfo[idx].title
.toLowerCase()
.replace(/\s+/g, '-');
return buildEntityMetricsChartConfig({
id: `${category}-${widgetTitle}`,
isDarkMode,
dimensions,
currentQuery,
onDragSelect,
apiResponse: data?.payload,
timezone,
yAxisUnit: entityWidgetInfo[idx].yAxisUnit,
softMax: null,
softMin: null,
minTimeScale: timeRange.startTime,
maxTimeScale: timeRange.endTime,
onDragSelect,
query: currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}): void => {
legendScrollPositionRef.current = position;
},
});
}),
[
queries,
queryPayloads,
category,
isDarkMode,
dimensions,
currentQuery,
onDragSelect,
timezone,
entityWidgetInfo,
timeRange.startTime,
timeRange.endTime,
onDragSelect,
currentQuery,
],
);
@@ -170,14 +161,22 @@ function EntityMetrics<T>({
>
{panelType === PANEL_TYPES.TABLE ? (
<MetricsTable
rows={chartData[idx]?.[0]?.rows ?? []}
columns={chartData[idx]?.[0]?.columns ?? []}
rows={tableData[idx]?.[0]?.rows ?? []}
columns={tableData[idx]?.[0]?.columns ?? []}
/>
) : (
<Uplot
options={options[idx] as Options}
data={chartData[idx] as AlignedData}
/>
configs[idx] &&
chartData[idx] && (
<TimeSeries
config={configs[idx]}
data={chartData[idx]}
legendConfig={{ position: LegendPosition.BOTTOM }}
width={dimensions.width}
height={dimensions.height}
timezone={timezone}
yAxisUnit={entityWidgetInfo[idx].yAxisUnit}
/>
)
)}
</div>
);

View File

@@ -3,6 +3,7 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import * as appContextHooks from 'providers/App/App';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
import uPlot from 'uplot';
import EntityMetrics from '../EntityMetrics';
import { useEntityMetrics } from '../hooks';
@@ -15,12 +16,15 @@ const mockUseEntityMetrics = useEntityMetrics as jest.MockedFunction<
typeof useEntityMetrics
>;
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
getUPlotChartOptions: jest.fn().mockReturnValue({}),
jest.mock('../configBuilder', () => ({
buildEntityMetricsChartConfig: jest.fn().mockReturnValue({
getId: jest.fn().mockReturnValue('mock-id'),
}),
}));
jest.mock('lib/uPlotLib/utils/getUplotChartData', () => ({
getUPlotChartData: jest.fn().mockReturnValue([]),
jest.mock('lib/uPlotV2/utils/dataUtils', () => ({
prepareChartData: jest.fn().mockReturnValue([]),
hasSingleVisiblePoint: jest.fn().mockReturnValue(false),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
@@ -30,9 +34,20 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
),
}));
jest.mock('components/Uplot', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="uplot-chart">Uplot Chart</div>,
jest.mock(
'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries',
() => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="uplot-chart">TimeSeries Chart</div>
),
}),
);
jest.mock('providers/Timezone', () => ({
useTimezone: (): { timezone: { value: string } } => ({
timezone: { value: 'UTC' },
}),
}));
jest.mock('../MetricsTable', () => ({
@@ -294,20 +309,28 @@ const renderEntityMetrics = (overrides = {}): any => {
);
};
const mockChartData = [
[], // time_series chart data (uplot handles empty array)
const mockChartData: (uPlot.AlignedData | null)[] = [
[
[1705315200, 1705318800],
[42.5, 43.2],
], // time_series chart data (AlignedData)
null, // table uses tableData
];
const mockTableData: (import('../utils').MetricsTableData[] | null)[] = [
null, // time_series uses chartData
[
{
rows: [
{ data: { timestamp: '2024-01-15T10:00:00Z', value: '1024' } },
{ data: { timestamp: '2024-01-15T10:01:00Z', value: '1028' } },
{ timestamp: '2024-01-15T10:00:00Z', value: '1024' },
{ timestamp: '2024-01-15T10:01:00Z', value: '1028' },
],
columns: [
{ key: 'timestamp', label: 'Timestamp', isValueColumn: false },
{ key: 'value', label: 'Value', isValueColumn: true },
],
},
], // table chart data
], // table data
];
const mockQueryPayloads = [
@@ -321,6 +344,7 @@ describe('EntityMetrics', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockQueries as any,
chartData: mockChartData,
tableData: mockTableData,
queryPayloads: mockQueryPayloads as any,
});
mockUseQuery.mockReturnValue({
@@ -351,7 +375,8 @@ describe('EntityMetrics', () => {
it('renders loading state when fetching metrics', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockLoadingQueries as any,
chartData: [[], []],
chartData: [null, null],
tableData: [null, null],
queryPayloads: mockQueryPayloads as any,
});
renderEntityMetrics();
@@ -362,7 +387,8 @@ describe('EntityMetrics', () => {
it('renders error state when query fails', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockErrorQueries as any,
chartData: [[], []],
chartData: [null, null],
tableData: [null, null],
queryPayloads: mockQueryPayloads as any,
});
renderEntityMetrics();
@@ -373,8 +399,9 @@ describe('EntityMetrics', () => {
it('renders empty state when no metrics data', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockEmptyQueries as any,
chartData: [
[],
chartData: [[[]], null],
tableData: [
null,
[
{
rows: [],

View File

@@ -0,0 +1,122 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
DrawStyle,
FillMode,
LineInterpolation,
LineStyle,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
import { get } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import uPlot from 'uplot';
export interface EntityMetricsChartConfigProps {
id: string;
isDarkMode: boolean;
currentQuery: Query;
onDragSelect: (startTime: number, endTime: number) => void;
apiResponse?: MetricRangePayloadProps;
timezone: Timezone;
yAxisUnit: string;
minTimeScale?: number;
maxTimeScale?: number;
}
export function buildEntityMetricsChartConfig({
id,
isDarkMode,
currentQuery,
onDragSelect,
apiResponse,
timezone,
yAxisUnit,
minTimeScale,
maxTimeScale,
}: EntityMetricsChartConfigProps): UPlotConfigBuilder {
const stepIntervals = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
) as Record<string, number>;
const minStepInterval = Object.keys(stepIntervals).length
? Math.min(...Object.values(stepIntervals))
: undefined;
const tzDate = (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
const builder = new UPlotConfigBuilder({
id,
onDragSelect,
tzDate,
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
stepInterval: minStepInterval,
});
builder.addScale({
scaleKey: 'x',
time: true,
min: minTimeScale,
max: maxTimeScale,
});
builder.addScale({
scaleKey: 'y',
time: false,
});
builder.addAxis({
scaleKey: 'x',
show: true,
side: 2,
isDarkMode,
panelType: PANEL_TYPES.TIME_SERIES,
});
builder.addAxis({
scaleKey: 'y',
show: true,
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.TIME_SERIES,
});
if (!apiResponse?.data?.result) {
return builder;
}
apiResponse.data.result.forEach((series) => {
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
const baseLabelName = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = getLegend(series, currentQuery, baseLabelName);
builder.addSeries({
scaleKey: 'y',
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label,
colorMapping: {},
spanGaps: true,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
showPoints: hasSingleValidPoint,
pointSize: 5,
fillMode: FillMode.None,
isDarkMode,
metric: series.metric,
});
});
return builder;
}

View File

@@ -8,13 +8,14 @@ import {
GetMetricQueryRange,
GetQueryResultsProps,
} from 'lib/dashboard/getQueryResults';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { FeatureKeys } from '../../../../constants/features';
import { useAppContext } from '../../../../providers/App/App';
import { getMetricsTableData } from './utils';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import { getMetricsTableData, MetricsTableData } from './utils';
export interface UseEntityMetricsParams<T> {
queryKey: string;
@@ -32,10 +33,8 @@ export interface UseEntityMetricsParams<T> {
export interface UseEntityMetricsResult {
queries: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>[];
chartData: (
| ReturnType<typeof getUPlotChartData>
| ReturnType<typeof getMetricsTableData>
)[];
chartData: (uPlot.AlignedData | null)[];
tableData: (MetricsTableData[] | null)[];
queryPayloads: GetQueryResultsProps[];
}
@@ -93,9 +92,22 @@ export function useEntityMetrics<T>({
() =>
queries.map(({ data }, index) => {
const panelType = queryPayloads[index]?.graphType;
return panelType === PANEL_TYPES.TABLE
? getMetricsTableData(data)
: getUPlotChartData(data?.payload);
if (panelType === PANEL_TYPES.TABLE) {
return null;
}
return data?.payload ? prepareChartData(data.payload) : null;
}),
[queries, queryPayloads],
);
const tableData = useMemo(
() =>
queries.map(({ data }, index) => {
const panelType = queryPayloads[index]?.graphType;
if (panelType !== PANEL_TYPES.TABLE) {
return null;
}
return getMetricsTableData(data);
}),
[queries, queryPayloads],
);
@@ -103,6 +115,7 @@ export function useEntityMetrics<T>({
return {
queries,
chartData,
tableData,
queryPayloads,
};
}

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

@@ -0,0 +1,8 @@
.emptyMeterSearch {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,26 @@
import { Empty } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styles from './EmptyMeterSearch.module.scss';
interface EmptyMeterSearchProps {
hasQueryResult?: boolean;
}
export default function EmptyMeterSearch({
hasQueryResult,
}: EmptyMeterSearchProps): JSX.Element {
return (
<div className={styles.emptyMeterSearch}>
<Empty
description={
<Typography.Title level={5}>
{hasQueryResult
? 'No data'
: 'Select a metric and run a query to see the results'}
</Typography.Title>
}
/>
</div>
);
}

View File

@@ -73,34 +73,6 @@
margin-top: 10px;
margin-bottom: 20px;
}
.empty-meter-search {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.time-series-view-panel {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
padding: 8px !important;
margin: 8px;
}
.time-series-container {
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(min(100%, calc(50% - 8px)), 1fr)
);
gap: 16px;
width: 100%;
height: fit-content;
}
}
}
@@ -113,22 +85,6 @@
padding-bottom: 80px;
}
.meter-time-series-container {
display: flex;
flex-direction: column;
gap: 10px;
.builder-units-filter {
padding: 0 8px;
margin-bottom: 0px !important;
.builder-units-filter-label {
margin-bottom: 0px !important;
font-size: 12px;
}
}
}
.dashboards-and-alerts-popover-container {
display: flex;
gap: 16px;

View File

@@ -35,7 +35,6 @@ function Explorer(): JSX.Element {
handleRunQuery,
stagedQuery,
updateAllQueriesOperators,
handleSetQueryData,
currentQuery,
} = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
@@ -67,15 +66,6 @@ function Explorer(): JSX.Element {
[updateAllQueriesOperators],
);
useEffect(() => {
handleSetQueryData(0, {
...initialQueryMeterWithType.builder.queryData[0],
source: 'meter',
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const exportDefaultQuery = useMemo(
() =>
updateAllQueriesOperators(

View File

@@ -0,0 +1,18 @@
.loadingMeter {
display: flex;
justify-content: center;
align-items: flex-start;
height: 240px;
padding: var(--spacing-12) 0;
}
.loadingMeterContent {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.loadingGif {
height: 72px;
margin-left: calc(-1 * var(--spacing-12));
}

View File

@@ -0,0 +1,17 @@
import { Typography } from '@signozhq/ui/typography';
import { DataSource } from 'types/common/queryBuilder';
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
import styles from './MeterLoading.module.scss';
export default function MeterLoading(): JSX.Element {
return (
<div className={styles.loadingMeter}>
<div className={styles.loadingMeterContent}>
<img className={styles.loadingGif} src={loadingPlaneUrl} alt="wait-icon" />
<Typography>Retrieving your {DataSource.METRICS}</Typography>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
.meterTimeSeriesContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-5);
width: 100%;
:global(.builder-units-filter) {
padding: 0 var(--spacing-4);
margin-bottom: 0 !important;
}
:global(.builder-units-filter-label) {
margin-bottom: 0 !important;
font-size: 12px;
}
}
.timeSeriesContainer {
gap: var(--spacing-8);
width: 100%;
height: 50vh;
max-height: 50vh;
padding-right: 16px;
padding-left: 8px;
}
.timeSeriesViewPanel {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
}

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