Compare commits

..

29 Commits

Author SHA1 Message Date
Gaurav Tewari
3765ca3d42 chore: migrate dropdown 2026-05-20 17:39:33 +05:30
primus-bot[bot]
74c875ec79 chore(release): bump to v0.125.0 (#11369)
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
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-05-20 07:18:40 +00:00
Jatinderjit Singh
a27b7d3d8e move planned maintenance to alertmanager pipeline (#11130)
* add maintenanceMuteStage to move planned maintenance to alertmanager

Rules previously skipped rule.Eval() entirely during maintenance windows.
This change moves suppression to MaintenanceMuter, injected as a Stage
in the alertmanager notification pipeline. Now rules always evaluate and
everys suppression is handled by alertmanager.

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

* refactor: wrap routing pipeline once instead of per-route injection

Replace the per-route-entry loop with a single MultiStage wrap so
maintenance suppression runs once per dispatch group before routing.

* refactor: move maintenance mute stage into custom pipelineBuilder

Copy notify.PipelineBuilder locally so we can inject mms between the
silence stage and the receiver stage (GossipSettle → Inhibit →
TimeActive → TimeMute → Silence → mms → Receiver), matching the
correct suppression order the team requires.

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

* chore: add license header to pipeline_builder.go

Copied code originates from Apache-2.0 licensed Prometheus Alertmanager;
add dual copyright + SPDX identifier following the repo's convention.

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

* chore: replace SPDX tag with full Apache 2.0 license boilerplate

The full license text is unambiguously compliant with Apache 2.0 Section 4(a),
which requires giving recipients "a copy of this License".

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

* refactor: pass MaintenanceMuter directly to pipelineBuilder

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

* refactor: remove dead orgID param from task constructors

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

* rename buildReceiverStage -> createReceiverStage

* refactor: replace maintenanceMuteStage with notify.NewMuteStage

MaintenanceMuter already satisfies types.Muter, and pipelineBuilder has
its own pb.metrics, so the hand-rolled maintenanceMuteStage wrapper is
redundant. Use notify.NewMuteStage(pb.muter, pb.metrics) directly.

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

* refactor: hoist MuteStage construction out of the receiver loop

MuteStage holds no per-receiver state, so one instance shared across
all receivers is sufficient — matching how is/ss are handled upstream.

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

* refactor: always initialize maintenanceStore; remove nil guards

Tests now use a real sqlrulestore-backed MaintenanceMuter instead of
passing nil. With nil no longer a valid input, remove the nil guards
in server.go and pipeline_builder.go.

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

* refactor: move MaintenanceMuter to Server and pass it to pipelineBuilder.New

- Remove muter from pipelineBuilder struct and newPipelineBuilder();
  pass it as a parameter to New() instead, consistent with inhibitor/silencer
- Store muter on Server so GetAlerts can call Mutes() alongside the
  inhibitor and silencer, ensuring maintenance-suppressed alerts show
  the correct muted status in API responses

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

* remove redundant MemMarker wrapper

* feat: surface maintenance-suppressed alerts via mutedBy in GetAlerts

Alerts suppressed by an active maintenance window were being correctly
muted in the notification pipeline but appeared as state=active in the
v2 GetAlerts response, since MaintenanceMuter.Mutes had no marker
side-effect (unlike inhibitor/silencer).

Add MaintenanceMuter.MutedBy returning the matching window IDs, and
plumb a mutedByFunc callback through NewGettableAlertsFromAlertProvider
into AlertToOpenAPIAlert. The upstream v2 API forces state=suppressed
when mutedBy is non-empty, so the frontend's existing state-based
rendering picks it up without further changes.

Use the dedicated mutedBy field rather than SilencedBy to avoid
violating the "complete set of silence IDs" contract that anything
querying silences by ID would rely on.

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

* code cleanup

* refactor: move maintenance (planned downtime) to alertmanager packages

Types move from pkg/types/ruletypes/ to pkg/types/alertmanagertypes/:
- maintenance.go, recurrence.go, schedule.go (+ tests)

Store impl moves from pkg/ruler/rulestore/sqlrulestore/ to
pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore/.

Maintenance windows mute alerts, so they belong with alertmanager
rather than the rule types.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: add unit tests for MaintenanceMuter

Covers Mutes/MutedBy semantics (empty label, rule match, empty-RuleIDs
matches-all, future windows, multi-window) and the result cache
(single-fetch within TTL, stale-cache fallback on store error,
re-fetch after expiry, concurrency safety).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Update schema changes

* Re-add marker

* fix NewMaintenanceStore in tests

* Go lint fixes

* test: use mockery-generated mock for MaintenanceStore in muter tests

Replace hand-written fakeMaintenanceStore with a mockery-generated
MockMaintenanceStore, consistent with the alertmanagertest pattern.
Also adds MaintenanceStore to .mockery.yml so the mock stays in sync.

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

* chore: regenerate mocks via make gen-mocks

Picks up new MockHandler for the Handler interface in pkg/alertmanager
and regenerates MockMaintenanceStore with canonical mockery formatting.

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

* cleanup test

* test: add e2e muting tests for maintenance window behaviour

* fix updates: omit empty endTime from serialization

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 04:20:16 +00:00
Vishal Sharma
eab8d45611 chore(ai-assistant): add product analytics events (#11331)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
* chore(ai-assistant): add product analytics events

Wire 14 frontend product-analytics events for the AI Assistant feature
so we can measure the open→send funnel, action conversion, voice usage,
and feature adoption. All events go through the existing `logEvent`
helper, with a shared `useAIAssistantAnalyticsContext` hook providing
`{ threadId, page, mode }`.

Events shipped:
- AI Assistant: Opened (source: icon | shortcut | deeplink)
- AI Assistant: New chat clicked
- AI Assistant: Message sent
- AI Assistant: Suggested prompt clicked
- AI Assistant: Cancel clicked
- AI Assistant: Regenerate clicked
- AI Assistant: Message copied
- AI Assistant: Feedback submitted
- AI Assistant: Resource opened
- AI Assistant: Doc opened
- AI Assistant: Apply filter clicked
- AI Assistant: Thread opened from history
- AI Assistant: Voice input used
- AI Assistant: Voice input failed

Additional changes:
- Suppress duplicate `Opened` fires when expanding drawer/modal to the
  full-screen page (markExpandFromInApp / consumeExpandFromInApp flag).
- Toast + analytics + sessionStorage-persisted hide for voice failures
  on Chromium derivatives that lack the Google Speech API key.
- Browser info (name, version, platform, userAgent) attached to voice
  events to triage browser-specific failures.

Skipped per scope: executionId on Cancel clicked, toolName on action
events, turnCategory on Feedback submitted, promptCategory on suggested
prompts — would require store/DTO changes beyond instrumentation.

* fix(ai-assistant): address review feedback on analytics events

- Replace markExpand module flag with router state so the Opened event
  stays correct across StrictMode double-mounts and aborted navigations.
- Guard the voice push-to-talk shortcut on voiceUnavailable so it can't
  bypass the persisted hide-after-failure flag.
- Fire SuggestedPromptClicked (category: follow_up) alongside MessageSent
  on server-emitted follow_up chips so click-through can be measured.
- Normalize the page/currentPage attribute to its ROUTES template via
  matchPath, bounding cardinality and avoiding customer IDs in analytics.
- Pick browsers from userAgentData via a derivative-first priority list,
  fall through to UA sniffing for generic Chromium hits, and probe
  navigator.brave to distinguish Brave from plain Chrome.

* refactor(ai-assistant): simplify analytics scaffolding

- Trim getBrowserInfo to UA-sniffing + Brave probe; drop the brand
  priority list, isGenericBrand gate, and userAgent/platform fields
  the backend can derive from request headers anyway.
- Inline the router-state shape at its three call sites instead of
  exporting a named interface for { fromInApp?: boolean }.
- Tighten comments across the module — keep the non-obvious "why"
  bits, drop the restated ones.

* fix(ai-assistant): apply PR review feedback on analytics events

- HeaderRightSection: rename Opened source 'icon' -> 'header' to reflect
  where the icon lives, not how it looks.
- AIAssistantPage: normalize pathname on NewChatClicked so the
  conversation id doesn't leak into the page attribute.
- ConversationView: invert the streaming useEffect to an early bailout
  when not streaming for readability.
- ActionsSection: extract resource-type case strings into a ResourceType
  constants object shared by targetModuleForResource and resourceRoute.
- VirtualizedMessages + ActionsSection: replace 'follow_up' / 'empty_state'
  magic strings with a SuggestedPromptCategory constants in events.ts.
2026-05-19 16:33:30 +00:00
Jatinderjit Singh
c73fdd1f81 fix(planned-downtime): timezone handling (#11318)
* fix(planned-downtime): timezone handling

Don't convert the start/end times to UTC for the request. Serialize
as per the input timezone instead.

* Fix date/string conversion issues

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-05-19 15:55:11 +00:00
Aditya Singh
6094a5eee2 fix: remove spanScopeSelector key to sync scope filters (#11355) 2026-05-19 15:39:23 +00:00
swapnil-signoz
93407d3bcc feat(sqlmigration): add integration_dashboard table (#11343)
* feat(sqlmigration): add integration_dashboards table (migration 079)

Adds the `integration_dashboards` relations table that stores the
integration-specific identity for dashboards provisioned from cloud
or builtin integrations. Columns: id, org_id, dashboard_id, provider,
slug, created_at, updated_at. Includes a unique index on dashboard_id.

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

* chore: adding comment for fk

* refactor: renaming table name

* chore: file rename

* refactor: removing org_id column and adding fk relation

* refactor: rename integration dashboards factory to singular

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:24:27 +00:00
Abhi kumar
5bd4cabbca fix: added fix for widget warning/save modal states (#11356)
Some checks failed
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
2026-05-19 13:31:14 +00:00
Aditya Singh
f9e21cecd8 feat: field selector migrated to telemetry field key (#11360)
* feat: field selector migrated to telemetry field key

* feat: move floating panel to field selector
2026-05-19 13:14:44 +00:00
Ashwin Bhatkal
4b98b0bb27 fix(dashboard): component UX updates in widget header and settings panel (#11357)
Bundles four small UX fixes — three regressions from the typography
(#11199) and icons (#11222) migrations, plus the DashboardDescription
fallout from #11352:

- Widget panel title truncates to "Title..." even when the panel has
  plenty of horizontal space. The title container had no `flex: 1` /
  `min-width: 0`, so it collapsed to content width and the 80% cap
  triggered early truncation. Make the title row a real flex item.
- Variable editor "Default Value" label and helper text run together
  on one line. `Typography` from `@signozhq/ui` defaults to
  `display: inline`, so the helper text sat next to the label. Force
  block layout in the default-value-section.
- Cross-Panel Sync info icon was the outline `Info`, inconsistent with
  the `SolidInfoCircle` used everywhere else (widget header, threshold,
  status message). Swap to the standard icon at size "md".
- After #11352, DeleteButton renders as an antd `<Button>`, but the
  DashboardDescription action menu still targeted `.ant-typography`
  for the delete entry, so it picked up the list-page module's 8px /
  12px styling and went out of sync with its peers. Consolidate the
  three near-duplicate `section-1` / `section-2` / `delete-dashboard`
  blocks into a single `section .ant-btn` rule, with section dividers
  and the danger color as the only per-section overrides.
2026-05-19 10:49:46 +00:00
Jatinderjit Singh
b48851e487 fix: maintenance ignores recurrence when fixed times also set (#11121)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: maintenance ignores recurrence when fixed times also set

* send empty start/end dates in frontend for recurring windows

* handle zero start and end times in schedule

* Revert "send empty start/end dates in frontend for recurring windows"

This reverts commit 87bc3fae274ccfd9ce98aeae5ac379fadf657df3.

* Remove start and end time from recurrence

* fix display timezone

* remove redundant param `shouldKeepLocalTime`

* handle empty initial start time

* fix CI issues

* Revert "fix CI issues"

This reverts commit 772e6486bb03ec836ebdce436e820aa0d1defdda.

* Revert "handle empty initial start time"

This reverts commit 82e7c72a338b019dea57def1c61795ca749aacc0.

* Revert "remove redundant param `shouldKeepLocalTime`"

This reverts commit ed942426745b8b534cdc47dc8b885beef0d6c2f1.

* Revert "fix display timezone"

This reverts commit 9b2a61674e883f2b47f5bd52413e257ef6f861d3.

* Revert "Remove start and end time from recurrence"

This reverts commit ab0df8e22d6099772eec79af11d2453a9d95e157.

* Revert "Revert "send empty start/end dates in frontend for recurring windows""

This reverts commit 15a4166d3740877b601f16ba208dd3c291b387f2.

* Revert "handle zero start and end times in schedule"

This reverts commit 58a5aecb82f1aa4f8d5549e391f1f2c5c7574be2.

* Revert "send empty start/end dates in frontend for recurring windows"

This reverts commit 0470cc7a84f6e9f91cccd73d7841b884342031d4.

* log maintenance window for schedule-recurrence timestamp mismatch

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-05-19 06:52:22 +00:00
Ashwin Bhatkal
279a71c5b3 fix(dashboard): align Delete dashboard with other action menu items (#11352)
The Delete dashboard entry in the dashboards action menu was rendered with
a `<Flex justify="center">` and a custom `TableLinkText` span. This caused
the icon and label to be center-aligned, sized differently, and spaced
differently from the four sibling entries (View, Open in New Tab, Copy
Link, Export JSON) which use an antd `<Button>` with `.action-btn` styling.

Switch the Delete entry to the same antd `<Button>` structure as the rest
of the menu so the icon size, icon-to-text spacing, and left alignment
all match. While here, collapse the `section-1` / `section-2` wrappers
into a single `.actionContent` and move the action-menu styles into a
co-located CSS module (`DashboardActions.module.scss`) with a `deleteBtn`
modifier that carries the divider and the danger color via the
`--danger-background` semantic token.
2026-05-19 04:11:57 +00:00
Vinicius Lourenço
7e63e35113 fix(form-alert-rules): confirm modal broken (#11347)
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
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / staging (push) Has been cancelled
2026-05-18 22:53:47 +00:00
SagarRajput-7
d5a50fe456 feat(role-fga): added feature flag gate on roles fga - create and details page (#11350)
* feat(role-fga): added feature flag gate on roles fga - create and details page

* feat(role-fga): updated tests

* feat(role-fga): added is role gate fetching logic including feature flag loading

* feat(role-fga): fix the rolesselect search not working for the dropdown options

* feat(role-fga): updated tests and refactor
2026-05-18 20:56:39 +00:00
Vikrant Gupta
885b41356a chore(authz): add authz feature flag (#11341) 2026-05-18 15:29:05 +00:00
Ashwin Bhatkal
b653c69e29 fix(frontend): unblock pnpm generate:api after orval v8 upgrade (#11346)
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
Orval v8's pre-generation validator (@scalar/openapi-parser) treats every
`$ref` key as a JSON Reference. Our spec embeds Perses' `common.JSONRef`
struct, which has a property literally named `$ref`, so validation aborts
with `INVALID_REFERENCE`. Set `input.unsafeDisableValidation: true` to
bypass — codegen itself handles the spec correctly, and the spec is
backend-generated and CI-gated.

Closes SigNoz/engineering-pod#4963
2026-05-18 14:33:23 +00:00
Abhi kumar
7d2f8b291e chore: added changes for sorting tooltip content (#11320) 2026-05-18 13:53:19 +00:00
Aditya Singh
3bea4484f9 Enable new trace details page (#11296)
* feat: span details floating drawer added

* feat: span details folder rename

* feat: replace draggable package

* feat: fix pinning. fix drag on top

* feat: add bound to drags while floating

* feat: add collapsible sections in trace details

* feat: use resizable for waterfall table as well

* feat: copy link change and url clear on span close

* feat: fix span details headr

* feat: key value label style fixes

* feat: linked spans

* feat: style fixes

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* feat: api integration

* feat: add limit

* feat: minor change

* feat: supress click

* chore: generate openapi spec for v3 waterfall

* feat: fix test

* feat: fix test

* feat: lint fix

* feat: span details ux

* feat: analytics

* feat: add icons

* feat: added loading to flamegraph and timeout to webworker

* feat: sync error and loading state for flamegraph for n/w and computation logic

* feat: auto scroll horizontally to span

* feat: show total span count

* feat: disable anaytics span tab for now

* feat: add span details loader

* feat: prevent api call on closing span detail

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* feat: make filter and search work with flamegraph

* feat: filter ui fix

* feat: remove trace header

* feat: new filter ui

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* chore: generate openapi spec for v3 waterfall

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* feat: api integration

* feat: automatically scroll left on vertical scroll

* feat: reduce time

* feat: set limit to 100k for flamegraph

* feat: show child count in waterfall

* fix: align timeline and span length in flamegraph and waterfall

* feat: fix flamegraph and waterfall bg color

* feat: show caution on sampled flamegraph

* feat: api integration v3

* feat: disable scroll to view for collapse and uncollapse

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* chore: generate openapi spec for v3 waterfall

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* refactor: break down GetWaterfall method for readability

* chore: avoid returning nil, nil

* refactor: move type creation and constants to types package

- Move DB/table/cache/windowing constants to tracedetailtypes package
- Add NewWaterfallTrace and NewWaterfallResponse constructors in types
- Use constructors in module.go instead of inline struct literals
- Reorder waterfall.go so public functions precede private ones

* refactor: extract ClickHouse queries into a store abstraction

Move GetTraceSummary and GetTraceSpans out of module.go into a
traceStore interface backed by clickhouseTraceStore in store.go.
The module struct now holds a traceStore instead of a raw
telemetrystore.TelemetryStore, keeping DB access separate from
business logic.

* refactor: move error to types as well

* refactor: separate out store calls and computations

* refactor: breakdown GetSelectedSpans for readability

* refactor: return 404 on missing trace and other cleanup

* refactor: use same method for cache key creation

* chore: remove unused duration nano field

* chore: use sqlbuilder in clickhouse store where possible

* feat: dropdown added to span details

* feat: fix color duplications

* feat: no data screen

* feat: old trace btn added

* feat: minor fix

* feat: rename copy to copy value

* feat: delete unused file

* feat: use semantic tokens

* feat: use semantic tokens

* feat: add crosshair

* feat: fix test

* feat: disable crosshair in waterfall

* feat: fix colors

* feat: minor fix

* feat: add status codes

* feat: load all spans in waterfall under limit

* feat: uncollapse spans on select from flamegraph

* feat: style fix

* feat: add service name

* feat: open in new tab

* feat: add trace details header

* feat: add trace details header styles

* feat: add trace details header styles

* feat: minor changes

* feat: floating fields set

* feat: filters init

* feat: filter toggle added

* feat: fix color

* fix: scroll to span in frontend mode

* feat: delete waterfall go

* feat: minor change

* feat: minor change

* feat: lint fix

* feat: analytics spans

* feat: color by field

* feat: save color by pref in user pref

* feat: migrate v2 pinned attr

* feat: preview fields

* feat: minor refactors

* feat: minor refactors

* feat: v3 behind feature flag

* feat: minor refactors

* feat: packages remove

* feat: packages remove

* feat: remove common component

* feat: remove antd component usage

* feat: leaf node indent fix

* feat: fix mouse wheel in json view

* feat: update signoz ui

* feat: remove feature flag

* feat: fixed the waterfall span hover card

* feat: fix hidden filters

* feat: trace details always visible

* feat: correct status code

* fix: pagination calls in waterfall

* feat: fix failing test

* feat: show error count

* feat: fix waterfall child sibling indent

* feat: change how we show span hover data in waterfall

* feat: fix logs in span details styles

* feat: minor fixes

* feat: make trace id copyable

* feat: add status message to highlight section

* feat: persist user choosing old view

* feat: add more fields in color by

* feat: add llm as fast filter

* feat: show api error correctly

* feat: update test cases

* feat: revert route change

* feat: revert route change

* feat: replace antd btns

* feat: allow removing all fields in preview

* feat: send selected span when flamegraph is sampled

* feat: only scroll when span is not in view

* feat: auto expand on highlight errors

* feat: move analytics panel

* feat: additional check

* feat: minor fix

* feat: minor fix

* feat: dont use antd button and tooltip

* feat: dont use antd button and tooltip

* feat: update icons

* feat: minor change

* feat: minor change

* feat: move to zustand

* feat: update test cases

* feat: update border color

* feat: add icons

* feat: support filter on parent keys

* feat: add links to non filterable keys

* feat: minor fix

* feat: use pinned attributes accross views

* feat: update tests

* feat: hide v3

* feat: migrate to css modules

* feat: fix minor style

* feat: fix test

* feat: enable new trace details

* feat: remove unnecessary waterfall api calls if span already in the list

* feat: minor change

---------

Co-authored-by: Nikhil Soni <nikhil.soni@signoz.io>
2026-05-18 13:51:33 +00:00
SagarRajput-7
87ceba2d84 feat(role-sa-fga): role sa fga followup changes (#11330)
* feat(role-sa-fga): updated roles detail permission panel with the new allowedVerb gate

* feat(role-sa-fga): added anonymous in roles, sa routes to allow user access without managed role

* feat(role-sa-fga): gated roles create and details page behind a valid license check

* feat(role-sa-fga): added test and some refactor
2026-05-18 12:21:38 +00:00
Manika Malhotra
445dc3b290 chore(onboarding): shuffle ordering of interest in SigNoz based on version (#11336)
* chore(onboarding): shuffle ordering of interest in SigNoz based on version

* fix: formatting
2026-05-18 12:12:48 +00:00
Tushar Vats
76b35b9d8f fix: order by ignored in formula query (#10950)
* fix: order by ignored in formula query

* fix: order by ignored in formula query

* fix: added intergation test

* fix: revert integarion test changes

* fix: added an independent integration test

* fix: make py-fmt

* fix: removed comment

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
Co-authored-by: Pandey <vibhupandey28@gmail.com>
2026-05-18 11:38:40 +00:00
Tushar Vats
b860cce31d fix: enforce minimum step interval for v3 promql queries (#11293) 2026-05-18 11:27:52 +00:00
Tushar Vats
1bd4ca88de fix: cache memory leak (#10967)
* fix: added cost() to cloneable interface

* fix: added a new metrics and converted into counters

* fix: address comments

* fix: simplify test

* fix: use assert instead of require
2026-05-18 10:50:27 +00:00
SagarRajput-7
44470cb35b feat(sa-fga): service account fga (#11258)
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(sa-fga): changed the id from kind to kind+type

* feat(sa-fga): service account fga changes with common components for errors

* feat(sa-fga): added fga at more places in service account

* feat(sa-fga): refactor based on feedbacks

* feat(sa-fga): refactor and role page fga

* fix(authz): add attach detach permissions on metaresource

* feat(sa-fga): refactor and role page fga

* feat(sa-fga): test case fixes

* feat(sa-fga): enabled role detail page and remove the config flag

* feat(sa-fga): test case fixes

* feat(sa-fga): udpated the role details metaresource condition to list/create

* feat(sa-fga): test case fixes

* feat(sa-fga): feedback fixes from the copliot comments

* feat(sa-fga): feedback fixes from the reveiw comments and authztootip upgrade

* feat(sa-fga): feedback fixes from the testing and refactors

* feat(sa-fga): test cases fixes

* feat(sa-fga): added beta for the roles page

* feat(sa-fga): added roles doc and roles read check with name in the url param

* Revert "fix(authz): add attach detach permissions on metaresource"

This reverts commit 34938bb4ce.

---------

Co-authored-by: vikrantgupta25 <vikrant@signoz.io>
2026-05-17 14:35:27 +00:00
Vikrant Gupta
cf3fbb06c6 fix(authz): reserve signoz prefix for managed roles (#11327)
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(authz): reserve signoz-* for managed roles

* feat(authz): add integration tests

* feat(authz): reserve signoz-* for managed roles

* feat(authz): add integration tests
2026-05-16 16:03:21 +00:00
Vikrant Gupta
fd2e526f7c fix(deps): resolve all high/critical Dependabot security alerts (#11323)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(deps): upgrade dependencies to resolve high/critical security alerts

Upgrade pgx/v5 (v5.8.0→v5.9.2), prometheus (v0.310.0→v0.311.3),
gosaml2 (v0.9.0→v0.11.0), goxmldsig (v1.2.0→v1.6.0), and
urllib3 (2.6.3→2.7.0) to fix all open high/critical Dependabot alerts.

Adapt parser.ParseExpr calls to use the new Parser interface introduced
in prometheus v0.311.x.

* refactor: reuse a single PromQL parser instance instead of creating per call

Add Parser() to the prometheus.Prometheus interface so a single
parser.Parser is created at startup and shared across all consumers.
For the legacy v2 querier and PromQLFilterExtractor (which don't have
access to the Prometheus interface), store a parser instance on the
struct, created once during construction.

* refactor: centralize PromQL parser creation via prometheus.NewParser()

Add pkg/prometheus/parser.go with a Parser type alias and NewParser()
factory function, mirroring the existing Engine/NewEngine pattern.
All consumers now create parsers through this single entry point
instead of calling parser.NewParser(parser.Options{}) directly.
2026-05-15 20:02:39 +00:00
Vinicius Lourenço
afe8942368 fix(infra-monitoring): error due to invalid operators on query builder (#11300)
* fix(infra-monitoring): error due to invalid operators on query builder

* fix(query-builder): keep not_in and transform to nin, same for other operators

* chore(code-cleanup): clean the duplicated code and bugs
2026-05-15 18:42:00 +00:00
Vinicius Lourenço
2ae75e01b1 feat(k8s-base-details): migrate logs/traces/events to query builder v5 (#11060)
* feat(k8s-base-details): migrate logs/traces/events to query builder v5

* feat(infra-monitoring-details): migrate metrics to query range v5 (#11161)

* fix(query-builder): not updating query on hit enter / better context organization

* fix(hooks): missing cancel param

* fix(infra-monitoring): not invalidating queries on refresh button

* refactor(infra-monitoring): handle keys not found & avoid re-renders on query search change (#11312)

* fix(infra-monitoring): do not render error when key not found

* fix(query-search): reduce amount of re-renders due to need of initial expression
2026-05-15 14:45:30 +00:00
Vikrant Gupta
d2f3659df2 fix(authz): add role CRUD permissions (#11315)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(authz): add attach detach permissions on metaresource

* fix(authz): add role CRUD permissions

* feat(authz): add support for supported verbs per metaresource

* feat(authz): fix formatting for generated files

* feat(authz): fix formatting for generated files

* feat(authz): fix formatting for generated files

* feat(authz): remove frontend changes

* feat(authz): fix jest test
2026-05-15 12:53:48 +00:00
320 changed files with 11618 additions and 5826 deletions

View File

@@ -8,6 +8,14 @@ packages:
filename: "alertmanager.go"
structname: 'Mock{{.InterfaceName}}'
pkgname: '{{.SrcPackageName}}test'
github.com/SigNoz/signoz/pkg/types/alertmanagertypes:
interfaces:
MaintenanceStore:
config:
dir: '{{.InterfaceDir}}/alertmanagertypestest'
filename: "maintenance.go"
structname: 'Mock{{.InterfaceName}}'
pkgname: '{{.SrcPackageName}}test'
github.com/SigNoz/signoz/pkg/tokenizer:
config:
all: true

View File

@@ -5,6 +5,7 @@ import (
"context"
"os"
"sort"
"strings"
"text/template"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -23,6 +24,7 @@ export default {
{
kind: '{{ .Kind }}',
type: '{{ .Type }}',
{{ .FormattedAllowedVerbs }}
},
{{- end }}
],
@@ -41,8 +43,9 @@ type permissionsTypeRelation struct {
}
type permissionsTypeResource struct {
Kind string
Type string
Kind string
Type string
FormattedAllowedVerbs string
}
type permissionsTypeData struct {
@@ -50,6 +53,30 @@ type permissionsTypeData struct {
Relations []permissionsTypeRelation
}
// formatAllowedVerbs returns a prettier-compatible formatted allowedVerbs line.
// indentLevel is the number of tabs for the property (matching kind/type indent).
// printWidth is prettier's printWidth; tabWidth is assumed to be 1 (each \t = 1 char).
func formatAllowedVerbs(verbs []string, indentLevel int, printWidth int) string {
quoted := make([]string, len(verbs))
for i, v := range verbs {
quoted[i] = "'" + v + "'"
}
indent := strings.Repeat("\t", indentLevel)
oneLine := indent + "allowedVerbs: [" + strings.Join(quoted, ", ") + "],"
if len(oneLine) <= printWidth {
return oneLine
}
var b strings.Builder
b.WriteString(indent + "allowedVerbs: [\n")
for _, q := range quoted {
b.WriteString(indent + "\t" + q + ",\n")
}
b.WriteString(indent + "],")
return b.String()
}
func registerGenerateAuthz(parentCmd *cobra.Command) {
authzCmd := &cobra.Command{
Use: "authz",
@@ -66,8 +93,8 @@ func runGenerateAuthz(_ context.Context) error {
registry := coretypes.NewRegistry()
allowedResources := map[string]bool{
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
}
@@ -80,9 +107,23 @@ func runGenerateAuthz(_ context.Context) error {
continue
}
allowedTypes[ref.Type.StringValue()] = true
resource, err := coretypes.NewResourceFromTypeAndKind(ref.Type, ref.Kind)
if err != nil {
return err
}
verbs := resource.AllowedVerbs()
allowedVerbStrings := make([]string, 0, len(verbs))
for _, verb := range verbs {
allowedVerbStrings = append(allowedVerbStrings, verb.StringValue())
}
sort.Strings(allowedVerbStrings)
resources = append(resources, permissionsTypeResource{
Kind: ref.Kind.String(),
Type: ref.Type.StringValue(),
Kind: ref.Kind.String(),
Type: ref.Type.StringValue(),
FormattedAllowedVerbs: formatAllowedVerbs(allowedVerbStrings, 4, 80),
})
}

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.124.0
image: signoz/signoz:v0.125.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

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.124.0
image: signoz/signoz:v0.125.0
ports:
- "8080:8080" # signoz port
volumes:

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.124.0}
image: signoz/signoz:${VERSION:-v0.125.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

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.124.0}
image: signoz/signoz:${VERSION:-v0.125.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -96,6 +96,53 @@ components:
- createdAt
- updatedAt
type: object
AlertmanagertypesMaintenanceKind:
enum:
- fixed
- recurring
type: string
AlertmanagertypesMaintenanceStatus:
enum:
- active
- upcoming
- expired
type: string
AlertmanagertypesPlannedMaintenance:
properties:
alertIds:
items:
type: string
nullable: true
type: array
createdAt:
format: date-time
type: string
createdBy:
type: string
description:
type: string
id:
type: string
kind:
$ref: '#/components/schemas/AlertmanagertypesMaintenanceKind'
name:
type: string
schedule:
$ref: '#/components/schemas/AlertmanagertypesSchedule'
status:
$ref: '#/components/schemas/AlertmanagertypesMaintenanceStatus'
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
- name
- schedule
- status
- kind
type: object
AlertmanagertypesPostableChannel:
oneOf:
- required:
@@ -212,6 +259,23 @@ components:
required:
- name
type: object
AlertmanagertypesPostablePlannedMaintenance:
properties:
alertIds:
items:
type: string
nullable: true
type: array
description:
type: string
name:
type: string
schedule:
$ref: '#/components/schemas/AlertmanagertypesSchedule'
required:
- name
- schedule
type: object
AlertmanagertypesPostableRoutePolicy:
properties:
channels:
@@ -237,6 +301,60 @@ components:
- channels
- name
type: object
AlertmanagertypesRecurrence:
properties:
duration:
type: string
endTime:
format: date-time
nullable: true
type: string
repeatOn:
items:
$ref: '#/components/schemas/AlertmanagertypesRepeatOn'
nullable: true
type: array
repeatType:
$ref: '#/components/schemas/AlertmanagertypesRepeatType'
startTime:
format: date-time
type: string
required:
- startTime
- duration
- repeatType
type: object
AlertmanagertypesRepeatOn:
enum:
- sunday
- monday
- tuesday
- wednesday
- thursday
- friday
- saturday
type: string
AlertmanagertypesRepeatType:
enum:
- daily
- weekly
- monthly
type: string
AlertmanagertypesSchedule:
properties:
endTime:
format: date-time
type: string
recurrence:
$ref: '#/components/schemas/AlertmanagertypesRecurrence'
startTime:
format: date-time
type: string
timezone:
type: string
required:
- timezone
type: object
AuthtypesAttributeMapping:
properties:
email:
@@ -5137,17 +5255,6 @@ components:
message:
type: string
type: object
RuletypesMaintenanceKind:
enum:
- fixed
- recurring
type: string
RuletypesMaintenanceStatus:
enum:
- active
- upcoming
- expired
type: string
RuletypesMatchType:
enum:
- at_least_once
@@ -5175,59 +5282,6 @@ components:
- table
- graph
type: string
RuletypesPlannedMaintenance:
properties:
alertIds:
items:
type: string
nullable: true
type: array
createdAt:
format: date-time
type: string
createdBy:
type: string
description:
type: string
id:
type: string
kind:
$ref: '#/components/schemas/RuletypesMaintenanceKind'
name:
type: string
schedule:
$ref: '#/components/schemas/RuletypesSchedule'
status:
$ref: '#/components/schemas/RuletypesMaintenanceStatus'
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
- name
- schedule
- status
- kind
type: object
RuletypesPostablePlannedMaintenance:
properties:
alertIds:
items:
type: string
nullable: true
type: array
description:
type: string
name:
type: string
schedule:
$ref: '#/components/schemas/RuletypesSchedule'
required:
- name
- schedule
type: object
RuletypesPostableRule:
properties:
alert:
@@ -5280,29 +5334,6 @@ components:
- clickhouse_sql
- promql
type: string
RuletypesRecurrence:
properties:
duration:
type: string
endTime:
format: date-time
nullable: true
type: string
repeatOn:
items:
$ref: '#/components/schemas/RuletypesRepeatOn'
nullable: true
type: array
repeatType:
$ref: '#/components/schemas/RuletypesRepeatType'
startTime:
format: date-time
type: string
required:
- startTime
- duration
- repeatType
type: object
RuletypesRenotify:
properties:
alertStates:
@@ -5314,22 +5345,6 @@ components:
interval:
type: string
type: object
RuletypesRepeatOn:
enum:
- sunday
- monday
- tuesday
- wednesday
- thursday
- friday
- saturday
type: string
RuletypesRepeatType:
enum:
- daily
- weekly
- monthly
type: string
RuletypesRollingWindow:
properties:
evalWindow:
@@ -5449,21 +5464,6 @@ components:
- promql_rule
- anomaly_rule
type: string
RuletypesSchedule:
properties:
endTime:
format: date-time
type: string
recurrence:
$ref: '#/components/schemas/RuletypesRecurrence'
startTime:
format: date-time
type: string
timezone:
type: string
required:
- timezone
type: object
RuletypesScheduleType:
enum:
- hourly
@@ -8024,7 +8024,7 @@ paths:
properties:
data:
items:
$ref: '#/components/schemas/RuletypesPlannedMaintenance'
$ref: '#/components/schemas/AlertmanagertypesPlannedMaintenance'
type: array
status:
type: string
@@ -8067,7 +8067,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/RuletypesPostablePlannedMaintenance'
$ref: '#/components/schemas/AlertmanagertypesPostablePlannedMaintenance'
responses:
"201":
content:
@@ -8075,7 +8075,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesPlannedMaintenance'
$ref: '#/components/schemas/AlertmanagertypesPlannedMaintenance'
status:
type: string
required:
@@ -8178,7 +8178,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesPlannedMaintenance'
$ref: '#/components/schemas/AlertmanagertypesPlannedMaintenance'
status:
type: string
required:
@@ -8232,7 +8232,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/RuletypesPostablePlannedMaintenance'
$ref: '#/components/schemas/AlertmanagertypesPostablePlannedMaintenance'
responses:
"204":
description: No Content

View File

@@ -54,6 +54,9 @@ type metaresource
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define attach: [user, serviceaccount, role#assignee]
define detach: [user, serviceaccount, role#assignee]
define block: [user, serviceaccount, role#assignee]

View File

@@ -80,6 +80,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
fineGrainedAuthz := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseFineGrainedAuthz, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureUseFineGrainedAuthz.String()),
Active: fineGrainedAuthz,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -13,7 +13,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) {
@@ -49,7 +48,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, tr)
// create ch rule task for evaluation
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
@@ -73,7 +72,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, pr)
// create promql rule task for evaluation
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
@@ -96,7 +95,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, ar)
// create anomaly rule task for evaluation
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
} else {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
@@ -210,9 +209,9 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
}
// newTask returns an appropriate group for the rule type
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) baserules.Task {
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc) baserules.Task {
if taskType == baserules.TaskTypeCh {
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify)
}
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify)
}

View File

@@ -10,6 +10,13 @@ export default defineConfig({
signoz: {
input: {
target: '../docs/api/openapi.yml',
// Perses' `common.JSONRef` (used by `DashboardGridItem.content`) has a
// field tagged `json:"$ref"`, so our spec contains a property literally
// named `$ref`.
// Orval v8's validator (`@scalar/openapi-parser`) treats every `$ref` key
// as a JSON Reference and aborts with `INVALID_REFERENCE` when the value isn't a URI string.
// Safe to disable: yes, the spec is generated by `cmd/openapi.go` and gated by backend CI, not hand-edited.
unsafeDisableValidation: true,
},
output: {
target: './src/api/generated/services',
@@ -27,7 +34,7 @@ export default defineConfig({
signal: true,
useOperationIdAsQueryKey: false,
},
useDates: true,
useDates: false,
useNamedParameters: true,
enumGenerationType: 'enum',
mutator: {

View File

@@ -144,18 +144,18 @@ const routes: AppRoutes[] = [
// /trace-old serves V3 (URL-only access). Flip the two `component`
// values back to release V3.
{
path: ROUTES.TRACE_DETAIL,
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
component: TraceDetail,
isPrivate: true,
key: 'TRACE_DETAIL',
key: 'TRACE_DETAIL_OLD',
},
{
path: ROUTES.TRACE_DETAIL_OLD,
path: ROUTES.TRACE_DETAIL,
exact: true,
component: TraceDetailV3,
isPrivate: true,
key: 'TRACE_DETAIL_OLD',
key: 'TRACE_DETAIL',
},
{
path: ROUTES.SETTINGS,

View File

@@ -0,0 +1,271 @@
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest, RestRequest } from 'msw';
import { MetricRangePayloadV5 } from 'types/api/v5/queryRange';
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
export type MockLogsOptions = {
offset?: number;
pageSize?: number;
hasMore?: boolean;
delay?: number;
onReceiveRequest?: (
req: RestRequest,
) =>
| undefined
| void
| Omit<MockLogsOptions, 'onReceiveRequest'>
| Promise<Omit<MockLogsOptions, 'onReceiveRequest'>>
| Promise<void>;
};
const createLogsResponse = ({
offset = 0,
pageSize = 100,
hasMore = true,
}: MockLogsOptions): MetricRangePayloadV5 => {
const itemsForThisPage = hasMore ? pageSize : pageSize / 2;
return {
data: {
type: 'raw',
data: {
results: [
{
queryName: 'A',
rows: Array.from({ length: itemsForThisPage }, (_, index) => {
const cumulativeIndex = offset + index;
const baseTimestamp = new Date('2024-02-15T21:20:22Z').getTime();
const currentTimestamp = new Date(
baseTimestamp - cumulativeIndex * 1000,
);
const timestampString = currentTimestamp.toISOString();
const id = `log-id-${cumulativeIndex}`;
const logLevel = ['INFO', 'WARN', 'ERROR'][cumulativeIndex % 3];
const service = ['frontend', 'backend', 'database'][cumulativeIndex % 3];
return {
timestamp: timestampString,
data: {
attributes_bool: {},
attributes_float64: {},
attributes_int64: {},
attributes_string: {
host_name: 'test-host',
log_level: logLevel,
service,
},
body: `${timestampString} ${logLevel} ${service} Log message ${cumulativeIndex}`,
id,
resources_string: {
'host.name': 'test-host',
},
severity_number: [9, 13, 17][cumulativeIndex % 3],
severity_text: logLevel,
span_id: `span-${cumulativeIndex}`,
trace_flags: 0,
trace_id: `trace-${cumulativeIndex}`,
},
};
}),
},
],
},
meta: {
bytesScanned: 0,
durationMs: 0,
rowsScanned: 0,
stepIntervals: {},
},
},
};
};
export function mockQueryRangeV5WithLogsResponse({
hasMore = true,
offset = 0,
pageSize = 100,
delay = 0,
onReceiveRequest,
}: MockLogsOptions = {}): void {
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) =>
res(
...(delay ? [ctx.delay(delay)] : []),
ctx.status(200),
ctx.json(
createLogsResponse(
(await onReceiveRequest?.(req)) ?? {
hasMore,
pageSize,
offset,
},
),
),
),
),
);
}
export function mockQueryRangeV5WithError(
error: string,
statusCode = 500,
): void {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(
ctx.status(statusCode),
ctx.json({
error,
}),
),
),
);
}
export type MockEventsOptions = {
offset?: number;
pageSize?: number;
hasMore?: boolean;
delay?: number;
onReceiveRequest?: (
req: RestRequest,
) =>
| undefined
| void
| Omit<MockEventsOptions, 'onReceiveRequest'>
| Promise<Omit<MockEventsOptions, 'onReceiveRequest'>>
| Promise<void>;
};
const createEventsResponse = ({
offset = 0,
pageSize = 10,
hasMore = true,
}: MockEventsOptions): MetricRangePayloadV5 => {
const itemsForThisPage = hasMore ? pageSize : Math.ceil(pageSize / 2);
const eventReasons = [
'BackoffLimitExceeded',
'SuccessfulCreate',
'Pulled',
'Created',
'Started',
'Killing',
];
const severityTexts = ['Warning', 'Normal'];
const severityNumbers = [13, 9];
const objectKinds = ['Job', 'Pod', 'Deployment', 'ReplicaSet'];
const eventBodies = [
'Job has reached the specified backoff limit',
'Created pod: demo-pod',
'Successfully pulled image',
'Created container',
'Started container',
'Stopping container',
];
return {
data: {
type: 'raw',
data: {
results: [
{
queryName: 'A',
nextCursor: hasMore ? 'next-cursor-token' : '',
rows: Array.from({ length: itemsForThisPage }, (_, index) => {
const cumulativeIndex = offset + index;
const baseTimestamp = new Date('2026-04-21T17:54:33Z').getTime();
const currentTimestamp = new Date(
baseTimestamp - cumulativeIndex * 60000,
);
const timestampString = currentTimestamp.toISOString();
const id = `event-id-${cumulativeIndex}`;
const severityIndex = cumulativeIndex % 2;
const reasonIndex = cumulativeIndex % eventReasons.length;
const kindIndex = cumulativeIndex % objectKinds.length;
return {
timestamp: timestampString,
data: {
attributes_bool: {},
attributes_number: {
'k8s.event.count': 1,
},
attributes_string: {
'k8s.event.action': '',
'k8s.event.name': `demo-event-${cumulativeIndex}.${Math.random()
.toString(36)
.substring(7)}`,
'k8s.event.reason': eventReasons[reasonIndex],
'k8s.event.start_time': `${currentTimestamp.toISOString()} +0000 UTC`,
'k8s.event.uid': `uid-${cumulativeIndex}`,
'k8s.namespace.name': 'demo-apps',
},
body: eventBodies[reasonIndex],
id,
resources_string: {
'k8s.cluster.name': 'signoz-test',
'k8s.node.name': '',
'k8s.object.api_version': 'batch/v1',
'k8s.object.fieldpath': '',
'k8s.object.kind': objectKinds[kindIndex],
'k8s.object.name': `demo-object-${cumulativeIndex}`,
'k8s.object.resource_version': `${462900 + cumulativeIndex}`,
'k8s.object.uid': `object-uid-${cumulativeIndex}`,
'signoz.component': 'otel-deployment',
},
scope_name:
'github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8seventsreceiver',
scope_string: {},
scope_version: '0.139.0',
severity_number: severityNumbers[severityIndex],
severity_text: severityTexts[severityIndex],
span_id: '',
timestamp: currentTimestamp.getTime() * 1000000,
trace_flags: 0,
trace_id: '',
},
};
}),
},
],
},
meta: {
bytesScanned: 9682976,
durationMs: 295,
rowsScanned: 34198,
stepIntervals: {
A: 170,
},
},
},
};
};
export function mockQueryRangeV5WithEventsResponse({
hasMore = true,
offset = 0,
pageSize = 10,
delay = 0,
onReceiveRequest,
}: MockEventsOptions = {}): void {
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) =>
res(
...(delay ? [ctx.delay(delay)] : []),
ctx.status(200),
ctx.json(
createEventsResponse(
(await onReceiveRequest?.(req)) ?? {
hasMore,
pageSize,
offset,
},
),
),
),
),
);
}

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {
@@ -19,6 +18,7 @@ import type {
} from 'react-query';
import type {
AlertmanagertypesPostablePlannedMaintenanceDTO,
CreateDowntimeSchedule201,
DeleteDowntimeScheduleByIDPathParameters,
GetDowntimeScheduleByID200,
@@ -26,7 +26,6 @@ import type {
ListDowntimeSchedules200,
ListDowntimeSchedulesParams,
RenderErrorResponseDTO,
RuletypesPostablePlannedMaintenanceDTO,
UpdateDowntimeScheduleByIDPathParameters,
} from '../sigNoz.schemas';
@@ -136,14 +135,14 @@ export const invalidateListDowntimeSchedules = async (
* @summary Create downtime schedule
*/
export const createDowntimeSchedule = (
ruletypesPostablePlannedMaintenanceDTO?: BodyType<RuletypesPostablePlannedMaintenanceDTO>,
alertmanagertypesPostablePlannedMaintenanceDTO?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateDowntimeSchedule201>({
url: `/api/v1/downtime_schedules`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostablePlannedMaintenanceDTO,
data: alertmanagertypesPostablePlannedMaintenanceDTO,
signal,
});
};
@@ -155,13 +154,13 @@ export const getCreateDowntimeScheduleMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDowntimeSchedule>>,
TError,
{ data?: BodyType<RuletypesPostablePlannedMaintenanceDTO> },
{ data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createDowntimeSchedule>>,
TError,
{ data?: BodyType<RuletypesPostablePlannedMaintenanceDTO> },
{ data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO> },
TContext
> => {
const mutationKey = ['createDowntimeSchedule'];
@@ -175,7 +174,7 @@ export const getCreateDowntimeScheduleMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createDowntimeSchedule>>,
{ data?: BodyType<RuletypesPostablePlannedMaintenanceDTO> }
{ data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO> }
> = (props) => {
const { data } = props ?? {};
@@ -189,7 +188,7 @@ export type CreateDowntimeScheduleMutationResult = NonNullable<
Awaited<ReturnType<typeof createDowntimeSchedule>>
>;
export type CreateDowntimeScheduleMutationBody =
| BodyType<RuletypesPostablePlannedMaintenanceDTO>
| BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>
| undefined;
export type CreateDowntimeScheduleMutationError =
ErrorType<RenderErrorResponseDTO>;
@@ -204,13 +203,13 @@ export const useCreateDowntimeSchedule = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDowntimeSchedule>>,
TError,
{ data?: BodyType<RuletypesPostablePlannedMaintenanceDTO> },
{ data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createDowntimeSchedule>>,
TError,
{ data?: BodyType<RuletypesPostablePlannedMaintenanceDTO> },
{ data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO> },
TContext
> => {
return useMutation(getCreateDowntimeScheduleMutationOptions(options));
@@ -404,14 +403,14 @@ export const invalidateGetDowntimeScheduleByID = async (
*/
export const updateDowntimeScheduleByID = (
{ id }: UpdateDowntimeScheduleByIDPathParameters,
ruletypesPostablePlannedMaintenanceDTO?: BodyType<RuletypesPostablePlannedMaintenanceDTO>,
alertmanagertypesPostablePlannedMaintenanceDTO?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/downtime_schedules/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostablePlannedMaintenanceDTO,
data: alertmanagertypesPostablePlannedMaintenanceDTO,
signal,
});
};
@@ -425,7 +424,7 @@ export const getUpdateDowntimeScheduleByIDMutationOptions = <
TError,
{
pathParams: UpdateDowntimeScheduleByIDPathParameters;
data?: BodyType<RuletypesPostablePlannedMaintenanceDTO>;
data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>;
},
TContext
>;
@@ -434,7 +433,7 @@ export const getUpdateDowntimeScheduleByIDMutationOptions = <
TError,
{
pathParams: UpdateDowntimeScheduleByIDPathParameters;
data?: BodyType<RuletypesPostablePlannedMaintenanceDTO>;
data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>;
},
TContext
> => {
@@ -451,7 +450,7 @@ export const getUpdateDowntimeScheduleByIDMutationOptions = <
Awaited<ReturnType<typeof updateDowntimeScheduleByID>>,
{
pathParams: UpdateDowntimeScheduleByIDPathParameters;
data?: BodyType<RuletypesPostablePlannedMaintenanceDTO>;
data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -466,7 +465,7 @@ export type UpdateDowntimeScheduleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof updateDowntimeScheduleByID>>
>;
export type UpdateDowntimeScheduleByIDMutationBody =
| BodyType<RuletypesPostablePlannedMaintenanceDTO>
| BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>
| undefined;
export type UpdateDowntimeScheduleByIDMutationError =
ErrorType<RenderErrorResponseDTO>;
@@ -483,7 +482,7 @@ export const useUpdateDowntimeScheduleByID = <
TError,
{
pathParams: UpdateDowntimeScheduleByIDPathParameters;
data?: BodyType<RuletypesPostablePlannedMaintenanceDTO>;
data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>;
},
TContext
>;
@@ -492,7 +491,7 @@ export const useUpdateDowntimeScheduleByID = <
TError,
{
pathParams: UpdateDowntimeScheduleByIDPathParameters;
data?: BodyType<RuletypesPostablePlannedMaintenanceDTO>;
data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>;
},
TContext
> => {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,14 +3,13 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
export interface AlertmanagertypesChannelDTO {
/**
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -35,7 +34,7 @@ export interface AlertmanagertypesChannelDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ModelLabelSetDTO {
@@ -63,7 +62,7 @@ export interface AlertmanagertypesDeprecatedGettableAlertDTO {
* @type string
* @format date-time
*/
endsAt?: Date;
endsAt?: string;
/**
* @type string
*/
@@ -81,7 +80,7 @@ export interface AlertmanagertypesDeprecatedGettableAlertDTO {
* @type string
* @format date-time
*/
startsAt?: Date;
startsAt?: string;
status?: TypesAlertStatusDTO;
}
@@ -98,7 +97,7 @@ export interface AlertmanagertypesGettableRoutePolicyDTO {
* @type string
* @format date-time
*/
createdAt: Date;
createdAt: string;
/**
* @type string,null
*/
@@ -128,13 +127,116 @@ export interface AlertmanagertypesGettableRoutePolicyDTO {
* @type string
* @format date-time
*/
updatedAt: Date;
updatedAt: string;
/**
* @type string,null
*/
updatedBy?: string | null;
}
export enum AlertmanagertypesMaintenanceKindDTO {
fixed = 'fixed',
recurring = 'recurring',
}
export enum AlertmanagertypesMaintenanceStatusDTO {
active = 'active',
upcoming = 'upcoming',
expired = 'expired',
}
export enum AlertmanagertypesRepeatOnDTO {
sunday = 'sunday',
monday = 'monday',
tuesday = 'tuesday',
wednesday = 'wednesday',
thursday = 'thursday',
friday = 'friday',
saturday = 'saturday',
}
export enum AlertmanagertypesRepeatTypeDTO {
daily = 'daily',
weekly = 'weekly',
monthly = 'monthly',
}
export interface AlertmanagertypesRecurrenceDTO {
/**
* @type string
*/
duration: string;
/**
* @type string,null
* @format date-time
*/
endTime?: string | null;
/**
* @type array,null
*/
repeatOn?: AlertmanagertypesRepeatOnDTO[] | null;
repeatType: AlertmanagertypesRepeatTypeDTO;
/**
* @type string
* @format date-time
*/
startTime: string;
}
export interface AlertmanagertypesScheduleDTO {
/**
* @type string
* @format date-time
*/
endTime?: string;
recurrence?: AlertmanagertypesRecurrenceDTO;
/**
* @type string
* @format date-time
*/
startTime?: string;
/**
* @type string
*/
timezone: string;
}
export interface AlertmanagertypesPlannedMaintenanceDTO {
/**
* @type array,null
*/
alertIds?: string[] | null;
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
id: string;
kind: AlertmanagertypesMaintenanceKindDTO;
/**
* @type string
*/
name: string;
schedule: AlertmanagertypesScheduleDTO;
status: AlertmanagertypesMaintenanceStatusDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface ConfigAuthorizationDTO {
/**
* @type string
@@ -1598,6 +1700,22 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
wechat_configs?: ConfigWechatConfigDTO[];
};
export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
/**
* @type array,null
*/
alertIds?: string[] | null;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name: string;
schedule: AlertmanagertypesScheduleDTO;
}
export interface AlertmanagertypesPostableRoutePolicyDTO {
/**
* @type array,null
@@ -1835,7 +1953,7 @@ export interface AuthtypesGettableAuthDomainDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -1852,7 +1970,7 @@ export interface AuthtypesGettableAuthDomainDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface AuthtypesGettableTokenDTO {
@@ -2010,7 +2128,7 @@ export interface AuthtypesRoleDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -2035,7 +2153,7 @@ export interface AuthtypesRoleDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface AuthtypesSessionContextDTO {
@@ -2063,7 +2181,7 @@ export interface AuthtypesUserRoleDTO {
* @type string
* @format date-time
*/
createdAt: Date;
createdAt: string;
/**
* @type string
*/
@@ -2077,7 +2195,7 @@ export interface AuthtypesUserRoleDTO {
* @type string
* @format date-time
*/
updatedAt: Date;
updatedAt: string;
/**
* @type string
*/
@@ -2089,7 +2207,7 @@ export interface AuthtypesUserWithRolesDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -2118,7 +2236,7 @@ export interface AuthtypesUserWithRolesDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type array,null
*/
@@ -2285,7 +2403,7 @@ export interface CloudintegrationtypesAccountDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -2306,12 +2424,12 @@ export interface CloudintegrationtypesAccountDTO {
* @type string,null
* @format date-time
*/
removedAt: Date | null;
removedAt: string | null;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface DashboardtypesStorableDashboardDataDTO {
@@ -2442,7 +2560,7 @@ export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -2452,7 +2570,7 @@ export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
};
/**
@@ -2646,12 +2764,12 @@ export interface CloudintegrationtypesGettableAgentCheckInDTO {
* @type string,null
* @format date-time
*/
removed_at: Date | null;
removed_at: string | null;
/**
* @type string,null
* @format date-time
*/
removedAt: Date | null;
removedAt: string | null;
}
export interface CloudintegrationtypesServiceMetadataDTO {
@@ -2886,7 +3004,7 @@ export interface DashboardtypesDashboardDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -2908,7 +3026,7 @@ export interface DashboardtypesDashboardDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type string
*/
@@ -3090,7 +3208,7 @@ export interface GatewaytypesLimitDTO {
* @type string
* @format date-time
*/
created_at?: Date;
created_at?: string;
/**
* @type string
*/
@@ -3112,7 +3230,7 @@ export interface GatewaytypesLimitDTO {
* @type string
* @format date-time
*/
updated_at?: Date;
updated_at?: string;
}
export interface GatewaytypesIngestionKeyDTO {
@@ -3120,12 +3238,12 @@ export interface GatewaytypesIngestionKeyDTO {
* @type string
* @format date-time
*/
created_at?: Date;
created_at?: string;
/**
* @type string
* @format date-time
*/
expires_at?: Date;
expires_at?: string;
/**
* @type string
*/
@@ -3146,7 +3264,7 @@ export interface GatewaytypesIngestionKeyDTO {
* @type string
* @format date-time
*/
updated_at?: Date;
updated_at?: string;
/**
* @type string
*/
@@ -3170,7 +3288,7 @@ export interface GatewaytypesPostableIngestionKeyDTO {
* @type string
* @format date-time
*/
expires_at?: Date;
expires_at?: string;
/**
* @type string
*/
@@ -4440,7 +4558,7 @@ export interface LlmpricingruletypesLLMPricingRuleDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -4479,13 +4597,13 @@ export interface LlmpricingruletypesLLMPricingRuleDTO {
* @type string,null
* @format date-time
*/
syncedAt?: Date | null;
syncedAt?: string | null;
unit: LlmpricingruletypesLLMPricingRuleUnitDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type string
*/
@@ -5711,7 +5829,7 @@ export interface Querybuildertypesv5RawRowDTO {
* @type string
* @format date-time
*/
timestamp?: Date;
timestamp?: string;
}
export interface Querybuildertypesv5RawDataDTO {
@@ -6117,15 +6235,6 @@ export interface RuletypesGettableTestRuleDTO {
message?: string;
}
export enum RuletypesMaintenanceKindDTO {
fixed = 'fixed',
recurring = 'recurring',
}
export enum RuletypesMaintenanceStatusDTO {
active = 'active',
upcoming = 'upcoming',
expired = 'expired',
}
export interface RuletypesRenotifyDTO {
/**
* @type array
@@ -6157,116 +6266,6 @@ export interface RuletypesNotificationSettingsDTO {
usePolicy?: boolean;
}
export enum RuletypesRepeatOnDTO {
sunday = 'sunday',
monday = 'monday',
tuesday = 'tuesday',
wednesday = 'wednesday',
thursday = 'thursday',
friday = 'friday',
saturday = 'saturday',
}
export enum RuletypesRepeatTypeDTO {
daily = 'daily',
weekly = 'weekly',
monthly = 'monthly',
}
export interface RuletypesRecurrenceDTO {
/**
* @type string
*/
duration: string;
/**
* @type string,null
* @format date-time
*/
endTime?: Date | null;
/**
* @type array,null
*/
repeatOn?: RuletypesRepeatOnDTO[] | null;
repeatType: RuletypesRepeatTypeDTO;
/**
* @type string
* @format date-time
*/
startTime: Date;
}
export interface RuletypesScheduleDTO {
/**
* @type string
* @format date-time
*/
endTime?: Date;
recurrence?: RuletypesRecurrenceDTO;
/**
* @type string
* @format date-time
*/
startTime?: Date;
/**
* @type string
*/
timezone: string;
}
export interface RuletypesPlannedMaintenanceDTO {
/**
* @type array,null
*/
alertIds?: string[] | null;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
id: string;
kind: RuletypesMaintenanceKindDTO;
/**
* @type string
*/
name: string;
schedule: RuletypesScheduleDTO;
status: RuletypesMaintenanceStatusDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
/**
* @type string
*/
updatedBy?: string;
}
export interface RuletypesPostablePlannedMaintenanceDTO {
/**
* @type array,null
*/
alertIds?: string[] | null;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name: string;
schedule: RuletypesScheduleDTO;
}
export type RuletypesPostableRuleDTOAnnotations = { [key: string]: string };
export type RuletypesPostableRuleDTOLabels = { [key: string]: string };
@@ -6407,7 +6406,7 @@ export interface RuletypesRuleDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6456,7 +6455,7 @@ export interface RuletypesRuleDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type string
*/
@@ -6475,7 +6474,7 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type integer
* @minimum 0
@@ -6489,7 +6488,7 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
* @type string
* @format date-time
*/
lastObservedAt: Date;
lastObservedAt: string;
/**
* @type string
*/
@@ -6502,7 +6501,7 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO {
@@ -6547,7 +6546,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6572,7 +6571,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ServiceaccounttypesServiceAccountRoleDTO {
@@ -6580,7 +6579,7 @@ export interface ServiceaccounttypesServiceAccountRoleDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6598,7 +6597,7 @@ export interface ServiceaccounttypesServiceAccountRoleDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ServiceaccounttypesServiceAccountWithRolesDTO {
@@ -6606,7 +6605,7 @@ export interface ServiceaccounttypesServiceAccountWithRolesDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6635,7 +6634,7 @@ export interface ServiceaccounttypesServiceAccountWithRolesDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
@@ -6677,7 +6676,7 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6702,7 +6701,7 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type string
*/
@@ -6771,7 +6770,7 @@ export interface SpantypesSpanMapperDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6797,7 +6796,7 @@ export interface SpantypesSpanMapperDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type string
*/
@@ -7164,7 +7163,7 @@ export interface TypesDeprecatedUserDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -7197,7 +7196,7 @@ export interface TypesDeprecatedUserDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface TypesIdentifiableDTO {
@@ -7212,7 +7211,7 @@ export interface TypesInviteDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -7245,7 +7244,7 @@ export interface TypesInviteDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface TypesOrganizationDTO {
@@ -7257,7 +7256,7 @@ export interface TypesOrganizationDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -7279,7 +7278,7 @@ export interface TypesOrganizationDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface TypesPostableInviteDTO {
@@ -7346,7 +7345,7 @@ export interface TypesResetPasswordTokenDTO {
* @type string
* @format date-time
*/
expiresAt?: Date;
expiresAt?: string;
/**
* @type string
*/
@@ -7373,7 +7372,7 @@ export interface TypesUserDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -7402,7 +7401,7 @@ export interface TypesUserDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ZeustypesHostDTO {
@@ -7794,7 +7793,7 @@ export type ListDowntimeSchedules200 = {
/**
* @type array
*/
data: RuletypesPlannedMaintenanceDTO[];
data: AlertmanagertypesPlannedMaintenanceDTO[];
/**
* @type string
*/
@@ -7802,7 +7801,7 @@ export type ListDowntimeSchedules200 = {
};
export type CreateDowntimeSchedule201 = {
data: RuletypesPlannedMaintenanceDTO;
data: AlertmanagertypesPlannedMaintenanceDTO;
/**
* @type string
*/
@@ -7816,7 +7815,7 @@ export type GetDowntimeScheduleByIDPathParameters = {
id: string;
};
export type GetDowntimeScheduleByID200 = {
data: RuletypesPlannedMaintenanceDTO;
data: AlertmanagertypesPlannedMaintenanceDTO;
/**
* @type string
*/

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -264,6 +264,7 @@ function convertRawData(
date: row.timestamp,
} as any,
})),
nextCursor: rawData.nextCursor,
};
}

View File

@@ -181,7 +181,12 @@ function createBaseSpec(
)
: undefined,
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
having: isEmpty(queryData.having) ? undefined : (queryData?.having as Having),
// V4 uses having as array, V5 uses having as object with expression field
// If having is an array (V4 format), treat it as undefined for V5
having:
isEmpty(queryData.having) || Array.isArray(queryData.having)
? undefined
: (queryData?.having as Having),
functions: isEmpty(queryData.functions)
? undefined
: queryData.functions.map((func: QueryFunction): QueryFunction => {
@@ -409,7 +414,10 @@ function createTraceOperatorBaseSpec(
)
: undefined,
legend: isEmpty(legend) ? undefined : legend,
having: isEmpty(having) ? undefined : (having as Having),
// V4 uses having as array, V5 uses having as object with expression field
// If having is an array (V4 format), treat it as undefined for V5
having:
isEmpty(having) || Array.isArray(having) ? undefined : (having as Having),
selectFields: isEmpty(nonEmptySelectColumns)
? undefined
: nonEmptySelectColumns?.map(

View File

@@ -0,0 +1,14 @@
.wrapper {
cursor: not-allowed;
}
.errorContent {
background: var(--callout-error-background) !important;
border-color: var(--callout-error-border) !important;
backdrop-filter: blur(15px);
border-radius: 4px !important;
color: var(--foreground) !important;
font-style: normal;
font-weight: 400;
white-space: nowrap;
}

View File

@@ -0,0 +1,145 @@
import { ReactElement } from 'react';
import { render, screen } from 'tests/test-utils';
import { buildPermission } from 'hooks/useAuthZ/utils';
import type { AuthZObject, BrandedPermission } from 'hooks/useAuthZ/types';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import AuthZTooltip from './AuthZTooltip';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const noPermissions = {
isLoading: false,
isFetching: false,
error: null,
permissions: null,
refetchPermissions: jest.fn(),
};
const TestButton = (
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
): ReactElement => (
<button type="button" {...props}>
Action
</button>
);
const createPerm = buildPermission(
'create',
'serviceaccount:*' as AuthZObject<'create'>,
);
const attachSAPerm = (id: string): BrandedPermission =>
buildPermission('attach', `serviceaccount:${id}` as AuthZObject<'attach'>);
const attachRolePerm = buildPermission(
'attach',
'role:*' as AuthZObject<'attach'>,
);
describe('AuthZTooltip — single check', () => {
it('renders child unchanged when permission is granted', () => {
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: { [createPerm]: { isGranted: true } },
});
render(
<AuthZTooltip checks={[createPerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).not.toBeDisabled();
});
it('disables child when permission is denied', () => {
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: { [createPerm]: { isGranted: false } },
});
render(
<AuthZTooltip checks={[createPerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
});
it('disables child while loading', () => {
mockUseAuthZ.mockReturnValue({ ...noPermissions, isLoading: true });
render(
<AuthZTooltip checks={[createPerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
});
});
describe('AuthZTooltip — multi-check (checks array)', () => {
it('renders child enabled when all checks are granted', () => {
const sa = attachSAPerm('sa-1');
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: {
[sa]: { isGranted: true },
[attachRolePerm]: { isGranted: true },
},
});
render(
<AuthZTooltip checks={[sa, attachRolePerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).not.toBeDisabled();
});
it('disables child when first check is denied, second granted', () => {
const sa = attachSAPerm('sa-1');
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: {
[sa]: { isGranted: false },
[attachRolePerm]: { isGranted: true },
},
});
render(
<AuthZTooltip checks={[sa, attachRolePerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
});
it('disables child when both checks are denied and lists denied permissions in data attr', () => {
const sa = attachSAPerm('sa-1');
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: {
[sa]: { isGranted: false },
[attachRolePerm]: { isGranted: false },
},
});
render(
<AuthZTooltip checks={[sa, attachRolePerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
const wrapper = screen.getByRole('button', { name: 'Action' }).parentElement;
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(sa);
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(
attachRolePerm,
);
});
});

View File

@@ -0,0 +1,85 @@
import { ReactElement, cloneElement, useMemo } from 'react';
import {
TooltipRoot,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import type { BrandedPermission } from 'hooks/useAuthZ/types';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { parsePermission } from 'hooks/useAuthZ/utils';
import styles from './AuthZTooltip.module.scss';
interface AuthZTooltipProps {
checks: BrandedPermission[];
children: ReactElement;
enabled?: boolean;
tooltipMessage?: string;
}
function formatDeniedMessage(
denied: BrandedPermission[],
override?: string,
): string {
if (override) {
return override;
}
const labels = denied.map((p) => {
const { relation, object } = parsePermission(p);
const resource = object.split(':')[0];
return `${relation} ${resource}`;
});
return labels.length === 1
? `You don't have ${labels[0]} permission`
: `You don't have ${labels.join(', ')} permissions`;
}
function AuthZTooltip({
checks,
children,
enabled = true,
tooltipMessage,
}: AuthZTooltipProps): JSX.Element {
const shouldCheck = enabled && checks.length > 0;
const { permissions, isLoading } = useAuthZ(checks, { enabled: shouldCheck });
const deniedPermissions = useMemo(() => {
if (!permissions) {
return [];
}
return checks.filter((p) => permissions[p]?.isGranted === false);
}, [checks, permissions]);
if (shouldCheck && isLoading) {
return (
<span className={styles.wrapper}>
{cloneElement(children, { disabled: true })}
</span>
);
}
if (!shouldCheck || deniedPermissions.length === 0) {
return children;
}
return (
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger asChild>
<span
className={styles.wrapper}
data-denied-permissions={deniedPermissions.join(',')}
>
{cloneElement(children, { disabled: true })}
</span>
</TooltipTrigger>
<TooltipContent className={styles.errorContent}>
{formatDeniedMessage(deniedPermissions, tooltipMessage)}
</TooltipContent>
</TooltipRoot>
</TooltipProvider>
);
}
export default AuthZTooltip;

View File

@@ -2,6 +2,8 @@ import { Controller, useForm } from 'react-hook-form';
import { useQueryClient } from 'react-query';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { SACreatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
@@ -132,17 +134,19 @@ function CreateServiceAccountModal(): JSX.Element {
Cancel
</Button>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form="create-sa-form"
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Service Account
</Button>
<AuthZTooltip checks={[SACreatePermission]}>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form="create-sa-form"
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Service Account
</Button>
</AuthZTooltip>
</DialogFooter>
</DialogWrapper>
);

View File

@@ -11,6 +11,15 @@ import {
import CreateServiceAccountModal from '../CreateServiceAccountModal';
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,
}: {
children: React.ReactElement;
}): React.ReactElement => children,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
@@ -113,7 +122,9 @@ describe('CreateServiceAccountModal', () => {
getErrorMessage: expect.any(Function),
}),
);
const passedError = showErrorModal.mock.calls[0][0] as any;
const passedError = showErrorModal.mock.calls[0][0] as {
getErrorMessage: () => string;
};
expect(passedError.getErrorMessage()).toBe('Internal Server Error');
});
@@ -132,6 +143,9 @@ describe('CreateServiceAccountModal', () => {
await user.click(screen.getByRole('button', { name: /Cancel/i }));
await waitForElementToBeRemoved(dialog);
expect(
screen.queryByRole('dialog', { name: /New Service Account/i }),
).not.toBeInTheDocument();
});
it('shows "Name is required" after clearing the name field', async () => {
@@ -142,6 +156,8 @@ describe('CreateServiceAccountModal', () => {
await user.type(nameInput, 'Bot');
await user.clear(nameInput);
await screen.findByText('Name is required');
await expect(
screen.findByText('Name is required'),
).resolves.toBeInTheDocument();
});
});

View File

@@ -1,46 +1,30 @@
import { useState } from 'react';
import { Ellipsis } from '@signozhq/icons';
import { Button, Dropdown, MenuProps } from 'antd';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { Button } from 'antd';
import './DropDown.styles.scss';
type DropDownItemClick = (info: { key: string; keyPath: string[] }) => void;
function DropDown({
element,
onDropDownItemClick,
}: {
element: JSX.Element[];
onDropDownItemClick?: MenuProps['onClick'];
onDropDownItemClick?: DropDownItemClick;
}): JSX.Element {
const items: MenuProps['items'] = element.map(
(e: JSX.Element, index: number) => ({
label: e,
key: index,
}),
);
const [isDdOpen, setDdOpen] = useState<boolean>(false);
const items: MenuItem[] = element.map((e, index) => ({
key: String(index),
label: e,
onClick: onDropDownItemClick,
}));
return (
<Dropdown
menu={{
items,
onMouseEnter: (): void => setDdOpen(true),
onMouseLeave: (): void => setDdOpen(false),
onClick: (item): void => onDropDownItemClick?.(item),
}}
open={isDdOpen}
>
<Button
type="link"
className={`dropdown-button`}
onClick={(e): void => {
e.preventDefault();
setDdOpen(true);
}}
>
<DropdownMenuSimple menu={{ items }}>
<Button type="link" className="dropdown-button">
<Ellipsis className="dropdown-icon" size={16} />
</Button>
</Dropdown>
</DropdownMenuSimple>
);
}

View File

@@ -59,7 +59,7 @@ function getDeleteTooltip(
function getInviteButtonLabel(
isLoading: boolean,
existingToken: { expiresAt?: Date } | undefined,
existingToken: { expiresAt?: string } | undefined,
isExpired: boolean,
notFound: boolean,
): string {

View File

@@ -1,15 +1,7 @@
import { useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import {
Button,
Col,
Dropdown,
MenuProps,
Popover,
Row,
Select,
Space,
} from 'antd';
import { Button, Col, Popover, Row, Select, Space } from 'antd';
import { DropdownMenuSimple, type MenuProps } from '@signozhq/ui/dropdown-menu';
import { Typography } from '@signozhq/ui/typography';
import axios from 'axios';
import TextToolTip from 'components/TextToolTip';
@@ -241,9 +233,9 @@ function ExplorerCard({
</Popover>
<Share2 onClick={onCopyUrlHandler} size="md" />
{viewKey && (
<Dropdown trigger={['click']} menu={moreOptionMenu}>
<DropdownMenuSimple menu={moreOptionMenu}>
<Ellipsis size="md" />
</Dropdown>
</DropdownMenuSimple>
)}
</Space>
</OffSetCol>

View File

@@ -18,21 +18,22 @@ import { Button } from '@signozhq/ui/button';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { Typography } from '@signozhq/ui/typography';
import styles from './FieldsSettings.module.scss';
import styles from './FieldsSelector.module.scss';
function SortableField({
field,
onRemove,
allowDrag,
}: {
field: BaseAutocompleteData;
onRemove: (field: BaseAutocompleteData) => void;
field: TelemetryFieldKey;
onRemove: (field: TelemetryFieldKey) => void;
allowDrag: boolean;
}): JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: field.key });
useSortable({ id: field.name });
const style = {
transform: CSS.Transform.toString(transform),
@@ -50,7 +51,7 @@ function SortableField({
>
<div {...attributes} {...listeners} className={styles.dragHandle}>
{allowDrag && <GripVertical size={14} />}
<span className={styles.fieldKey}>{field.key}</span>
<span className={styles.fieldKey}>{field.name}</span>
</div>
<Button
className={cx(styles.removeBtn, 'periscope-btn')}
@@ -67,41 +68,52 @@ function SortableField({
interface AddedFieldsProps {
inputValue: string;
fields: BaseAutocompleteData[];
onFieldsChange: (fields: BaseAutocompleteData[]) => void;
fields: TelemetryFieldKey[];
onFieldsChange: (fields: TelemetryFieldKey[]) => void;
maxFields?: number;
}
function AddedFields({
inputValue,
fields,
onFieldsChange,
maxFields,
}: AddedFieldsProps): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = fields.findIndex((f) => f.key === active.id);
const newIndex = fields.findIndex((f) => f.key === over.id);
const oldIndex = fields.findIndex((f) => f.name === active.id);
const newIndex = fields.findIndex((f) => f.name === over.id);
onFieldsChange(arrayMove(fields, oldIndex, newIndex));
}
};
const filteredFields = useMemo(
() =>
fields.filter((f) => f.key.toLowerCase().includes(inputValue.toLowerCase())),
fields.filter((f) =>
f.name.toLowerCase().includes(inputValue.toLowerCase()),
),
[fields, inputValue],
);
const handleRemove = (field: BaseAutocompleteData): void => {
onFieldsChange(fields.filter((f) => f.key !== field.key));
const handleRemove = (field: TelemetryFieldKey): void => {
onFieldsChange(fields.filter((f) => f.name !== field.name));
};
const allowDrag = inputValue.length === 0;
return (
<div className={cx(styles.section, styles.sectionAdded)}>
<div className={styles.sectionHeader}>ADDED FIELDS</div>
<div className={styles.sectionHeader}>
<span>ADDED FIELDS</span>
{maxFields !== undefined && (
<Typography.Text size="sm" weight="medium" color="muted">
Max Allowed: {maxFields}
</Typography.Text>
)}
</div>
<div className={styles.addedList}>
<OverlayScrollbar>
<DndContext
@@ -113,13 +125,13 @@ function AddedFields({
<div className={styles.noValues}>No values found</div>
) : (
<SortableContext
items={fields.map((f) => f.key)}
items={fields.map((f) => f.name)}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>
{filteredFields.map((field) => (
<SortableField
key={field.key}
key={field.name}
field={field}
onRemove={handleRemove}
allowDrag={allowDrag}

View File

@@ -56,12 +56,14 @@
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--muted-foreground);
font-size: 11px;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 8px 12px;
}
@@ -89,13 +91,6 @@
font-size: 12px;
}
.limitHint {
padding: 8px 12px;
text-align: center;
color: var(--muted-foreground);
font-size: 11px;
}
.fieldItem {
display: flex;
align-items: center;

View File

@@ -0,0 +1,176 @@
import { useCallback, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { Button } from '@signozhq/ui/button';
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 { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import AddedFields from './AddedFields';
import OtherFields from './OtherFields';
import styles from './FieldsSelector.module.scss';
const DEFAULT_PANEL_WIDTH = 350;
const DEFAULT_PANEL_HEIGHT_OFFSET = 100;
const DEFAULT_PANEL_RIGHT_INSET = 100;
const DEFAULT_PANEL_TOP_INSET = 50;
interface FieldsSelectorProps {
isOpen: boolean;
title: string;
fields: TelemetryFieldKey[];
onFieldsChange: (fields: TelemetryFieldKey[]) => void;
onClose: () => void;
signal: DataSource;
maxFields?: number;
width?: number;
height?: number;
defaultPosition?: { x: number; y: number };
}
function FieldsSelector({
isOpen,
title,
fields,
onFieldsChange,
onClose,
signal,
maxFields,
width = DEFAULT_PANEL_WIDTH,
height,
defaultPosition,
}: FieldsSelectorProps): JSX.Element | null {
if (!isOpen) {
return null;
}
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('');
const debouncedUpdate = useDebouncedFn((value) => {
setDebouncedInputValue(value as string);
}, 400);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value = e.target.value.trim().toLowerCase();
setInputValue(value);
debouncedUpdate(value);
},
[debouncedUpdate],
);
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]);
},
[draftFields, maxFields],
);
const handleSave = useCallback((): void => {
onFieldsChange(draftFields);
toast.success('Saved successfully', {
position: 'top-right',
});
onClose();
}, [draftFields, onFieldsChange, onClose]);
const handleDiscard = useCallback((): void => {
setDraftFields(fields);
}, [fields]);
const hasUnsavedChanges = useMemo(
() =>
!(
draftFields.length === fields.length &&
draftFields.every((f, i) => f.name === fields[i]?.name)
),
[draftFields, fields],
);
const isAtLimit = maxFields !== undefined && draftFields.length >= maxFields;
return (
<FloatingPanel
isOpen
width={width}
height={resolvedHeight}
defaultPosition={resolvedPosition}
enableResizing={false}
>
<div className={styles.root}>
<div className={styles.header}>
<div className={styles.title}>
<TableColumnsSplit size={16} />
{title}
</div>
<X className={styles.closeIcon} size={16} onClick={onClose} />
</div>
<section>
<Input
className={styles.searchInput}
type="text"
value={inputValue}
placeholder="Search for a field..."
onChange={handleInputChange}
/>
</section>
<AddedFields
inputValue={inputValue}
fields={draftFields}
onFieldsChange={setDraftFields}
maxFields={maxFields}
/>
<OtherFields
signal={signal}
debouncedInputValue={debouncedInputValue}
addedFields={draftFields}
onAdd={handleAdd}
isAtLimit={isAtLimit}
/>
{hasUnsavedChanges && (
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={handleDiscard}
prefix={<X width={14} height={14} />}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={handleSave}
prefix={<Check width={14} height={14} />}
>
Save changes
</Button>
</div>
)}
</div>
</FloatingPanel>
);
}
export default FieldsSelector;

View File

@@ -4,51 +4,58 @@ import { Skeleton } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import {
FieldContext,
FieldDataType,
SignalType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import styles from './FieldsSettings.module.scss';
import styles from './FieldsSelector.module.scss';
interface OtherFieldsProps {
dataSource: DataSource;
signal: DataSource;
debouncedInputValue: string;
addedFields: BaseAutocompleteData[];
onAdd: (field: BaseAutocompleteData) => void;
addedFields: TelemetryFieldKey[];
onAdd: (field: TelemetryFieldKey) => void;
isAtLimit: boolean;
}
function OtherFields({
dataSource,
signal,
debouncedInputValue,
addedFields,
onAdd,
isAtLimit,
}: OtherFieldsProps): JSX.Element {
// API call to get available attribute keys
const { data, isFetching } = useGetAggregateKeys(
const { data, isFetching } = useGetQueryKeySuggestions(
{
signal,
searchText: debouncedInputValue,
dataSource,
aggregateOperator: 'noop',
aggregateAttribute: '',
tagType: '',
},
{
queryKey: [
REACT_QUERY_KEY.GET_OTHER_FILTERS,
'preview-fields',
REACT_QUERY_KEY.GET_FIELDS_SELECTOR_SUGGESTIONS,
signal,
debouncedInputValue,
],
enabled: true,
},
);
// Filter out already-added fields, match on .key from API response objects
const otherFields = useMemo(() => {
const attributes = data?.payload?.attributeKeys || [];
const addedKeys = new Set(addedFields.map((f) => f.key));
return attributes.filter((attr) => !addedKeys.has(attr.key));
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) => ({
...attr,
signal: attr.signal as SignalType,
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType as FieldDataType,
}));
}, [data, addedFields]);
if (isFetching) {
@@ -76,10 +83,10 @@ function OtherFields({
) : (
otherFields.map((attr) => (
<div
key={attr.key}
key={attr.name}
className={cx(styles.fieldItem, styles.otherFieldItem)}
>
<span className={styles.fieldKey}>{attr.key}</span>
<span className={styles.fieldKey}>{attr.name}</span>
{!isAtLimit && (
<Button
className={cx(styles.addBtn, 'periscope-btn')}
@@ -94,7 +101,6 @@ function OtherFields({
</div>
))
)}
{isAtLimit && <div className={styles.limitHint}>Maximum 10 fields</div>}
</>
</OverlayScrollbar>
</div>

View File

@@ -0,0 +1 @@
export { default } from './FieldsSelector';

View File

@@ -1,34 +1,13 @@
import { ReactElement } from 'react';
import {
AuthtypesGettableTransactionDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ENVIRONMENT } from 'constants/env';
import { BrandedPermission } from 'hooks/useAuthZ/types';
import { buildPermission } from 'hooks/useAuthZ/utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
import { GuardAuthZ } from './GuardAuthZ';
const BASE_URL = ENVIRONMENT.baseURL || '';
const AUTHZ_CHECK_URL = `${BASE_URL}/api/v1/authz/check`;
function authzMockResponse(
payload: AuthtypesTransactionDTO[],
authorizedByIndex: boolean[],
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
return {
data: payload.map((txn, i) => ({
relation: txn.relation,
object: txn.object,
authorized: authorizedByIndex[i] ?? false,
})),
status: 'success',
};
}
describe('GuardAuthZ', () => {
const TestChild = (): ReactElement => <div>Protected Content</div>;
const LoadingFallback = (): ReactElement => <div>Loading...</div>;

View File

@@ -5,6 +5,8 @@ import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents } from 'container/AIAssistant/events';
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
import {
openAIAssistant,
useAIAssistantStore,
@@ -50,6 +52,14 @@ function HeaderRightSection({
setOpenAnnouncementsModal(false);
}, [location.pathname]);
const handleOpenAIAssistant = useCallback((): void => {
void logEvent(AIAssistantEvents.Opened, {
source: 'header',
currentPage: normalizePage(location.pathname),
});
openAIAssistant();
}, [location.pathname]);
const handleOpenShareURLModal = useCallback((): void => {
logEvent('Share: Clicked', {
page: location.pathname,
@@ -101,7 +111,7 @@ function HeaderRightSection({
<Button
variant="solid"
color="secondary"
onClick={openAIAssistant}
onClick={handleOpenAIAssistant}
aria-label={
showHeaderPendingBadge
? pendingUserInputCount === 1

View File

@@ -0,0 +1,4 @@
.callout {
box-sizing: border-box;
width: 100%;
}

View File

@@ -0,0 +1,22 @@
import { render, screen } from 'tests/test-utils';
import PermissionDeniedCallout from './PermissionDeniedCallout';
describe('PermissionDeniedCallout', () => {
it('renders the permission name in the callout message', () => {
render(<PermissionDeniedCallout permissionName="serviceaccount:attach" />);
expect(screen.getByText(/You don't have/)).toBeInTheDocument();
expect(screen.getByText(/serviceaccount:attach/)).toBeInTheDocument();
expect(screen.getByText(/permission/)).toBeInTheDocument();
});
it('accepts an optional className', () => {
const { container } = render(
<PermissionDeniedCallout
permissionName="serviceaccount:read"
className="custom-class"
/>,
);
expect(container.firstChild).toHaveClass('custom-class');
});
});

View File

@@ -0,0 +1,26 @@
import { Callout } from '@signozhq/ui/callout';
import cx from 'classnames';
import styles from './PermissionDeniedCallout.module.scss';
interface PermissionDeniedCalloutProps {
permissionName: string;
className?: string;
}
function PermissionDeniedCallout({
permissionName,
className,
}: PermissionDeniedCalloutProps): JSX.Element {
return (
<Callout
type="error"
showIcon
size="small"
className={cx(styles.callout, className)}
>
{`You don't have ${permissionName} permission`}
</Callout>
);
}
export default PermissionDeniedCallout;

View File

@@ -0,0 +1,44 @@
.container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 50vh;
padding: var(--spacing-10);
}
.content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-2);
max-width: 512px;
}
.icon {
margin-bottom: var(--spacing-1);
}
.title {
margin: 0;
font-size: var(--label-base-500-font-size);
font-weight: var(--label-base-500-font-weight);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
color: var(--l1-foreground);
}
.subtitle {
margin: 0;
font-size: var(--label-base-400-font-size);
font-weight: var(--label-base-400-font-weight);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
color: var(--l2-foreground);
}
.permission {
font-family: monospace;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,21 @@
import { render, screen } from 'tests/test-utils';
import PermissionDeniedFullPage from './PermissionDeniedFullPage';
describe('PermissionDeniedFullPage', () => {
it('renders the title and subtitle with the permissionName interpolated', () => {
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
expect(
screen.getByText("Uh-oh! You don't have permission to view this page."),
).toBeInTheDocument();
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
expect(
screen.getByText(/Please ask your SigNoz administrator to grant access/),
).toBeInTheDocument();
});
it('renders with a different permissionName', () => {
render(<PermissionDeniedFullPage permissionName="role:read" />);
expect(screen.getByText(/role:read/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,31 @@
import { CircleSlash2 } from '@signozhq/icons';
import styles from './PermissionDeniedFullPage.module.scss';
import { Style } from '@signozhq/design-tokens';
interface PermissionDeniedFullPageProps {
permissionName: string;
}
function PermissionDeniedFullPage({
permissionName,
}: PermissionDeniedFullPageProps): JSX.Element {
return (
<div className={styles.container}>
<div className={styles.content}>
<span className={styles.icon}>
<CircleSlash2 color={Style.CALLOUT_WARNING_TITLE} size={14} />
</span>
<p className={styles.title}>
Uh-oh! You don&apos;t have permission to view this page.
</p>
<p className={styles.subtitle}>
You need <code className={styles.permission}>{permissionName}</code> to
view this page. Please ask your SigNoz administrator to grant access.
</p>
</div>
</div>
);
}
export default PermissionDeniedFullPage;

View File

@@ -1,14 +1,14 @@
import { ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
import { ReactNode, useEffect, useRef } from 'react';
import { parseAsString, useQueryState } from 'nuqs';
import { useStore } from 'zustand';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from '../utils';
import { getUserExpressionFromCombined } from '../utils';
import { QuerySearchV2Context } from './context';
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
import { createExpressionStore } from './QuerySearchV2.store';
import {
createExpressionStore,
QuerySearchV2Store,
} from './QuerySearchV2.store';
import type { StoreApi } from 'zustand';
export interface QuerySearchV2ProviderProps {
queryParamKey: string;
@@ -22,7 +22,7 @@ export interface QuerySearchV2ProviderProps {
/**
* Provider component that creates a scoped zustand store and exposes
* expression state to children via context.
* the store via context. Handles URL synchronization.
*/
export function QuerySearchV2Provider({
initialExpression = '',
@@ -30,7 +30,10 @@ export function QuerySearchV2Provider({
queryParamKey,
children,
}: QuerySearchV2ProviderProps): JSX.Element {
const storeRef = useRef(createExpressionStore());
const storeRef = useRef<StoreApi<QuerySearchV2Store> | null>(null);
if (!storeRef.current) {
storeRef.current = createExpressionStore();
}
const store = storeRef.current;
const [urlExpression, setUrlExpression] = useQueryState(
@@ -39,10 +42,10 @@ export function QuerySearchV2Provider({
);
const committedExpression = useStore(store, (s) => s.committedExpression);
const setInputExpression = useStore(store, (s) => s.setInputExpression);
const commitExpression = useStore(store, (s) => s.commitExpression);
const initializeFromUrl = useStore(store, (s) => s.initializeFromUrl);
const resetExpression = useStore(store, (s) => s.resetExpression);
useEffect(() => {
store.getState().setInitialExpression(initialExpression);
}, [initialExpression, store]);
const isInitialized = useRef(false);
useEffect(() => {
@@ -51,10 +54,10 @@ export function QuerySearchV2Provider({
initialExpression,
urlExpression,
);
initializeFromUrl(cleanedExpression);
store.getState().initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [urlExpression, initialExpression, initializeFromUrl]);
}, [urlExpression, initialExpression, store]);
useEffect(() => {
if (isInitialized.current || !urlExpression) {
@@ -66,60 +69,13 @@ export function QuerySearchV2Provider({
return (): void => {
if (!persistOnUnmount) {
setUrlExpression(null);
resetExpression();
store.getState().resetExpression();
}
};
}, [persistOnUnmount, setUrlExpression, resetExpression]);
const handleChange = useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
setInputExpression(userOnly);
},
[initialExpression, setInputExpression],
);
const handleRun = useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
commitExpression(userOnly);
},
[initialExpression, commitExpression],
);
const combinedExpression = useMemo(
() => combineInitialAndUserExpression(initialExpression, committedExpression),
[initialExpression, committedExpression],
);
const contextValue = useMemo<QuerySearchV2ContextValue>(
() => ({
expression: combinedExpression,
userExpression: committedExpression,
initialExpression,
querySearchProps: {
initialExpression: initialExpression.trim() ? initialExpression : undefined,
onChange: handleChange,
onRun: handleRun,
},
}),
[
combinedExpression,
committedExpression,
initialExpression,
handleChange,
handleRun,
],
);
}, [persistOnUnmount, setUrlExpression, store]);
return (
<QuerySearchV2Context.Provider value={contextValue}>
<QuerySearchV2Context.Provider value={store}>
{children}
</QuerySearchV2Context.Provider>
);

View File

@@ -1,6 +1,10 @@
import { createStore, StoreApi } from 'zustand';
export type QuerySearchV2Store = {
/**
* Initial expression (set by provider, used to combine with user expression)
*/
initialExpression: string;
/**
* User-typed expression (local state, updates on typing)
*/
@@ -9,32 +13,21 @@ export type QuerySearchV2Store = {
* Committed expression (synced to URL, updates on submit)
*/
committedExpression: string;
setInitialExpression: (expression: string) => void;
setInputExpression: (expression: string) => void;
commitExpression: (expression: string) => void;
resetExpression: () => void;
initializeFromUrl: (urlExpression: string) => void;
};
export interface QuerySearchProps {
initialExpression: string | undefined;
onChange: (expression: string) => void;
onRun: (expression: string) => void;
}
export interface QuerySearchV2ContextValue {
/**
* Combined expression: "initialExpression AND (userExpression)"
*/
expression: string;
userExpression: string;
initialExpression: string;
querySearchProps: QuerySearchProps;
}
export function createExpressionStore(): StoreApi<QuerySearchV2Store> {
return createStore<QuerySearchV2Store>((set) => ({
initialExpression: '',
inputExpression: '',
committedExpression: '',
setInitialExpression: (expression: string): void => {
set({ initialExpression: expression });
},
setInputExpression: (expression: string): void => {
set({ inputExpression: expression });
},

View File

@@ -1,7 +1,14 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { useQuerySearchV2Context } from '../context';
import {
useExpression,
useInitialExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from '../context';
import {
QuerySearchV2Provider,
QuerySearchV2ProviderProps,
@@ -27,6 +34,24 @@ function createWrapper(
};
}
function useTestHooks(): {
expression: string;
userExpression: string;
initialExpression: string;
querySearchInitialExpressionProp: string | undefined;
onChange: (expr: string) => void;
onRun: (expr: string) => void;
} {
return {
expression: useExpression(),
userExpression: useUserExpression(),
initialExpression: useInitialExpression(),
querySearchInitialExpressionProp: useQuerySearchInitialExpressionProp(),
onChange: useQuerySearchOnChange(),
onRun: useQuerySearchOnRun(),
};
}
describe('QuerySearchExpressionProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -34,7 +59,7 @@ describe('QuerySearchExpressionProvider', () => {
});
it('should provide initial context values', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
@@ -44,7 +69,7 @@ describe('QuerySearchExpressionProvider', () => {
});
it('should combine initialExpression with userExpression', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper({ initialExpression: 'k8s.pod.name = "my-pod"' }),
});
@@ -52,10 +77,10 @@ describe('QuerySearchExpressionProvider', () => {
expect(result.current.initialExpression).toBe('k8s.pod.name = "my-pod"');
act(() => {
result.current.querySearchProps.onChange('service = "api"');
result.current.onChange('service = "api"');
});
act(() => {
result.current.querySearchProps.onRun('service = "api"');
result.current.onRun('service = "api"');
});
expect(result.current.expression).toBe(
@@ -65,19 +90,19 @@ describe('QuerySearchExpressionProvider', () => {
});
it('should provide querySearchProps with correct callbacks', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper({ initialExpression: 'initial' }),
});
expect(result.current.querySearchProps.initialExpression).toBe('initial');
expect(typeof result.current.querySearchProps.onChange).toBe('function');
expect(typeof result.current.querySearchProps.onRun).toBe('function');
expect(result.current.querySearchInitialExpressionProp).toBe('initial');
expect(typeof result.current.onChange).toBe('function');
expect(typeof result.current.onRun).toBe('function');
});
it('should initialize from URL value on mount', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useQuerySearchV2Context(), {
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
@@ -87,9 +112,9 @@ describe('QuerySearchExpressionProvider', () => {
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useQuerySearchV2Context());
renderHook(() => useExpression());
}).toThrow(
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
'useQuerySearchV2Store must be used within a QuerySearchV2Provider',
);
});
});

View File

@@ -1,17 +1,80 @@
// eslint-disable-next-line no-restricted-imports -- React Context required for scoped store pattern
import { createContext, useContext } from 'react';
import { createContext, useCallback, useContext } from 'react';
import { StoreApi, useStore } from 'zustand';
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from '../utils';
import type { QuerySearchV2Store } from './QuerySearchV2.store';
export const QuerySearchV2Context =
createContext<QuerySearchV2ContextValue | null>(null);
createContext<StoreApi<QuerySearchV2Store> | null>(null);
export function useQuerySearchV2Context(): QuerySearchV2ContextValue {
const context = useContext(QuerySearchV2Context);
if (!context) {
function useQuerySearchV2Store(): StoreApi<QuerySearchV2Store> {
const store = useContext(QuerySearchV2Context);
if (!store) {
throw new Error(
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
'useQuerySearchV2Store must be used within a QuerySearchV2Provider',
);
}
return context;
return store;
}
export function useInitialExpression(): string {
const store = useQuerySearchV2Store();
return useStore(store, (s) => s.initialExpression);
}
export function useInputExpression(): string {
const store = useQuerySearchV2Store();
return useStore(store, (s) => s.inputExpression);
}
export function useUserExpression(): string {
const store = useQuerySearchV2Store();
return useStore(store, (s) => s.committedExpression);
}
export function useExpression(): string {
const store = useQuerySearchV2Store();
return useStore(store, (s) =>
combineInitialAndUserExpression(s.initialExpression, s.committedExpression),
);
}
export function useQuerySearchOnChange(): (expression: string) => void {
const store = useQuerySearchV2Store();
return useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
store.getState().initialExpression,
expression,
);
store.getState().setInputExpression(userOnly);
},
[store],
);
}
export function useQuerySearchOnRun(): (expression: string) => void {
const store = useQuerySearchV2Store();
const initialExpression = useStore(store, (s) => s.initialExpression);
return useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
store.getState().commitExpression(userOnly);
},
[store, initialExpression],
);
}
export function useQuerySearchInitialExpressionProp(): string | undefined {
const initialExpression = useInitialExpression();
return initialExpression.trim() ? initialExpression : undefined;
}

View File

@@ -1,8 +1,12 @@
export { useQuerySearchV2Context } from './context';
export {
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from './context';
export type { QuerySearchV2ProviderProps } from './QuerySearchV2.provider';
export { QuerySearchV2Provider } from './QuerySearchV2.provider';
export type {
QuerySearchProps,
QuerySearchV2ContextValue,
QuerySearchV2Store,
} from './QuerySearchV2.store';
export type { QuerySearchV2Store } from './QuerySearchV2.store';

View File

@@ -1,11 +1,16 @@
export type {
QuerySearchProps,
QuerySearchV2ContextValue,
QuerySearchV2ProviderProps,
QuerySearchV2Store,
} from './QueryV2/QuerySearch/Provider';
export {
QuerySearchV2Provider,
useQuerySearchV2Context,
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from './QueryV2/QuerySearch/Provider';
export { QueryBuilderV2 } from './QueryBuilderV2';
export {

View File

@@ -32,10 +32,24 @@ import { isKeyMatch } from './utils';
import './Checkbox.styles.scss';
const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in'];
const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
const SOURCES_WITH_EMPTY_STATE_ENABLED = [QuickFiltersSource.LOGS_EXPLORER];
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
/**
* Returns the correct NOT_IN operator value based on source.
* InfraMonitoring backend expects 'nin', others expect 'not in'.
*/
function getNotInOperator(source: QuickFiltersSource): string {
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
return 'nin';
}
return getOperatorValue('NOT_IN');
}
function setDefaultValues(
values: string[],
trueOrFalse: boolean,
@@ -401,6 +415,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
}
}
break;
case 'nin':
case 'not in':
// if the current running operator is NIN then when unchecking the value it gets
// added to the clause like key NIN [value1 , currentUnselectedValue]
@@ -495,7 +510,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
if (!checked) {
const newFilter = {
...currentFilter,
op: getOperatorValue('NOT_IN'),
op: getNotInOperator(source),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
@@ -518,7 +533,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
// case - when there is no filter for the current key that means all are selected right now.
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getOperatorValue('NOT_IN'),
op: getNotInOperator(source),
key: filter.attributeKey,
value,
};

View File

@@ -4,13 +4,13 @@ import type {
TableColumnsType as ColumnsType,
TableColumnType as ColumnType,
} from 'antd';
import { Button, Dropdown, Flex, MenuProps, Switch } from 'antd';
import { Button, Flex, Switch } from 'antd';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import logEvent from 'api/common/logEvent';
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { SlidersHorizontal } from '@signozhq/icons';
import { popupContainer } from 'utils/selectPopupContainer';
import ResizeTable from './ResizeTable';
import { DynamicColumnTableProps } from './types';
@@ -85,8 +85,9 @@ function DynamicColumnTable({
);
};
const items: MenuProps['items'] =
const items: MenuItem[] =
dynamicColumns?.map((column, index) => ({
key: String(index),
label: (
<div className="dynamicColumnsTable-items">
<div>{column.title?.toString()}</div>
@@ -96,8 +97,6 @@ function DynamicColumnTable({
/>
</div>
),
key: index,
type: 'checkbox',
})) || [];
// Get current page from URL or default to 1
@@ -126,18 +125,14 @@ function DynamicColumnTable({
<Flex justify="flex-end" align="center" gap={8}>
{facingIssueBtn && <LaunchChatSupport {...facingIssueBtn} />}
{dynamicColumns && (
<Dropdown
getPopupContainer={popupContainer}
menu={{ items }}
trigger={['click']}
>
<DropdownMenuSimple menu={{ items }}>
<Button
className="dynamicColumnTable-button filter-btn"
size="middle"
icon={<SlidersHorizontal size={14} />}
data-testid="additional-filters-button"
/>
</Dropdown>
</DropdownMenuSimple>
)}
</Flex>

View File

@@ -80,6 +80,7 @@ interface BaseProps {
isError?: boolean;
error?: APIError;
onRefetch?: () => void;
disabled?: boolean;
}
interface SingleProps extends BaseProps {
@@ -123,6 +124,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
isError = internalError,
error = convertToApiError(internalErrorObj),
onRefetch = externalRoles === undefined ? internalRefetch : undefined,
disabled,
} = props;
const notFoundContent = isError ? (
@@ -142,6 +144,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
loading={loading}
notFoundContent={notFoundContent}
options={options}
optionFilterProp="label"
optionRender={(option): JSX.Element => (
<Checkbox
checked={value.includes(option.value as string)}
@@ -151,6 +154,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
</Checkbox>
)}
getPopupContainer={getPopupContainer}
disabled={disabled}
/>
);
}
@@ -159,6 +163,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
return (
<Select
id={id}
showSearch
value={value || undefined}
onChange={onChange}
placeholder={placeholder}
@@ -167,7 +172,9 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
loading={loading}
notFoundContent={notFoundContent}
options={options}
optionFilterProp="label"
getPopupContainer={getPopupContainer}
disabled={disabled}
/>
);
}

View File

@@ -4,6 +4,11 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
APIKeyCreatePermission,
buildSAAttachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { popupContainer } from 'utils/selectPopupContainer';
import { disabledDate } from '../utils';
@@ -18,6 +23,7 @@ export interface KeyFormPhaseProps {
isValid: boolean;
onSubmit: () => void;
onClose: () => void;
accountId?: string;
}
function KeyFormPhase({
@@ -28,6 +34,7 @@ function KeyFormPhase({
isValid,
onSubmit,
onClose,
accountId,
}: KeyFormPhaseProps): JSX.Element {
return (
<>
@@ -111,17 +118,25 @@ function KeyFormPhase({
<Button variant="solid" color="secondary" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
<AuthZTooltip
checks={[
APIKeyCreatePermission,
buildSAAttachPermission(accountId ?? ''),
]}
enabled={!!accountId}
>
Create Key
</Button>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Key
</Button>
</AuthZTooltip>
</div>
</div>
</>

View File

@@ -161,6 +161,7 @@ function AddKeyModal(): JSX.Element {
isValid={isValid}
onSubmit={handleSubmit(handleCreate)}
onClose={handleClose}
accountId={accountId ?? undefined}
/>
)}

View File

@@ -1,6 +1,8 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { buildSADeletePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
@@ -65,7 +67,7 @@ function DeleteAccountModal(): JSX.Element {
}
function handleCancel(): void {
setIsDeleteOpen(null);
void setIsDeleteOpen(null);
}
const content = (
@@ -82,15 +84,20 @@ function DeleteAccountModal(): JSX.Element {
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
loading={isDeleting}
onClick={handleConfirm}
<AuthZTooltip
checks={[buildSADeletePermission(accountId ?? '')]}
enabled={!!accountId}
>
<Trash2 size={12} />
Delete
</Button>
<Button
variant="solid"
color="destructive"
loading={isDeleting}
onClick={handleConfirm}
>
<Trash2 size={12} />
Delete
</Button>
</AuthZTooltip>
</div>
);

View File

@@ -7,6 +7,12 @@ import { Input } from '@signozhq/ui/input';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
buildAPIKeyDeletePermission,
buildAPIKeyUpdatePermission,
buildSADetachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { popupContainer } from 'utils/selectPopupContainer';
import { disabledDate, formatLastObservedAt } from '../utils';
@@ -24,6 +30,8 @@ export interface EditKeyFormProps {
onClose: () => void;
onRevokeClick: () => void;
formatTimezoneAdjustedTimestamp: (ts: string, format: string) => string;
canUpdate?: boolean;
accountId?: string;
}
function EditKeyForm({
@@ -37,6 +45,8 @@ function EditKeyForm({
onClose,
onRevokeClick,
formatTimezoneAdjustedTimestamp,
canUpdate = true,
accountId = '',
}: EditKeyFormProps): JSX.Element {
return (
<>
@@ -45,12 +55,34 @@ function EditKeyForm({
<label className="edit-key-modal__label" htmlFor="edit-key-name">
Name
</label>
<Input
id="edit-key-name"
className="edit-key-modal__input"
placeholder="Enter key name"
{...register('name')}
/>
{!canUpdate ? (
<AuthZTooltip
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
enabled={!!keyItem?.id}
>
<div className="edit-key-modal__key-display">
<span className="edit-key-modal__id-text">{keyItem?.name || '—'}</span>
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
</div>
</AuthZTooltip>
) : (
<Input
id="edit-key-name"
className="edit-key-modal__input"
placeholder="Enter key name"
{...register('name')}
/>
)}
</div>
<div className="edit-key-modal__field">
<label className="edit-key-modal__label" htmlFor="edit-key-id">
ID
</label>
<div id="edit-key-id" className="edit-key-modal__key-display">
<span className="edit-key-modal__id-text">{keyItem?.id || '—'}</span>
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
</div>
</div>
<div className="edit-key-modal__field">
@@ -73,21 +105,22 @@ function EditKeyForm({
type="single"
value={field.value}
onChange={(val): void => {
if (val) {
if (val && canUpdate) {
field.onChange(val);
}
}}
size="sm"
className="edit-key-modal__expiry-toggle"
>
<ToggleGroupItem
value={ExpiryMode.NONE}
disabled={!canUpdate}
className="edit-key-modal__expiry-toggle-btn"
>
No Expiration
</ToggleGroupItem>
<ToggleGroupItem
value={ExpiryMode.DATE}
disabled={!canUpdate}
className="edit-key-modal__expiry-toggle-btn"
>
Set Expiration Date
@@ -114,6 +147,7 @@ function EditKeyForm({
popupClassName="edit-key-modal-datepicker-popup"
getPopupContainer={popupContainer}
disabledDate={disabledDate}
disabled={!canUpdate}
/>
)}
/>
@@ -133,26 +167,39 @@ function EditKeyForm({
</form>
<div className="edit-key-modal__footer">
<Button variant="link" color="destructive" onClick={onRevokeClick}>
<Trash2 size={12} />
Revoke Key
</Button>
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(keyItem?.id ?? ''),
buildSADetachPermission(accountId ?? ''),
]}
enabled={!!accountId && !!keyItem?.id}
>
<Button variant="link" color="destructive" onClick={onRevokeClick}>
<Trash2 size={12} />
Revoke Key
</Button>
</AuthZTooltip>
<div className="edit-key-modal__footer-right">
<Button variant="solid" color="secondary" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
<AuthZTooltip
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
enabled={!!accountId && !!keyItem?.id}
>
Save Changes
</Button>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
>
Save Changes
</Button>
</AuthZTooltip>
</div>
</div>
</>

View File

@@ -60,6 +60,16 @@
letter-spacing: 2px;
}
&__id-text {
font-size: 13px;
font-family: monospace;
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
&__lock-icon {
color: var(--foreground);
flex-shrink: 0;

View File

@@ -16,6 +16,8 @@ import type {
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import dayjs from 'dayjs';
import { buildAPIKeyUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';
@@ -69,6 +71,16 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
const expiryMode = watch('expiryMode');
const { permissions: editPermissions, isLoading: isAuthZLoading } = useAuthZ(
editKeyId ? [buildAPIKeyUpdatePermission(editKeyId)] : [],
{ enabled: !!editKeyId },
);
const canUpdate = isAuthZLoading
? false
: (editPermissions?.[buildAPIKeyUpdatePermission(editKeyId ?? '')]
?.isGranted ?? true);
const { mutate: updateKey, isLoading: isSaving } = useUpdateServiceAccountKey({
mutation: {
onSuccess: async () => {
@@ -115,7 +127,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
});
function handleClose(): void {
setEditKeyId(null);
void setEditKeyId(null);
setIsRevokeConfirmOpen(false);
}
@@ -169,6 +181,8 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
isRevoking={isRevoking}
onCancel={(): void => setIsRevokeConfirmOpen(false)}
onConfirm={handleRevoke}
accountId={selectedAccountId ?? undefined}
keyId={keyItem?.id ?? undefined}
/>
) : undefined
}
@@ -190,6 +204,8 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
onClose={handleClose}
onRevokeClick={(): void => setIsRevokeConfirmOpen(true)}
formatTimezoneAdjustedTimestamp={formatTimezoneAdjustedTimestamp}
canUpdate={canUpdate}
accountId={selectedAccountId ?? ''}
/>
)}
</DialogWrapper>

View File

@@ -1,9 +1,16 @@
import { useCallback, useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { KeyRound, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Skeleton, Table, Tooltip } from 'antd';
import { Skeleton, Table } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
APIKeyCreatePermission,
buildAPIKeyDeletePermission,
buildSAAttachPermission,
buildSADetachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs';
@@ -17,12 +24,15 @@ interface KeysTabProps {
keys: ServiceaccounttypesGettableFactorAPIKeyDTO[];
isLoading: boolean;
isDisabled?: boolean;
canUpdate?: boolean;
accountId?: string;
currentPage: number;
pageSize: number;
}
interface BuildColumnsParams {
isDisabled: boolean;
accountId: string;
onRevokeClick: (keyId: string) => void;
handleformatLastObservedAt: (
lastObservedAt: Date | null | undefined,
@@ -42,6 +52,7 @@ function formatExpiry(expiresAt: number): JSX.Element {
function buildColumns({
isDisabled,
accountId,
onRevokeClick,
handleformatLastObservedAt,
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesGettableFactorAPIKeyDTO> {
@@ -92,22 +103,34 @@ function buildColumns({
key: 'action',
width: 48,
align: 'right' as const,
onCell: (): {
onClick: (e: React.MouseEvent) => void;
style: React.CSSProperties;
} => ({
onClick: (e): void => e.stopPropagation(),
style: { cursor: 'default' },
}),
render: (_, record): JSX.Element => (
<Tooltip title={isDisabled ? 'Service account disabled' : 'Revoke Key'}>
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(record.id),
buildSADetachPermission(accountId),
]}
enabled={!isDisabled && !!accountId}
>
<Button
variant="ghost"
size="sm"
color="destructive"
disabled={isDisabled}
onClick={(e): void => {
e.stopPropagation();
onClick={(): void => {
onRevokeClick(record.id);
}}
className="keys-tab__revoke-btn"
>
<X size={12} />
</Button>
</Tooltip>
</AuthZTooltip>
),
},
];
@@ -117,6 +140,7 @@ function KeysTab({
keys,
isLoading,
isDisabled = false,
accountId = '',
currentPage,
pageSize,
}: KeysTabProps): JSX.Element {
@@ -143,14 +167,20 @@ function KeysTab({
const onRevokeClick = useCallback(
(keyId: string): void => {
setRevokeKeyId(keyId);
void setRevokeKeyId(keyId);
},
[setRevokeKeyId],
);
const columns = useMemo(
() => buildColumns({ isDisabled, onRevokeClick, handleformatLastObservedAt }),
[isDisabled, onRevokeClick, handleformatLastObservedAt],
() =>
buildColumns({
isDisabled,
accountId,
onRevokeClick,
handleformatLastObservedAt,
}),
[isDisabled, accountId, onRevokeClick, handleformatLastObservedAt],
);
if (isLoading) {
@@ -176,16 +206,21 @@ function KeysTab({
Learn more
</a>
</p>
<Button
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
<AuthZTooltip
checks={[APIKeyCreatePermission, buildSAAttachPermission(accountId)]}
enabled={!isDisabled && !!accountId}
>
+ Add your first key
</Button>
<Button
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
>
+ Add your first key
</Button>
</AuthZTooltip>
</div>
);
}

View File

@@ -3,9 +3,11 @@ import { LockKeyhole } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Input } from '@signozhq/ui/input';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import RolesSelect from 'components/RolesSelect';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { buildSAUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
@@ -19,6 +21,7 @@ interface OverviewTabProps {
localRoles: string[];
onRolesChange: (v: string[]) => void;
isDisabled: boolean;
canUpdate?: boolean;
availableRoles: AuthtypesRoleDTO[];
rolesLoading?: boolean;
rolesError?: boolean;
@@ -34,6 +37,7 @@ function OverviewTab({
localRoles,
onRolesChange,
isDisabled,
canUpdate = true,
availableRoles,
rolesLoading,
rolesError,
@@ -63,11 +67,16 @@ function OverviewTab({
<label className="sa-drawer__label" htmlFor="sa-name">
Name
</label>
{isDisabled ? (
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{localName || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
{isDisabled || !canUpdate ? (
<AuthZTooltip
checks={[buildSAUpdatePermission(account.id)]}
enabled={!isDisabled && !canUpdate}
>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{localName || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</AuthZTooltip>
) : (
<Input
id="sa-name"
@@ -78,6 +87,16 @@ function OverviewTab({
)}
</div>
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-id">
ID
</label>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{account.id || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</div>
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-email">
Email Address

View File

@@ -1,6 +1,11 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
buildAPIKeyDeletePermission,
buildSADetachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
@@ -23,12 +28,16 @@ export interface RevokeKeyFooterProps {
isRevoking: boolean;
onCancel: () => void;
onConfirm: () => void;
accountId?: string;
keyId?: string;
}
export function RevokeKeyFooter({
isRevoking,
onCancel,
onConfirm,
accountId,
keyId,
}: RevokeKeyFooterProps): JSX.Element {
return (
<>
@@ -36,15 +45,23 @@ export function RevokeKeyFooter({
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
loading={isRevoking}
onClick={onConfirm}
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(keyId ?? ''),
buildSADetachPermission(accountId ?? ''),
]}
enabled={!!accountId && !!keyId}
>
<Trash2 size={12} />
Revoke Key
</Button>
<Button
variant="solid"
color="destructive"
loading={isRevoking}
onClick={onConfirm}
>
<Trash2 size={12} />
Revoke Key
</Button>
</AuthZTooltip>
</>
);
}
@@ -115,6 +132,8 @@ function RevokeKeyModal(): JSX.Element {
isRevoking={isRevoking}
onCancel={handleCancel}
onConfirm={handleConfirm}
accountId={accountId ?? undefined}
keyId={revokeKeyId || undefined}
/>
}
>

View File

@@ -16,6 +16,8 @@ import {
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { GuardAuthZ } from 'components/GuardAuthZ/GuardAuthZ';
import PermissionDeniedCallout from 'components/PermissionDeniedCallout/PermissionDeniedCallout';
import { useRoles } from 'components/RolesSelect';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import {
@@ -27,6 +29,15 @@ import {
RoleUpdateFailure,
useServiceAccountRoleManager,
} from 'hooks/serviceAccount/useServiceAccountRoleManager';
import {
APIKeyCreatePermission,
APIKeyListPermission,
buildSAAttachPermission,
buildSADeletePermission,
buildSAReadPermission,
buildSAUpdatePermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import {
parseAsBoolean,
parseAsInteger,
@@ -37,6 +48,7 @@ import {
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AddKeyModal from './AddKeyModal';
import DeleteAccountModal from './DeleteAccountModal';
import KeysTab from './KeysTab';
@@ -96,6 +108,22 @@ function ServiceAccountDrawer({
const queryClient = useQueryClient();
const { permissions: drawerPermissions, isLoading: isAuthZLoading } = useAuthZ(
selectedAccountId
? [
buildSAReadPermission(selectedAccountId),
buildSAUpdatePermission(selectedAccountId),
buildSADeletePermission(selectedAccountId),
APIKeyListPermission,
]
: [],
{ enabled: !!selectedAccountId },
);
const canRead =
drawerPermissions?.[buildSAReadPermission(selectedAccountId ?? '')]
?.isGranted ?? false;
const {
data: accountData,
isLoading: isAccountLoading,
@@ -104,7 +132,7 @@ function ServiceAccountDrawer({
refetch: refetchAccount,
} = useGetServiceAccount(
{ id: selectedAccountId ?? '' },
{ query: { enabled: !!selectedAccountId } },
{ query: { enabled: canRead && !!selectedAccountId } },
);
const account = useMemo(
@@ -117,7 +145,9 @@ function ServiceAccountDrawer({
currentRoles,
isLoading: isRolesLoading,
applyDiff,
} = useServiceAccountRoleManager(selectedAccountId ?? '');
} = useServiceAccountRoleManager(selectedAccountId ?? '', {
enabled: canRead && !!selectedAccountId,
});
const roleSessionRef = useRef<string | null>(null);
@@ -165,9 +195,16 @@ function ServiceAccountDrawer({
refetch: refetchRoles,
} = useRoles();
const canListKeys =
drawerPermissions?.[APIKeyListPermission]?.isGranted ?? false;
const canUpdate =
drawerPermissions?.[buildSAUpdatePermission(selectedAccountId ?? '')]
?.isGranted ?? true;
const { data: keysData, isLoading: keysLoading } = useListServiceAccountKeys(
{ id: selectedAccountId ?? '' },
{ query: { enabled: !!selectedAccountId } },
{ query: { enabled: !!selectedAccountId && canListKeys } },
);
const keys = keysData?.data ?? [];
@@ -392,18 +429,26 @@ function ServiceAccountDrawer({
</ToggleGroupItem>
</ToggleGroup>
{activeTab === ServiceAccountDrawerTab.Keys && (
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
<AuthZTooltip
checks={[
APIKeyCreatePermission,
buildSAAttachPermission(selectedAccountId ?? ''),
]}
enabled={!isDeleted && !!selectedAccountId}
>
<Plus size={12} />
Add Key
</Button>
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
>
<Plus size={12} />
Add Key
</Button>
</AuthZTooltip>
)}
</div>
@@ -412,7 +457,9 @@ function ServiceAccountDrawer({
activeTab === ServiceAccountDrawerTab.Keys ? ' sa-drawer__body--keys' : ''
}`}
>
{isAccountLoading && <Skeleton active paragraph={{ rows: 6 }} />}
{(isAuthZLoading || isAccountLoading) && (
<Skeleton active paragraph={{ rows: 6 }} />
)}
{isAccountError && (
<ErrorInPlace
error={toAPIError(
@@ -421,38 +468,55 @@ function ServiceAccountDrawer({
)}
/>
)}
{!isAccountLoading && !isAccountError && (
<>
{activeTab === ServiceAccountDrawerTab.Overview && account && (
<OverviewTab
account={account}
localName={localName}
onNameChange={handleNameChange}
localRoles={localRoles}
onRolesChange={(roles): void => {
setLocalRoles(roles);
clearRoleErrors();
}}
isDisabled={isDeleted}
availableRoles={availableRoles}
rolesLoading={rolesLoading}
rolesError={rolesError}
rolesErrorObj={rolesErrorObj}
onRefetchRoles={refetchRoles}
saveErrors={saveErrors}
/>
)}
{activeTab === ServiceAccountDrawerTab.Keys && (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
)}
</>
)}
{!isAuthZLoading &&
!isAccountLoading &&
!isAccountError &&
selectedAccountId && (
<GuardAuthZ
relation="read"
object={`serviceaccount:${selectedAccountId}`}
fallbackOnNoPermissions={(): JSX.Element => (
<PermissionDeniedCallout permissionName="serviceaccount:read" />
)}
>
<>
{activeTab === ServiceAccountDrawerTab.Overview && account && (
<OverviewTab
account={account}
localName={localName}
onNameChange={handleNameChange}
localRoles={localRoles}
onRolesChange={(roles): void => {
setLocalRoles(roles);
clearRoleErrors();
}}
isDisabled={isDeleted}
canUpdate={canUpdate}
availableRoles={availableRoles}
rolesLoading={rolesLoading}
rolesError={rolesError}
rolesErrorObj={rolesErrorObj}
onRefetchRoles={refetchRoles}
saveErrors={saveErrors}
/>
)}
{activeTab === ServiceAccountDrawerTab.Keys &&
(canListKeys ? (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
canUpdate={canUpdate}
accountId={selectedAccountId}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
) : (
<PermissionDeniedCallout permissionName="factor-api-key:list" />
))}
</>
</GuardAuthZ>
)}
</div>
</div>
);
@@ -482,16 +546,21 @@ function ServiceAccountDrawer({
) : (
<>
{!isDeleted && (
<Button
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
<AuthZTooltip
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
enabled={!!selectedAccountId}
>
<Trash2 size={12} />
Delete Service Account
</Button>
<Button
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
>
<Trash2 size={12} />
Delete Service Account
</Button>
</AuthZTooltip>
)}
{!isDeleted && (
<div className="sa-drawer__footer-right">

View File

@@ -6,6 +6,15 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import EditKeyModal from '../EditKeyModal';
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,
}: {
children: React.ReactElement;
}): React.ReactElement => children,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
@@ -19,7 +28,7 @@ const mockKey: ServiceaccounttypesGettableFactorAPIKeyDTO = {
id: 'key-1',
name: 'Original Key Name',
expiresAt: 0,
lastObservedAt: null as any,
lastObservedAt: null as unknown as string,
serviceAccountId: 'sa-1',
};

View File

@@ -6,6 +6,15 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import KeysTab from '../KeysTab';
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,
}: {
children: React.ReactElement;
}): React.ReactElement => children,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
@@ -20,14 +29,14 @@ const keys: ServiceaccounttypesGettableFactorAPIKeyDTO[] = [
id: 'key-1',
name: 'Production Key',
expiresAt: 0,
lastObservedAt: null as any,
lastObservedAt: null as unknown as string,
serviceAccountId: 'sa-1',
},
{
id: 'key-2',
name: 'Staging Key',
expiresAt: 1924905600, // 2030-12-31
lastObservedAt: new Date('2026-03-10T10:00:00Z'),
lastObservedAt: '2026-03-10T10:00:00Z',
serviceAccountId: 'sa-1',
},
];

View File

@@ -0,0 +1,158 @@
import type { ReactNode } from 'react';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import {
setupAuthzAdmin,
setupAuthzDeny,
setupAuthzDenyAll,
} from 'tests/authz-test-utils';
import {
APIKeyListPermission,
buildSADeletePermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import ServiceAccountDrawer from '../ServiceAccountDrawer';
const ROLES_ENDPOINT = '*/api/v1/roles';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_DELETE_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
const activeAccountResponse = {
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
};
jest.mock('@signozhq/ui/drawer', () => ({
...jest.requireActual('@signozhq/ui/drawer'),
DrawerWrapper: ({
children,
footer,
open,
}: {
children?: ReactNode;
footer?: ReactNode;
open: boolean;
}): JSX.Element | null =>
open ? (
<div>
{children}
{footer}
</div>
) : null,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
function renderDrawer(
searchParams: Record<string, string> = { account: 'sa-1' },
): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<ServiceAccountDrawer onSuccess={jest.fn()} />
</NuqsTestingAdapter>,
);
}
function setupBaseHandlers(): void {
server.use(
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: activeAccountResponse })),
),
rest.put(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: listRolesSuccessResponse.data.filter(
(r) => r.name === 'signoz-admin',
),
}),
),
),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
);
}
describe('ServiceAccountDrawer — permissions', () => {
beforeEach(() => {
jest.clearAllMocks();
setupBaseHandlers();
});
afterEach(() => {
server.resetHandlers();
});
it('shows PermissionDeniedCallout inside drawer when read permission is denied', async () => {
server.use(setupAuthzDenyAll());
renderDrawer();
await waitFor(() => {
expect(screen.getByText(/serviceaccount:read/)).toBeInTheDocument();
});
});
it('shows drawer content when read permission is granted', async () => {
server.use(setupAuthzAdmin());
renderDrawer();
await screen.findByDisplayValue('CI Bot');
expect(screen.queryByText(/serviceaccount:read/)).not.toBeInTheDocument();
});
it('shows PermissionDeniedCallout in Keys tab when list-keys permission is denied', async () => {
server.use(setupAuthzDeny(APIKeyListPermission));
renderDrawer();
await screen.findByDisplayValue('CI Bot');
fireEvent.click(screen.getByRole('radio', { name: /keys/i }));
await waitFor(() => {
expect(screen.getByText(/factor-api-key:list/)).toBeInTheDocument();
});
});
it('disables Delete button when delete permission is denied', async () => {
server.use(setupAuthzDeny(buildSADeletePermission('sa-1')));
renderDrawer();
await screen.findByDisplayValue('CI Bot');
const deleteBtn = screen.getByRole('button', {
name: /Delete Service Account/i,
});
await waitFor(() => expect(deleteBtn).toBeDisabled());
});
});

View File

@@ -3,6 +3,7 @@ import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { setupAuthzAdmin } from 'tests/authz-test-utils';
import ServiceAccountDrawer from '../ServiceAccountDrawer';
@@ -98,6 +99,7 @@ describe('ServiceAccountDrawer', () => {
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
setupAuthzAdmin(),
);
});
@@ -300,13 +302,6 @@ describe('ServiceAccountDrawer', () => {
await screen.findByText(/No keys/i);
});
it('shows skeleton while loading account data', () => {
renderDrawer();
// Skeleton renders while the fetch is in-flight
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('shows error state when account fetch fails', async () => {
server.use(
rest.get(SA_ENDPOINT, (_, res, ctx) =>
@@ -359,6 +354,7 @@ describe('ServiceAccountDrawer save-error UX', () => {
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
setupAuthzAdmin(),
);
});

View File

@@ -39,6 +39,7 @@ import {
} from './TanStackTableStateContext';
import {
FlatItem,
SortState,
TableRowContext,
TanStackTableHandle,
TanStackTableProps,
@@ -88,6 +89,7 @@ function TanStackTableInner<TData>(
enableQueryParams,
pagination,
paginationClassname,
onSort,
onEndReached,
getRowKey,
getItemKey,
@@ -130,7 +132,7 @@ function TanStackTableInner<TData>(
setPage,
setLimit,
orderBy,
setOrderBy,
setOrderBy: internalSetOrderBy,
expanded,
setExpanded,
} = useTableParams(enableQueryParams, {
@@ -138,6 +140,14 @@ function TanStackTableInner<TData>(
limit: pagination?.defaultLimit,
});
const setOrderBy = useCallback(
(sort: SortState | null) => {
internalSetOrderBy(sort);
onSort?.(sort);
},
[internalSetOrderBy, onSort],
);
const isGrouped = (groupBy?.length ?? 0) > 0;
const {
@@ -605,16 +615,22 @@ function TanStackTableInner<TData>(
total={effectiveTotalCount}
onPageChange={(p): void => {
setPage(p);
pagination.onPageChange?.(p);
}}
/>
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => setLimit(+value)}
items={paginationPageSizeItems}
/>
</div>
{pagination.showPageSize !== false && (
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => {
setLimit(+value);
pagination.onLimitChange?.(+value);
}}
items={paginationPageSizeItems}
/>
</div>
)}
{suffixPaginationContent}
</div>
)}

View File

@@ -23,6 +23,13 @@ jest.mock('../TanStackTable.module.scss', () => ({
},
}));
// Mock ResizeObserver for combobox tests
global.ResizeObserver = class ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
};
describe('TanStackTableView Integration', () => {
describe('rendering', () => {
it('renders all data rows', async () => {
@@ -269,6 +276,131 @@ describe('TanStackTableView Integration', () => {
screen.queryByTestId('pagination-total-count'),
).not.toBeInTheDocument();
});
it('shows page size selector by default (showPageSize undefined)', async () => {
renderTanStackTable({
props: {
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
},
});
await waitFor(() => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
// Page size combobox trigger should be visible by default (button with aria-haspopup)
const comboboxTrigger = document.querySelector(
'button[aria-haspopup="dialog"]',
);
expect(comboboxTrigger).toBeInTheDocument();
});
it('shows page size selector when showPageSize is true', async () => {
renderTanStackTable({
props: {
pagination: {
total: 100,
defaultPage: 1,
defaultLimit: 10,
showPageSize: true,
},
},
});
await waitFor(() => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
const comboboxTrigger = document.querySelector(
'button[aria-haspopup="dialog"]',
);
expect(comboboxTrigger).toBeInTheDocument();
});
it('hides page size selector when showPageSize is false', async () => {
renderTanStackTable({
props: {
pagination: {
total: 100,
defaultPage: 1,
defaultLimit: 10,
showPageSize: false,
},
},
});
await waitFor(() => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
const comboboxTrigger = document.querySelector(
'button[aria-haspopup="dialog"]',
);
expect(comboboxTrigger).not.toBeInTheDocument();
});
it('calls onPageChange callback when page changes', async () => {
const user = userEvent.setup();
const onPageChange = jest.fn();
renderTanStackTable({
props: {
pagination: { total: 100, defaultPage: 1, defaultLimit: 10, onPageChange },
},
});
await waitFor(() => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
const nav = screen.getByRole('navigation');
const page2 = Array.from(nav.querySelectorAll('button')).find(
(btn) => btn.textContent?.trim() === '2',
);
if (!page2) {
throw new Error('Page 2 button not found in pagination');
}
await user.click(page2);
await waitFor(() => {
expect(onPageChange).toHaveBeenCalledWith(2);
});
});
it('calls onLimitChange callback when limit changes', async () => {
const user = userEvent.setup();
const onLimitChange = jest.fn();
renderTanStackTable({
props: {
pagination: {
total: 100,
defaultPage: 1,
defaultLimit: 10,
onLimitChange,
},
},
});
await waitFor(() => {
expect(
document.querySelector('button[aria-haspopup="dialog"]'),
).toBeInTheDocument();
});
const comboboxTrigger = document.querySelector(
'button[aria-haspopup="dialog"]',
) as HTMLElement;
await user.click(comboboxTrigger);
// Select a different page size option
const option20 = await screen.findByRole('option', { name: '20' });
await user.click(option20);
await waitFor(() => {
expect(onLimitChange).toHaveBeenCalledWith(20);
});
});
});
describe('sorting', () => {
@@ -332,6 +464,55 @@ describe('TanStackTableView Integration', () => {
}
});
});
it('calls onSort callback when sorting', async () => {
const user = userEvent.setup();
const onSort = jest.fn();
renderTanStackTable({
props: { onSort },
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
const sortButton = screen.getByTitle('ID');
await user.click(sortButton);
await waitFor(() => {
expect(onSort).toHaveBeenCalledWith(
expect.objectContaining({ columnName: 'id', order: 'asc' }),
);
});
});
it('calls onSort with null when sort is cleared', async () => {
const user = userEvent.setup();
const onSort = jest.fn();
renderTanStackTable({
props: { onSort },
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
const sortButton = screen.getByTitle('ID');
// First click - asc
await user.click(sortButton);
// Second click - desc
await user.click(sortButton);
// Third click - clear
await user.click(sortButton);
await waitFor(() => {
const lastCall = onSort.mock.calls[onSort.mock.calls.length - 1];
expect(lastCall[0]).toBeNull();
});
});
});
describe('row selection', () => {

View File

@@ -115,6 +115,12 @@ export type PaginationProps = {
total: number;
defaultPage?: number;
defaultLimit?: number;
/**
* @default true
*/
showPageSize?: boolean;
onPageChange?: (page: number) => void;
onLimitChange?: (limit: number) => void;
showTotalCount?: boolean;
totalCountLabel?: string;
};
@@ -142,6 +148,8 @@ export type TanStackTableProps<TData> = {
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig;
pagination?: PaginationProps;
paginationClassname?: string;
/** Callback when sort changes. */
onSort?: (sort: SortState | null) => void;
onEndReached?: (index: number) => void;
/** Function to get the unique key for a row (before duplicate handling).
* When set, enables automatic duplicate key detection and group-aware key composition. */

View File

@@ -1,33 +1,16 @@
import { ReactElement } from 'react';
import type { RouteComponentProps } from 'react-router-dom';
import {
import type {
AuthtypesGettableTransactionDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
import { createGuardedRoute } from './createGuardedRoute';
const BASE_URL = ENVIRONMENT.baseURL || '';
const AUTHZ_CHECK_URL = `${BASE_URL}/api/v1/authz/check`;
function authzMockResponse(
payload: AuthtypesTransactionDTO[],
authorizedByIndex: boolean[],
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
return {
data: payload.map((txn, i) => ({
relation: txn.relation,
object: txn.object,
authorized: authorizedByIndex[i] ?? false,
})),
status: 'success',
};
}
describe('createGuardedRoute', () => {
const TestComponent = ({ testProp }: { testProp: string }): ReactElement => (
<div>Test Component: {testProp}</div>

View File

@@ -34,7 +34,7 @@ function OnNoPermissionsFallback(response: {
<br />
Object: <span>{object}</span>
<br />
Ask your SigNoz administrator to grant access.
Please ask your SigNoz administrator to grant access.
</p>
</div>
</div>

View File

@@ -10,4 +10,5 @@ export enum FeatureKeys {
ONBOARDING_V3 = 'onboarding_v3',
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
}

View File

@@ -431,6 +431,31 @@ export const OPERATORS = {
NOTILIKE: 'NOT_ILIKE',
};
/**
* Maps short-form InfraMonitoring operators to long-form display labels.
* InfraMonitoring backend uses short forms (NIN), UI displays long forms (NOT_IN).
*/
export const INFRA_SHORT_TO_LONG_OPERATOR_MAP: Record<string, string> = {
NIN: 'NOT_IN',
NLIKE: 'NOT_LIKE',
NOTILIKE: 'NOT_ILIKE',
NREGEX: 'NOT_REGEX',
NEXISTS: 'NOT_EXISTS',
NCONTAINS: 'NOT_CONTAINS',
};
/**
* Maps long-form operators to short-form for InfraMonitoring API.
*/
export const INFRA_LONG_TO_SHORT_OPERATOR_MAP: Record<string, string> = {
NOT_IN: 'NIN',
NOT_LIKE: 'NLIKE',
NOT_ILIKE: 'NOTILIKE',
NOT_REGEX: 'NREGEX',
NOT_EXISTS: 'NEXISTS',
NOT_CONTAINS: 'NCONTAINS',
};
export const QUERY_BUILDER_OPERATORS_BY_TYPES = {
string: [
OPERATORS['='],

View File

@@ -108,4 +108,7 @@ export const REACT_QUERY_KEY = {
// Dashboard Grid Card Query Keys
DASHBOARD_GRID_CARD_QUERY_RANGE: 'DASHBOARD_GRID_CARD_QUERY_RANGE',
// Fields Selector Query Keys
GET_FIELDS_SELECTOR_SUGGESTIONS: 'GET_FIELDS_SELECTOR_SUGGESTIONS',
} as const;

View File

@@ -1,13 +1,20 @@
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useHistory } from 'react-router-dom';
import { useHistory, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Minus, Plus, Sparkles, X } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import HistorySidebar from '../components/ConversationsList';
import ConversationView from '../ConversationView';
import { AIAssistantEvents } from '../events';
import {
normalizePage,
useAIAssistantAnalyticsContext,
} from '../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { VariantContext } from '../VariantContext';
@@ -24,6 +31,7 @@ import styles from './AIAssistantModal.module.scss';
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function AIAssistantModal(): JSX.Element | null {
const history = useHistory();
const { pathname } = useLocation();
const [showHistory, setShowHistory] = useState(false);
const isOpen = useAIAssistantStore((s) => s.isModalOpen);
@@ -36,6 +44,7 @@ export default function AIAssistantModal(): JSX.Element | null {
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const analyticsCtx = useAIAssistantAnalyticsContext();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
@@ -55,6 +64,10 @@ export default function AIAssistantModal(): JSX.Element | null {
} else {
startNewConversation();
setShowHistory(false);
void logEvent(AIAssistantEvents.Opened, {
source: 'shortcut',
currentPage: normalizePage(pathname),
});
openModal();
}
return;
@@ -68,7 +81,7 @@ export default function AIAssistantModal(): JSX.Element | null {
window.addEventListener('keydown', handleKeyDown);
return (): void => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, openModal, closeModal, startNewConversation]);
}, [isOpen, openModal, closeModal, startNewConversation, pathname]);
// ── Handlers ────────────────────────────────────────────────────────────────
@@ -77,15 +90,28 @@ export default function AIAssistantModal(): JSX.Element | null {
return;
}
closeModal();
// Router state tells AIAssistantPage to skip its mount-time Opened fire:
// the assistant was already open in the modal, so this is a surface
// switch, not a new open.
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
{ fromInApp: true },
);
}, [activeConversationId, closeModal, history]);
const handleNew = useCallback(() => {
void logEvent(AIAssistantEvents.NewChatClicked, {
...analyticsCtx,
// useAIAssistantAnalyticsContext() runs above this component's
// VariantContext.Provider, so the hook reports the default 'page'
// mode. Override here: the modal collapses to 'sidepane' in our
// taxonomy alongside the drawer.
mode: 'sidepane',
source: 'header',
});
startNewConversation();
setShowHistory(false);
}, [startNewConversation]);
}, [startNewConversation, analyticsCtx]);
const handleHistorySelect = useCallback(() => {
setShowHistory(false);

View File

@@ -5,8 +5,12 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Plus, Sparkles, X } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import ConversationsList from '../components/ConversationsList';
import ConversationView from '../ConversationView';
import { AIAssistantEvents } from '../events';
import { useAIAssistantAnalyticsContext } from '../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { VariantContext } from '../VariantContext';
@@ -32,21 +36,35 @@ export default function AIAssistantPanel(): JSX.Element | null {
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const analyticsCtx = useAIAssistantAnalyticsContext();
const handleExpand = useCallback(() => {
if (!activeConversationId) {
return;
}
closeDrawer();
// Router state tells AIAssistantPage to skip its mount-time Opened fire:
// the assistant was already open in the drawer, so this is a surface
// switch, not a new open.
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
{ fromInApp: true },
);
}, [activeConversationId, closeDrawer, history]);
const handleNew = useCallback(() => {
void logEvent(AIAssistantEvents.NewChatClicked, {
...analyticsCtx,
// useAIAssistantAnalyticsContext() runs above this component's
// VariantContext.Provider, so the hook reports the default 'page'
// mode. Override here: this handler only runs when the drawer
// itself is mounted, which is unambiguously the sidepane surface.
mode: 'sidepane',
source: 'header',
});
startNewConversation();
setShowHistory(false);
}, [startNewConversation]);
}, [startNewConversation, analyticsCtx]);
// When user picks a conversation from the list, close the sidebar
const handleHistorySelect = useCallback(() => {

View File

@@ -1,9 +1,13 @@
import { useCallback } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import { Bot } from '@signozhq/icons';
import { AIAssistantEvents } from '../events';
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
import {
openAIAssistant,
useAIAssistantStore,
@@ -25,6 +29,14 @@ export default function AIAssistantTrigger(): JSX.Element | null {
exact: true,
});
const handleOpen = useCallback((): void => {
void logEvent(AIAssistantEvents.Opened, {
source: 'icon',
currentPage: normalizePage(pathname),
});
openAIAssistant();
}, [pathname]);
if (isDrawerOpen || isModalOpen || isFullScreenPage) {
return null;
}
@@ -35,7 +47,7 @@ export default function AIAssistantTrigger(): JSX.Element | null {
variant="solid"
color="primary"
className={styles.trigger}
onClick={openAIAssistant}
onClick={handleOpen}
aria-label="Open AI Assistant"
>
<Bot size={20} />

View File

@@ -1,11 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import cx from 'classnames';
import logEvent from 'api/common/logEvent';
import ChatInput, { autoContextKey } from '../components/ChatInput';
import ConversationSkeleton from '../components/ConversationSkeleton';
import VirtualizedMessages from '../components/VirtualizedMessages';
import { AIAssistantEvents } from '../events';
import { getAutoContexts } from '../getAutoContexts';
import { useAIAssistantAnalyticsContext } from '../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { MessageAttachment } from '../types';
import { MessageContext } from '../../../api/ai-assistant/chat';
@@ -39,6 +43,7 @@ export default function ConversationView({
);
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const cancelStream = useAIAssistantStore((s) => s.cancelStream);
const analyticsCtx = useAIAssistantAnalyticsContext(conversationId);
// Auto-derived contexts come from the route the user is currently looking
// at (dashboard detail, service metrics, an explorer, …). Skip when the
@@ -82,14 +87,50 @@ export default function ConversationView({
attachments?: MessageAttachment[],
contexts?: MessageContext[],
) => {
const hasAuto = contexts?.some((c) => c.source === 'auto') ?? false;
const hasManual = contexts?.some((c) => c.source === 'mention') ?? false;
let contextType: 'manual' | 'auto' | 'both' | undefined;
if (hasAuto && hasManual) {
contextType = 'both';
} else if (hasAuto) {
contextType = 'auto';
} else if (hasManual) {
contextType = 'manual';
}
void logEvent(AIAssistantEvents.MessageSent, {
...analyticsCtx,
queryLength: text.length,
hasContext: hasAuto || hasManual,
contextType,
respondingToClarification: Boolean(pendingClarificationHere),
});
void sendMessage(text, attachments, contexts);
},
[sendMessage],
[sendMessage, analyticsCtx, pendingClarificationHere],
);
// Wall-clock timestamp of the current streaming start, used to compute
// `secondsSinceStart` on Cancel clicked. Cleared whenever streaming ends.
const streamStartedAtRef = useRef<number | null>(null);
useEffect(() => {
if (!isStreamingHere) {
streamStartedAtRef.current = null;
return;
}
if (streamStartedAtRef.current === null) {
streamStartedAtRef.current = Date.now();
}
}, [isStreamingHere]);
const handleCancel = useCallback(() => {
const startedAt = streamStartedAtRef.current;
void logEvent(AIAssistantEvents.CancelClicked, {
threadId: analyticsCtx.threadId,
secondsSinceStart:
startedAt !== null ? Math.round((Date.now() - startedAt) / 1000) : null,
});
cancelStream(conversationId);
}, [cancelStream, conversationId]);
}, [cancelStream, conversationId, analyticsCtx.threadId]);
const messages = conversation?.messages ?? [];
const showDisclaimer = messages.length > 0;
@@ -134,6 +175,7 @@ export default function ConversationView({
conversationId={conversationId}
messages={messages}
isStreaming={isStreamingHere}
onSendSuggestedPrompt={(text): void => handleSend(text)}
/>
{showDisclaimer && (
<div className={disclaimerClass} role="note" aria-live="polite">

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