Compare commits

..

37 Commits

Author SHA1 Message Date
SagarRajput-7
840914ae58 Merge branch 'main' into billing-graph-enhancement 2026-06-04 05:58:21 +05:30
SagarRajput-7
7fb222b6ee feat(billing): migrate BillingUsageGraph from uPlotLib to uPlotV2 and added stacking 2026-06-04 05:57:11 +05:30
Vinicius Lourenço
fdb22e6669 test(alerts): add tests for list alerts & triggered alerts (#11554)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* test(alerts): add tests for triggered and list alerts page

* test(alerts): add tests for list alerts and triggered alerts

* test(fireEvent): replace by userEvent
2026-06-03 19:53:35 +00:00
Ashwin Bhatkal
95adbc31cc chore(dashboard): remove obsolete Sentry query-range timeout warning (#11576)
Removes the useEffect that captured a Sentry warning when a widget's
query range was not called within 120s, along with the now-unused
queryRangeCalledRef and the @sentry/react import in GridCard.

Closes SigNoz/engineering-pod#5217
2026-06-03 19:50:16 +00:00
Vinicius Lourenço
c5288fc1ea fix(dashboards): variables can be undefined when create new dashboard (#11155) 2026-06-03 16:46:33 +00:00
Rinky Devi
86e71151d7 fix: remove widget filter references when a dashboard variable is deleted (#11270)
* fix: the query being updated after deleting the variables

* fix: use exact variable string match when removing clauses on delete

Passing `true` to removeKeysFromExpression removed the first clause whose
value contained any `$`, which corrupted expressions when two variables
shared the same filter attribute (e.g. $env and $env_region both backed
by deployment.environment). Switching to the exact variable string
(`$${variableName}`) ensures only the deleted variable's clause is removed.

Also adds 9 targeted edge-case tests covering shared-key variables,
variable-name boundary ($env vs $environment), mixed literal/variable
clauses, multi-value array filter items, clickhouse_sql, idempotency,
empty widgets, and unrelated-variable no-ops.

* fix: refined the deletiong process

* fix: adding toast

* fix: resolved comments

* fix: updated the tests and moved func to utils
2026-06-03 16:46:04 +00:00
Nikhil Soni
4fce33e2b3 chore: add metric for waterfall monitoring (#11557)
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
* chore: add metric for waterfall monitoring

Gauge for config limit and counter to count large traces

* chore: use traces instead of tracedetail in metric name

* chore: try removing high cardinality attributes

* chore: use typed argument in logs

* chore: add another metric to get idea of trace sizes

* chore: change config namespace to trace instead of tracedetail

To make it consistant with metric namespaces

* chore: add unit to the windowed response count metric

* chore: use metrics names as per otel conventions

* chore: use constants for metric attributes

* refactor: return error on metric creation failure

* Revert "refactor: return error on metric creation failure"

This reverts commit 091c93e80b.

* chore: panic on metric initiliazation error
2026-06-03 13:30:25 +00:00
Ashwin Bhatkal
a0ae4dfd05 feat(dashboards): V2 dashboard details — base scaffolding (#11543)
* feat(dashboard-v2): route entry — DashboardPageV2 fetches dashboard, gates loading/error

* feat(dashboard-v2): presentational container — compose description & sections layout

* feat(dashboard-v2): breadcrumbs + header chrome

* feat(dashboard-v2): dashboard description — title, meta, actions, rename

* feat(dashboard-v2): read-only panels & sections layout
2026-06-03 12:55:14 +00:00
Naman Verma
a0b14e0835 fix: do not show errors for non-existent cost meter metrics (#10843)
* fix: show warning for non-existent cost meter metrics

* chore: lint fix by removing unused list

* chore: py fmt add new line

* chore: missing newline between tests

* fix: no warnings or errors for internal metrics

* fix: pylint fix by adding new line

* fix: lint fix in test
2026-06-03 10:09:08 +00:00
Abhi kumar
987844dbc8 refactor(types): tighten MetricQueryRangeSuccessResponse shape (#11562)
Replaces the `SuccessResponse<...> & { warning?: Warning; meta?: ExecStats }`
intersection with a single interface that extends SuccessResponse and declares
warning + meta on its own — no stitched intersection. Also types `params` as
`QueryRangeRequestV5` (was `unknown`), so callers can drop the redundant
`as QueryRangeRequestV5` casts.

Knock-on updates: 13 consumers switched from the raw
`SuccessResponse<MetricRangePayloadProps, unknown>` shape to the new
`MetricQueryRangeSuccessResponse`; 3 prod casts removed;
convertV5ResponseToLegacy and getTimeRange tightened their accept-params types.
2026-06-03 07:55:44 +00:00
primus-bot[bot]
e0ad7e487a chore(release): bump to v0.127.0 (#11558)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-06-03 07:22:32 +00:00
Ashwin Bhatkal
af72a4118b fix: resolve UX regressions across dashboards, metrics and alerts pages after component migration (#11546)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: resolve UX regressions across dashboards, metrics and alerts pages after component migration

- NewSelect: make the @signozhq checkbox label fill the row (min-width: 0)
  so long option text truncates with an ellipsis; size Only/Toggle buttons to
  the resting row height so hover no longer grows the row
- PlannedDowntime: reset field-label styles bleeding into the migrated
  RadioGroup option labels and add spacing between options
- Page-level contrast, spacing, alignment and icon-size fixes across
  dashboard settings, metrics explorer, alert rules, routing policies,
  auto-refresh and the time picker

* fix(multiselect): surface clear icon and remove dropdown double-scroll

- add .ant-select-clear color override so the multiselect clear (x) icon is
  visible on the dark selector (the single-select had it, multiselect didn't)
- raise the dropdown max-height so the react-virtuoso list (<=300px) plus the
  header/footer fits, leaving the list as the single scroller instead of the
  list and the container both scrolling
- StatsCard: drop the unnecessary !important on .count-label color

* fix: success colors
2026-06-03 05:16:01 +00:00
Nityananda Gohain
dc6a1d0a4e fix: skip resource filter if it exceeds a threshold (#11524)
* fix: skip resource filter if it exceeds a threshold

* fix: test cleanup

* fix: test cleanup

* fix: more cleanup

* fix: tests
2026-06-03 05:08:40 +00:00
Aditya Singh
2e60ab0f81 fix(logs): dedupe body/timestamp columns in explorer picker (#11553)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: duplicate body issue

* fix: update test
2026-06-02 20:15:32 +00:00
SagarRajput-7
29ed44a8ce fix(sso): send empty object when clearing group/domain mappings on save (#11550) 2026-06-02 19:30:09 +00:00
Aditya Singh
76ee298605 feat(logs/traces): migrate column picker UI to FieldsSelector (#11516)
* feat: field selector migrated to telemetry field key

* feat: move floating panel to field selector

* feat: sync columns state in logs

* feat: sync columns state in traces

* feat: logs field migration integrate

* feat: traces field migration integrate

* feat: minor refactor

* feat: tests updated

* feat: move to key from name on fields for logs and traces

* feat: update tests

* feat: update tests
2026-06-02 17:37:27 +00:00
Tushar Vats
218f8269dd fix: donot close suggestion on typing period (#11490) 2026-06-02 17:35:12 +00:00
Nityananda Gohain
e8effa5b3f feat: [traces] time aware dynamic field mapper (#11234)
* feat: [traces] time aware dynamic field mapper

* fix: minor changes

* fix: get keys after modifying the selectkeys

* fix: more updated

* fix: lint

* fix: address comments

* fix: tests

* fix: tests

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-02 16:17:42 +00:00
Vikrant Gupta
ca5ff7f617 fix(web): update web settings config (#11548)
* fix(web): update web settings config

* fix(web): add test case for config settings
2026-06-02 15:12:51 +00:00
Vinicius Lourenço
a487b311bc fix(metrics-explorer): handle in case .data is undefined (#11527)
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-06-02 13:46:53 +00:00
Vinicius Lourenço
6473066193 fix(metrics-explorer): handle in case .atributes is undefined (#11528) 2026-06-02 13:46:52 +00:00
SagarRajput-7
fb0d34ae35 feat(auth): validate reset password token on page load before showing form (#11522)
* feat(auth): validate reset password token on page load before showing form

* fix(auth): distinct error copy for expired vs invalid token; skip 401 rotation on verify endpoint

* fix(auth): use endsWith for orval-generated endpoint guards in interceptorRejected

* Revert "fix(auth): use endsWith for orval-generated endpoint guards in interceptorRejected"

This reverts commit 00aa23b8fc.
2026-06-02 13:38:18 +00:00
Vinicius Lourenço
ba684acba3 fix(create-alert-v2): tooltip not showing due to pointer-events none (#11489) 2026-06-02 12:05:02 +00:00
Gaurav Tewari
184724003a chore: remove query status (#11476)
* chore: remove confusing query status

* chore: remove extra things

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-02 11:48:40 +00:00
Vinicius Lourenço
a4d3f10da8 chore(codeowners): add pulse for alerts and infra monitoring pages (#11508) 2026-06-02 11:44:38 +00:00
Nityananda Gohain
a71ac2ada6 fix: add adjustkeys in trace operator cte builder (#11349)
* fix: add adjustkeys in trace operator cte builder

* fix: more fixes

* fix: cleanup

* fix: move tests to trace operator file

* fix: address comments

* fix: lint issues

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-02 10:59:15 +00:00
Vikrant Gupta
0963ff08cd feat(web): disable all integrations by default (#11539) 2026-06-02 09:32:20 +00:00
Nikhil Soni
e43aeb8e24 fix: add references in waterfall response (#11536)
* fix: add references in waterfall response

* chore: update openapi specs

* chore: mark reference a non nullable field
2026-06-02 08:42:26 +00:00
Aditya Singh
9074208b09 feat: added trace events (#11538) 2026-06-02 08:41:39 +00:00
Vishal Sharma
571e23910e feat(ai-assistant): show descriptive Noz hover tooltip on all entry points (#11526)
Noz launched under early access and its three entry points (header button,
floating trigger, sidebar nav item) previously showed a bare "Noz" tooltip,
which does not educate users who don't yet recognize the name. Replace it with
a shared descriptive tooltip ("Noz, your AI teammate") sourced from a single
constant so the surfaces never drift.

- Add NOZ_TOOLTIP_TITLE in components/Noz/Noz.constants.ts
- Header button and floating trigger read the constant
- Add optional tooltip field to SidebarItem; NavItem wraps the whole row in a
  Tooltip (non-pinnable items only, to avoid nesting with the pin tooltip)
- Move the trigger's Noz icon to the Button prefix slot to match the codebase
  convention for icon-only buttons
2026-06-02 06:53:57 +00:00
Nikhil Soni
5e94f7ac6e feat(trace-details): Add API endpoint & module for trace aggregations (#11452)
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: add endpoint & module for trace aggregations

* chore: use v1 for aggregations api

* chore: update openapi specs

* feat: add implementation for aggregation store

* chore: use query builder for count by field

* chore: remove support for aggregations on attributes

Only supporting resource fields for now, since this
is not user input but hard coded client experience

* chore: extract out inner query as cte

* chore: again extract out inner query

* chore: move end_ns computation to first cte for simplicity

* chore: use notEmpty function instead of having

* fix: type cast issue in sum function

* chore: use query builder for duration query as well

* chore: add tests for trace store sql

* chore: remove unnecessary column checks

* chore: format the expected sql queries

* fix: formating was breaking test

* fix: change import and remove formating from sql

* chore: remove formating from store impl as well

* fix: mark required fields as required

* fix: explicitly mark nullable false for required field

* fix: mark required fields in response as well
2026-06-01 15:32:09 +00:00
Gaurav Tewari
387ad06c2d fix: tag container styles in qb (#11503)
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-01 14:09:06 +00:00
Aditya Singh
72ff433c20 feat(logs/traces): streamline column state — selectColumns becomes canonical source (#11426)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
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
* feat: field selector migrated to telemetry field key

* feat: move floating panel to field selector

* feat: sync columns state in logs

* feat: sync columns state in traces
2026-06-01 13:01:05 +00:00
Vinicius Lourenço
587f518599 refactor(infra-monitoring): removed primary filters deprecated prop (#11505) 2026-06-01 11:32:15 +00:00
Nityananda Gohain
bfc50ee9c3 chore: use distinct fingerprint in resource filter (#11521)
* chore: use distinct fingerprint in resource filter

* fix: use group by instead of distinct
2026-06-01 09:47:56 +00:00
Tushar Vats
4b08ba1330 fix: qb warnings (#11518) 2026-06-01 09:43:22 +00:00
Ashwin Bhatkal
557a7120df feat: v2 dashboards list page (#11404)
* feat: add useIsDashboardV2 hook

* feat(dashboards-list-v2): page shell

* feat(dashboards-list-v2): loading / error / empty / no-results state components

* feat(dashboards-list-v2): SearchBar input component

* feat(dashboards-list-v2): CreateDashboardDropdown (new / import JSON entry)

* feat(dashboards-list-v2): DashboardRow + per-row ActionsPopover with v2 delete

* feat(dashboards-list-v2): ListHeader with sort/order + Configure Metadata trigger

* feat(dashboards-list-v2): JSON import modal (Monaco editor + sample)

* feat(dashboards-list-v2): Configure Metadata modal + persisted visible-columns store

* feat(dashboards-list-v2): wire list to v2 API with pagination, URL state, debounced search

* chore: park v2 dashboard pages until backend lands on main

* refactor(dashboards-list-v2): extract lastUpdatedLabel into utils

Dedupe the relative-time formatter that was copied into both DashboardRow
and ConfigureMetadataModal. Single source in DashboardsListPageV2/utils.ts.

* refactor(dashboards-list-v2): group state components under states/

Move EmptyState, ErrorState, LoadingState, and NoResultsState under
components/states/. They're a coherent family (interchangeable view
branches in the list orchestrator) and grouping them sets up shared
styling extraction next.

Pure relocation — git mv preserves history; only DashboardsList.tsx's
imports change.

* refactor(dashboards-list-v2): extract shared state wrapper styles

Pull the dashed-border card layout, body text, and learn-more CTA into
states/states.module.scss. EmptyState, ErrorState, and NoResultsState
now compose from the shared base and only declare what differs
(padding, gap, color, font-weight). LoadingState is a different shape
(skeleton stack) and stays untouched.

Cascaded properties are byte-equivalent — pure de-duplication.

* refactor(dashboards-list-v2): port to @signozhq/ui primitives

Replace antd Button/Input usages and bespoke <button> rows with the
@signozhq/ui equivalents across the four review-flagged surfaces:

  - EmptyState "Learn more" → Button variant="link" color="primary"
  - ErrorState "Retry" → Button variant="outlined" color="secondary"
    with prefix icon; drops the sub-pixel .retryButton overrides in
    favour of the variant's tokenized layout.
  - ErrorState "Contact Support" → Button variant="link" color="primary"
  - SearchBar Input → signoz Input (data-testid → testId)
  - ActionsPopover rows → Button variant="ghost" color="secondary"
    (color="destructive" for Delete) with prefix icons
  - ActionsPopover trigger → Button size="icon" variant="ghost"

The signoz button variant handles the icon-slot alignment that the
removed .actionItem styling reimplemented manually, so the bulk of
ActionsPopover SCSS is deleted. A minimal .menuItem class still
left-aligns + fills the popover row, and .deleteDivider keeps the
hairline separator above the destructive action.

Popover itself stays on antd — migrating to @signozhq/ui/dropdown-menu
is deferred (same TODO as CreateDashboardDropdown).

* style(dashboards-list-v2): use font tokens across new SCSS

Replace literal font-size/weight values across the V2 page SCSS with
the shared design-token custom properties already used elsewhere:

  - 14px → var(--font-size-sm)
  - 12px → var(--font-size-xs)
  - 400  → var(--font-weight-normal)
  - 500  → var(--font-weight-medium)
  - 600  → var(--font-weight-semibold)

ErrorState.module.scss was tokenized alongside its .retryButton
removal in the previous commit. Three literals remain because they
have no clean token equivalent: 11px (sub-token), 8px (avatar
initial), and 12.805px (sub-pixel artifact from a Figma export,
a separate concern).

* style(dashboards-list-v2): replace sub-pixel padding in CreateDashboardDropdown

Round 5.937px / 11.875px / 1.484px to clean integer pixel values
(6px / 12px / 2px). The sub-pixel values were the "trial-and-error"
artifact reviewer flagged — likely a Figma scaling round-trip.

Difference is sub-pixel and not visible on any standard DPR.

* feat(dashboards-list-v2): treat search input as DSL with explicit submission

What the user types is what gets sent to the API as the `query`
param — no more wrapping the input in a synthesized
`name CONTAINS '…' or description CONTAINS '…'` clause.

This gives users the full DSL surface (name, description, created_by,
created_at, updated_at, locked, public, source, tag-key equality)
instead of the name+description CONTAINS approximation.

Submission is explicit (Enter, blur, or a return-key icon button
in the input's suffix slot) so an in-progress DSL string never
hits the API mid-typing. On error, the toolbar (search + create)
stays visible above the ErrorState so users can edit and retry
without losing their place.

  - Drop buildSearchDSL helper and the 300ms debounce.
  - Pass searchInput.trim() to the API; empty input clears filter.
  - SearchBar gains onSubmit (Enter / blur / suffix click).
  - Move ErrorState below the toolbar in DashboardsList layout.
  - Sync local input from searchString on URL changes (back/fwd).
  - Placeholder updated to hint at DSL syntax.

Trade-off: a bare "foo" no longer matches anything; users now need
to type "name CONTAINS 'foo'". This is the new UX.

* fix(dashboards-list-v2): tighten ActionsPopover button styling

  - Drop variant="ghost" on the three non-destructive row buttons so
    they pick up the signoz Button default.
  - Use <Divider /> from @signozhq/ui above the destructive Delete
    button instead of a bespoke .deleteDivider class.
  - Drop the surrounding .content padding and the now-unused
    .deleteDivider rule. Keep .menuItem for left-alignment + width.

* fix(dashboards-list-v2): surface DSL parse errors on 400 responses

When the BE rejects the search query with a 4xx, show the server-
provided detail (e.g. `invalid filter query: unsupported expression
"asdas" — every term must be of the form \`key OP value\``) instead
of the generic "Something went wrong" panel.

  - ErrorState gains optional httpStatus + errorMessage props.
    For 4xx it renders a two-line layout (title + cleaned detail)
    and drops the Retry button (retrying the same bad query won't
    help). Contact Support stays.
  - Add formatQueryErrorMessage util: strips the "invalid filter
    query:" prefix that the FE title already implies, and rewrites
    backtick-quoted format hints to use double quotes.
  - DashboardsList extracts status + message via toAPIError and
    passes them through. Search toolbar remains visible above the
    error, so the user can fix and resubmit.

* chore: fix lint error due to rebase
2026-06-01 08:48:50 +00:00
340 changed files with 14717 additions and 5137 deletions

19
.github/CODEOWNERS vendored
View File

@@ -169,3 +169,22 @@ go.mod @therealpandey
## Dashboard V2
/frontend/src/pages/DashboardPageV2/ @SigNoz/pulse-frontend
/frontend/src/pages/DashboardsListPageV2/ @SigNoz/pulse-frontend
## Infrastructure Monitoring
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringHosts/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringK8s/ @SigNoz/pulse-frontend
## Alerts
/frontend/src/pages/AlertList/ @SigNoz/pulse-frontend
/frontend/src/pages/AlertDetails/ @SigNoz/pulse-frontend
/frontend/src/pages/CreateAlert/ @SigNoz/pulse-frontend
/frontend/src/pages/EditRules/ @SigNoz/pulse-frontend
/frontend/src/container/AlertHistory/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertRule/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertV2/ @SigNoz/pulse-frontend
/frontend/src/container/EditAlertV2/ @SigNoz/pulse-frontend
/frontend/src/container/FormAlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend

View File

@@ -52,6 +52,7 @@ jobs:
- rootuser
- serviceaccount
- querier_json_body
- querier_skip_resource_fingerprint
- ttl
sqlstore-provider:
- postgres

View File

@@ -64,16 +64,16 @@ web:
settings:
posthog:
# Whether to enable PostHog in web.
enabled: true
enabled: false
appcues:
# Whether to enable Appcues in web.
enabled: true
enabled: false
sentry:
# Whether to enable Sentry in web.
enabled: true
enabled: false
pylon:
# Whether to enable Pylon in web.
enabled: true
enabled: false
##################### Cache #####################
cache:
@@ -432,7 +432,7 @@ cloudintegration:
version: v0.0.8
##################### Trace Detail #####################
tracedetail:
traces:
waterfall:
# Number of spans returned per request when the trace is too large to show all at once.
span_page_size: 500

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.126.1
image: signoz/signoz:v0.127.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port
@@ -213,7 +213,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.4
image: signoz/signoz-otel-collector:v0.144.5
entrypoint:
- /bin/sh
command:
@@ -241,7 +241,7 @@ services:
replicas: 3
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.4
image: signoz/signoz-otel-collector:v0.144.5
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.126.1
image: signoz/signoz:v0.127.0
ports:
- "8080:8080" # signoz port
volumes:
@@ -139,7 +139,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.4
image: signoz/signoz-otel-collector:v0.144.5
entrypoint:
- /bin/sh
command:
@@ -167,7 +167,7 @@ services:
replicas: 3
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.4
image: signoz/signoz-otel-collector:v0.144.5
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.126.1}
image: signoz/signoz:${VERSION:-v0.127.0}
container_name: signoz
ports:
- "8080:8080" # signoz port
@@ -204,7 +204,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
container_name: signoz-otel-collector
entrypoint:
- /bin/sh
@@ -229,7 +229,7 @@ services:
- "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
container_name: signoz-telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.126.1}
image: signoz/signoz:${VERSION:-v0.127.0}
container_name: signoz
ports:
- "8080:8080" # signoz port
@@ -132,7 +132,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
container_name: signoz-otel-collector
entrypoint:
- /bin/sh
@@ -157,7 +157,7 @@ services:
- "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
container_name: signoz-telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -6525,6 +6525,15 @@ components:
required:
- items
type: object
SpantypesGettableTraceAggregations:
properties:
aggregations:
items:
$ref: '#/components/schemas/SpantypesSpanAggregationResult'
type: array
required:
- aggregations
type: object
SpantypesGettableWaterfallTrace:
properties:
aggregations:
@@ -6563,6 +6572,15 @@ components:
nullable: true
type: array
type: object
SpantypesOtelSpanRef:
properties:
refType:
type: string
spanId:
type: string
traceId:
type: string
type: object
SpantypesPostableSpanMapper:
properties:
config:
@@ -6590,6 +6608,15 @@ components:
- name
- condition
type: object
SpantypesPostableTraceAggregations:
properties:
aggregations:
items:
$ref: '#/components/schemas/SpantypesSpanAggregation'
type: array
required:
- aggregations
type: object
SpantypesPostableWaterfall:
properties:
aggregations:
@@ -6614,6 +6641,9 @@ components:
$ref: '#/components/schemas/SpantypesSpanAggregationType'
field:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
required:
- field
- aggregation
type: object
SpantypesSpanAggregationResult:
properties:
@@ -6627,6 +6657,10 @@ components:
type: integer
nullable: true
type: object
required:
- field
- aggregation
- value
type: object
SpantypesSpanAggregationType:
enum:
@@ -6810,6 +6844,10 @@ components:
type: string
parent_span_id:
type: string
references:
items:
$ref: '#/components/schemas/SpantypesOtelSpanRef'
type: array
resource:
additionalProperties:
type: string
@@ -6835,6 +6873,8 @@ components:
type: string
trace_state:
type: string
required:
- references
type: object
TagtypesPostableTag:
properties:
@@ -12265,6 +12305,75 @@ paths:
summary: Test notification channel (deprecated)
tags:
- channels
/api/v1/traces/{traceID}/aggregations:
post:
deprecated: false
description: Computes span aggregations grouped by requested field.
operationId: GetTraceAggregations
parameters:
- in: path
name: traceID
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesPostableTraceAggregations'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableTraceAggregations'
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:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get aggregations for a trace
tags:
- tracedetail
/api/v1/user:
get:
deprecated: false

View File

@@ -1,28 +0,0 @@
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
interface SafeNavigateOptions {
replace?: boolean;
state?: unknown;
newTab?: boolean;
}
interface SafeNavigateTo {
pathname?: string;
search?: string;
hash?: string;
}
type SafeNavigateToType = string | SafeNavigateTo;
interface UseSafeNavigateReturn {
safeNavigate: jest.MockedFunction<
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
>;
}
export const useSafeNavigate = (): UseSafeNavigateReturn => ({
safeNavigate: jest.fn(
(_to: SafeNavigateToType, _options?: SafeNavigateOptions) => {},
) as jest.MockedFunction<
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
>,
});

View File

@@ -1,6 +1,8 @@
import type { Config } from '@jest/types';
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
const USE_SAFE_NAVIGATE_MOCK_PATH =
'<rootDir>/src/__tests__/safeNavigateMock.ts';
const LOG_EVENT_MOCK_PATH = '<rootDir>/src/__tests__/logEventMock.ts';
const config: Config.InitialOptions = {
silent: true,
@@ -22,6 +24,8 @@ const config: Config.InitialOptions = {
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^api/common/logEvent$': LOG_EVENT_MOCK_PATH,
'^src/api/common/logEvent$': LOG_EVENT_MOCK_PATH,
'^constants/env$': '<rootDir>/__mocks__/env.ts',
'^src/constants/env$': '<rootDir>/__mocks__/env.ts',
'^@signozhq/icons$': '<rootDir>/__mocks__/signozhqIconsMock.tsx',

View File

@@ -0,0 +1,11 @@
// Shared mock for `api/common/logEvent`.
// Wired into jest.config.ts moduleNameMapper, so any import of
// `api/common/logEvent` in test code resolves to this file.
// Tests can import `logEventMock` to assert analytics calls — Jest's
// `clearMocks: true` resets call history between tests.
export const logEventMock: jest.MockedFunction<
(eventName: string, attributes?: Record<string, unknown>) => void
> = jest.fn();
export default logEventMock;

View File

@@ -0,0 +1,29 @@
// Shared mock for `hooks/useSafeNavigate`.
// Wired into jest.config.ts moduleNameMapper, so any import of
// `hooks/useSafeNavigate` in test code resolves to this file.
// Tests can import `safeNavigateMock` to assert navigation calls — Jest's
// `clearMocks: true` resets call history between tests.
interface SafeNavigateOptions {
replace?: boolean;
state?: unknown;
newTab?: boolean;
}
interface SafeNavigateTo {
pathname?: string;
search?: string;
hash?: string;
}
type SafeNavigateToType = string | SafeNavigateTo;
export const safeNavigateMock: jest.MockedFunction<
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
> = jest.fn();
export const useSafeNavigate = (): {
safeNavigate: typeof safeNavigateMock;
} => ({
safeNavigate: safeNavigateMock,
});

View File

@@ -3,13 +3,29 @@ import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
export interface DayBreakdownEntry {
timestamp: number;
total: number;
quantity: number;
count: number;
size: number;
}
export interface BreakdownEntry {
type: string;
unit: string;
dayWiseBreakdown: {
breakdown: DayBreakdownEntry[];
};
}
export interface UsageResponsePayloadProps {
billingPeriodStart: Date;
billingPeriodEnd: Date;
billingPeriodStart: number;
billingPeriodEnd: number;
details: {
total: number;
baseFee: number;
breakdown: [];
breakdown: BreakdownEntry[];
billTotal: number;
};
discount: number;

View File

@@ -7753,12 +7753,34 @@ export type SpantypesSpanAggregationResultDTOValue =
SpantypesSpanAggregationResultDTOValueAnyOf | null;
export interface SpantypesSpanAggregationResultDTO {
aggregation?: SpantypesSpanAggregationTypeDTO;
field?: TelemetrytypesTelemetryFieldKeyDTO;
aggregation: SpantypesSpanAggregationTypeDTO;
field: TelemetrytypesTelemetryFieldKeyDTO;
/**
* @type object,null
*/
value?: SpantypesSpanAggregationResultDTOValue;
value: SpantypesSpanAggregationResultDTOValue;
}
export interface SpantypesGettableTraceAggregationsDTO {
/**
* @type array
*/
aggregations: SpantypesSpanAggregationResultDTO[];
}
export interface SpantypesOtelSpanRefDTO {
/**
* @type string
*/
refType?: string;
/**
* @type string
*/
spanId?: string;
/**
* @type string
*/
traceId?: string;
}
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
@@ -7855,6 +7877,10 @@ export interface SpantypesWaterfallSpanDTO {
* @type string
*/
parent_span_id?: string;
/**
* @type array
*/
references: SpantypesOtelSpanRefDTO[];
/**
* @type object,null
*/
@@ -8000,8 +8026,15 @@ export interface SpantypesPostableSpanMapperGroupDTO {
}
export interface SpantypesSpanAggregationDTO {
aggregation?: SpantypesSpanAggregationTypeDTO;
field?: TelemetrytypesTelemetryFieldKeyDTO;
aggregation: SpantypesSpanAggregationTypeDTO;
field: TelemetrytypesTelemetryFieldKeyDTO;
}
export interface SpantypesPostableTraceAggregationsDTO {
/**
* @type array
*/
aggregations: SpantypesSpanAggregationDTO[];
}
export interface SpantypesPostableWaterfallDTO {
@@ -9344,6 +9377,17 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type GetTraceAggregationsPathParameters = {
traceID: string;
};
export type GetTraceAggregations200 = {
data: SpantypesGettableTraceAggregationsDTO;
/**
* @type string
*/
status: string;
};
export type ListUsersDeprecated200 = {
/**
* @type array

View File

@@ -12,17 +12,120 @@ import type {
} from 'react-query';
import type {
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetWaterfall200,
GetWaterfallPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
SpantypesPostableTraceAggregationsDTO,
SpantypesPostableWaterfallDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Computes span aggregations grouped by requested field.
* @summary Get aggregations for a trace
*/
export const getTraceAggregations = (
{ traceID }: GetTraceAggregationsPathParameters,
spantypesPostableTraceAggregationsDTO?: BodyType<SpantypesPostableTraceAggregationsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceAggregations200>({
url: `/api/v1/traces/${traceID}/aggregations`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableTraceAggregationsDTO,
signal,
});
};
export const getGetTraceAggregationsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
> => {
const mutationKey = ['getTraceAggregations'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof getTraceAggregations>>,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return getTraceAggregations(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type GetTraceAggregationsMutationResult = NonNullable<
Awaited<ReturnType<typeof getTraceAggregations>>
>;
export type GetTraceAggregationsMutationBody =
| BodyType<SpantypesPostableTraceAggregationsDTO>
| undefined;
export type GetTraceAggregationsMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get aggregations for a trace
*/
export const useGetTraceAggregations = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
> => {
return useMutation(getGetTraceAggregationsMutationOptions(options));
};
/**
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
* @summary Get waterfall view for a trace

View File

@@ -120,7 +120,8 @@ export const interceptorRejected = async (
!(
response.config.url === '/sessions' && response.config.method === 'delete'
) &&
response.config.url !== '/authz/check'
response.config.url !== '/authz/check' &&
response.config.url !== '/api/v2/reset_password_tokens/verify'
) {
try {
const accessToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN);

View File

@@ -349,7 +349,7 @@ function convertV5DataByType(
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export function convertV5ResponseToLegacy(
v5Response: SuccessResponse<MetricRangePayloadV5>,
v5Response: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5>,
legendMap: Record<string, string>,
formatForWeb?: boolean,
): SuccessResponse<MetricRangePayloadV3> & { warning?: Warning } {
@@ -357,7 +357,7 @@ export function convertV5ResponseToLegacy(
const v5Data = payload?.data;
const aggregationPerQuery =
(params as QueryRangeRequestV5)?.compositeQuery?.queries
params?.compositeQuery?.queries
?.filter((query) => query.type === 'builder_query')
.reduce(
(acc, query) => {

View File

@@ -1 +0,0 @@
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#prefix__clip0_4062_7291)" stroke-width="1.167" stroke-linecap="round" stroke-linejoin="round"><path d="M7 12.833A5.833 5.833 0 107 1.167a5.833 5.833 0 000 11.666z" fill="#E5484D" stroke="#E5484D"/><path d="M8.75 5.25l-3.5 3.5M5.25 5.25l3.5 3.5" stroke="#121317"/></g><defs><clipPath id="prefix__clip0_4062_7291"><path fill="#fff" d="M0 0h14v14H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 467 B

View File

@@ -359,8 +359,7 @@ function CustomTimePickerPopoverContent({
<Clock
color={Color.BG_ROBIN_400}
className="timezone-container__clock-icon"
height={12}
width={12}
size={14}
/>
<span className="timezone__name">{timezone.name}</span>

View File

@@ -27,13 +27,15 @@ function SortableField({
field,
onRemove,
allowDrag,
isRequired,
}: {
field: TelemetryFieldKey;
onRemove: (field: TelemetryFieldKey) => void;
allowDrag: boolean;
isRequired: boolean;
}): JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: field.name });
useSortable({ id: field.key as string });
const style = {
transform: CSS.Transform.toString(transform),
@@ -53,15 +55,17 @@ function SortableField({
{allowDrag && <GripVertical size={14} />}
<span className={styles.fieldKey}>{field.name}</span>
</div>
<Button
className={cx(styles.removeBtn, 'periscope-btn')}
variant="outlined"
color="destructive"
size="sm"
onClick={(): void => onRemove(field)}
>
Remove
</Button>
{!isRequired && (
<Button
className={cx(styles.removeBtn, 'periscope-btn')}
variant="outlined"
color="destructive"
size="sm"
onClick={(): void => onRemove(field)}
>
Remove
</Button>
)}
</div>
);
}
@@ -71,6 +75,7 @@ interface AddedFieldsProps {
fields: TelemetryFieldKey[];
onFieldsChange: (fields: TelemetryFieldKey[]) => void;
maxFields?: number;
requiredFields?: readonly string[];
}
function AddedFields({
@@ -78,14 +83,18 @@ function AddedFields({
fields,
onFieldsChange,
maxFields,
requiredFields = [],
}: AddedFieldsProps): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
// Contract: caller (FieldsSelector) normalizes `fields` so every entry has
// `.key` populated. AddedFields reads it directly.
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = fields.findIndex((f) => f.name === active.id);
const newIndex = fields.findIndex((f) => f.name === over.id);
const oldIndex = fields.findIndex((f) => f.key === active.id);
const newIndex = fields.findIndex((f) => f.key === over.id);
onFieldsChange(arrayMove(fields, oldIndex, newIndex));
}
};
@@ -99,7 +108,7 @@ function AddedFields({
);
const handleRemove = (field: TelemetryFieldKey): void => {
onFieldsChange(fields.filter((f) => f.name !== field.name));
onFieldsChange(fields.filter((f) => f.key !== field.key));
};
const allowDrag = inputValue.length === 0;
@@ -125,16 +134,17 @@ function AddedFields({
<div className={styles.noValues}>No values found</div>
) : (
<SortableContext
items={fields.map((f) => f.name)}
items={fields.map((f) => f.key as string)}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>
{filteredFields.map((field) => (
<SortableField
key={field.name}
key={field.key}
field={field}
onRemove={handleRemove}
allowDrag={allowDrag}
isRequired={requiredFields.includes(field.key as string)}
/>
))}
</SortableContext>

View File

@@ -76,11 +76,8 @@
min-height: 0;
overflow: hidden;
// Ant Skeleton.Input rendered inside the loading state — override its
// hard-coded width.
:global(.ant-skeleton-input) {
width: 300px;
margin: 8px 12px;
width: 50% !important;
}
}
@@ -95,7 +92,8 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
height: 36px;
padding: 0 12px;
border-radius: 4px;
user-select: none;
font-size: 13px;
@@ -132,7 +130,7 @@
}
.isDragDisabled {
padding: 6px 12px;
cursor: default;
}
.otherFieldItem {

View File

@@ -5,6 +5,7 @@ import { Input } from '@signozhq/ui/input';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
@@ -26,33 +27,36 @@ interface FieldsSelectorProps {
onClose: () => void;
signal: DataSource;
maxFields?: number;
requiredFields?: readonly string[];
width?: number;
height?: number;
defaultPosition?: { x: number; y: number };
}
function FieldsSelector({
isOpen,
type FieldsSelectorContentProps = Omit<FieldsSelectorProps, 'isOpen'>;
// Inner component: holds all hooks + UI. Gets mounted/unmounted via the
// outer gate so opening always seeds a fresh draft from `fields`.
// Assumes `fields` arrives normalized (key populated) — see outer gate.
function FieldsSelectorContent({
title,
fields,
onFieldsChange,
onClose,
signal,
maxFields,
requiredFields,
width = DEFAULT_PANEL_WIDTH,
height,
defaultPosition,
}: FieldsSelectorProps): JSX.Element | null {
if (!isOpen) {
return null;
}
}: FieldsSelectorContentProps): JSX.Element {
const resolvedHeight =
height ?? window.innerHeight - DEFAULT_PANEL_HEIGHT_OFFSET;
const resolvedPosition = defaultPosition ?? {
x: window.innerWidth - width - DEFAULT_PANEL_RIGHT_INSET,
y: DEFAULT_PANEL_TOP_INSET,
};
const [draftFields, setDraftFields] = useState<TelemetryFieldKey[]>(fields);
const [inputValue, setInputValue] = useState('');
const [debouncedInputValue, setDebouncedInputValue] = useState('');
@@ -72,15 +76,17 @@ function FieldsSelector({
const handleAdd = useCallback(
(field: TelemetryFieldKey): void => {
if (maxFields !== undefined && draftFields.length >= maxFields) {
return;
}
if (draftFields.some((f) => f.name === field.name)) {
return;
}
setDraftFields((prev) => [...prev, field]);
setDraftFields((prev) => {
if (maxFields !== undefined && prev.length >= maxFields) {
return prev;
}
if (prev.some((f) => f.key === field.key)) {
return prev;
}
return [...prev, field];
});
},
[draftFields, maxFields],
[maxFields],
);
const handleSave = useCallback((): void => {
@@ -99,7 +105,7 @@ function FieldsSelector({
() =>
!(
draftFields.length === fields.length &&
draftFields.every((f, i) => f.name === fields[i]?.name)
draftFields.every((f, i) => f.key === fields[i]?.key)
),
[draftFields, fields],
);
@@ -138,6 +144,7 @@ function FieldsSelector({
fields={draftFields}
onFieldsChange={setDraftFields}
maxFields={maxFields}
requiredFields={requiredFields}
/>
<OtherFields
@@ -173,4 +180,27 @@ function FieldsSelector({
);
}
// Outer gate: normalizes `fields` once (populates `key` so downstream code
// can read it directly) and decides whether the inner component renders.
// When isOpen flips false→true, the inner remounts → draft state seeds fresh.
function FieldsSelector({
isOpen,
fields,
...rest
}: FieldsSelectorProps): JSX.Element | null {
const normalizedFields = useMemo<TelemetryFieldKey[]>(
() =>
fields.map((f) => ({
...f,
key: f.key ?? buildCompositeKey(f.name, f.fieldContext),
})),
[fields],
);
if (!isOpen) {
return null;
}
return <FieldsSelectorContent {...rest} fields={normalizedFields} />;
}
export default FieldsSelector;

View File

@@ -4,6 +4,7 @@ import { Skeleton } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import {
FieldContext,
@@ -47,15 +48,22 @@ function OtherFields({
const otherFields: TelemetryFieldKey[] = useMemo(() => {
const suggestions = Object.values(data?.data.data.keys || {}).flat();
const addedNames = new Set(addedFields.map((f) => f.name));
return suggestions
.filter((attr) => !addedNames.has(attr.name))
.map((attr) => ({
// Normalize: synthesize `key` once so downstream reads can trust it.
const normalizedSuggestions: TelemetryFieldKey[] = suggestions.map(
(attr) => ({
...attr,
key: buildCompositeKey(attr.name, attr.fieldContext as string),
signal: attr.signal as SignalType,
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType as FieldDataType,
}));
}),
);
const addedIds = new Set(
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
);
return normalizedSuggestions.filter(
(attr) => !addedIds.has(attr.key as string),
);
}, [data, addedFields]);
if (isFetching) {
@@ -64,8 +72,13 @@ function OtherFields({
<div className={styles.sectionHeader}>OTHER FIELDS</div>
<div className={styles.otherList}>
{Array.from({ length: 5 }).map((_, i) => (
// eslint-disable-next-line react/no-array-index-key
<Skeleton.Input active size="small" key={i} />
<div
// eslint-disable-next-line react/no-array-index-key
key={i}
className={cx(styles.fieldItem, styles.otherFieldItem)}
>
<Skeleton.Input active size="small" block />
</div>
))}
</div>
</div>
@@ -83,7 +96,7 @@ function OtherFields({
) : (
otherFields.map((attr) => (
<div
key={attr.name}
key={attr.key}
className={cx(styles.fieldItem, styles.otherFieldItem)}
>
<span className={styles.fieldKey}>{attr.name}</span>

View File

@@ -0,0 +1,111 @@
import { render, screen } from 'tests/test-utils';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import AddedFields from '../AddedFields';
// AddedFields assumes the caller has populated `key` (the parent
// FieldsSelector does this via its normalization useMemo). Tests pre-populate
// it directly.
const makeField = (name: string, fieldContext = 'log'): TelemetryFieldKey => ({
name,
signal: 'logs',
fieldContext: fieldContext as TelemetryFieldKey['fieldContext'],
fieldDataType: 'string',
key: `${fieldContext}.${name}`,
});
describe('AddedFields — requiredFields', () => {
it('renders a Remove button for every field when no requiredFields are passed', () => {
const fields = [makeField('a'), makeField('b'), makeField('c')];
render(
<AddedFields inputValue="" fields={fields} onFieldsChange={jest.fn()} />,
);
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(3);
});
it('hides the Remove button for fields whose composite key is in requiredFields', () => {
const fields = [makeField('a'), makeField('b'), makeField('c')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.a', 'log.c']}
/>,
);
// Only 'b' is removable.
const removeButtons = screen.getAllByRole('button', { name: /remove/i });
expect(removeButtons).toHaveLength(1);
});
it('still renders the field name for required fields', () => {
const fields = [makeField('a'), makeField('b')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.a']}
/>,
);
expect(screen.getByText('a')).toBeInTheDocument();
expect(screen.getByText('b')).toBeInTheDocument();
});
it('locks ONLY the canonical variant — a same-name field from another context stays removable', () => {
// Two `body` fields with different contexts. requiredFields holds the
// canonical composite key only, so the attribute variant is deletable.
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.body']}
/>,
);
// One Remove button: the attribute variant. log variant is locked.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
});
it('does not lock anything when a bare name is passed (composite key required)', () => {
// Bare `body` no longer matches — matching is composite-key only now.
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['body']}
/>,
);
// Neither variant locked → both removable.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(2);
});
it('treats requiredFields as exact composite-key match (substring does not lock)', () => {
const fields = [makeField('body'), makeField('body_extra')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.body']}
/>,
);
// 'log.body' locked, 'log.body_extra' removable.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
});
});

View File

@@ -4,6 +4,7 @@ import { Dot } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import { Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents } from 'container/AIAssistant/events';
@@ -109,7 +110,7 @@ function HeaderRightSection({
</span>
) : null}
<TooltipSimple title="Noz">
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
<Button
variant="solid"
color="secondary"

View File

@@ -3,17 +3,12 @@ import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { logEventMock } from '__tests__/logEventMock';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import FeedbackModal from '../FeedbackModal';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve()),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
@@ -35,7 +30,6 @@ jest.mock('container/Integrations/utils', () => ({
handleContactSupport: jest.fn(),
}));
const mockLogEvent = logEvent as jest.MockedFunction<typeof logEvent>;
const mockUseLocation = useLocation as jest.Mock;
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
const mockHandleContactSupport = handleContactSupport as jest.Mock;
@@ -50,6 +44,7 @@ const mockLocation = {
describe('FeedbackModal', () => {
beforeEach(() => {
jest.clearAllMocks();
logEventMock.mockReturnValue(Promise.resolve() as never);
mockUseLocation.mockReturnValue(mockLocation);
mockUseGetTenantLicense.mockReturnValue({
isCloudUser: false,
@@ -116,7 +111,7 @@ describe('FeedbackModal', () => {
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'feedback',
page: mockLocation.pathname,
@@ -149,7 +144,7 @@ describe('FeedbackModal', () => {
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'reportBug',
page: mockLocation.pathname,
@@ -182,7 +177,7 @@ describe('FeedbackModal', () => {
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'featureRequest',
page: mockLocation.pathname,

View File

@@ -2,16 +2,11 @@
import { useLocation } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { logEventMock } from '__tests__/logEventMock';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import HeaderRightSection from '../HeaderRightSection';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
@@ -50,7 +45,6 @@ jest.mock('hooks/useIsAIAssistantEnabled', () => ({
useIsAIAssistantEnabled: (): boolean => false,
}));
const mockLogEvent = logEvent as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
@@ -120,7 +114,7 @@ describe('HeaderRightSection', () => {
await user.click(feedbackButton!);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Clicked', {
expect(logEventMock).toHaveBeenCalledWith('Feedback: Clicked', {
page: mockLocation.pathname,
});
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
@@ -133,7 +127,7 @@ describe('HeaderRightSection', () => {
const shareButton = screen.getByRole('button', { name: /share/i });
await user.click(shareButton);
expect(mockLogEvent).toHaveBeenCalledWith('Share: Clicked', {
expect(logEventMock).toHaveBeenCalledWith('Share: Clicked', {
page: mockLocation.pathname,
});
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
@@ -150,7 +144,7 @@ describe('HeaderRightSection', () => {
await user.click(announcementsButton!);
expect(mockLogEvent).toHaveBeenCalledWith('Announcements: Clicked', {
expect(logEventMock).toHaveBeenCalledWith('Announcements: Clicked', {
page: mockLocation.pathname,
});
});

View File

@@ -5,18 +5,13 @@ import { matchPath, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { logEventMock } from '__tests__/logEventMock';
import ROUTES from 'constants/routes';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import ShareURLModal from '../ShareURLModal';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
@@ -53,7 +48,6 @@ Object.defineProperty(window, 'location', {
writable: true,
});
const mockLogEvent = logEvent as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const mockUseUrlQuery = useUrlQuery as jest.Mock;
const mockUseSelector = useSelector as jest.Mock;
@@ -125,7 +119,7 @@ describe('ShareURLModal', () => {
await user.click(copyButton);
expect(mockHandleCopyToClipboard).toHaveBeenCalled();
expect(mockLogEvent).toHaveBeenCalledWith('Share: Copy link clicked', {
expect(logEventMock).toHaveBeenCalledWith('Share: Copy link clicked', {
page: TEST_PATH,
URL: expect.any(String),
});

View File

@@ -0,0 +1,145 @@
import { renderHook } from '@testing-library/react';
import { FontSize } from 'container/OptionsMenu/types';
import { IField } from 'types/api/logs/fields';
import { useLogsTableColumns } from '../useLogsTableColumns';
jest.mock('providers/Timezone', () => ({
useTimezone: (): { formatTimezoneAdjustedTimestamp: jest.Mock } => ({
formatTimezoneAdjustedTimestamp: jest.fn(() => 'TS'),
}),
}));
const field = (name: string, type = ''): IField => ({
name,
type,
dataType: 'string',
});
describe('useLogsTableColumns — selectColumns-order respected', () => {
it('prepends stateIndicator and renders user fields in array order', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('c'), field('a'), field('b')],
fontSize: FontSize.SMALL,
}),
);
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'c',
'a',
'b',
]);
});
it('slots body and timestamp at their position in the fields array', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [
field('service.name'),
field('body', 'log'),
field('request.id'),
field('timestamp', 'log'),
],
fontSize: FontSize.SMALL,
}),
);
// body/timestamp appear where the caller placed them, keyed by their
// composite IDs ('log.*'); contextless user fields collapse to bare name.
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'service.name',
'log.body',
'request.id',
'log.timestamp',
]);
});
it('renders a same-name field from another context as a DISTINCT column (no collision)', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('body', 'log'), field('body', 'attribute')],
fontSize: FontSize.SMALL,
}),
);
const byId = new Map(result.current.map((c) => [c.id, c]));
// Attribute variant is its own column, not a duplicate 'log.body'.
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'log.body',
'attribute.body',
]);
expect(byId.get('log.body')?.enableRemove).toBe(false);
expect(byId.get('attribute.body')?.enableRemove).toBe(true);
});
it('applies the same distinct-column treatment to timestamp variants', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('timestamp', 'log'), field('timestamp', 'attribute')],
fontSize: FontSize.SMALL,
}),
);
const byId = new Map(result.current.map((c) => [c.id, c]));
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'log.timestamp',
'attribute.timestamp',
]);
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
expect(byId.get('attribute.timestamp')?.enableRemove).toBe(true);
});
it('skips the synthetic "id" field name', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('id'), field('a'), field('b')],
fontSize: FontSize.SMALL,
}),
);
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'a',
'b',
]);
});
it('uses the special body/timestamp coldefs (canBeHidden=false), not the generic user field def', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [
field('body', 'log'),
field('timestamp', 'log'),
field('user_field'),
],
fontSize: FontSize.SMALL,
}),
);
const byId = new Map(result.current.map((c) => [c.id, c]));
// body + timestamp are locked from the table-X removal pathway.
expect(byId.get('log.body')?.canBeHidden).toBe(false);
expect(byId.get('log.body')?.enableRemove).toBe(false);
expect(byId.get('log.timestamp')?.canBeHidden).toBe(false);
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
// User-added fields stay removable. User field has type='' so composite
// collapses to bare name.
expect(byId.get('user_field')?.enableRemove).toBe(true);
});
it('renders only the stateIndicator when fields is empty', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [],
fontSize: FontSize.SMALL,
}),
);
expect(result.current.map((c) => c.id)).toStrictEqual(['state-indicator']);
});
});

View File

@@ -7,6 +7,7 @@ import {
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
import { IField } from 'types/api/logs/fields';
@@ -18,13 +19,11 @@ import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
type UseLogsTableColumnsProps = {
fields: IField[];
fontSize: FontSize;
appendTo?: 'center' | 'end';
};
export function useLogsTableColumns({
fields,
fontSize,
appendTo = 'center',
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
@@ -47,71 +46,74 @@ export function useLogsTableColumns({
),
};
const fieldColumns: TableColumnDef<ILog>[] = fields
.filter((f): boolean => !['id', 'body', 'timestamp'].includes(f.name))
.map(
(f): TableColumnDef<ILog> => ({
id: f.name,
header: f.name,
accessorFn: (log): unknown => FlatLogData(log)[f.name],
enableRemove: true,
width: { min: 192 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
),
}),
);
const timestampCol: TableColumnDef<ILog> = {
id: buildCompositeKey('timestamp', 'log'),
header: 'Timestamp',
accessorFn: (log): unknown => log.timestamp,
canBeHidden: false,
enableRemove: false,
width: { default: 170, min: 170 },
cell: ({ value }): ReactElement => {
const ts = value as string | number;
const formatted =
typeof ts === 'string'
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
: formatTimezoneAdjustedTimestamp(
ts / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return <TanStackTable.Text>{formatted}</TanStackTable.Text>;
},
};
const timestampCol: TableColumnDef<ILog> | null = fields.some(
(f) => f.name === 'timestamp',
)
? {
id: 'timestamp',
header: 'Timestamp',
accessorFn: (log): unknown => log.timestamp,
width: { default: 170, min: 170 },
cell: ({ value }): ReactElement => {
const ts = value as string | number;
const formatted =
typeof ts === 'string'
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
: formatTimezoneAdjustedTimestamp(
ts / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return <TanStackTable.Text>{formatted}</TanStackTable.Text>;
},
const bodyCol: TableColumnDef<ILog> = {
id: buildCompositeKey('body', 'log'),
header: 'Body',
accessorFn: (log): string => getBodyDisplayString(log.body),
canBeHidden: false,
enableRemove: false,
width: { default: '100%', min: 300 },
cell: ({ value, isActive }): ReactElement => (
<TanStackTable.Text
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(value as string, {
shouldEscapeHtml: true,
}),
}}
data-active={isActive}
/>
),
};
const makeUserFieldCol = (f: IField): TableColumnDef<ILog> => ({
id: buildCompositeKey(f.name, f.type),
header: f.name,
accessorFn: (log): unknown => FlatLogData(log)[f.name],
enableRemove: true,
width: { min: 192 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
),
});
// Match body/timestamp by composite key, not bare name — else a variant
// like `attribute.body` collapses onto `log.body`, duplicating the column.
const fieldCols = fields
.map((f): TableColumnDef<ILog> | null => {
if (f.name === 'id') {
return null;
}
: null;
const bodyCol: TableColumnDef<ILog> | null = fields.some(
(f) => f.name === 'body',
)
? {
id: 'body',
header: 'Body',
accessorFn: (log): string => getBodyDisplayString(log.body),
canBeHidden: false,
width: { default: '100%', min: 300 },
cell: ({ value, isActive }): ReactElement => (
<TanStackTable.Text
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(value as string, {
shouldEscapeHtml: true,
}),
}}
data-active={isActive}
/>
),
const compositeKey = buildCompositeKey(f.name, f.type);
if (compositeKey === timestampCol.id) {
return timestampCol;
}
: null;
if (compositeKey === bodyCol.id) {
return bodyCol;
}
return makeUserFieldCol(f);
})
.filter((c): c is TableColumnDef<ILog> => c !== null);
return [
stateIndicatorCol,
...(timestampCol ? [timestampCol] : []),
...(appendTo === 'center' ? fieldColumns : []),
...(bodyCol ? [bodyCol] : []),
...(appendTo === 'end' ? fieldColumns : []),
];
}, [fields, appendTo, fontSize, formatTimezoneAdjustedTimestamp]);
return [stateIndicatorCol, ...fieldCols];
}, [fields, fontSize, formatTimezoneAdjustedTimestamp]);
}

View File

@@ -256,6 +256,34 @@
}
}
.edit-columns-container {
padding: 12px;
.edit-columns-btn {
display: flex;
width: 100%;
height: 20px;
padding: 4px 0px;
justify-content: space-between;
align-items: center;
border: none !important;
.edit-columns-text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
}
}
.edit-columns-btn:hover {
background-color: unset !important;
}
}
.add-new-column-container {
display: flex;
flex-direction: column;

View File

@@ -1,12 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { useCallback, useEffect, useState } from 'react';
import { Button, InputNumber, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
import cx from 'classnames';
import { LogViewMode } from 'container/LogsTable';
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import {
Check,
ChevronLeft,
@@ -14,7 +11,6 @@ import {
Minus,
Plus,
SlidersVertical,
X,
} from '@signozhq/icons';
import './LogsFormatOptionsMenu.styles.scss';
@@ -23,14 +19,21 @@ interface LogsFormatOptionsMenuProps {
items: any;
selectedOptionFormat: any;
config: OptionsMenuConfig;
onOpenColumns?: () => void;
}
interface OptionsMenuContentProps extends LogsFormatOptionsMenuProps {
closePopover: () => void;
}
function OptionsMenu({
items,
selectedOptionFormat,
config,
}: LogsFormatOptionsMenuProps): JSX.Element {
const { maxLines, format, addColumn, fontSize } = config;
onOpenColumns,
closePopover,
}: OptionsMenuContentProps): JSX.Element {
const { maxLines, format, fontSize } = config;
const [selectedItem, setSelectedItem] = useState(selectedOptionFormat);
const maxLinesNumber = (maxLines?.value as number) || 1;
const [maxLinesPerRow, setMaxLinesPerRow] = useState<number>(maxLinesNumber);
@@ -40,13 +43,6 @@ function OptionsMenu({
const [isFontSizeOptionsOpen, setIsFontSizeOptionsOpen] =
useState<boolean>(false);
const [showAddNewColumnContainer, setShowAddNewColumnContainer] =
useState(false);
const [selectedValue, setSelectedValue] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const initialMouseEnterRef = useRef<boolean>(false);
const onChange = useCallback(
(key: LogViewMode) => {
if (!format) {
@@ -61,7 +57,6 @@ function OptionsMenu({
const handleMenuItemClick = (key: LogViewMode): void => {
setSelectedItem(key);
onChange(key);
setShowAddNewColumnContainer(false);
};
const incrementMaxLinesPerRow = (): void => {
@@ -76,20 +71,6 @@ function OptionsMenu({
}
};
const handleSearchValueChange = useDebouncedFn((event): void => {
// @ts-expect-error
const value = event?.target?.value || '';
if (addColumn && addColumn?.onSearch) {
addColumn?.onSearch(value);
}
}, 300);
const handleToggleAddNewColumn = (): void => {
addColumn?.onSearch?.('');
setShowAddNewColumnContainer(!showAddNewColumnContainer);
};
const handleLinesPerRowChange = (maxLinesPerRow: number | null): void => {
if (
maxLinesPerRow &&
@@ -100,6 +81,11 @@ function OptionsMenu({
}
};
const handleEditColumns = (): void => {
onOpenColumns?.();
closePopover();
};
useEffect(() => {
if (maxLinesPerRow && config && config.maxLines?.onChange) {
config.maxLines.onChange(maxLinesPerRow);
@@ -112,110 +98,10 @@ function OptionsMenu({
}
}, [fontSizeValue]);
function handleColumnSelection(
currentIndex: number,
optionsData: DefaultOptionType[],
): void {
const currentItem = optionsData[currentIndex];
const itemLength = optionsData.length;
if (addColumn && addColumn?.onSelect) {
addColumn?.onSelect(selectedValue, {
label: currentItem.label,
disabled: false,
});
// if the last element is selected then select the previous one
if (currentIndex === itemLength - 1) {
// there should be more than 1 element in the list
if (currentIndex - 1 >= 0) {
const prevValue = optionsData[currentIndex - 1]?.value || null;
setSelectedValue(prevValue as string | null);
} else {
// if there is only one element then just select and do nothing
setSelectedValue(null);
}
} else {
// selecting any random element from the list except the last one
const nextIndex = currentIndex + 1;
const nextValue = optionsData[nextIndex]?.value || null;
setSelectedValue(nextValue as string | null);
}
}
}
const handleKeyDown = (e: KeyboardEvent): void => {
if (!selectedValue) {
return;
}
const optionsData = addColumn?.options || [];
const currentIndex = optionsData.findIndex(
(item) => item?.value === selectedValue,
);
const itemLength = optionsData.length;
switch (e.key) {
case 'ArrowUp': {
const newValue = optionsData[Math.max(0, currentIndex - 1)]?.value;
setSelectedValue(newValue as string | null);
e.preventDefault();
break;
}
case 'ArrowDown': {
const newValue =
optionsData[Math.min(itemLength - 1, currentIndex + 1)]?.value;
setSelectedValue(newValue as string | null);
e.preventDefault();
break;
}
case 'Enter':
e.preventDefault();
handleColumnSelection(currentIndex, optionsData);
break;
default:
break;
}
};
useEffect(() => {
// Scroll the selected item into view
const listNode = listRef.current;
if (listNode && selectedValue) {
const optionsData = addColumn?.options || [];
const currentIndex = optionsData.findIndex(
(item) => item?.value === selectedValue,
);
const itemNode = listNode.children[currentIndex] as HTMLElement;
if (itemNode) {
itemNode.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
}
}, [selectedValue]);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return (): void => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [selectedValue]);
return (
<div
className={cx(
'nested-menu-container',
showAddNewColumnContainer ? 'active' : '',
)}
className="nested-menu-container"
onClick={(event): void => {
// this is to restrict click events to propogate to parent
event.stopPropagation();
}}
>
@@ -269,71 +155,7 @@ function OptionsMenu({
</Button>
</div>
</div>
) : null}
{showAddNewColumnContainer && (
<div className="add-new-column-container">
<div className="add-new-column-header">
<div className="title">
<div className="periscope-btn ghost" onClick={handleToggleAddNewColumn}>
<ChevronLeft
size={14}
className="back-icon"
onClick={handleToggleAddNewColumn}
/>
</div>
Add New Column
</div>
<Input
tabIndex={0}
type="text"
autoFocus
onFocus={addColumn?.onFocus}
onChange={handleSearchValueChange}
placeholder="Search..."
/>
</div>
<div className="add-new-column-content">
{addColumn?.isFetching && (
<div className="loading-container"> Loading ... </div>
)}
<div className="column-format-new-options" ref={listRef}>
{addColumn?.options?.map(({ label, value }, index) => (
<div
className={cx('column-name', value === selectedValue && 'selected')}
key={value}
onMouseEnter={(): void => {
if (!initialMouseEnterRef.current) {
setSelectedValue(value as string | null);
}
initialMouseEnterRef.current = true;
}}
onMouseMove={(): void => {
// this is added to handle the mouse move explicit event and not the re-rendered on mouse enter event
setSelectedValue(value as string | null);
}}
onClick={(eve): void => {
eve.stopPropagation();
handleColumnSelection(index, addColumn?.options || []);
}}
>
<div className="name">
<Tooltip placement="left" title={label}>
{label}
</Tooltip>
</div>
</div>
))}
</div>
</div>
</div>
)}
{!isFontSizeOptionsOpen && !showAddNewColumnContainer && (
) : (
<div>
<div className="font-size-container">
<div className="title">Font Size</div>
@@ -373,72 +195,52 @@ function OptionsMenu({
{selectedItem && (
<>
<>
<div className="horizontal-line" />
<div className="max-lines-per-row">
<div className="title"> max lines per row </div>
<div className="raw-format max-lines-per-row-input">
<button
type="button"
className="periscope-btn"
onClick={decrementMaxLinesPerRow}
>
{' '}
<Minus size={12} />{' '}
</button>
<InputNumber
min={1}
max={10}
value={maxLinesPerRow}
onChange={handleLinesPerRowChange}
/>
<button
type="button"
className="periscope-btn"
onClick={incrementMaxLinesPerRow}
>
{' '}
<Plus size={12} />{' '}
</button>
</div>
<div className="horizontal-line" />
<div className="max-lines-per-row">
<div className="title"> max lines per row </div>
<div className="raw-format max-lines-per-row-input">
<button
type="button"
className="periscope-btn"
onClick={decrementMaxLinesPerRow}
>
{' '}
<Minus size={12} />{' '}
</button>
<InputNumber
min={1}
max={10}
value={maxLinesPerRow}
onChange={handleLinesPerRowChange}
/>
<button
type="button"
className="periscope-btn"
onClick={incrementMaxLinesPerRow}
>
{' '}
<Plus size={12} />{' '}
</button>
</div>
</>
</div>
</>
)}
<div className="selected-item-content-container active">
{!showAddNewColumnContainer && <div className="horizontal-line" />}
<div className="item-content">
{!showAddNewColumnContainer && (
<div className="title">
columns
<Plus size={14} onClick={handleToggleAddNewColumn} />{' '}
</div>
)}
<div className="column-format">
{addColumn?.value?.map(({ name }) => (
<div className="column-name" key={name}>
<div className="name">
<Tooltip placement="left" title={name}>
{name}
</Tooltip>
</div>
{addColumn?.value?.length > 1 && (
<X
className="delete-btn"
size={14}
onClick={(): void => addColumn.onRemove(name)}
/>
)}
</div>
))}
{addColumn && addColumn?.value?.length === 0 && (
<div className="column-name no-columns-selected">
No columns selected
</div>
)}
</div>
</div>
{selectedItem === 'table' && onOpenColumns && (
<>
<div className="horizontal-line" />
<div className="edit-columns-container">
<Button
className="edit-columns-btn"
type="text"
onClick={handleEditColumns}
data-testid="periscope-btn-edit-columns"
>
<Typography.Text className="edit-columns-text">
Edit columns
</Typography.Text>
<ChevronRight size={14} className="icon" />
</Button>
</div>
</>
)}
@@ -452,6 +254,7 @@ function LogsFormatOptionsMenu({
items,
selectedOptionFormat,
config,
onOpenColumns,
}: LogsFormatOptionsMenuProps): JSX.Element {
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
return (
@@ -461,6 +264,8 @@ function LogsFormatOptionsMenu({
items={items}
selectedOptionFormat={selectedOptionFormat}
config={config}
onOpenColumns={onOpenColumns}
closePopover={(): void => setIsPopoverOpen(false)}
/>
}
trigger="click"

View File

@@ -62,6 +62,7 @@ describe('LogsFormatOptionsMenu (unit)', () => {
onSearch: jest.fn(),
onSelect: jest.fn(),
onRemove: jest.fn(),
onReorder: jest.fn(),
},
}}
/>,
@@ -154,4 +155,66 @@ describe('LogsFormatOptionsMenu (unit)', () => {
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);
});
});
function renderWithOnOpen(
onOpenColumns?: jest.Mock,
selectedOptionFormat: 'table' | 'raw' | 'list' = 'table',
): { getByTestId: ReturnType<typeof render>['getByTestId'] } {
const items = [
{ key: 'raw', label: 'Raw', data: { title: 'max lines per row' } },
{ key: 'list', label: 'Default' },
{ key: 'table', label: 'Column', data: { title: 'columns' } },
];
const { getByTestId } = render(
<LogsFormatOptionsMenu
items={items}
selectedOptionFormat={selectedOptionFormat}
config={{
format: { value: selectedOptionFormat, onChange: jest.fn() },
maxLines: { value: 1, onChange: jest.fn() },
fontSize: { value: FontSize.SMALL, onChange: jest.fn() },
}}
onOpenColumns={onOpenColumns}
/>,
);
fireEvent.click(getByTestId('periscope-btn-format-options'));
return { getByTestId };
}
it('renders "Edit columns" row when format=table and onOpenColumns provided', () => {
const onOpenColumns = jest.fn();
const { getByTestId } = renderWithOnOpen(onOpenColumns, 'table');
expect(getByTestId('periscope-btn-edit-columns')).toBeInTheDocument();
});
it('does not render "Edit columns" row when onOpenColumns is not provided', () => {
renderWithOnOpen(undefined, 'table');
expect(
document.querySelector('[data-testid="periscope-btn-edit-columns"]'),
).toBeNull();
});
it('does not render "Edit columns" row when format is not table', () => {
renderWithOnOpen(jest.fn(), 'raw');
expect(
document.querySelector('[data-testid="periscope-btn-edit-columns"]'),
).toBeNull();
});
it('fires onOpenColumns and closes the popover when "Edit columns" is clicked', async () => {
const onOpenColumns = jest.fn();
const { getByTestId } = renderWithOnOpen(onOpenColumns, 'table');
fireEvent.click(getByTestId('periscope-btn-edit-columns'));
expect(onOpenColumns).toHaveBeenCalledTimes(1);
await waitFor(() => {
// Popover content unmounts on close.
expect(document.querySelector('.menu-container')).toBeNull();
});
});
});

View File

@@ -109,6 +109,16 @@ $custom-border-color: #2c3044;
color: color-mix(in srgb, var(--l2-foreground) 45%, transparent);
}
.ant-select-clear {
background-color: var(--l2-background);
color: var(--l2-foreground);
font-size: 12px;
&:hover {
color: var(--l1-foreground);
}
}
// Customize tags in multiselect (dark mode by default)
.ant-select-selection-item {
background-color: var(--l1-border);
@@ -392,7 +402,9 @@ $custom-border-color: #2c3044;
// Custom dropdown styles for multi-select
.custom-multiselect-dropdown {
padding: 8px 0 0 0;
max-height: 350px;
// Tall enough to hold the react-virtuoso list (<=300px) + header/footer
// so only the list scrolls (avoids a second scrollbar on this container).
max-height: 410px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
@@ -483,8 +495,12 @@ $custom-border-color: #2c3044;
.option-checkbox {
width: 100%;
> span:not(.ant-checkbox) {
width: 100%;
// @signozhq/ui Checkbox renders children inside a <label> that is
// content-sized by default. Make it fill the row (min-width: 0 lets it
// shrink) so the option text below can truncate instead of overflowing.
> label {
flex: 1 1 auto;
min-width: 0;
}
.all-option-text {
@@ -501,7 +517,12 @@ $custom-border-color: #2c3044;
width: 100%;
.option-label-text {
flex: 1 1 auto;
min-width: 0;
margin-bottom: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.option-badge {
@@ -514,26 +535,30 @@ $custom-border-color: #2c3044;
}
}
.only-btn {
display: none;
}
// Size the buttons to the row's resting content height (20px) and fully
// override antd's default 32px Button box, so revealing them on hover
// never changes the row height.
.only-btn,
.toggle-btn {
display: none;
}
align-items: center;
justify-content: center;
height: 18px;
min-height: 0;
padding: 0 6px;
font-size: 12px;
line-height: 1;
border: none;
box-shadow: none;
.only-btn:hover {
background-color: unset;
}
.toggle-btn:hover {
background-color: unset;
&:hover {
background-color: unset;
}
}
.option-content:hover {
.only-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
.toggle-btn {
display: none;
@@ -548,9 +573,6 @@ $custom-border-color: #2c3044;
.option-checkbox:hover {
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
.option-badge {
display: none;

View File

@@ -0,0 +1,2 @@
/** Shared hover copy for every Noz entry point (header, floating trigger, sidebar). */
export const NOZ_TOOLTIP_TITLE = 'Noz, your AI teammate';

View File

@@ -721,6 +721,53 @@ export const removeKeysFromExpression = (
return result?.text ?? '';
};
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const createVariablePlaceholderRegExp = (
variableName: string,
): RegExp => {
const escapedName = escapeRegExp(variableName);
// (?![\w.]) prevents $env from matching inside $environment or $env.attr
return new RegExp(
`(\\$${escapedName}(?![\\w.])|\\{\\{\\s*\\.?${escapedName}\\s*\\}\\}|\\[\\[\\s*${escapedName}\\s*\\]\\])`,
'g',
);
};
const matchesVariablePlaceholder = (
text: string,
variableName: string,
): boolean => createVariablePlaceholderRegExp(variableName).test(text);
export const removeVariableFromExpression = (
expression: string | undefined,
variableName: string,
): string => {
if (!expression) {
return '';
}
const queryPairs = extractQueryPairs(expression);
const keysToRemove = queryPairs
.filter((pair) => {
const singleValue = pair.value?.toString() ?? '';
const listValues = (pair.valueList ?? []).join(' ');
return (
matchesVariablePlaceholder(singleValue, variableName) ||
matchesVariablePlaceholder(listValues, variableName)
);
})
.map((pair) => pair.key);
if (keysToRemove.length === 0) {
return expression;
}
return removeKeysFromExpression(expression, keysToRemove, `$${variableName}`);
};
/**
* Convert old having format to new having format
* @param having - Array of old having objects with columnName, op, and value

View File

@@ -72,6 +72,7 @@ function TanStackTableInner<TData>(
data,
columns,
columnStorageKey,
respectColumnOrder = true,
columnSizing: columnSizingProp,
onColumnSizingChange,
onColumnOrderChange,
@@ -175,6 +176,7 @@ function TanStackTableInner<TData>(
storageKey: columnStorageKey,
columns,
isGrouped,
respectColumnOrder,
});
// Use store values when columnStorageKey is provided, otherwise fall back to props/defaults
@@ -206,6 +208,7 @@ function TanStackTableInner<TData>(
handleRemoveColumn,
} = useColumnHandlers({
columnStorageKey,
respectColumnOrder,
effectiveSizing,
storeSetSizing,
storeSetOrder,
@@ -322,9 +325,7 @@ function TanStackTableInner<TData>(
});
const hasSingleColumn = useMemo(
() =>
effectiveColumns.filter((c) => !c.pin && c.enableRemove !== false).length <=
1,
() => effectiveColumns.filter((c) => !c.pin).length <= 1,
[effectiveColumns],
);

View File

@@ -24,6 +24,8 @@ jest.mock('../TanStackTable.module.scss', () => ({
}));
beforeAll(() => {
// jsdom doesn't include ResizeObserver — must direct-assign rather than
// spyOn (spyOn requires the property to already exist).
window.ResizeObserver = jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
@@ -867,4 +869,110 @@ describe('TanStackTableView Integration', () => {
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
});
describe('hasSingleColumn — gates the Remove popover per-column', () => {
const hasSingleColumnFlagPresent = (): boolean =>
Boolean(document.querySelector('th[data-single-column="true"]'));
it('is true when only one non-pinned column exists', async () => {
renderTanStackTable({
props: {
data: [{ id: '1', name: 'Item 1', value: 100 }],
columns: [
{
id: 'name',
header: 'Name',
accessorKey: 'name',
cell: ({ value }): string => String(value),
},
],
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
expect(hasSingleColumnFlagPresent()).toBe(true);
});
it('is false when multiple non-pinned columns exist (all removable)', async () => {
renderTanStackTable({});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// 3 default columns (id/name/value), none pinned, none non-removable
// → table is not single-column.
expect(hasSingleColumnFlagPresent()).toBe(false);
});
it('is false when removable + non-removable mix exists (the body/timestamp case)', async () => {
renderTanStackTable({
props: {
data: [{ id: '1', name: 'Item 1', value: 100 }],
columns: [
{
id: 'name',
header: 'Timestamp',
accessorKey: 'name',
enableRemove: false,
cell: ({ value }): string => String(value),
},
{
id: 'value',
header: 'Body',
accessorKey: 'value',
enableRemove: false,
cell: ({ value }): string => String(value),
},
{
id: 'id',
header: 'User',
accessorKey: 'id',
enableRemove: true,
cell: ({ value }): string => String(value),
},
],
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
expect(hasSingleColumnFlagPresent()).toBe(false);
});
it('does not count pinned columns toward the total', async () => {
renderTanStackTable({
props: {
data: [{ id: '1', name: 'Item 1', value: 100 }],
columns: [
{
id: 'stateIndicator',
header: '',
pin: 'left',
accessorKey: 'id',
cell: ({ value }): string => String(value),
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
cell: ({ value }): string => String(value),
},
],
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// 1 pinned + 1 non-pinned → only the non-pinned counts → single-column.
expect(hasSingleColumnFlagPresent()).toBe(true);
});
});
});

View File

@@ -168,6 +168,53 @@ describe('useColumnState', () => {
'a',
]);
});
it('ignores stored columnOrder when respectColumnOrder=false', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'a', 'b']);
});
const { result } = renderHook(() =>
useColumnState({
storageKey: TEST_KEY,
columns,
respectColumnOrder: false,
}),
);
// Falls through to natural columns-array order; stored order is ignored.
expect(result.current.sortedColumns.map((c) => c.id)).toStrictEqual([
'a',
'b',
'c',
]);
});
it('honors stored columnOrder when respectColumnOrder=true (default)', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'a', 'b']);
});
const { result } = renderHook(() =>
useColumnState({
storageKey: TEST_KEY,
columns,
respectColumnOrder: true,
}),
);
expect(result.current.sortedColumns.map((c) => c.id)).toStrictEqual([
'c',
'a',
'b',
]);
});
});
describe('actions', () => {

View File

@@ -152,6 +152,8 @@ export type TanStackTableProps<TData> = {
columns: TableColumnDef<TData>[];
/** Storage key for column state persistence (visibility, sizing, ordering). When set, enables unified column management. */
columnStorageKey?: string;
/** When false, the table renders columns in their natural array order and the persisted columnOrder slot is ignored on read and skipped on write. Use when an external source (e.g. preferences.selectColumns) is the canonical order. Default true. */
respectColumnOrder?: boolean;
columnSizing?: ColumnSizingState;
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
onColumnOrderChange?: (cols: TableColumnDef<TData>[]) => void;

View File

@@ -7,6 +7,8 @@ import { TableColumnDef } from './types';
export interface UseColumnHandlersOptions<TData> {
/** Storage key for persisting column state (enables store mode) */
columnStorageKey?: string;
/** When false, drag-reorder skips the persisted columnOrder write — order flows back via onColumnOrderChange only. */
respectColumnOrder?: boolean;
effectiveSizing: ColumnSizingState;
storeSetSizing: (sizing: ColumnSizingState) => void;
storeSetOrder: (columns: TableColumnDef<TData>[]) => void;
@@ -28,6 +30,7 @@ export interface UseColumnHandlersResult<TData> {
*/
export function useColumnHandlers<TData>({
columnStorageKey,
respectColumnOrder = true,
effectiveSizing,
storeSetSizing,
storeSetOrder,
@@ -50,12 +53,12 @@ export function useColumnHandlers<TData>({
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TData>[]) => {
if (columnStorageKey) {
if (columnStorageKey && respectColumnOrder) {
storeSetOrder(cols);
}
onColumnOrderChange?.(cols);
},
[columnStorageKey, storeSetOrder, onColumnOrderChange],
[columnStorageKey, respectColumnOrder, storeSetOrder, onColumnOrderChange],
);
const handleRemoveColumn = useCallback(

View File

@@ -20,6 +20,7 @@ type UseColumnStateOptions<TData> = {
storageKey?: string;
columns: TableColumnDef<TData>[];
isGrouped?: boolean;
respectColumnOrder?: boolean;
};
type UseColumnStateResult<TData> = {
@@ -40,6 +41,7 @@ export function useColumnState<TData>({
storageKey,
columns,
isGrouped = false,
respectColumnOrder = true,
}: UseColumnStateOptions<TData>): UseColumnStateResult<TData> {
useEffect(() => {
if (storageKey) {
@@ -130,7 +132,7 @@ export function useColumnState<TData>({
}, [hiddenColumnIds, columns, isGrouped]);
const sortedColumns = useMemo((): TableColumnDef<TData>[] => {
if (columnOrder.length === 0) {
if (!respectColumnOrder || columnOrder.length === 0) {
return columns;
}
@@ -144,7 +146,7 @@ export function useColumnState<TData>({
});
return [...pinned, ...sortedRest];
}, [columns, columnOrder]);
}, [columns, columnOrder, respectColumnOrder]);
const hideColumn = useCallback(
(columnId: string) => {

View File

@@ -93,7 +93,6 @@ function ValueGraph({
<div
ref={containerRef}
className="value-graph-container"
data-testid="value-graph-container"
style={{
backgroundColor:
threshold.thresholdFormat === 'Background'

View File

@@ -139,7 +139,6 @@ jest.mock('react-query', (): unknown => {
});
// mock other side-effecty modules
jest.mock('api/common/logEvent', () => jest.fn());
jest.mock('api/browser/localstorage/set', () => jest.fn());
jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() }));

View File

@@ -42,4 +42,5 @@ export enum LOCALSTORAGE {
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
}

View File

@@ -5,6 +5,7 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
@@ -42,16 +43,15 @@ export default function AIAssistantTrigger(): JSX.Element | null {
}
return (
<TooltipSimple title="Noz">
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
<Button
variant="solid"
color="primary"
className={`${styles.trigger} noz-wave`}
onClick={handleOpen}
aria-label="Open Noz"
>
<Noz size={24} />
</Button>
prefix={<Noz size={24} />}
/>
</TooltipSimple>
);
}

View File

@@ -67,8 +67,8 @@
gap: 4px;
&--success {
background: color-mix(in srgb, var(--success-background) 10%, transparent);
color: var(--success-foreground);
background: color-mix(in srgb, var(--text-forest-500) 10%, transparent);
color: var(--text-forest-400);
}
&--error {

View File

@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { logEventMock } from '__tests__/logEventMock';
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
import { USER_PREFERENCES } from 'constants/userPreferences';
import {
@@ -24,8 +24,6 @@ jest.mock('providers/cmdKProvider', () => ({
}),
}));
jest.mock('api/common/logEvent', () => jest.fn());
// Mock the AppContext
const mockUpdateUserPreferenceInContext = jest.fn();
@@ -139,7 +137,7 @@ describe('Sidebar Toggle Shortcut', () => {
it('should log the toggle event with correct parameters', async () => {
const user = userEvent.setup();
const mockHandleShortcut = jest.fn(() => {
logEvent('Global Shortcut: Sidebar Toggle', {
logEventMock('Global Shortcut: Sidebar Toggle', {
previousState: false,
newState: true,
});
@@ -155,10 +153,13 @@ describe('Sidebar Toggle Shortcut', () => {
await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT);
expect(logEvent).toHaveBeenCalledWith('Global Shortcut: Sidebar Toggle', {
previousState: false,
newState: true,
});
expect(logEventMock).toHaveBeenCalledWith(
'Global Shortcut: Sidebar Toggle',
{
previousState: false,
newState: true,
},
);
});
it('should update user preference in context', async () => {

View File

@@ -0,0 +1,9 @@
.headerRow {
padding: var(--spacing-8);
}
.itemList {
overflow-y: auto;
max-height: 300px;
padding: 12px;
}

View File

@@ -0,0 +1,93 @@
import { useMemo } from 'react';
import cx from 'classnames';
import TooltipHeader from 'lib/uPlotV2/components/Tooltip/components/TooltipHeader/TooltipHeader';
import TooltipItem from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
import {
TooltipContentItem,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import TooltipStyles from 'lib/uPlotV2/components/Tooltip/Tooltip.module.scss';
import Styles from './BillingBarChartTooltip.module.scss';
interface BillingBarChartTooltipProps extends TooltipRenderArgs {
billingApiResponse: MetricRangePayloadProps;
}
export function BillingBarChartTooltip({
billingApiResponse,
uPlotInstance,
dataIndexes,
seriesIndex,
isPinned,
}: BillingBarChartTooltipProps): JSX.Element {
const content = useMemo((): TooltipContentItem[] => {
const baseItems = buildTooltipContent({
data: uPlotInstance.data,
series: uPlotInstance.series,
dataIndexes,
activeSeriesIndex: seriesIndex,
uPlotInstance,
yAxisUnit: '',
isStackedBarChart: true,
});
return baseItems.map((item) => {
const match = billingApiResponse.data.result.find(
(r) => (r.legend || r.queryName) === item.label,
);
if (!match) {
return item;
}
const seriesIdx = uPlotInstance.series.findIndex(
(s) => s.label === item.label,
);
if (seriesIdx === -1) {
return item;
}
const dataIndex = dataIndexes[seriesIdx];
const quantity = dataIndex != null ? match.quantity?.[dataIndex] : null;
const unit = match.unit ?? '';
const quantityStr =
quantity != null ? ` - ${getToolTipValue(quantity)} ${unit}` : '';
return {
...item,
tooltipValue: `$${getToolTipValue(item.value, '')}${quantityStr}`,
};
});
}, [uPlotInstance, seriesIndex, dataIndexes, billingApiResponse]);
const activeItem = content.find((item) => item.isActive) ?? null;
return (
<div
className={cx(TooltipStyles.container, {
[TooltipStyles.pinned]: isPinned,
})}
data-testid="uplot-tooltip-container"
>
<TooltipHeader
uPlotInstance={uPlotInstance}
showTooltipHeader
isPinned={isPinned}
activeItem={null}
headerRowClassName={Styles.headerRow}
dateFormat={DATE_TIME_FORMATS.MONTH_DATE}
/>
{activeItem != null && <span className={TooltipStyles.divider} />}
<div className={Styles.itemList} data-testid="uplot-tooltip-list">
{content.map((item) => (
<TooltipItem key={item.label} item={item} isItemActive={item.isActive} />
))}
</div>
</div>
);
}

View File

@@ -1,4 +1,14 @@
.graph-container {
height: 100%;
}
.billing-graph-card {
.uplot-no-data {
display: flex;
align-items: center;
justify-content: center;
}
.ant-card-body {
height: 40vh;
.uplot-graph-container {
@@ -6,7 +16,7 @@
}
}
.total-spent {
font-family: 'SF Mono' monospace;
font-family: 'SF Mono', monospace;
font-size: 16px;
font-style: normal;
font-weight: 600;

View File

@@ -1,206 +1,120 @@
import { useMemo, useRef } from 'react';
import { Color } from '@signozhq/design-tokens';
import { useCallback, useMemo, useRef } from 'react';
import { Card, Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import Uplot from 'components/Uplot';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import tooltipPlugin from 'lib/uPlotLib/plugins/tooltipPlugin';
import getAxes from 'lib/uPlotLib/utils/getAxes';
import getRenderer from 'lib/uPlotLib/utils/getRenderer';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { getXAxisScale } from 'lib/uPlotLib/utils/getXAxisScale';
import { getYAxisScale } from 'lib/uPlotLib/utils/getYAxisScale';
import uPlot from 'uplot';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
import { BillingBarChartTooltip } from './BillingBarChartTooltip';
import { prepareBillingBarConfig } from './prepareBillingBarConfig';
import {
calculateStartEndTime,
convertDataToMetricRangePayload,
fillMissingValuesForQuantities,
} from './utils';
import './BillingUsageGraph.styles.scss';
import '../../../lib/uPlotLib/uPlotLib.styles.scss';
interface BillingUsageGraphProps {
data: any;
data: Partial<UsageResponsePayloadProps>;
billAmount: number;
}
const paths = (
u: any,
seriesIdx: number,
idx0: number,
idx1: number,
extendGap: boolean,
buildClip: boolean,
): uPlot.Series.PathBuilder => {
const s = u.series[seriesIdx];
const style = s.drawStyle;
const interp = s.lineInterpolation;
const renderer = getRenderer(style, interp);
return renderer(u, seriesIdx, idx0, idx1, extendGap, buildClip);
};
const calculateStartEndTime = (
data: any,
): { startTime: number; endTime: number } => {
const timestamps: number[] = [];
data?.details?.breakdown?.forEach((breakdown: any) => {
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry: any) => {
timestamps.push(entry?.timestamp);
});
});
const billingTime = [data?.billingPeriodStart, data?.billingPeriodEnd];
const startTime: number = Math.min(...timestamps, ...billingTime);
const endTime: number = Math.max(...timestamps, ...billingTime);
return { startTime, endTime };
};
const numberFormatter = new Intl.NumberFormat('en-US');
export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
const { data, billAmount } = props;
// Added this to fix the issue where breakdown with one day data are causing the bars to spread across multiple days
data?.details?.breakdown?.forEach((breakdown: any) => {
if (breakdown?.dayWiseBreakdown?.breakdown?.length === 1) {
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
const nextDay = {
...currentDay,
timestamp: currentDay.timestamp + 86400,
count: 0,
size: 0,
quantity: 0,
total: 0,
};
breakdown.dayWiseBreakdown.breakdown.push(nextDay);
}
});
const graphCompatibleData = useMemo(
() => convertDataToMetricRangePayload(data),
[data],
);
const chartData = getUPlotChartData(graphCompatibleData);
const graphRef = useRef<HTMLDivElement>(null);
const isDarkMode = useIsDarkMode();
const containerDimensions = useResizeObserver(graphRef);
const { startTime, endTime } = useMemo(
() => calculateStartEndTime(data),
[data],
);
const getGraphSeries = (color: string, label: string): any => ({
drawStyle: 'bars',
paths,
lineInterpolation: 'spline',
show: true,
label,
fill: color,
stroke: color,
width: 2,
spanGaps: true,
points: {
size: 5,
show: false,
stroke: color,
},
});
const uPlotSeries: any = useMemo(
() => [
{ label: 'Timestamp', stroke: 'purple' },
getGraphSeries(
'#7CEDBE',
graphCompatibleData.data.result[0]?.legend as string,
),
getGraphSeries(
'#4E74F8',
graphCompatibleData.data.result[1]?.legend as string,
),
getGraphSeries(
'#F24769',
graphCompatibleData.data.result[2]?.legend as string,
),
],
[graphCompatibleData.data.result],
);
const axesOptions = getAxes({ isDarkMode, yAxisUnit: '' });
const optionsForChart: uPlot.Options = useMemo(
() => ({
id: 'billing-usage-breakdown',
series: uPlotSeries,
width: containerDimensions.width,
height: containerDimensions.height - 30,
axes: [
{
...axesOptions[0],
grid: {
...axesOptions.grid,
show: false,
stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400,
},
},
{
...axesOptions[1],
stroke: isDarkMode ? Color.BG_SLATE_200 : Color.BG_INK_400,
},
],
scales: {
x: {
...getXAxisScale(startTime - 86400, endTime), // Minus 86400 from startTime to decrease a day to have a buffer start
},
y: {
...getYAxisScale({
series: graphCompatibleData?.data?.newResult?.data?.result,
yAxisUnit: '',
softMax: null,
softMin: null,
}),
},
},
legend: {
show: true,
live: false,
isolate: true,
},
cursor: {
lock: false,
focus: {
prox: 1e6,
bias: 1,
},
},
focus: {
alpha: 0.3,
},
padding: [32, 32, 16, 16],
plugins: [
tooltipPlugin({
apiResponse: fillMissingValuesForQuantities(
graphCompatibleData,
chartData[0],
),
yAxisUnit: '',
isBillingUsageGraphs: true,
isDarkMode,
// Single-day data causes bars to span multiple days — add a synthetic
// zero-value next-day entry so uPlot renders a correctly-sized single-day bar.
const normalizedData = useMemo(() => {
if (!data?.details?.breakdown) {
return data;
}
return {
...data,
details: {
...data.details,
breakdown: data.details.breakdown.map((breakdown) => {
if (breakdown?.dayWiseBreakdown?.breakdown?.length !== 1) {
return breakdown;
}
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
const nextDay = {
...currentDay,
timestamp: currentDay.timestamp + 86400,
count: 0,
size: 0,
quantity: 0,
total: 0,
};
return {
...breakdown,
dayWiseBreakdown: {
...breakdown.dayWiseBreakdown,
breakdown: [...breakdown.dayWiseBreakdown.breakdown, nextDay],
},
};
}),
],
}),
[
axesOptions,
chartData,
containerDimensions.height,
containerDimensions.width,
endTime,
graphCompatibleData,
isDarkMode,
startTime,
uPlotSeries,
],
},
};
}, [data]);
const graphCompatibleData = useMemo(
() => convertDataToMetricRangePayload(normalizedData),
[normalizedData],
);
const numberFormatter = new Intl.NumberFormat('en-US');
const chartData = useMemo(
() => prepareChartData(graphCompatibleData) as uPlot.AlignedData,
[graphCompatibleData],
);
const filledApiResponse = useMemo(
(): MetricRangePayloadProps =>
fillMissingValuesForQuantities(
graphCompatibleData,
chartData[0] as number[],
),
[graphCompatibleData, chartData],
);
const { startTime, endTime } = useMemo(
() =>
calculateStartEndTime(normalizedData as Partial<UsageResponsePayloadProps>),
[normalizedData],
);
const config = useMemo(
() =>
prepareBillingBarConfig({
isDarkMode,
// Subtract 86400s (one day) from startTime to add a buffer before first bar
minTimeScale: startTime !== undefined ? startTime - 86400 : undefined,
maxTimeScale: endTime,
apiResponse: graphCompatibleData,
}),
[isDarkMode, startTime, endTime, graphCompatibleData],
);
const renderBillingTooltip = useCallback(
(args: TooltipRenderArgs) => (
<BillingBarChartTooltip billingApiResponse={filledApiResponse} {...args} />
),
[filledApiResponse],
);
return (
<Card bordered={false} className="billing-graph-card">
@@ -214,8 +128,19 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
</Typography.Text>
</Flex>
</Flex>
<div ref={graphRef} style={{ height: '100%', paddingBottom: 48 }}>
<Uplot data={chartData} options={optionsForChart} />
<div ref={graphRef} className="graph-container">
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
config={config}
data={chartData}
isStackedBarChart
legendConfig={{ position: LegendPosition.BOTTOM }}
customTooltip={renderBillingTooltip}
width={containerDimensions.width}
height={containerDimensions.height - 30}
canPinTooltip
/>
)}
</div>
</Card>
);

View File

@@ -0,0 +1,165 @@
import React from 'react';
import { render, screen } from 'tests/test-utils';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { BillingBarChartTooltip } from '../BillingBarChartTooltip';
// Mock buildTooltipContent so tests don't depend on uPlot stacking math
jest.mock('lib/uPlotV2/components/Tooltip/utils', () => ({
buildTooltipContent: jest.fn().mockReturnValue([
{
label: 'Logs',
value: 100,
tooltipValue: '$100.00',
color: '#7CEDBE',
isActive: true,
isHighlighted: false,
},
{
label: 'Traces',
value: 50,
tooltipValue: '$50.00',
color: '#4E74F8',
isActive: false,
isHighlighted: false,
},
]),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: jest.fn().mockReturnValue(false),
}));
function makeUPlotInstance(seriesLabels: string[]): uPlot {
return {
data: [
[1000, 2000],
[100, 200],
[50, 80],
],
cursor: { idx: 0 },
series: [
{ label: 'Timestamp', show: true, stroke: '#000' },
...seriesLabels.map((label) => ({
label,
show: true,
stroke: '#aabbcc',
})),
],
} as unknown as uPlot;
}
function makeBillingApiResponse(
entries: { legend: string; quantity: (number | null)[]; unit: string }[],
): MetricRangePayloadProps {
return {
data: {
result: entries.map((e) => ({
legend: e.legend,
queryName: e.legend,
metric: {},
values: [[1000, '10']] as [number, string][],
quantity: e.quantity as number[],
unit: e.unit,
})),
resultType: '',
newResult: { data: { result: [], resultType: '' } },
},
};
}
const baseTooltipArgs = {
isPinned: false,
dismiss: jest.fn(),
viaSync: false,
seriesIndex: 1,
dataIndexes: [null, 0, 0],
};
describe('BillingBarChartTooltip', () => {
it('augments tooltipValue with quantity and unit for each series', () => {
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
const billingApiResponse = makeBillingApiResponse([
{ legend: 'Logs', quantity: [1.5, 2.0], unit: 'GB' },
{ legend: 'Traces', quantity: [500, 800], unit: 'spans' },
]);
render(
<BillingBarChartTooltip
{...baseTooltipArgs}
uPlotInstance={uPlotInstance}
billingApiResponse={billingApiResponse}
/>,
);
expect(screen.getAllByText(/1\.5 GB/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/500 spans/i).length).toBeGreaterThan(0);
});
it('omits quantity line when quantity at dataIndex is null', () => {
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
const billingApiResponse = makeBillingApiResponse([
{ legend: 'Logs', quantity: [null, null], unit: 'GB' },
{ legend: 'Traces', quantity: [null, null], unit: 'spans' },
]);
render(
<BillingBarChartTooltip
{...baseTooltipArgs}
uPlotInstance={uPlotInstance}
billingApiResponse={billingApiResponse}
/>,
);
expect(screen.queryByText(/null GB/i)).not.toBeInTheDocument();
expect(screen.queryByText(/null spans/i)).not.toBeInTheDocument();
expect(screen.getByTestId('uplot-tooltip-container')).toBeInTheDocument();
});
it('formats dollar value via getToolTipValue — strips trailing zeros (0.3076 → $0.3)', () => {
const uPlotInstance = makeUPlotInstance(['Logs']);
const { buildTooltipContent } = jest.requireMock(
'lib/uPlotV2/components/Tooltip/utils',
) as { buildTooltipContent: jest.Mock };
buildTooltipContent.mockReturnValueOnce([
{
label: 'Logs',
value: 0.3076171875,
tooltipValue: '$0.31',
color: '#7CEDBE',
isActive: true,
isHighlighted: false,
},
]);
const billingApiResponse = makeBillingApiResponse([
{ legend: 'Logs', quantity: [1.23], unit: 'GB' },
]);
render(
<BillingBarChartTooltip
{...baseTooltipArgs}
uPlotInstance={uPlotInstance}
billingApiResponse={billingApiResponse}
/>,
);
expect(screen.getAllByText(/\$0\.3 -/i).length).toBeGreaterThan(0);
});
it('passes through base tooltipValue when series is not in billingApiResponse', () => {
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
const billingApiResponse = makeBillingApiResponse([]);
render(
<BillingBarChartTooltip
{...baseTooltipArgs}
uPlotInstance={uPlotInstance}
billingApiResponse={billingApiResponse}
/>,
);
expect(screen.getAllByText('$100.00').length).toBeGreaterThan(0);
expect(screen.getAllByText('$50.00').length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,101 @@
import { Color } from '@signozhq/design-tokens';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { prepareBillingBarConfig } from '../prepareBillingBarConfig';
const makeApiResponse = (legends: string[]): MetricRangePayloadProps => ({
data: {
result: legends.map((legend) => ({
legend,
queryName: legend,
metric: {},
values: [[1000, '10']],
})),
resultType: '',
newResult: { data: { result: [], resultType: '' } },
},
});
describe('prepareBillingBarConfig', () => {
const baseProps = { isDarkMode: false };
it('returns a builder with no series when apiResponse is undefined', () => {
const builder = prepareBillingBarConfig(baseProps);
const config = builder.getConfig();
expect(config.series).toHaveLength(1);
});
it('returns a builder with no series when result is empty', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse([]),
});
const config = builder.getConfig();
expect(config.series).toHaveLength(1);
});
it('adds one series per result entry with correct labels and colors', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
});
const config = builder.getConfig();
expect(config.series).toHaveLength(4);
expect(config.series?.[1]?.label).toBe('Logs');
expect(config.series?.[1]?.stroke).toBe(Color.BG_FOREST_300);
expect(config.series?.[2]?.label).toBe('Traces');
expect(config.series?.[2]?.stroke).toBe(Color.BG_ROBIN_500);
expect(config.series?.[3]?.label).toBe('Metrics');
expect(config.series?.[3]?.stroke).toBe(Color.BG_SAKURA_500);
});
it('assigns fallback color (Amber500) for signals beyond the 3-color palette', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse(['A', 'B', 'C', 'D']),
});
const config = builder.getConfig();
expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500);
});
it('sets stacking bands, padding, and focus alpha for behavioral parity', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
});
const config = builder.getConfig();
expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
expect(config.padding).toStrictEqual([32, 32, 16, 16]);
expect(config.focus).toStrictEqual({ alpha: 0.3 });
});
it('sets no bands when result is empty', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse([]),
});
const config = builder.getConfig();
expect(config.bands).toBeUndefined();
});
it('uses queryName as label when legend is undefined', () => {
const apiResponse: MetricRangePayloadProps = {
data: {
result: [
{
legend: undefined as any,
queryName: 'Logs',
metric: {},
values: [[1000, '10']],
},
],
resultType: '',
newResult: { data: { result: [], resultType: '' } },
},
};
const builder = prepareBillingBarConfig({ isDarkMode: false, apiResponse });
const config = builder.getConfig();
expect(config.series?.[1]?.label).toBe('Logs');
expect(config.series?.[1]?.stroke).toBe(Color.BG_FOREST_300);
});
});

View File

@@ -0,0 +1,80 @@
import { calculateStartEndTime } from '../utils';
const makeData = (
timestamps: number[],
billingPeriodStart?: number,
billingPeriodEnd?: number,
) => ({
billingPeriodStart,
billingPeriodEnd,
details: {
total: 0,
baseFee: 0,
billTotal: 0,
breakdown: [
{
type: 'Logs',
unit: 'GB',
dayWiseBreakdown: {
breakdown: timestamps.map((timestamp) => ({
timestamp,
total: 0,
quantity: 0,
count: 0,
size: 0,
})),
},
},
],
},
});
describe('calculateStartEndTime', () => {
it('returns min/max of all breakdown timestamps', () => {
const data = makeData([1000, 3000, 2000]);
expect(calculateStartEndTime(data)).toStrictEqual({
startTime: 1000,
endTime: 3000,
});
});
it('includes billingPeriodStart and billingPeriodEnd in the range', () => {
const data = makeData([2000, 3000], 500, 4000);
expect(calculateStartEndTime(data)).toStrictEqual({
startTime: 500,
endTime: 4000,
});
});
it('returns undefined when there are no timestamps and no billing period', () => {
expect(calculateStartEndTime({})).toStrictEqual({
startTime: undefined,
endTime: undefined,
});
});
it('returns undefined when breakdown is empty', () => {
const data = makeData([]);
expect(calculateStartEndTime(data)).toStrictEqual({
startTime: undefined,
endTime: undefined,
});
});
it('filters out non-finite billingPeriod values', () => {
const data = makeData([1000], NaN, Infinity);
expect(calculateStartEndTime(data)).toStrictEqual({
startTime: 1000,
endTime: 1000,
});
});
it('works when details is missing', () => {
expect(
calculateStartEndTime({ billingPeriodStart: 100, billingPeriodEnd: 200 }),
).toStrictEqual({
startTime: 100,
endTime: 200,
});
});
});

View File

@@ -0,0 +1,71 @@
import { Color } from '@signozhq/design-tokens';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
const BILLING_SERIES_COLORS = [
Color.BG_FOREST_300,
Color.BG_ROBIN_500,
Color.BG_SAKURA_500,
];
export interface PrepareBillingBarConfigProps {
isDarkMode: boolean;
timezone?: Timezone;
minTimeScale?: number;
maxTimeScale?: number;
apiResponse?: MetricRangePayloadProps;
}
export function prepareBillingBarConfig({
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
apiResponse,
}: PrepareBillingBarConfigProps): UPlotConfigBuilder {
const builder = buildBaseConfig({
id: 'billing-usage-breakdown',
isDarkMode,
timezone,
panelType: PANEL_TYPES.BAR,
minTimeScale,
maxTimeScale,
});
const results = apiResponse?.data?.result;
if (!results?.length) {
return builder;
}
const labels = results.map((s) => s.legend || s.queryName || '');
const colorMapping = labels.reduce<Record<string, string>>(
(acc, label, index) => {
acc[label] = BILLING_SERIES_COLORS[index] ?? Color.BG_AMBER_500;
return acc;
},
{},
);
labels.forEach((label) => {
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label,
colorMapping,
isDarkMode,
metric: {},
});
});
builder.setBands(getInitialStackedBands(results.length));
builder.setPadding([32, 32, 16, 16]);
builder.setFocus({ alpha: 0.3 });
return builder;
}

View File

@@ -1,7 +1,7 @@
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { isEmpty, isNull } from 'lodash-es';
import { unparse } from 'papaparse';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
@@ -120,11 +120,40 @@ export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
fileName: string;
} {
const graphCompatibleData = convertDataToMetricRangePayload(data);
const chartData = getUPlotChartData(graphCompatibleData);
const quantityMapArr = quantityDataArr(graphCompatibleData, chartData[0]);
const chartData = prepareChartData(graphCompatibleData);
const quantityMapArr = quantityDataArr(
graphCompatibleData,
chartData[0] as number[],
);
return {
csvData: unparse(generateCsvData(quantityMapArr)),
fileName: csvFileName(quantityMapArr),
};
}
export function calculateStartEndTime(
data: Partial<UsageResponsePayloadProps>,
): { startTime: number | undefined; endTime: number | undefined } {
const timestamps: number[] = [];
data?.details?.breakdown?.forEach((breakdown) => {
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry) => {
timestamps.push(entry.timestamp);
});
});
const billingTime: number[] = [
data?.billingPeriodStart,
data?.billingPeriodEnd,
].filter((t): t is number => typeof t === 'number' && Number.isFinite(t));
const allTimes = [...timestamps, ...billingTime];
if (allTimes.length === 0) {
return { startTime: undefined, endTime: undefined };
}
return {
startTime: Math.min(...allTimes),
endTime: Math.max(...allTimes),
};
}

View File

@@ -196,7 +196,11 @@ function Footer(): JSX.Element {
</Button>
);
if (alertValidationMessage) {
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
button = (
<Tooltip title={alertValidationMessage}>
<span>{button}</span>
</Tooltip>
);
}
return button;
}, [
@@ -224,7 +228,11 @@ function Footer(): JSX.Element {
</Button>
);
if (alertValidationMessage) {
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
button = (
<Tooltip title={alertValidationMessage}>
<span>{button}</span>
</Tooltip>
);
}
return button;
}, [

View File

@@ -153,6 +153,7 @@
font-size: 10px;
color: var(--l2-foreground);
margin-top: 4px;
display: block;
}
}

View File

@@ -47,18 +47,10 @@
}
.ant-tabs-tab-active {
.overview-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
.variables-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
.overview-btn,
.variables-btn,
.public-dashboard-btn {
border-radius: 2px 0px 0px 2px;
color: var(--primary-background);
background: var(--l1-border);
}
}

View File

@@ -127,6 +127,15 @@
align-items: center;
justify-content: center;
gap: 4px;
.sidenav-beta-tag {
margin-left: 4px;
}
div {
display: flex;
align-items: center;
}
}
.variable-type-btn + .variable-type-btn {
@@ -177,6 +186,7 @@
.multiple-values-section {
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0;
.typography-variables {
@@ -193,6 +203,7 @@
.all-option-section {
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0;
.typography-variables {

View File

@@ -518,7 +518,6 @@ function VariableItem({
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}
@@ -614,7 +613,6 @@ function VariableItem({
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}

View File

@@ -0,0 +1,328 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Dashboard } from 'types/api/dashboard/getAll';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard';
// ---------------------------------------------------------------------------
// Shared fixture helpers
// ---------------------------------------------------------------------------
const EMPTY_BUILDER = {
queryData: [] as any,
queryFormulas: [],
queryTraceOperator: [],
};
const BASE_WIDGET = {
opacity: '1',
nullZeroValues: 'null',
timePreferance: 'GLOBAL_TIME' as const,
softMin: null,
softMax: null,
selectedLogFields: null,
selectedTracesFields: null,
};
const DEFAULT_QUERY_DATA = {
queryName: 'q1',
// In QB v5, expression holds the query label (A/B/C), not a filter expression
expression: 'A',
dataSource: DataSource.METRICS,
functions: [],
groupBy: [],
filters: { items: [] as any[], op: 'AND' as const },
legend: '',
disabled: false,
having: [],
limit: null,
stepInterval: null,
orderBy: [],
selectColumns: [],
source: '' as const,
};
/**
* Build a dashboard with a single builder widget.
* Only supply the fields your test actually cares about.
*/
const buildBuilderDashboard = (
filterExpression: string,
queryDataOverrides: Record<string, any> = {},
): Dashboard => ({
id: 'dash1',
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: {
title: 'Test Dashboard',
widgets: [
{
...BASE_WIDGET,
id: 'widget-1',
panelTypes: PANEL_TYPES.TIME_SERIES,
title: 'Widget 1',
description: '',
query: {
id: 'query1',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
{
...DEFAULT_QUERY_DATA,
...queryDataOverrides,
filter: { expression: filterExpression },
},
],
queryFormulas: [],
queryTraceOperator: [],
},
unit: '',
},
},
],
variables: {},
},
});
const buildClickhouseDashboard = (query: string): Dashboard => ({
id: 'dash-ch',
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: {
title: 'CH',
widgets: [
{
...BASE_WIDGET,
id: 'w1',
panelTypes: PANEL_TYPES.TIME_SERIES,
title: '',
description: '',
query: {
id: 'q1',
queryType: EQueryType.CLICKHOUSE,
promql: [],
clickhouse_sql: [{ name: 'A', query, legend: '', disabled: false }],
builder: EMPTY_BUILDER,
unit: '',
},
},
],
variables: {},
},
});
const buildPromqlDashboard = (query: string): Dashboard => ({
id: 'dash-prom',
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: {
title: 'PromQL Dashboard',
widgets: [
{
...BASE_WIDGET,
id: 'widget-prom',
panelTypes: PANEL_TYPES.TIME_SERIES,
title: 'PromQL Widget',
description: '',
query: {
id: 'query-prom',
queryType: EQueryType.PROM,
promql: [{ name: 'A', query, legend: '', disabled: false }],
clickhouse_sql: [],
builder: EMPTY_BUILDER,
unit: '',
},
},
],
variables: {},
},
});
/** Run removeVariableReferencesFromDashboard on a single-widget clickhouse dashboard and return the cleaned SQL. */
const chQuery = (sql: string, varName: string): string => {
const result = removeVariableReferencesFromDashboard(
buildClickhouseDashboard(sql),
varName,
);
return (result!.data.widgets![0] as any).query.clickhouse_sql[0].query;
};
/** Extract the first builder queryData from a cleaned dashboard. */
const firstBuilderQueryData = (dashboard: Dashboard | undefined): any =>
(dashboard!.data.widgets![0] as any).query.builder.queryData[0];
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('removeVariableReferencesFromDashboard', () => {
describe('builder filter expression cleanup', () => {
it('removes a variable clause from filter.expression', () => {
const dashboard = buildBuilderDashboard(
"service.name IN $service AND env = 'prod'",
);
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'");
});
it('leaves no dangling AND/OR after removing a variable clause', () => {
const dashboard = buildBuilderDashboard(
"service.name IN $service AND env = 'prod'",
);
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
const { expression } = firstBuilderQueryData(result).filter;
expect(expression).toBe("env = 'prod'");
expect(expression).not.toMatch(/^\s*(AND|OR)/i);
expect(expression).not.toMatch(/(AND|OR)\s*$/i);
});
it('does not remove $environment clause when deleting $env', () => {
const dashboard = buildBuilderDashboard(
'env = $env AND deployment.environment = $environment',
);
const result = removeVariableReferencesFromDashboard(dashboard, 'env');
expect(firstBuilderQueryData(result).filter.expression).toBe(
'deployment.environment = $environment',
);
});
it('leaves literal filter expressions untouched when removing a variable', () => {
const dashboard = buildBuilderDashboard(
"service.name = 'api-gateway' AND env = 'prod'",
);
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
expect(firstBuilderQueryData(result).filter.expression).toBe(
"service.name = 'api-gateway' AND env = 'prod'",
);
});
it('removes only the variable clause, preserving a literal clause on the same key', () => {
const dashboard = buildBuilderDashboard(
"service.name IN $service AND service.name = 'api-gateway'",
);
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
expect(firstBuilderQueryData(result).filter.expression).toBe(
"service.name = 'api-gateway'",
);
});
it('returns filter.expression unchanged when the variable has no clauses in it', () => {
const dashboard = buildBuilderDashboard("env = 'prod'");
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'");
});
});
describe('PromQL query cleanup', () => {
it('removes variable placeholder from a promql query', () => {
const result = removeVariableReferencesFromDashboard(
buildPromqlDashboard('sum(rate(http_requests_total{$service}[5m]))'),
'service',
);
const widget = result!.data.widgets![0] as any;
expect(widget.query.promql[0].query).toBe(
'sum(rate(http_requests_total{}[5m]))',
);
});
it('strips only the variable token inside a PromQL label matcher (token-only path)', () => {
const result = removeVariableReferencesFromDashboard(
buildPromqlDashboard('up{env="$env", job="api"}'),
'env',
);
const widget = result!.data.widgets![0] as any;
expect(widget.query.promql[0].query).toBe('up{env="", job="api"}');
});
});
describe('ClickHouse SQL query cleanup', () => {
it('removes a quoted variable clause and its WHERE keyword', () => {
expect(
chQuery(
"SELECT count() FROM signoz_logs WHERE service_name = '$service'",
'service',
),
).toBe('SELECT count() FROM signoz_logs');
});
it('removes a middle clause: AND env={{.env}} AND', () => {
expect(
chQuery('SELECT count() FROM t WHERE a=1 AND env={{.env}} AND b=2', 'env'),
).toBe('SELECT count() FROM t WHERE a=1 AND b=2');
});
it('removes the first clause: env={{.env}} AND rest', () => {
expect(
chQuery('SELECT count() FROM t WHERE env={{.env}} AND b=2', 'env'),
).toBe('SELECT count() FROM t WHERE b=2');
});
it('removes the last clause: rest AND env=$env', () => {
expect(chQuery('SELECT count() FROM t WHERE a=1 AND env=$env', 'env')).toBe(
'SELECT count() FROM t WHERE a=1',
);
});
it('removes a clause with double-bracket syntax: service=[[svc]]', () => {
expect(chQuery('SELECT count() FROM t WHERE service=[[svc]]', 'svc')).toBe(
'SELECT count() FROM t',
);
});
it('falls back to token-only strip for a bare variable in SELECT', () => {
expect(chQuery('SELECT $metric FROM table', 'metric')).toBe(
'SELECT FROM table',
);
});
});
describe('edge cases', () => {
it('is idempotent — calling twice produces the same result', () => {
const dashboard = buildBuilderDashboard(
"service.name IN $service AND env = 'prod'",
);
const once = removeVariableReferencesFromDashboard(dashboard, 'service');
const twice = removeVariableReferencesFromDashboard(once, 'service');
expect(twice).toStrictEqual(once);
});
it('handles a dashboard with no widgets without throwing', () => {
const dashboard: Dashboard = {
id: 'dash-empty',
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: { title: 'Empty Dashboard', widgets: undefined, variables: {} },
};
expect(() =>
removeVariableReferencesFromDashboard(dashboard, 'service'),
).not.toThrow();
});
});
});

View File

@@ -1,6 +1,8 @@
import {
convertFiltersToExpressionWithExistingQuery,
createVariablePlaceholderRegExp,
removeKeysFromExpression,
removeVariableFromExpression,
} from 'components/QueryBuilderV2/utils';
import { cloneDeep, isArray, isEmpty } from 'lodash-es';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
@@ -157,6 +159,139 @@ const updateAfterRemoval = (
};
};
const removeVariablePlaceholders = (
text: string | undefined,
variableName: string,
): string => {
if (!text) {
return '';
}
const tokenPattern = createVariablePlaceholderRegExp(variableName);
// Step 1: attempt clause-aware removal for SQL WHERE patterns.
// Strips the entire `key op $var` unit plus its adjacent AND/OR so we
// never leave a dangling `key = ` in unquoted ClickHouse SQL clauses.
// Handles three shapes:
// (a) preceding conjunction: AND key = $var
// (b) following conjunction: key = $var AND
// (c) standalone clause: key = $var (end of expression)
const escapedToken = tokenPattern.source;
const clausePattern = new RegExp(
// (a) conjunction before the clause
`\\s*\\b(?:AND|OR)\\b\\s+[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?` +
// (b)+(c) clause first, optional conjunction after
`|[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?(?:\\s*\\b(?:AND|OR)\\b)?`,
'gi',
);
const withClauseRemoval = text.replace(clausePattern, '');
if (withClauseRemoval !== text) {
return withClauseRemoval
.replace(/\s{2,}/g, ' ')
.replace(/\bWHERE\s*$/i, '')
.trim();
}
// Step 2: fallback — bare variable usage outside a key-op-value pattern
// (e.g. SELECT $metric, LIMIT $n). Token-only removal is correct here.
return text
.replace(tokenPattern, '')
.replace(/\s{2,}/g, ' ')
.trim();
};
const removeVariableReferencesFromQueryData = (
queryData: IBuilderQuery,
variableName: string,
): IBuilderQuery => {
const updatedFilter = queryData.filter?.expression
? {
...queryData.filter,
expression: removeVariableFromExpression(
queryData.filter.expression,
variableName,
),
}
: queryData.filter;
return { ...queryData, filter: updatedFilter };
};
const removeVariableReferencesFromWidget = (
widget: Widgets,
variableName: string,
): Widgets => {
let updatedWidget = { ...widget };
if (updatedWidget.query?.builder?.queryData) {
updatedWidget = {
...updatedWidget,
query: {
...updatedWidget.query,
builder: {
...updatedWidget.query.builder,
queryData: updatedWidget.query.builder.queryData.map((queryData) =>
removeVariableReferencesFromQueryData(queryData, variableName),
),
},
},
};
}
if (updatedWidget.query?.promql) {
updatedWidget = {
...updatedWidget,
query: {
...updatedWidget.query,
promql: updatedWidget.query.promql.map((promqlQuery) => ({
...promqlQuery,
query: removeVariablePlaceholders(promqlQuery.query, variableName),
})),
},
};
}
if (updatedWidget.query?.clickhouse_sql) {
updatedWidget = {
...updatedWidget,
query: {
...updatedWidget.query,
clickhouse_sql: updatedWidget.query.clickhouse_sql.map((sqlQuery) => ({
...sqlQuery,
query: removeVariablePlaceholders(sqlQuery.query, variableName),
})),
},
};
}
return updatedWidget;
};
export const removeVariableReferencesFromDashboard = (
dashboard: Dashboard | undefined,
variableName: string,
): Dashboard | undefined => {
if (!dashboard || !variableName) {
return dashboard;
}
const updatedDashboard = cloneDeep(dashboard);
if (updatedDashboard.data.widgets) {
updatedDashboard.data.widgets = updatedDashboard.data.widgets.map(
(widget) => {
if ('query' in widget) {
return removeVariableReferencesFromWidget(widget as Widgets, variableName);
}
return widget;
},
);
}
return updatedDashboard;
};
/**
* A function that takes a dashboard configuration and a list of tag filters
* and returns an updated dashboard with the filters appended to widget queries.

View File

@@ -18,10 +18,11 @@ import { convertVariablesToDbFormat } from 'container/DashboardContainer/Dashboa
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { toast } from '@signozhq/ui/sonner';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard';
import { TVariableMode } from './types';
import VariableItem from './VariableItem/VariableItem';
@@ -92,8 +93,6 @@ function VariablesSettings({
const { dashboardData, setDashboardData } = useDashboardStore();
const { dashboardVariables } = useDashboardVariables();
const { notifications } = useNotifications();
const [variablesTableData, setVariablesTableData] = useState<any>([]);
const [variblesOrderArr, setVariablesOrderArr] = useState<number[]>([]);
const [existingVariableNamesMap, setExistingVariableNamesMap] = useState<
@@ -201,9 +200,7 @@ function VariablesSettings({
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
notifications.success({
message: t('variable_updated_successfully'),
});
toast.success(t('variable_updated_successfully'));
}
},
},
@@ -256,6 +253,11 @@ function VariablesSettings({
};
const handleDeleteConfirm = (): void => {
if (!dashboardData || !variableToDelete.current) {
setDeleteVariableModal(false);
return;
}
const newVariablesArr = variablesTableData.filter(
(variable: IDashboardVariable) =>
variable.id !== variableToDelete?.current?.id,
@@ -263,7 +265,31 @@ function VariablesSettings({
const updatedVariables = convertVariablesToDbFormat(newVariablesArr);
updateVariables(updatedVariables);
const cleanedDashboard =
removeVariableReferencesFromDashboard(
dashboardData,
variableToDelete.current.name || '',
) || dashboardData;
updateMutation.mutateAsync(
{
id: dashboardData.id,
data: {
...cleanedDashboard.data,
variables: updatedVariables,
},
},
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
toast.success(t('variable_updated_successfully'));
}
},
},
);
variableToDelete.current = null;
setDeleteVariableModal(false);
};
@@ -476,6 +502,7 @@ function VariablesSettings({
open={deleteVariableModal}
onOk={handleDeleteConfirm}
onCancel={handleDeleteCancel}
okButtonProps={{ loading: updateMutation.isLoading }}
>
<Typography.Text>
Are you sure you want to delete variable{' '}

View File

@@ -1,8 +1,7 @@
import { renderHook } from '@testing-library/react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { usePanelContextMenu } from '../usePanelContextMenu';
@@ -47,10 +46,7 @@ const mockWidget = { id: 'w-1', query: {} } as unknown as Widgets;
const mockQueryResponse = {
data: undefined,
isLoading: false,
} as unknown as UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
} as unknown as UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
describe('usePanelContextMenu', () => {
beforeEach(() => {

View File

@@ -10,17 +10,13 @@ import {
PopoverPosition,
useCoordinates,
} from 'periscope/components/ContextMenu';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
interface UseTimeSeriesContextMenuParams {
widget: Widgets;
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
queryResponse: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
enableDrillDown?: boolean;
}

View File

@@ -1,16 +1,10 @@
import { logEventMock } from '__tests__/logEventMock';
import { Events } from 'constants/events';
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { render, screen, userEvent } from 'tests/test-utils';
import TooltipFooter from '../TooltipFooter';
const mockLogEvent = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): unknown => mockLogEvent(...args),
}));
describe('TooltipFooter', () => {
const defaultProps = {
id: 'panel-123',
@@ -84,7 +78,7 @@ describe('TooltipFooter', () => {
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
expect(logEventMock).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
id: 'panel-123',
});
expect(dismiss).toHaveBeenCalledTimes(1);

View File

@@ -19,6 +19,7 @@
}
.ant-btn-default {
border-color: transparent;
box-shadow: none;
}
}
.ant-tabs-tab-active {

View File

@@ -1,7 +1,6 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
@@ -67,20 +66,6 @@ function GridCardGraph({
const [errorMessage, setErrorMessage] = useState<string>();
const [isInternalServerError, setIsInternalServerError] =
useState<boolean>(false);
const queryRangeCalledRef = useRef(false);
useEffect(() => {
const timeoutId = setTimeout(() => {
if (!queryRangeCalledRef.current) {
Sentry.captureEvent({
message: `Dashboard query range not called within expected timeframe for widget ${widget?.id}`,
level: 'warning',
});
}
}, 120000);
return (): void => clearTimeout(timeoutId);
}, [widget?.id]);
const {
minTime,
maxTime,
@@ -271,14 +256,12 @@ function GridCardGraph({
});
}
}
queryRangeCalledRef.current = true;
},
onSettled: (data) => {
dataAvailable?.(
isDataAvailableByPanelType(data?.payload?.data, widget?.panelTypes),
);
getGraphData?.(data?.payload?.data);
queryRangeCalledRef.current = true;
},
},
);

View File

@@ -5,9 +5,11 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import {
MetricQueryRangeSuccessResponse,
MetricRangePayloadProps,
} from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import uPlot from 'uplot';
@@ -21,10 +23,7 @@ export interface GraphVisibilityLegendEntryProps {
export interface WidgetGraphComponentProps {
widget: Widgets;
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
queryResponse: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
errorMessage: string | undefined;
version?: string;
threshold?: ReactNode;

View File

@@ -159,8 +159,6 @@ function GridTableComponent({
if (threshold && idx !== -1) {
return (
<div
data-testid="threshold-styled-cell"
data-threshold-format={threshold.thresholdFormat}
style={
threshold.thresholdFormat === 'Background'
? { backgroundColor: threshold.thresholdColor }

View File

@@ -1,8 +1,7 @@
import { UseQueryResult } from 'react-query';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { SuccessResponse } from 'types/api';
import { ContextLinksData, Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
export type GridValueComponentProps = {
@@ -13,10 +12,7 @@ export type GridValueComponentProps = {
thresholds?: ThresholdProps[];
// Context menu related props
widget?: Widgets;
queryResponse?: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
queryResponse?: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
contextLinks?: ContextLinksData;
enableDrillDown?: boolean;
};

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
@@ -121,11 +121,6 @@ function Hosts(): JSX.Element {
[dotMetricsEnabled],
);
const primaryFilterKeys = useMemo(
() => [dotMetricsEnabled ? 'host.name' : 'host_name'],
[dotMetricsEnabled],
);
const controlListPrefix = !showFilters ? (
<div className={styles.quickFiltersToggleContainer}>
<Button
@@ -188,7 +183,6 @@ function Hosts(): JSX.Element {
getEntityName={hostGetEntityName}
getInitialLogTracesFilters={getInitialLogTracesFilters}
getInitialEventsFilters={hostInitialEventsFilter}
primaryFilterKeys={primaryFilterKeys}
metadataConfig={hostDetailsMetadataConfig}
entityWidgetInfo={hostWidgetInfo}
getEntityQueryPayload={getHostMetricsQueryPayload}

View File

@@ -101,10 +101,6 @@ export interface K8sBaseDetailsProps<T> {
getEntityName: (entity: T) => string;
getInitialLogTracesFilters: (entity: T) => TagFilterItem[];
getInitialEventsFilters: (entity: T) => TagFilterItem[];
/**
* @deprecated It's not needed anymore, remove in the next PR
*/
primaryFilterKeys: string[];
metadataConfig: K8sDetailsMetadataConfig<T>[];
entityWidgetInfo: {
title: string;

View File

@@ -15,7 +15,6 @@ import {
k8sClusterGetEntityName,
k8sClusterGetSelectedItemFilters,
k8sClusterInitialEventsFilter,
k8sClusterInitialFilters,
k8sClusterInitialLogTracesFilter,
} from './constants';
import {
@@ -106,7 +105,6 @@ function K8sClustersList({
getEntityName={k8sClusterGetEntityName}
getInitialLogTracesFilters={k8sClusterInitialLogTracesFilter}
getInitialEventsFilters={k8sClusterInitialEventsFilter}
primaryFilterKeys={k8sClusterInitialFilters}
metadataConfig={k8sClusterDetailsMetadataConfig}
entityWidgetInfo={clusterWidgetInfo}
getEntityQueryPayload={getClusterMetricsQueryPayload}

View File

@@ -33,8 +33,6 @@ export const k8sClusterGetSelectedItemFilters = (
export const k8sClusterDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sClusterData>[] =
[{ label: 'Cluster Name', getValue: (p): string => p.meta.k8s_cluster_name }];
export const k8sClusterInitialFilters = [QUERY_KEYS.K8S_CLUSTER_NAME];
export const k8sClusterInitialEventsFilter = (
item: K8sClusterData,
): ReturnType<typeof createFilterItem>[] => [

View File

@@ -15,7 +15,6 @@ import {
k8sDaemonSetGetEntityName,
k8sDaemonSetGetSelectedItemFilters,
k8sDaemonSetInitialEventsFilter,
k8sDaemonSetInitialFilters,
k8sDaemonSetInitialLogTracesFilter,
} from './constants';
import {
@@ -106,7 +105,6 @@ function K8sDaemonSetsList({
getEntityName={k8sDaemonSetGetEntityName}
getInitialLogTracesFilters={k8sDaemonSetInitialLogTracesFilter}
getInitialEventsFilters={k8sDaemonSetInitialEventsFilter}
primaryFilterKeys={k8sDaemonSetInitialFilters}
metadataConfig={k8sDaemonSetDetailsMetadataConfig}
entityWidgetInfo={daemonSetWidgetInfo}
getEntityQueryPayload={getDaemonSetMetricsQueryPayload}

View File

@@ -46,11 +46,6 @@ export const k8sDaemonSetDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sDaem
},
];
export const k8sDaemonSetInitialFilters = [
QUERY_KEYS.K8S_DAEMON_SET_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sDaemonSetInitialEventsFilter = (
item: K8sDaemonSetsData,
): ReturnType<typeof createFilterItem>[] => [

View File

@@ -15,7 +15,6 @@ import {
k8sDeploymentGetEntityName,
k8sDeploymentGetSelectedItemFilters,
k8sDeploymentInitialEventsFilter,
k8sDeploymentInitialFilters,
k8sDeploymentInitialLogTracesFilter,
} from './constants';
import {
@@ -106,7 +105,6 @@ function K8sDeploymentsList({
getEntityName={k8sDeploymentGetEntityName}
getInitialLogTracesFilters={k8sDeploymentInitialLogTracesFilter}
getInitialEventsFilters={k8sDeploymentInitialEventsFilter}
primaryFilterKeys={k8sDeploymentInitialFilters}
metadataConfig={k8sDeploymentDetailsMetadataConfig}
entityWidgetInfo={deploymentWidgetInfo}
getEntityQueryPayload={getDeploymentMetricsQueryPayload}

View File

@@ -46,11 +46,6 @@ export const k8sDeploymentDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sDep
},
];
export const k8sDeploymentInitialFilters = [
QUERY_KEYS.K8S_DEPLOYMENT_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sDeploymentInitialEventsFilter = (
item: K8sDeploymentsData,
): ReturnType<typeof createFilterItem>[] => [

View File

@@ -15,7 +15,6 @@ import {
k8sJobGetEntityName,
k8sJobGetSelectedItemFilters,
k8sJobInitialEventsFilter,
k8sJobInitialFilters,
k8sJobInitialLogTracesFilter,
} from './constants';
import {
@@ -106,7 +105,6 @@ function K8sJobsList({
getEntityName={k8sJobGetEntityName}
getInitialLogTracesFilters={k8sJobInitialLogTracesFilter}
getInitialEventsFilters={k8sJobInitialEventsFilter}
primaryFilterKeys={k8sJobInitialFilters}
metadataConfig={k8sJobDetailsMetadataConfig}
entityWidgetInfo={jobWidgetInfo}
getEntityQueryPayload={getJobMetricsQueryPayload}

View File

@@ -46,11 +46,6 @@ export const k8sJobDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sJobsData>[
},
];
export const k8sJobInitialFilters = [
QUERY_KEYS.K8S_JOB_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sJobInitialEventsFilter = (
item: K8sJobsData,
): ReturnType<typeof createFilterItem>[] => [

View File

@@ -14,7 +14,6 @@ import {
k8sNamespaceGetEntityName,
k8sNamespaceGetSelectedItemFilters,
k8sNamespaceInitialEventsFilter,
k8sNamespaceInitialFilters,
k8sNamespaceInitialLogTracesFilter,
namespaceWidgetInfo,
} from './constants';
@@ -106,7 +105,6 @@ function K8sNamespacesList({
getEntityName={k8sNamespaceGetEntityName}
getInitialLogTracesFilters={k8sNamespaceInitialLogTracesFilter}
getInitialEventsFilters={k8sNamespaceInitialEventsFilter}
primaryFilterKeys={k8sNamespaceInitialFilters}
metadataConfig={k8sNamespaceDetailsMetadataConfig}
entityWidgetInfo={namespaceWidgetInfo}
getEntityQueryPayload={getNamespaceMetricsQueryPayload}

View File

@@ -14,7 +14,6 @@ import {
k8sNodeGetEntityName,
k8sNodeGetSelectedItemFilters,
k8sNodeInitialEventsFilter,
k8sNodeInitialFilters,
k8sNodeInitialLogTracesFilter,
nodeWidgetInfo,
} from './constants';
@@ -106,7 +105,6 @@ function K8sNodesList({
getEntityName={k8sNodeGetEntityName}
getInitialLogTracesFilters={k8sNodeInitialLogTracesFilter}
getInitialEventsFilters={k8sNodeInitialEventsFilter}
primaryFilterKeys={k8sNodeInitialFilters}
metadataConfig={k8sNodeDetailsMetadataConfig}
entityWidgetInfo={nodeWidgetInfo}
getEntityQueryPayload={getNodeMetricsQueryPayload}

View File

@@ -14,7 +14,6 @@ import {
k8sPodGetEntityName,
k8sPodGetSelectedItemFilters,
k8sPodInitialEventsFilter,
k8sPodInitialFilters,
k8sPodInitialLogTracesFilter,
podWidgetInfo,
} from './constants';
@@ -106,7 +105,6 @@ function K8sPodsList({
getEntityName={k8sPodGetEntityName}
getInitialLogTracesFilters={k8sPodInitialLogTracesFilter}
getInitialEventsFilters={k8sPodInitialEventsFilter}
primaryFilterKeys={k8sPodInitialFilters}
metadataConfig={k8sPodDetailsMetadataConfig}
entityWidgetInfo={podWidgetInfo}
getEntityQueryPayload={getPodMetricsQueryPayload}

View File

@@ -42,12 +42,6 @@ export const k8sPodDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sPodsData>[
{ label: 'Node', getValue: (p): string => p.meta.k8s_node_name },
];
export const k8sPodInitialFilters = [
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sPodInitialEventsFilter = (
pod: K8sPodsData,
): ReturnType<typeof createFilterItem>[] => [

View File

@@ -14,7 +14,6 @@ import {
k8sStatefulSetGetEntityName,
k8sStatefulSetGetSelectedItemFilters,
k8sStatefulSetInitialEventsFilter,
k8sStatefulSetInitialFilters,
k8sStatefulSetInitialLogTracesFilter,
statefulSetWidgetInfo,
} from './constants';
@@ -106,7 +105,6 @@ function K8sStatefulSetsList({
getEntityName={k8sStatefulSetGetEntityName}
getInitialLogTracesFilters={k8sStatefulSetInitialLogTracesFilter}
getInitialEventsFilters={k8sStatefulSetInitialEventsFilter}
primaryFilterKeys={k8sStatefulSetInitialFilters}
metadataConfig={k8sStatefulSetDetailsMetadataConfig}
entityWidgetInfo={statefulSetWidgetInfo}
getEntityQueryPayload={getStatefulSetMetricsQueryPayload}

View File

@@ -42,11 +42,6 @@ export const k8sStatefulSetDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sSt
},
];
export const k8sStatefulSetInitialFilters = [
QUERY_KEYS.K8S_STATEFUL_SET_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sStatefulSetInitialEventsFilter = (
item: K8sStatefulSetsData,
): ReturnType<typeof createFilterItem>[] => [

View File

@@ -14,7 +14,6 @@ import {
k8sVolumeGetEntityName,
k8sVolumeGetSelectedItemFilters,
k8sVolumeInitialEventsFilter,
k8sVolumeInitialFilters,
k8sVolumeInitialLogTracesFilter,
volumeWidgetInfo,
} from './constants';
@@ -106,7 +105,6 @@ function K8sVolumesList({
getEntityName={k8sVolumeGetEntityName}
getInitialLogTracesFilters={k8sVolumeInitialLogTracesFilter}
getInitialEventsFilters={k8sVolumeInitialEventsFilter}
primaryFilterKeys={k8sVolumeInitialFilters}
metadataConfig={k8sVolumeDetailsMetadataConfig}
entityWidgetInfo={volumeWidgetInfo}
getEntityQueryPayload={getVolumeMetricsQueryPayload}

View File

@@ -46,11 +46,6 @@ export const k8sVolumeDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sVolumes
},
];
export const k8sVolumeInitialFilters = [
QUERY_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sVolumeInitialEventsFilter = (
item: K8sVolumesData,
): ReturnType<typeof createFilterItem>[] => [

View File

@@ -89,7 +89,7 @@ export function AlertsEmptyState({
onClick={onClickNewAlertHandler}
disabled={!addNewAlert}
loading={loading}
data-testid="add-alert"
testId="add-alert"
>
<span className={styles.buttonContent}>
<Plus size="md" />
@@ -97,7 +97,12 @@ export function AlertsEmptyState({
</span>
</Button>
{onRefresh && (
<Button onClick={onRefresh} prefix={<RefreshCw />} color="secondary">
<Button
onClick={onRefresh}
prefix={<RefreshCw />}
color="secondary"
testId="list-alerts-empty-refresh-button"
>
Refresh
</Button>
)}

View File

@@ -0,0 +1,215 @@
import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
import { findAlertRow, renderListAlertRules } from './_helpers';
async function openActionsMenu(row: HTMLElement): Promise<void> {
const trigger = row.querySelector(
'[data-testid="alert-actions"]',
) as HTMLElement | null;
expect(trigger).not.toBeNull();
const user = userEvent.setup({ delay: null });
await user.click(trigger as HTMLElement);
// Radix renders the menu items in a portal once the trigger is activated.
await screen.findByRole('menu');
}
async function clickMenuItem(label: string): Promise<void> {
const user = userEvent.setup({ delay: null });
const item = await screen.findByRole('menuitem', { name: label });
await user.click(item);
}
describe('ListAlertRules — actions menu', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('renders Enable/Disable/Edit/Edit in New Tab/Clone/Delete items after opening the menu', async () => {
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
await openActionsMenu(row);
const items = screen.getAllByRole('menuitem');
const labels = items.map((it) => it.textContent);
expect(labels).toStrictEqual(
expect.arrayContaining([
'Edit',
'Edit in New Tab',
'Clone',
'Delete',
'Disable',
]),
);
});
it('disabled rule (rule-4) shows "Enable" instead of "Disable"', async () => {
renderListAlertRules();
const row = await findAlertRow('Disabled Alert');
await openActionsMenu(row);
const items = screen.getAllByRole('menuitem');
const labels = items.map((it) => it.textContent);
expect(labels).toContain('Enable');
expect(labels).not.toContain('Disable');
});
it('toggle action: clicking Disable sends PATCH with disabled:true', async () => {
let capturedBody: unknown = null;
let capturedPath: string | null = null;
server.use(
rest.patch('http://localhost/api/v2/rules/:id', async (req, res, ctx) => {
capturedBody = await req.json();
capturedPath = req.params.id as string;
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Disable');
await waitFor(() => {
expect(capturedBody).toStrictEqual(
expect.objectContaining({ disabled: true }),
);
});
expect(capturedPath).toBe('rule-1');
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Enable/Disable', ruleId: 'rule-1' }),
);
});
it('edit action: clicking Edit navigates via safeNavigate and logs event', async () => {
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Edit');
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
expect(safeNavigateMock.mock.calls[0][0]).toContain('ruleId=rule-1');
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Edit', ruleId: 'rule-1' }),
);
});
it('edit in new tab action: clicking opens with newTab:true', async () => {
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Edit in New Tab');
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
const [url, options] = safeNavigateMock.mock.calls[0];
expect(url).toContain('ruleId=rule-1');
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
});
it('clone action: sends POST with " - Copy" suffix and opens the cloned rule returned by the API', async () => {
let capturedPostBody: unknown = null;
server.use(
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
capturedPostBody = await req.json();
return res(
ctx.status(201),
ctx.json({
data: {
...(capturedPostBody as Record<string, unknown>),
id: 'cloned-from-server',
},
status: 'success',
}),
);
}),
);
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Clone');
await waitFor(() => {
expect(capturedPostBody).toStrictEqual(
expect.objectContaining({ alert: 'High CPU Alert - Copy' }),
);
});
// The id from the server response round-trips into the navigate URL — this
// protects against a regression where the code hardcodes the id.
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
expect(safeNavigateMock.mock.calls[0][0]).toContain(
'ruleId=cloned-from-server',
);
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Clone', ruleId: 'rule-1' }),
);
});
it('delete action: sends DELETE for the rule id', async () => {
let deletedId: string | null = null;
server.use(
rest.delete('http://localhost/api/v2/rules/:id', (req, res, ctx) => {
deletedId = req.params.id as string;
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Delete');
await waitFor(() => {
expect(deletedId).toBe('rule-1');
});
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Delete', ruleId: 'rule-1' }),
);
});
it('error path: PATCH is still attempted when server returns 500', async () => {
let patchAttempted = false;
server.use(
rest.patch('http://localhost/api/v2/rules/:id', (_, res, ctx) => {
patchAttempted = true;
return res(ctx.status(500), ctx.json({ status: 'error' }));
}),
);
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Disable');
await waitFor(() => {
expect(patchAttempted).toBe(true);
});
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Enable/Disable', ruleId: 'rule-1' }),
);
});
});

View File

@@ -0,0 +1,79 @@
import userEvent from '@testing-library/user-event';
import { screen, waitFor } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
const COLUMN_STORAGE_KEY = '@signoz/table-columns/alert-rules-columns';
describe('ListAlertRules — columns selector', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('opens columns popover and lists toggleable columns', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.click(screen.getByTestId('alert-columns-button'));
// Popover should reveal "Toggle Columns" heading + per-column labels.
await screen.findByText('Toggle Columns');
expect(screen.getByText('Created At')).toBeInTheDocument();
expect(screen.getByText('Created By')).toBeInTheDocument();
expect(screen.getByText('Updated At')).toBeInTheDocument();
expect(screen.getByText('Updated By')).toBeInTheDocument();
});
it('default-hidden columns (Created At/By, Updated At/By) are not in the table header', async () => {
renderListAlertRules();
await screen.findByText('High CPU Alert');
const headers = document.querySelectorAll('th');
const headerTexts = Array.from(headers).map((h) => h.textContent || '');
expect(headerTexts.some((t) => t.includes('Created At'))).toBe(false);
expect(headerTexts.some((t) => t.includes('Created By'))).toBe(false);
expect(headerTexts.some((t) => t.includes('Updated At'))).toBe(false);
expect(headerTexts.some((t) => t.includes('Updated By'))).toBe(false);
});
it('toggling Created At on writes to localStorage and adds the header', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
const headersBefore = Array.from(document.querySelectorAll('th')).map(
(h) => h.textContent ?? '',
);
expect(headersBefore.some((t) => t.includes('Created At'))).toBe(false);
await user.click(screen.getByTestId('alert-columns-button'));
await screen.findByText('Toggle Columns');
const checkbox = document.getElementById('col-createdAt');
expect(checkbox).not.toBeNull();
await user.click(checkbox as HTMLElement);
await waitFor(() => {
const stored = window.localStorage.getItem(COLUMN_STORAGE_KEY);
expect(stored).not.toBeNull();
const parsed = JSON.parse(stored as string);
expect(parsed.hiddenColumnIds).not.toContain('createdAt');
});
await waitFor(() => {
const headersAfter = Array.from(document.querySelectorAll('th')).map(
(h) => h.textContent ?? '',
);
expect(headersAfter.some((t) => t.includes('Created At'))).toBe(true);
});
});
});

View File

@@ -0,0 +1,91 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — empty states', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('renders AlertsEmptyState when API returns no rules', async () => {
const user = userEvent.setup({ delay: null });
server.use(
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
),
);
renderListAlertRules();
await screen.findByText('No Alert rules yet.');
expect(
screen.getByText('Create an Alert Rule to get started'),
).toBeInTheDocument();
// New Alert Rule button is visible and triggers safeNavigate to ALERTS_NEW.
await user.click(screen.getByTestId('add-alert'));
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.ALERTS_NEW,
expect.objectContaining({ newTab: false }),
);
});
it('renders ErrorEmptyState when API returns 500; refresh triggers a refetch', async () => {
const user = userEvent.setup({ delay: null });
let callCount = 0;
server.use(
rest.get('http://localhost/api/v2/rules', (_, res, ctx) => {
callCount += 1;
if (callCount === 1) {
return res(ctx.status(500), ctx.json({ status: 'error' }));
}
return res(
ctx.status(200),
ctx.json({ data: alertRulesFixture, status: 'success' }),
);
}),
);
renderListAlertRules();
await screen.findByTestId('error-empty-state');
await user.click(screen.getByTestId('error-refresh-button'));
const rule = await screen.findByText('High CPU Alert');
expect(rule).toBeInTheDocument();
});
it('renders NoResultsEmptyState when search yields no match; Clear Search resets', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
const searchInput = screen.getByTestId('list-alerts-search-input');
await user.clear(searchInput);
await user.type(searchInput, 'totally-not-found');
await screen.findByTestId('no-results-empty-state');
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
'No matching alert rules',
);
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
'No alert rules match your search. Try adjusting your search criteria.',
);
await user.click(screen.getByTestId('no-results-clear-button'));
const rule = await screen.findByText('High CPU Alert');
expect(rule).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,123 @@
import { screen, waitFor } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — list rendering', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('renders alert rules from API', async () => {
renderListAlertRules();
await expect(
screen.findByTestId('alert-row-rule-1-name'),
).resolves.toHaveTextContent('High CPU Alert');
expect(screen.getByTestId('alert-row-rule-2-name')).toHaveTextContent(
'Memory Pending Alert',
);
expect(screen.getByTestId('alert-row-rule-3-name')).toHaveTextContent(
'Healthy Alert',
);
expect(screen.getByTestId('alert-row-rule-4-name')).toHaveTextContent(
'Disabled Alert',
);
});
it('renders state badges via STATE_CONFIG mapping', async () => {
renderListAlertRules();
await waitFor(() =>
expect(screen.getByTestId('alert-row-rule-1-state')).toBeInTheDocument(),
);
expect(screen.getByTestId('alert-row-rule-1-state')).toHaveTextContent(
'Firing',
);
expect(screen.getByTestId('alert-row-rule-2-state')).toHaveTextContent(
'Pending',
);
expect(screen.getByTestId('alert-row-rule-3-state')).toHaveTextContent('OK');
expect(screen.getByTestId('alert-row-rule-4-state')).toHaveTextContent(
'Disabled',
);
expect(screen.getByTestId('alert-row-rule-5-state')).toHaveTextContent('OK');
});
it('renders state badges with semantic colors', async () => {
renderListAlertRules();
await waitFor(() =>
expect(screen.getByTestId('alert-row-rule-1-state')).toBeInTheDocument(),
);
expect(screen.getByTestId('alert-row-rule-1-state')).toHaveAttribute(
'data-color',
'cherry',
);
expect(screen.getByTestId('alert-row-rule-2-state')).toHaveAttribute(
'data-color',
'amber',
);
expect(screen.getByTestId('alert-row-rule-3-state')).toHaveAttribute(
'data-color',
'forest',
);
expect(screen.getByTestId('alert-row-rule-4-state')).toHaveAttribute(
'data-color',
'vanilla',
);
});
it('renders severity badges for rules with severity', async () => {
renderListAlertRules();
await waitFor(() =>
expect(screen.getByTestId('alert-row-rule-1-severity')).toBeInTheDocument(),
);
expect(screen.getByTestId('alert-row-rule-1-severity')).toHaveTextContent(
'critical',
);
expect(screen.getByTestId('alert-row-rule-2-severity')).toHaveTextContent(
'warning',
);
expect(screen.getByTestId('alert-row-rule-3-severity')).toHaveTextContent(
'info',
);
expect(screen.getByTestId('alert-row-rule-4-severity')).toHaveTextContent(
'critical',
);
expect(screen.getByTestId('alert-row-rule-5-severity')).toHaveTextContent(
'-',
);
expect(screen.getByTestId('alert-row-rule-1-severity')).toHaveAttribute(
'data-color',
'cherry',
);
expect(screen.getByTestId('alert-row-rule-2-severity')).toHaveAttribute(
'data-color',
'amber',
);
});
it('renders header controls (search, columns, new alert)', async () => {
renderListAlertRules();
await waitFor(() =>
expect(screen.getByTestId('alert-row-rule-1-name')).toBeInTheDocument(),
);
expect(screen.getByTestId('list-alerts-search-input')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search by Alert Name, Severity and Labels'),
).toBeInTheDocument();
expect(screen.getByTestId('alert-columns-button')).toBeInTheDocument();
expect(
screen.getByTestId('list-alerts-new-alert-button'),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /new alert/i }),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,65 @@
import { logEventMock } from '__tests__/logEventMock';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { screen, waitFor } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — new alert button', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('plain click navigates to ALERTS_NEW with newTab:false', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.click(screen.getByRole('button', { name: /new alert/i }));
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.ALERTS_NEW,
expect.objectContaining({ newTab: false }),
);
});
it('logs Alert: New alert button clicked', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.click(screen.getByRole('button', { name: /new alert/i }));
await waitFor(() => {
expect(logEventMock).toHaveBeenCalledWith(
'Alert: New alert button clicked',
expect.objectContaining({ layout: 'new' }),
);
});
});
it('ctrl+click on New Alert opens in a new tab (newTab:true)', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.keyboard('{Control>}');
await user.click(screen.getByRole('button', { name: /new alert/i }));
await user.keyboard('{/Control}');
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.ALERTS_NEW,
expect.objectContaining({ newTab: true }),
);
});
});

View File

@@ -0,0 +1,64 @@
import userEvent from '@testing-library/user-event';
import { alertRulesPaginationFixture } from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — pagination', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
server.use(
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: alertRulesPaginationFixture, status: 'success' }),
),
),
);
});
it('shows first 10 rows on page 1 (default limit)', async () => {
renderListAlertRules();
await screen.findByText('Pag Rule 0');
for (let i = 0; i < 10; i += 1) {
expect(screen.getByText(`Pag Rule ${i}`)).toBeInTheDocument();
}
expect(screen.queryByText('Pag Rule 10')).not.toBeInTheDocument();
expect(screen.queryByText('Pag Rule 14')).not.toBeInTheDocument();
});
it('shows total count when showTotalCount is enabled', async () => {
renderListAlertRules();
await screen.findByText('Pag Rule 0');
const totalCount = await screen.findByTestId('pagination-total-count');
expect(totalCount.textContent).toContain('Showing');
expect(totalCount.textContent).toContain('of 15');
});
it('navigates to page 2 and shows remaining rows', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('Pag Rule 0');
const nextBtn = screen.getByLabelText('Go to next page');
await user.click(nextBtn);
await waitFor(() => {
expect(screen.getByText('Pag Rule 10')).toBeInTheDocument();
expect(screen.getByText('Pag Rule 14')).toBeInTheDocument();
expect(screen.queryByText('Pag Rule 0')).not.toBeInTheDocument();
});
await waitFor(() => {
expect(getCurrentNuqsQueryString()).toContain('page=2');
});
});
});

View File

@@ -0,0 +1,71 @@
import { screen, waitFor } from 'tests/test-utils';
import { USER_ROLES } from 'types/roles';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — permissions', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('VIEWER role hides "New Alert" button and "Actions" column', async () => {
renderListAlertRules({ role: USER_ROLES.VIEWER });
await screen.findByText('High CPU Alert');
expect(
screen.queryByTestId('list-alerts-new-alert-button'),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /new alert/i }),
).not.toBeInTheDocument();
const headers = Array.from(document.querySelectorAll('th')).map(
(h) => h.textContent ?? '',
);
expect(headers.some((t) => t.includes('Actions'))).toBe(false);
expect(screen.queryByTestId('alert-actions')).not.toBeInTheDocument();
});
it('ADMIN role shows "New Alert" button and "Actions" column', async () => {
renderListAlertRules({ role: USER_ROLES.ADMIN });
await screen.findByText('High CPU Alert');
expect(
screen.getByTestId('list-alerts-new-alert-button'),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /new alert/i }),
).toBeInTheDocument();
await waitFor(() => {
const headers = Array.from(document.querySelectorAll('th')).map(
(h) => h.textContent ?? '',
);
expect(headers.some((t) => t.includes('Actions'))).toBe(true);
});
expect(screen.getAllByTestId('alert-actions').length).toBeGreaterThan(0);
});
it('EDITOR role behaves like ADMIN (New Alert + Actions visible)', async () => {
renderListAlertRules({ role: USER_ROLES.EDITOR });
await screen.findByText('High CPU Alert');
expect(
screen.getByTestId('list-alerts-new-alert-button'),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /new alert/i }),
).toBeInTheDocument();
await waitFor(() => {
const headers = Array.from(document.querySelectorAll('th')).map(
(h) => h.textContent ?? '',
);
expect(headers.some((t) => t.includes('Actions'))).toBe(true);
});
expect(screen.getAllByTestId('alert-actions').length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,52 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import userEvent from '@testing-library/user-event';
import { screen, waitFor } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — row click navigation', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('clicking a row calls safeNavigate to alerts/overview with composite query + ruleId', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
const ruleCell = await screen.findByText('High CPU Alert');
const td = ruleCell.closest('td');
expect(td).not.toBeNull();
await user.click(td as HTMLElement);
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
const [url] = safeNavigateMock.mock.calls[0];
expect(url).toContain('/alerts/overview?');
expect(url).toContain('ruleId=rule-1');
expect(url).toContain('panelTypes=graph');
expect(url).toContain('compositeQuery=');
});
it('ctrl+click on a row navigates with newTab option', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
const ruleCell = await screen.findByText('High CPU Alert');
const td = ruleCell.closest('td');
await user.keyboard('{Control>}');
await user.click(td as HTMLElement);
await user.keyboard('{/Control}');
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
const [url, options] = safeNavigateMock.mock.calls[0];
expect(url).toContain('ruleId=rule-1');
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
});
});

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