Compare commits

...

47 Commits

Author SHA1 Message Date
grandwizard28
32ec41e080 style: formatting fix in tables file 2026-04-04 19:54:34 +05:30
grandwizard28
db535b8df7 style(audit): remove module docstring from integration test 2026-04-04 19:16:02 +05:30
grandwizard28
0c633ae829 fix(audit): remove silent empty-string guards in metadata store
Remove guards that silently returned nil/empty when audit DB params
were empty. All call sites now pass real constants, so misconfiguration
should fail loudly rather than produce silent empty results.
2026-04-04 19:14:16 +05:30
grandwizard28
47d6d234af refactor(audit): inline all data setup, use distinct scenarios per test
Remove _insert_standard_audit_events helper. Each test now owns its
data: list_all uses alert-rule/saved-view/user resource types,
scalar_count uses multiple failures from different principals (count=2),
leak test uses a single organization event. Parametrized filter tests
keep the original 5-event dataset.
2026-04-04 19:02:52 +05:30
grandwizard28
80e03eb053 refactor(audit): inline DEFAULT_ORDER using build_order_by
Use build_order_by from querier fixtures instead of OrderBy/
TelemetryFieldKey dataclasses. Allow BuilderQuery.order to accept
plain dicts alongside OrderBy objects.
2026-04-04 18:59:43 +05:30
grandwizard28
b24334ecce style(audit): use snake_case for pytest parametrize IDs 2026-04-04 18:51:34 +05:30
grandwizard28
dfbba7cada refactor(audit): inline _time_window into test functions 2026-04-04 18:49:18 +05:30
grandwizard28
04a80d52d7 refactor(audit): remove wrapper helpers, inline make_query_request calls
Remove _query_audit_raw and _query_audit_scalar helpers. Use
make_query_request, BuilderQuery, and build_scalar_query directly.
Compute time window at test execution time via _time_window() to
avoid stale module-level timestamps.
2026-04-04 18:47:16 +05:30
grandwizard28
2972bb2cda refactor(audit): reuse querier fixtures in integration tests
Add source param to BuilderQuery and build_scalar_query in the
querier fixture. Replace custom _build_audit_query and
_build_audit_ts_query helpers with BuilderQuery and
build_scalar_query from the shared fixtures.
2026-04-04 18:43:25 +05:30
grandwizard28
837fdf2873 fix(audit): add source to resource filter for correct metadata routing
Add source param to telemetryresourcefilter.New so the resource
filter's key selectors include Source when calling GetKeysMulti.
Without this, audit resource keys route to signoz_logs metadata
tables instead of signoz_audit. Fix scalar test to use table
response format (columns+data, not rows).
2026-04-04 17:45:41 +05:30
grandwizard28
34adaab599 refactor(audit): use pytest.mark.parametrize for filter integration tests
Consolidate filter test functions into a single parametrized test.
6/8 tests passing; resource kind+ID filter and scalar count need
further investigation (resource filter JSON key extraction with
dotted keys, scalar response format).
2026-04-04 17:28:29 +05:30
grandwizard28
fbf40d8f70 fix(audit): rename resource.name to resource.kind, move to resource attributes
Align with schema change from SigNoz/signoz#10826:
- signoz.audit.resource.name renamed to signoz.audit.resource.kind
- resource.kind and resource.id moved from event attributes to OTel
  Resource attributes (resource JSON column)
- Materialized columns reduced from 7 to 5 (resource.kind and
  resource.id no longer materialized)
2026-04-04 17:18:00 +05:30
grandwizard28
ee07cd4fa7 feat(audit): internalize resource filter in audit statement builder
Build the resource filter internally pointing at
signoz_audit.distributed_logs_resource. Add LogsResourceTableName
constant. Remove resourceFilterStmtBuilder from constructor params.
Update test expectations to use the audit resource table.
2026-04-04 16:26:47 +05:30
grandwizard28
6b42a249cc fix(audit): remove debug assertion from integration test 2026-04-04 16:26:47 +05:30
grandwizard28
a2d09e2cf3 fix(audit): revert sb.As in getAuditKeys, fix fixture column_names
Revert getAuditKeys to use raw SQL strings instead of sb.As() which
incorrectly treated string literals as column references. Add explicit
column_names to all ClickHouse insert calls in the audit fixture.
2026-04-04 16:26:47 +05:30
grandwizard28
c7e1b37f4b test(audit): add integration tests for audit log querying
Cover the documented query patterns: list all events, filter by
principal ID, filter by outcome, filter by resource name+ID,
filter by principal type, scalar count for alerting, and
isolation test ensuring audit data doesn't leak into regular logs.
2026-04-04 16:26:46 +05:30
grandwizard28
5bdbc23180 feat(audit): wire audit statement builder into querier
Add auditStmtBuilder to querier struct and route LogAggregation
queries with source=audit to it in all three dispatch locations
(main query, live tail, shiftedQuery). Create and wire the full
audit query stack in signozquerier provider.
2026-04-04 16:26:46 +05:30
grandwizard28
db9ad4b0a6 fix(audit): rename tag_attributes_v2 to tag_attributes
Migration uses tag_attributes/distributed_tag_attributes (no _v2
suffix). Rename constants and update all references including the
integration test fixture.
2026-04-04 16:26:46 +05:30
grandwizard28
a3eb7013b3 feat(audit): add integration test fixture for audit logs
AuditLog fixture inserts into all 5 signoz_audit tables matching
the schema migration DDL: distributed_logs (no resources_string,
has event_name), distributed_logs_resource, distributed_tag_attributes_v2,
distributed_logs_attribute_keys, distributed_logs_resource_keys.
2026-04-04 16:26:46 +05:30
grandwizard28
409576c12a fix(audit): align table names with schema migration
Migration uses logs/distributed_logs (not logs_v2/distributed_logs_v2).
Rename LogsV2TableName to LogsTableName and LogsV2LocalTableName to
LogsLocalTableName to match the actual signoz_audit DDL.
2026-04-04 16:26:46 +05:30
grandwizard28
3d9a8bae04 style(audit): move column map to const.go, use sqlbuilder.As in metadata
Move logsV2Columns from field_mapper.go to const.go to colocate all
column definitions. Switch getAuditKeys() to use sb.As() instead of
raw string formatting. Fix FieldContext alignment.
2026-04-04 16:26:46 +05:30
grandwizard28
1bc0ad5a88 refactor(audit): inline field key map into test file
Remove test_data.go and inline the audit field key map directly
into statement_builder_test.go with a compact helper function.
2026-04-04 16:26:34 +05:30
grandwizard28
74ae9baf81 test(audit): add statement builder tests
Cover all three request types (list, time series, scalar) with
audit-specific query patterns: materialized column filters, AND/OR
conditions, limit CTEs, and group-by expressions.
2026-04-04 16:26:33 +05:30
grandwizard28
2c493a5bcc feat(audit): add audit field value autocomplete support
Wire distributed_tag_attributes_v2 for signoz_audit into the
metadata store. Add getAuditFieldValues() and route SignalLogs +
SourceAudit to it in GetFieldValues().
2026-04-04 16:26:33 +05:30
grandwizard28
6514f8f852 fix(audit): align field mapper with actual audit DDL schema
Remove resources_string (not in audit table DDL).
Add event_name as intrinsic column.
Resource context resolves only through the resource JSON column.
2026-04-04 16:26:33 +05:30
grandwizard28
8467b6c268 chore: address review comments
Comment out SourceAudit from Enum() until frontend is ready.
Use actual audit table constants in metadata test helpers.
2026-04-04 16:26:33 +05:30
grandwizard28
7c686ecc90 feat(audit): add telemetry audit query infrastructure
Add pkg/telemetryaudit/ with tables, field mapper, condition builder,
and statement builder for querying audit logs from signoz_audit database.
Add SourceAudit to source enum and integrate audit key resolution
into the metadata store.
2026-04-04 16:26:33 +05:30
Srikanth Chekuri
b5b89bb678 fix: several panic errors (#10817)
* fix: several panic errors

* fix: add recover for dashboard sql
2026-04-04 10:38:42 +00:00
Pandey
b5eab118dd refactor: move and internalize resource filter statement builder (#10824)
* refactor: move resourcefilter to pkg/telemetryresourcefilter

Move pkg/querybuilder/resourcefilter to pkg/telemetryresourcefilter
to align with the existing telemetry package naming convention
(telemetrylogs, telemetrytraces, telemetrymetrics, telemetrymeter).
The resource filter is a statement builder, not a query builder utility.

* refactor: internalize resource filter construction in statement builders

Each telemetry statement builder (logs, traces) now creates its own
resource filter internally instead of receiving it as an injected
dependency. This makes it impossible to wire the wrong resource table
and simplifies the provider.

Delete telemetryresourcefilter/tables.go — each telemetry package now
owns its resource table constant (LogsResourceV2TableName in
telemetrylogs, TracesResourceV3TableName in telemetrytraces).

* refactor: create field mapper and condition builder inside resource filter New

Remove fieldMapper and conditionBuilder params from
telemetryresourcefilter.New — they are always the same
(NewFieldMapper + NewConditionBuilder) so create them internally.
2026-04-04 10:27:46 +00:00
Vishal Sharma
98d0bdfe49 chore(frontend): sync pylon chat widget theme with app theme (#10830)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-04-04 09:29:08 +00:00
Vikrant Gupta
a71006b662 chore(user): add partial index for email,org_id (#10828)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore(user): add partial index for email,org_id

* chore(user): fix integration test fmt

* chore(user): fix integration test lint
2026-04-03 20:27:04 +00:00
Vishal Sharma
769e36ec84 feat(frontend): add new onboarding datasource entries (#10829) 2026-04-03 20:13:08 +00:00
SagarRajput-7
363734054f feat: updated user api to v2 and accordingly update members page and role management (#10799)
* feat: updated user api to v2 and accordingly update members page and role management

* feat: updated members page to use new role management and v2 user api

* feat: updated test cases

* feat: code refactor

* feat: refactored code and addressed feedbacks

* feat: refactored code and addressed feedbacks

* feat: refactored code and addressed feedbacks

* fix(user): fix openapi spec

* feat: handle isRoot user and self user cases and added test cases

---------

Co-authored-by: vikrantgupta25 <vikrant@signoz.io>
2026-04-03 18:41:27 +00:00
Pandey
726948db9e feat(audittypes): align types with revised schema doc (#10826)
* feat(audittypes): align types with revised schema doc

Rename resource.name → resource.kind to match Typeable.Kind() rename.
Move resource attributes (kind, id) from event attributes to OTel
Resource, grouping events by target resource in NewPLogsFromAuditEvents.
Add network.protocol.name, network.protocol.version, url.scheme to
transport attributes for complete OTel semconv coverage.

* refactor(audittypes): inline resourceKey struct into function scope

* test(audittypes): add tests for NewPLogsFromAuditEvents

Cover resource grouping: empty input, single event, same resource
batched into one ResourceLogs, different resources split, same kind
with different IDs split, and interleaved events grouped correctly.
Verify resource attrs live on Resource (not event attributes).
2026-04-03 18:18:08 +00:00
Vikrant Gupta
13249b5e69 feat(sqlstore): enable wal mode by default (#10822)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore(sqlstore): run integration tests on wal and immediate

* chore(sqlstore): remove the txlock changes

* chore(sqlstore): make wal the default mode
2026-04-03 15:48:37 +00:00
Vikrant Gupta
fec24c2cc3 fix(authz): better retry loop for roles (#10821) 2026-04-03 10:07:38 +00:00
Ishan
0d3226f363 feat: table migration (#10625)
* feat: table migration

* feat: ux and smoothness improved

* feat: testcases and ux improve

* feat: ux improve

* feat: fixed settings

* feat: fixed scroll issue on drag, ux issue on cols and made scrollbar same

* feat: row merging header fix

* feat: remove column and ux fixes

* feat: scss prettier fix

* feat: css and cols persistant issue

* feat: cols persistant issue

* feat: pr ready, styling folder and unwanted code removal

* Feat/table enhancement (#10773)

* feat: add width for remaining space for col

* feat: style fixes

* feat: do not move state indicator in table

* feat: minor changes

* Feat/table enhancement (#10795)

* feat: add width for remaining space for col

* feat: style fixes

* feat: do not move state indicator in table

* feat: minor changes

* feat: memoise custom table row to reduce re-renders

* feat: remove typography and add hover state to show actions

* feat: add typography styles to p tag

* feat: action btn style fix

* feat: minor change

---------

Co-authored-by: Aditya Singh <adityasinghssj1@gmail.com>
Co-authored-by: Aditya Singh <aditya@signoz.io>
2026-04-03 10:07:35 +00:00
Vinicius Lourenço
419bd60a41 feat(global-time-adapter): start migration away from redux (#10780)
Some checks failed
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
* feat(global-time-adapter): start migration away from redux

* test(constant): add missing constants

* refactor(hooks): move to hooks folder
2026-04-02 11:17:49 +00:00
SagarRajput-7
b6b689902d feat: added doc links for service account and misc changes (#10804)
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: added doc links for service account and misc changes

* feat: remove announcement and presisted announcement banner, since we app this in periscope library

* feat: updated banner text
2026-04-02 08:22:31 +00:00
Naman Verma
65402ca367 fix: warning instead of error for dormant metrics in query range API (#10737)
* fix: warning instead of error for dormant metrics in query range API

* fix: add missing else

* fix: keep track of present aggregations

* fix: note present aggregation after type is set

* test: integration test fix and new test

* chore: lint errors

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-04-02 07:25:29 +00:00
Tushar Vats
f71d5bf8f1 fix: added validations for having expression (#10286)
* fix: added validations for having expression

* fix: added extra validation and unit tests

* fix: added antlr based parsing for validation

* fix: added more unit tests

* fix: removed validation on having in range request validations

* fix: generated lexer files and added more unit tests

* fix: edge cases

* fix: added cmnd to scripts for generating lexer

* fix: use std libg sorting instead of selection sort

* fix: support implicit and

* fix: allow bare not in expression

* fix: added suggestion for having expression

* fix: typo

* fix: added more unit tests, handle white space difference in aggregation exp and having exp

* fix: added support for in and not, updated errors

* fix: added support for brackets list

* fix: lint error

* fix: handle non spaced expression

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-04-02 03:52:11 +00:00
Srikanth Chekuri
5abfd0732a chore: remove deprecated v3/v4 support in rules (#10760)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: remove deprecated v3/v4 support in rules

* chore: fix test

* chore: fix logs

* chore: fix logging

* chore: fix ci

* chore: address review comments
2026-04-01 19:48:37 +00:00
swapnil-signoz
4f273b296e refactor: removing dashboard overview images (#10801)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-04-01 16:25:55 +00:00
swapnil-signoz
3dc0a7c8ce feat: adding aws service definitions in types (#10798)
* feat: adding aws service definitions in types

* refactor: moving definitions to module fs
2026-04-01 14:08:46 +00:00
Liapis Nikolaos
1080553905 feat(logs): pretty-print JSON attribute values when copying to clipboard (#10778)
When copying log attribute values that contain valid JSON objects or
arrays, the value is now pretty-printed with 2-space indentation.
This makes it easy to paste into JSON tools or editors.

Non-JSON values (strings, numbers, booleans) are unaffected.

Closes #8208
2026-04-01 13:01:09 +00:00
Nikhil Soni
23e3c75d24 feat: return all spans for flamegraph under a limit (#10757)
* feat: return all spans for flamegraph under a limit

* feat: increase fg limits and add timestamp boundaries

* fix: set default value for ts boundary

* fix: use correct value for boundary end ts

* chore: change info log of flamegraph to debug
2026-04-01 10:18:36 +00:00
Pandey
42415e0873 feat(audit): handler-level AuditDef, audit middleware, and response capture (#10791)
* feat(audit): handler-level AuditDef and response-capturing wrapper

Add declarative audit instrumentation to the handler package. Routes
declare an AuditDef alongside OpenAPIDef; the handler automatically
captures the response status/body and emits an audit event via
auditor.Audit() after every request.

* refactor(audit): move audit logic to middleware, merge with logging

Move audit event emission from handler to middleware layer. The handler
package keeps only the AuditDef struct and AuditDefProvider interface.
The logging middleware now handles both request logging and audit event
emission using a single response capture, avoiding double-wrapping.

Rename badResponseLoggingWriter to responseCapture with body capture
on all 4xx/5xx responses (previously only 400 and 5xx).

* refactor(audit): rename Logging middleware to Audit, merge into single file

Delete logging.go and merge its contents into audit.go. Rename
Logging/NewLogging to Audit/NewAudit. The response.go file with
responseCapture is unchanged.

* refactor(audit): extract NewAuditEventFromHTTPRequest factory into audittypes

Move event construction to audittypes.NewAuditEventFromHTTPRequest with
an AuditEventContext struct for caller-provided fields. The audittypes
layer reads only transport fields from *http.Request and has no mux,
authtypes, or context dependencies. The middleware pre-extracts
principal, trace, error, and route fields before calling the factory.

* refactor(audit): move error parsing to render.ErrorFromBody and render.ErrorTypeFromStatusCode

Add render.ErrorFromBody to extract errors.JSON from a JSON-encoded
ErrorResponse body, and render.ErrorTypeFromStatusCode to reverse-map
HTTP status codes to error type strings. The middleware now uses these
instead of local duplicates.

* refactor(audit): move AuditDef onto Handler interface, consolidate files

Move AuditDef() onto the Handler interface directly. All Handler
implementations now carry it: handler returns the configured def,
healthOpenAPIHandler returns nil. Delete the separate AuditDefProvider
interface and audit.go handler file. Move excludedRoutes check before
audit emission so excluded routes skip both logging and audit.

* feat(audit): add option.go with AuditDef, Option, and WithAuditDef

* refactor(audit): decompose AuditEvent into attribute sub-structs, add tests

Decompose flat AuditEvent fields into typed sub-structs
(AuditEventAuditAttributes, PrincipalAttributes, ResourceAttributes,
ErrorAttributes, TransportAttributes) each with a constructor and
Put(pcommon.Map) method. Simplify NewAuditEventFromHTTPRequest to
accept authtypes.Claims and oteltrace IDs directly. Simplify the
middleware caller accordingly.

Add unit tests for the factory, outcome boundary, and principal type
derivation.

* refactor(audit): shorten attribute struct names, drop error message

Rename AuditEventAuditAttributes to AuditAttributes,
AuditEventPrincipalAttributes to PrincipalAttributes, and likewise
for Resource, Error, and Transport. The package prefix already
disambiguates.

Remove ErrorMessage from ErrorAttributes to avoid leaking sensitive
or PII data into audit logs. Error type and code are sufficient for
filtering; investigators can correlate via trace ID.

* fix(audit): update auditorserver test and otlphttp provider for new struct layout

Update newTestEvent in server_test.go to use nested AuditAttributes
and ResourceAttributes. Update otlphttpauditor provider to access
PrincipalOrgID via PrincipalAttributes. Fix godot lint on attribute
section comments.

* fix(audit): fix gjson path in ErrorCodeFromBody, add tests

Fix ErrorCodeFromBody gjson path from "errors.code" to "error.code"
to match the ErrorResponse JSON structure. Add unit tests for valid
error response and invalid JSON cases.

* fix(audit): add CodeUnset, use ErrorCodeFromBody in middleware

Add errors.CodeUnset for responses missing an error code. Update the
audit middleware to use render.ErrorCodeFromBody instead of the removed
render.ErrorFromBody.

* test(audit): add unit tests for responseCapture

Test the four meaningful behaviors: success responses don't capture
body, error responses capture body, large error bodies truncate at
4096 bytes, and 204 No Content suppresses writes entirely.

* fix(audit): check rw.Write return values in response_test.go

* style(audit): rename want prefix to expected in test fields

* refactor(audit): replace Sprintf with strings.Builder in newBody

Handle edge cases where principal email, ID, or resource ID may be
empty. The builder conditionally includes each segment, avoiding
empty parentheses or leading spaces in the audit body.

Add test cases covering all meaningful combinations: success/failure
with full/partial/empty principal, resource ID, and error details.

* chore: fix formatting

* chore: remove json tags

* fix: rebase with main
2026-04-01 10:10:52 +00:00
341 changed files with 83271 additions and 6309 deletions

View File

@@ -55,6 +55,9 @@ jobs:
sqlstore-provider:
- postgres
- sqlite
sqlite-mode:
- delete
- wal
clickhouse-version:
- 25.5.6
- 25.12.5
@@ -62,6 +65,9 @@ jobs:
- v0.142.0
postgres-version:
- 15
exclude:
- sqlstore-provider: postgres
sqlite-mode: wal
if: |
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-integrate')
@@ -102,6 +108,7 @@ jobs:
--basetemp=./tmp/ \
src/${{matrix.src}} \
--sqlstore-provider ${{matrix.sqlstore-provider}} \
--sqlite-mode ${{matrix.sqlite-mode}} \
--postgres-version ${{matrix.postgres-version}} \
--clickhouse-version ${{matrix.clickhouse-version}} \
--schema-migrator-version ${{matrix.schema-migrator-version}}

View File

@@ -86,7 +86,7 @@ sqlstore:
# The path to the SQLite database file.
path: /var/lib/signoz/signoz.db
# The journal mode for the sqlite database. Supported values: delete, wal.
mode: delete
mode: wal
# The timeout for the sqlite database to wait for a lock.
busy_timeout: 10s
# The default transaction locking behavior. Supported values: deferred, immediate, exclusive.

View File

@@ -327,27 +327,6 @@ components:
nullable: true
type: array
type: object
AuthtypesStorableRole:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
type: object
AuthtypesTransaction:
properties:
object:
@@ -371,7 +350,7 @@ components:
id:
type: string
role:
$ref: '#/components/schemas/AuthtypesStorableRole'
$ref: '#/components/schemas/AuthtypesRole'
roleId:
type: string
updatedAt:
@@ -381,6 +360,11 @@ components:
type: string
required:
- id
- userId
- roleId
- createdAt
- updatedAt
- role
type: object
AuthtypesUserWithRoles:
properties:
@@ -2313,15 +2297,6 @@ components:
- status
- error
type: object
RulestatehistorytypesAlertState:
enum:
- inactive
- pending
- recovering
- firing
- nodata
- disabled
type: string
RulestatehistorytypesGettableRuleStateHistory:
properties:
fingerprint:
@@ -2333,15 +2308,15 @@ components:
nullable: true
type: array
overallState:
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
$ref: '#/components/schemas/RuletypesAlertState'
overallStateChanged:
type: boolean
ruleID:
ruleId:
type: string
ruleName:
type: string
state:
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
$ref: '#/components/schemas/RuletypesAlertState'
stateChanged:
type: boolean
unixMilli:
@@ -2351,7 +2326,7 @@ components:
format: double
type: number
required:
- ruleID
- ruleId
- ruleName
- overallState
- overallStateChanged
@@ -2441,12 +2416,21 @@ components:
format: int64
type: integer
state:
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
$ref: '#/components/schemas/RuletypesAlertState'
required:
- state
- start
- end
type: object
RuletypesAlertState:
enum:
- inactive
- pending
- recovering
- firing
- nodata
- disabled
type: string
ServiceaccounttypesGettableFactorAPIKey:
properties:
createdAt:
@@ -8469,7 +8453,7 @@ paths:
- in: query
name: state
schema:
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
$ref: '#/components/schemas/RuletypesAlertState'
- in: query
name: filterExpression
schema:

View File

@@ -193,6 +193,7 @@ uv run pytest --basetemp=./tmp/ -vv --reuse src/passwordauthn/01_register.py::te
Tests can be configured using pytest options:
- `--sqlstore-provider` - Choose database provider (default: postgres)
- `--sqlite-mode` - SQLite journal mode: `delete` or `wal` (default: delete). Only relevant when `--sqlstore-provider=sqlite`.
- `--postgres-version` - PostgreSQL version (default: 15)
- `--clickhouse-version` - ClickHouse version (default: 25.5.6)
- `--zookeeper-version` - Zookeeper version (default: 3.7.1)
@@ -202,7 +203,6 @@ Example:
uv run pytest --basetemp=./tmp/ -vv --reuse --sqlstore-provider=postgres --postgres-version=14 src/auth/
```
## What should I remember?
- **Always use the `--reuse` flag** when setting up the environment to keep containers running
@@ -213,3 +213,4 @@ uv run pytest --basetemp=./tmp/ -vv --reuse --sqlstore-provider=postgres --postg
- **Use descriptive test names** that clearly indicate what is being tested
- **Leverage fixtures** for common setup and authentication
- **Test both success and failure scenarios** to ensure robust functionality
- **`--sqlite-mode=wal` does not work on macOS.** The integration test environment runs SigNoz inside a Linux container with the SQLite database file mounted from the macOS host. WAL mode requires shared memory between connections, and connections crossing the VM boundary (macOS host ↔ Linux container) cannot share the WAL index, resulting in `SQLITE_IOERR_SHORT_READ`. WAL mode is tested in CI on Linux only.

View File

@@ -32,7 +32,7 @@ func (s Seasonality) IsValid() bool {
}
type AnomaliesRequest struct {
Params qbtypes.QueryRangeRequest
Params *qbtypes.QueryRangeRequest
Seasonality Seasonality
}
@@ -81,7 +81,7 @@ type anomalyQueryParams struct {
Past3SeasonQuery qbtypes.QueryRangeRequest
}
func prepareAnomalyQueryParams(req qbtypes.QueryRangeRequest, seasonality Seasonality) *anomalyQueryParams {
func prepareAnomalyQueryParams(req *qbtypes.QueryRangeRequest, seasonality Seasonality) *anomalyQueryParams {
start := req.Start
end := req.End

View File

@@ -76,12 +76,12 @@ func (provider *provider) Start(ctx context.Context) error {
}
func (provider *provider) Audit(ctx context.Context, event audittypes.AuditEvent) {
if event.PrincipalOrgID.IsZero() {
if event.PrincipalAttributes.PrincipalOrgID.IsZero() {
provider.settings.Logger().WarnContext(ctx, "audit event dropped as org_id is zero")
return
}
if _, err := provider.licensing.GetActive(ctx, event.PrincipalOrgID); err != nil {
if _, err := provider.licensing.GetActive(ctx, event.PrincipalAttributes.PrincipalOrgID); err != nil {
return
}

View File

@@ -65,7 +65,7 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
}
if anomalyQuery, ok := queryRangeRequest.IsAnomalyRequest(); ok {
anomalies, err := h.handleAnomalyQuery(ctx, orgID, anomalyQuery, queryRangeRequest)
anomalies, err := h.handleAnomalyQuery(ctx, orgID, anomalyQuery, &queryRangeRequest)
if err != nil {
render.Error(rw, errors.NewInternalf(errors.CodeInternal, "failed to get anomalies: %v", err))
return
@@ -149,7 +149,7 @@ func (h *handler) createAnomalyProvider(seasonality anomalyV2.Seasonality) anoma
}
}
func (h *handler) handleAnomalyQuery(ctx context.Context, orgID valuer.UUID, anomalyQuery *qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], queryRangeRequest qbtypes.QueryRangeRequest) (*anomalyV2.AnomaliesResponse, error) {
func (h *handler) handleAnomalyQuery(ctx context.Context, orgID valuer.UUID, anomalyQuery *qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], queryRangeRequest *qbtypes.QueryRangeRequest) (*anomalyV2.AnomaliesResponse, error) {
seasonality := extractSeasonality(anomalyQuery)
provider := h.createAnomalyProvider(seasonality)

View File

@@ -49,7 +49,6 @@ import (
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/utils"
)
@@ -99,7 +98,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
)
rm, err := makeRulesManager(
reader,
signoz.Cache,
signoz.Alertmanager,
signoz.SQLStore,
@@ -229,7 +227,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
r.Use(middleware.NewComment().Wrap)
apiHandler.RegisterRoutes(r, am)
@@ -345,7 +343,7 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}
func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, ruleStateHistoryModule rulestatehistory.Module, querier querier.Querier, providerSettings factory.ProviderSettings, queryParser queryparser.QueryParser) (*baserules.Manager, error) {
func makeRulesManager(cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, ruleStateHistoryModule rulestatehistory.Module, querier querier.Querier, providerSettings factory.ProviderSettings, queryParser queryparser.QueryParser) (*baserules.Manager, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
// create manager opts
@@ -354,7 +352,6 @@ func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertma
MetadataStore: metadataStore,
Prometheus: prometheus,
Context: context.Background(),
Reader: ch,
Querier: querier,
Logger: providerSettings.Logger,
Cache: cache,
@@ -365,7 +362,7 @@ func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertma
OrgGetter: orgGetter,
RuleStore: ruleStore,
MaintenanceStore: maintenanceStore,
SqlStore: sqlstore,
SQLStore: sqlstore,
QueryParser: queryParser,
RuleStateHistoryModule: ruleStateHistoryModule,
}

View File

@@ -5,58 +5,34 @@ import (
"encoding/json"
"fmt"
"log/slog"
"math"
"strings"
"sync"
"time"
"github.com/SigNoz/signoz/ee/query-service/anomaly"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/common"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
querierV2 "github.com/SigNoz/signoz/pkg/query-service/app/querier/v2"
"github.com/SigNoz/signoz/pkg/query-service/app/queryBuilder"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/query-service/utils/times"
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
"github.com/SigNoz/signoz/pkg/units"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
querierV5 "github.com/SigNoz/signoz/pkg/querier"
anomalyV2 "github.com/SigNoz/signoz/ee/anomaly"
"github.com/SigNoz/signoz/ee/anomaly"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
const (
RuleTypeAnomaly = "anomaly_rule"
)
type AnomalyRule struct {
*baserules.BaseRule
mtx sync.Mutex
reader interfaces.Reader
// querier is used for alerts migrated after the introduction of new query builder
querier querier.Querier
// querierV2 is used for alerts created after the introduction of new metrics query builder
querierV2 interfaces.Querier
// querierV5 is used for alerts migrated after the introduction of new query builder
querierV5 querierV5.Querier
provider anomaly.Provider
providerV2 anomalyV2.Provider
provider anomaly.Provider
version string
logger *slog.Logger
@@ -70,18 +46,16 @@ func NewAnomalyRule(
id string,
orgID valuer.UUID,
p *ruletypes.PostableRule,
reader interfaces.Reader,
querierV5 querierV5.Querier,
querier querier.Querier,
logger *slog.Logger,
cache cache.Cache,
opts ...baserules.RuleOption,
) (*AnomalyRule, error) {
logger.Info("creating new AnomalyRule", "rule_id", id)
logger.Info("creating new AnomalyRule", slog.String("rule.id", id))
opts = append(opts, baserules.WithLogger(logger))
baseRule, err := baserules.NewBaseRule(id, orgID, p, reader, opts...)
baseRule, err := baserules.NewBaseRule(id, orgID, p, opts...)
if err != nil {
return nil, err
}
@@ -101,93 +75,38 @@ func NewAnomalyRule(
t.seasonality = anomaly.SeasonalityDaily
}
logger.Info("using seasonality", "seasonality", t.seasonality.String())
logger.Info("using seasonality", slog.String("rule.id", id), slog.String("rule.seasonality", t.seasonality.StringValue()))
querierOptsV2 := querierV2.QuerierOptions{
Reader: reader,
Cache: cache,
KeyGenerator: queryBuilder.NewKeyGenerator(),
}
t.querierV2 = querierV2.NewQuerier(querierOptsV2)
t.reader = reader
if t.seasonality == anomaly.SeasonalityHourly {
t.provider = anomaly.NewHourlyProvider(
anomaly.WithCache[*anomaly.HourlyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.HourlyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.HourlyProvider](reader),
anomaly.WithQuerier[*anomaly.HourlyProvider](querier),
anomaly.WithLogger[*anomaly.HourlyProvider](logger),
)
} else if t.seasonality == anomaly.SeasonalityDaily {
t.provider = anomaly.NewDailyProvider(
anomaly.WithCache[*anomaly.DailyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.DailyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.DailyProvider](reader),
anomaly.WithQuerier[*anomaly.DailyProvider](querier),
anomaly.WithLogger[*anomaly.DailyProvider](logger),
)
} else if t.seasonality == anomaly.SeasonalityWeekly {
t.provider = anomaly.NewWeeklyProvider(
anomaly.WithCache[*anomaly.WeeklyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.WeeklyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.WeeklyProvider](reader),
anomaly.WithQuerier[*anomaly.WeeklyProvider](querier),
anomaly.WithLogger[*anomaly.WeeklyProvider](logger),
)
}
if t.seasonality == anomaly.SeasonalityHourly {
t.providerV2 = anomalyV2.NewHourlyProvider(
anomalyV2.WithQuerier[*anomalyV2.HourlyProvider](querierV5),
anomalyV2.WithLogger[*anomalyV2.HourlyProvider](logger),
)
} else if t.seasonality == anomaly.SeasonalityDaily {
t.providerV2 = anomalyV2.NewDailyProvider(
anomalyV2.WithQuerier[*anomalyV2.DailyProvider](querierV5),
anomalyV2.WithLogger[*anomalyV2.DailyProvider](logger),
)
} else if t.seasonality == anomaly.SeasonalityWeekly {
t.providerV2 = anomalyV2.NewWeeklyProvider(
anomalyV2.WithQuerier[*anomalyV2.WeeklyProvider](querierV5),
anomalyV2.WithLogger[*anomalyV2.WeeklyProvider](logger),
)
}
t.querierV5 = querierV5
t.querier = querier
t.version = p.Version
t.logger = logger
return &t, nil
}
func (r *AnomalyRule) Type() ruletypes.RuleType {
return RuleTypeAnomaly
return ruletypes.RuleTypeAnomaly
}
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v3.QueryRangeParamsV3, error) {
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
r.logger.InfoContext(
ctx, "prepare query range request v4", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds(),
)
st, en := r.Timestamps(ts)
start := st.UnixMilli()
end := en.UnixMilli()
compositeQuery := r.Condition().CompositeQuery
if compositeQuery.PanelType != v3.PanelTypeGraph {
compositeQuery.PanelType = v3.PanelTypeGraph
}
// default mode
return &v3.QueryRangeParamsV3{
Start: start,
End: end,
Step: int64(math.Max(float64(common.MinAllowedStepInterval(start, end)), 60)),
CompositeQuery: compositeQuery,
Variables: make(map[string]interface{}, 0),
NoCache: false,
}, nil
}
func (r *AnomalyRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (*qbtypes.QueryRangeRequest, error) {
r.logger.InfoContext(ctx, "prepare query range request v5", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds())
r.logger.InfoContext(ctx, "prepare query range request", slog.String("rule.id", r.ID()), slog.Int64("ts", ts.UnixMilli()), slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()), slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()))
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
@@ -203,25 +122,14 @@ func (r *AnomalyRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (*q
}
req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(r.Condition().CompositeQuery.Queries))
copy(req.CompositeQuery.Queries, r.Condition().CompositeQuery.Queries)
return req, nil
}
func (r *AnomalyRule) GetSelectedQuery() string {
return r.Condition().GetSelectedQueryName()
return req
}
func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
params, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return nil, err
}
err = r.PopulateTemporality(ctx, orgID, params)
if err != nil {
return nil, fmt.Errorf("internal error while setting temporality")
}
params := r.prepareQueryRange(ctx, ts)
anomalies, err := r.provider.GetAnomalies(ctx, orgID, &anomaly.GetAnomaliesRequest{
anomalies, err := r.provider.GetAnomalies(ctx, orgID, &anomaly.AnomaliesRequest{
Params: params,
Seasonality: r.seasonality,
})
@@ -229,87 +137,43 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
return nil, err
}
var queryResult *v3.Result
var queryResult *qbtypes.TimeSeriesData
for _, result := range anomalies.Results {
if result.QueryName == r.GetSelectedQuery() {
if result.QueryName == r.SelectedQuery(ctx) {
queryResult = result
break
}
}
hasData := len(queryResult.AnomalyScores) > 0
if queryResult == nil {
r.logger.WarnContext(ctx, "nil qb result", slog.String("rule.id", r.ID()), slog.Int64("ts", ts.UnixMilli()))
return ruletypes.Vector{}, nil
}
hasData := len(queryResult.Aggregations) > 0 &&
queryResult.Aggregations[0] != nil &&
len(queryResult.Aggregations[0].AnomalyScores) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
} else if !hasData {
r.logger.WarnContext(ctx, "no anomaly result", slog.String("rule.id", r.ID()))
return ruletypes.Vector{}, nil
}
var resultVector ruletypes.Vector
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
for _, series := range queryResult.AnomalyScores {
if !r.Condition().ShouldEval(series) {
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
continue
}
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
if err != nil {
return nil, err
}
resultVector = append(resultVector, results...)
}
return resultVector, nil
}
func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
params, err := r.prepareQueryRangeV5(ctx, ts)
if err != nil {
return nil, err
}
anomalies, err := r.providerV2.GetAnomalies(ctx, orgID, &anomalyV2.AnomaliesRequest{
Params: *params,
Seasonality: anomalyV2.Seasonality{String: valuer.NewString(r.seasonality.String())},
})
if err != nil {
return nil, err
}
var qbResult *qbtypes.TimeSeriesData
for _, result := range anomalies.Results {
if result.QueryName == r.GetSelectedQuery() {
qbResult = result
break
}
}
if qbResult == nil {
r.logger.WarnContext(ctx, "nil qb result", "ts", ts.UnixMilli())
}
queryResult := transition.ConvertV5TimeSeriesDataToV4Result(qbResult)
hasData := len(queryResult.AnomalyScores) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
}
var resultVector ruletypes.Vector
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
scoresJSON, _ := json.Marshal(queryResult.Aggregations[0].AnomalyScores)
// TODO(srikanthccv): this could be noisy but we do this to answer false alert requests
r.logger.InfoContext(ctx, "anomaly scores", slog.String("rule.id", r.ID()), slog.String("anomaly.scores", string(scoresJSON)))
// Filter out new series if newGroupEvalDelay is configured
seriesToProcess := queryResult.AnomalyScores
seriesToProcess := queryResult.Aggregations[0].AnomalyScores
if r.ShouldSkipNewGroups() {
filteredSeries, filterErr := r.BaseRule.FilterNewSeries(ctx, ts, seriesToProcess)
// In case of error we log the error and continue with the original series
if filterErr != nil {
r.logger.ErrorContext(ctx, "Error filtering new series, ", errors.Attr(filterErr), "rule_name", r.Name())
r.logger.ErrorContext(ctx, "error filtering new series", slog.String("rule.id", r.ID()), errors.Attr(filterErr))
} else {
seriesToProcess = filteredSeries
}
@@ -317,10 +181,10 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
for _, series := range seriesToProcess {
if !r.Condition().ShouldEval(series) {
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", slog.String("rule.id", r.ID()), slog.Int("series.num_points", len(series.Values)), slog.Int("series.required_points", r.Condition().RequiredNumPoints))
continue
}
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
results, err := r.Threshold.Eval(series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
@@ -341,13 +205,9 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
var res ruletypes.Vector
var err error
if r.version == "v5" {
r.logger.InfoContext(ctx, "running v5 query")
res, err = r.buildAndRunQueryV5(ctx, r.OrgID(), ts)
} else {
r.logger.InfoContext(ctx, "running v4 query")
res, err = r.buildAndRunQuery(ctx, r.OrgID(), ts)
}
r.logger.InfoContext(ctx, "running query", slog.String("rule.id", r.ID()))
res, err = r.buildAndRunQuery(ctx, r.OrgID(), ts)
if err != nil {
return 0, err
}
@@ -371,7 +231,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
value := valueFormatter.Format(smpl.V, r.Unit())
threshold := valueFormatter.Format(smpl.Target, smpl.TargetUnit)
r.logger.DebugContext(ctx, "Alert template data for rule", "rule_name", r.Name(), "formatter", valueFormatter.Name(), "value", value, "threshold", threshold)
r.logger.DebugContext(ctx, "alert template data for rule", slog.String("rule.id", r.ID()), slog.String("formatter.name", valueFormatter.Name()), slog.String("alert.value", value), slog.String("alert.threshold", threshold))
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
// Inject some convenience variables that are easier to remember for users
@@ -386,35 +246,34 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
defs+text,
"__alert_"+r.Name(),
tmplData,
times.Time(timestamp.FromTime(ts)),
nil,
)
result, err := tmpl.Expand()
if err != nil {
result = fmt.Sprintf("<error expanding template: %s>", err)
r.logger.ErrorContext(ctx, "Expanding alert template failed", errors.Attr(err), "data", tmplData, "rule_name", r.Name())
r.logger.ErrorContext(ctx, "expanding alert template failed", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.template_data", tmplData))
}
return result
}
lb := labels.NewBuilder(smpl.Metric).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel)
resultLabels := labels.NewBuilder(smpl.Metric).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel).Labels()
lb := ruletypes.NewBuilder(smpl.Metric...).Del(ruletypes.MetricNameLabel).Del(ruletypes.TemporalityLabel)
resultLabels := ruletypes.NewBuilder(smpl.Metric...).Del(ruletypes.MetricNameLabel).Del(ruletypes.TemporalityLabel).Labels()
for name, value := range r.Labels().Map() {
lb.Set(name, expand(value))
}
lb.Set(labels.AlertNameLabel, r.Name())
lb.Set(labels.AlertRuleIdLabel, r.ID())
lb.Set(labels.RuleSourceLabel, r.GeneratorURL())
lb.Set(ruletypes.AlertNameLabel, r.Name())
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := make(labels.Labels, 0, len(r.Annotations().Map()))
annotations := make(ruletypes.Labels, 0, len(r.Annotations().Map()))
for name, value := range r.Annotations().Map() {
annotations = append(annotations, labels.Label{Name: name, Value: expand(value)})
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
}
if smpl.IsMissing {
lb.Set(labels.AlertNameLabel, "[No data] "+r.Name())
lb.Set(labels.NoDataLabel, "true")
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")
}
lbs := lb.Labels()
@@ -422,17 +281,17 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
resultFPs[h] = struct{}{}
if _, ok := alerts[h]; ok {
r.logger.ErrorContext(ctx, "the alert query returns duplicate records", "rule_id", r.ID(), "alert", alerts[h])
err = fmt.Errorf("duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
r.logger.ErrorContext(ctx, "the alert query returns duplicate records", slog.String("rule.id", r.ID()), slog.Any("alert", alerts[h]))
err = errors.NewInternalf(errors.CodeInternal, "duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
return 0, err
}
alerts[h] = &ruletypes.Alert{
Labels: lbs,
QueryResultLables: resultLabels,
QueryResultLabels: resultLabels,
Annotations: annotations,
ActiveAt: ts,
State: model.StatePending,
State: ruletypes.StatePending,
Value: smpl.V,
GeneratorURL: r.GeneratorURL(),
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
@@ -441,12 +300,12 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
}
r.logger.InfoContext(ctx, "number of alerts found", "rule_name", r.Name(), "alerts_count", len(alerts))
r.logger.InfoContext(ctx, "number of alerts found", slog.String("rule.id", r.ID()), slog.Int("alert.count", len(alerts)))
// alerts[h] is ready, add or update active list now
for h, a := range alerts {
// Check whether we already have alerting state for the identifying label set.
// Update the last value and annotations if so, create a new alert entry otherwise.
if alert, ok := r.Active[h]; ok && alert.State != model.StateInactive {
if alert, ok := r.Active[h]; ok && alert.State != ruletypes.StateInactive {
alert.Value = a.Value
alert.Annotations = a.Annotations
@@ -462,76 +321,76 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
r.Active[h] = a
}
itemsToAdd := []model.RuleStateHistory{}
itemsToAdd := []rulestatehistorytypes.RuleStateHistory{}
// Check if any pending alerts should be removed or fire now. Write out alert timeseries.
for fp, a := range r.Active {
labelsJSON, err := json.Marshal(a.QueryResultLables)
labelsJSON, err := json.Marshal(a.QueryResultLabels)
if err != nil {
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err), "labels", a.Labels)
r.logger.ErrorContext(ctx, "error marshaling labels", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.labels", a.Labels))
}
if _, ok := resultFPs[fp]; !ok {
// If the alert was previously firing, keep it around for a given
// retention time so it is reported as resolved to the AlertManager.
if a.State == model.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
if a.State == ruletypes.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
delete(r.Active, fp)
}
if a.State != model.StateInactive {
a.State = model.StateInactive
if a.State != ruletypes.StateInactive {
a.State = ruletypes.StateInactive
a.ResolvedAt = ts
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: model.StateInactive,
State: ruletypes.StateInactive,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Value: a.Value,
})
}
continue
}
if a.State == model.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration().Duration() {
a.State = model.StateFiring
if a.State == ruletypes.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration().Duration() {
a.State = ruletypes.StateFiring
a.FiredAt = ts
state := model.StateFiring
state := ruletypes.StateFiring
if a.Missing {
state = model.StateNoData
state = ruletypes.StateNoData
}
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: state,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Value: a.Value,
})
}
// We need to change firing alert to recovering if the returned sample meets recovery threshold
changeFiringToRecovering := a.State == model.StateFiring && a.IsRecovering
changeFiringToRecovering := a.State == ruletypes.StateFiring && a.IsRecovering
// We need to change recovering alerts to firing if the returned sample meets target threshold
changeRecoveringToFiring := a.State == model.StateRecovering && !a.IsRecovering && !a.Missing
changeRecoveringToFiring := a.State == ruletypes.StateRecovering && !a.IsRecovering && !a.Missing
// in any of the above case we need to update the status of alert
if changeFiringToRecovering || changeRecoveringToFiring {
state := model.StateRecovering
state := ruletypes.StateRecovering
if changeRecoveringToFiring {
state = model.StateFiring
state = ruletypes.StateFiring
}
a.State = state
r.logger.DebugContext(ctx, "converting alert state", "name", r.Name(), "state", state)
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
r.logger.DebugContext(ctx, "converting alert state", slog.String("rule.id", r.ID()), slog.Any("alert.state", state))
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: state,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Value: a.Value,
})
}

View File

@@ -2,21 +2,19 @@ package rules
import (
"context"
"log/slog"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/ee/query-service/anomaly"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/ee/anomaly"
)
// mockAnomalyProvider is a mock implementation of anomaly.Provider for testing.
@@ -24,13 +22,13 @@ import (
// time periods (current, past period, current season, past season, past 2 seasons,
// past 3 seasons), making it cumbersome to create mock data.
type mockAnomalyProvider struct {
responses []*anomaly.GetAnomaliesResponse
responses []*anomaly.AnomaliesResponse
callCount int
}
func (m *mockAnomalyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *anomaly.GetAnomaliesRequest) (*anomaly.GetAnomaliesResponse, error) {
func (m *mockAnomalyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *anomaly.AnomaliesRequest) (*anomaly.AnomaliesResponse, error) {
if m.callCount >= len(m.responses) {
return &anomaly.GetAnomaliesResponse{Results: []*v3.Result{}}, nil
return &anomaly.AnomaliesResponse{Results: []*qbtypes.TimeSeriesData{}}, nil
}
resp := m.responses[m.callCount]
m.callCount++
@@ -49,45 +47,46 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Test anomaly no data",
AlertType: ruletypes.AlertTypeMetric,
RuleType: RuleTypeAnomaly,
RuleType: ruletypes.RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: evalWindow,
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOp: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
Expression: "A",
DataSource: v3.DataSourceMetrics,
Temporality: v3.Unspecified,
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
},
},
}},
},
SelectedQuery: "A",
Seasonality: "daily",
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
Name: "Test anomaly no data",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
Name: "Test anomaly no data",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
}},
},
},
}
responseNoData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
responseNoData := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
{
QueryName: "A",
AnomalyScores: []*v3.Series{},
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{},
}},
},
},
}
@@ -115,23 +114,17 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
t.Run(c.description, func(t *testing.T) {
postableRule.RuleCondition.AlertOnAbsent = c.alertOnAbsent
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, nil)
options := clickhouseReader.NewOptions("primaryNamespace")
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, nil, "", time.Second, nil, nil, options)
rule, err := NewAnomalyRule(
"test-anomaly-rule",
valuer.GenerateUUID(),
&postableRule,
reader,
nil,
logger,
nil,
)
require.NoError(t, err)
rule.provider = &mockAnomalyProvider{
responses: []*anomaly.GetAnomaliesResponse{responseNoData},
responses: []*anomaly.AnomaliesResponse{responseNoData},
}
alertsFound, err := rule.Eval(context.Background(), evalTime)
@@ -156,46 +149,47 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Test anomaly no data with AbsentFor",
AlertType: ruletypes.AlertTypeMetric,
RuleType: RuleTypeAnomaly,
RuleType: ruletypes.RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: evalWindow,
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOp: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
Expression: "A",
DataSource: v3.DataSourceMetrics,
Temporality: v3.Unspecified,
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
},
},
}},
},
SelectedQuery: "A",
Seasonality: "daily",
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
Name: "Test anomaly no data with AbsentFor",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
Name: "Test anomaly no data with AbsentFor",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
}},
},
},
}
responseNoData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
responseNoData := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
{
QueryName: "A",
AnomalyScores: []*v3.Series{},
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{},
}},
},
},
}
@@ -229,32 +223,35 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
t1 := baseTime.Add(5 * time.Minute)
t2 := t1.Add(c.timeBetweenEvals)
responseWithData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
responseWithData := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
{
QueryName: "A",
AnomalyScores: []*v3.Series{
{
Labels: map[string]string{"test": "label"},
Points: []v3.Point{
{Timestamp: baseTime.UnixMilli(), Value: 1.0},
{Timestamp: baseTime.Add(time.Minute).UnixMilli(), Value: 1.5},
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{
{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{Name: "Test"},
Value: "labels",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: baseTime.UnixMilli(), Value: 1.0},
{Timestamp: baseTime.Add(time.Minute).UnixMilli(), Value: 1.5},
},
},
},
},
}},
},
},
}
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, nil)
options := clickhouseReader.NewOptions("primaryNamespace")
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, nil, "", time.Second, nil, nil, options)
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, reader, nil, logger, nil)
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, nil, logger)
require.NoError(t, err)
rule.provider = &mockAnomalyProvider{
responses: []*anomaly.GetAnomaliesResponse{responseWithData, responseNoData},
responses: []*anomaly.AnomaliesResponse{responseWithData, responseNoData},
}
alertsFound1, err := rule.Eval(context.Background(), t1)

View File

@@ -11,9 +11,7 @@ import (
"github.com/google/uuid"
"github.com/SigNoz/signoz/pkg/errors"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -23,7 +21,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules := make([]baserules.Rule, 0)
var task baserules.Task
ruleId := baserules.RuleIdFromTaskName(opts.TaskName)
ruleID := baserules.RuleIDFromTaskName(opts.TaskName)
evaluation, err := opts.Rule.Evaluation.GetEvaluation()
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err)
@@ -32,10 +30,9 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
if opts.Rule.RuleType == ruletypes.RuleTypeThreshold {
// create a threshold rule
tr, err := baserules.NewThresholdRule(
ruleId,
ruleID,
opts.OrgID,
opts.Rule,
opts.Reader,
opts.Querier,
opts.Logger,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
@@ -58,11 +55,10 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
// create promql rule
pr, err := baserules.NewPromRule(
ruleId,
ruleID,
opts.OrgID,
opts.Rule,
opts.Logger,
opts.Reader,
opts.ManagerOpts.Prometheus,
baserules.WithSQLStore(opts.SQLStore),
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
@@ -82,13 +78,11 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
ar, err := NewAnomalyRule(
ruleId,
ruleID,
opts.OrgID,
opts.Rule,
opts.Reader,
opts.Querier,
opts.Logger,
opts.Cache,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
baserules.WithSQLStore(opts.SQLStore),
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
@@ -105,7 +99,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else {
return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
}
return task, nil
@@ -113,12 +107,12 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
// TestNotification prepares a dummy rule for given rule parameters and
// sends a test notification. returns alert count and error (if any)
func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.ApiError) {
func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
ctx := context.Background()
if opts.Rule == nil {
return 0, basemodel.BadRequest(fmt.Errorf("rule is required"))
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "rule is required")
}
parsedRule := opts.Rule
@@ -138,15 +132,14 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
if parsedRule.RuleType == ruletypes.RuleTypeThreshold {
// add special labels for test alerts
parsedRule.Labels[labels.RuleSourceLabel] = ""
parsedRule.Labels[labels.AlertRuleIdLabel] = ""
parsedRule.Labels[ruletypes.RuleSourceLabel] = ""
parsedRule.Labels[ruletypes.AlertRuleIDLabel] = ""
// create a threshold rule
rule, err = baserules.NewThresholdRule(
alertname,
opts.OrgID,
parsedRule,
opts.Reader,
opts.Querier,
opts.Logger,
baserules.WithSendAlways(),
@@ -158,7 +151,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
if err != nil {
slog.Error("failed to prepare a new threshold rule for test", "name", alertname, errors.Attr(err))
return 0, basemodel.BadRequest(err)
return 0, err
}
} else if parsedRule.RuleType == ruletypes.RuleTypeProm {
@@ -169,7 +162,6 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
opts.OrgID,
parsedRule,
opts.Logger,
opts.Reader,
opts.ManagerOpts.Prometheus,
baserules.WithSendAlways(),
baserules.WithSendUnmatched(),
@@ -180,7 +172,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
if err != nil {
slog.Error("failed to prepare a new promql rule for test", "name", alertname, errors.Attr(err))
return 0, basemodel.BadRequest(err)
return 0, err
}
} else if parsedRule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
@@ -188,10 +180,8 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
alertname,
opts.OrgID,
parsedRule,
opts.Reader,
opts.Querier,
opts.Logger,
opts.Cache,
baserules.WithSendAlways(),
baserules.WithSendUnmatched(),
baserules.WithSQLStore(opts.SQLStore),
@@ -200,10 +190,10 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
)
if err != nil {
slog.Error("failed to prepare a new anomaly rule for test", "name", alertname, errors.Attr(err))
return 0, basemodel.BadRequest(err)
return 0, err
}
} else {
return 0, basemodel.BadRequest(fmt.Errorf("failed to derive ruletype with given information"))
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to derive ruletype with given information")
}
// set timestamp to current utc time
@@ -212,7 +202,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
alertsFound, err := rule.Eval(ctx, ts)
if err != nil {
slog.Error("evaluating rule failed", "rule", rule.Name(), errors.Attr(err))
return 0, basemodel.InternalError(fmt.Errorf("rule evaluation failed"))
return 0, err
}
rule.SendAlerts(ctx, ts, 0, time.Minute, opts.NotifyFunc)

View File

@@ -114,11 +114,8 @@ func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
},
})
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
if apiErr != nil {
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
}
require.Nil(t, apiErr)
count, err := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
require.Nil(t, err)
assert.Equal(t, tc.ExpectAlerts, count)
if tc.ExpectAlerts > 0 {
@@ -268,11 +265,8 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
},
})
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
if apiErr != nil {
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
}
require.Nil(t, apiErr)
count, err := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
require.Nil(t, err)
assert.Equal(t, tc.ExpectAlerts, count)
if tc.ExpectAlerts > 0 {

View File

@@ -68,8 +68,8 @@
"@signozhq/toggle-group": "0.0.1",
"@signozhq/tooltip": "0.0.2",
"@signozhq/ui": "0.0.5",
"@tanstack/react-table": "8.20.6",
"@tanstack/react-virtual": "3.11.2",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
"@uiw/codemirror-theme-github": "4.24.1",
"@uiw/react-codemirror": "4.23.10",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#1eb4d4" viewBox="0 0 24 24"><title>Hasura</title><path d="M23.558 8.172c.707-2.152.282-6.447-1.09-8.032a.42.42 0 0 0-.664.051l-1.69 2.59a1.32 1.32 0 0 1-1.737.276C16.544 1.885 14.354 1.204 12 1.204s-4.544.68-6.378 1.853a1.326 1.326 0 0 1-1.736-.276L2.196.191A.42.42 0 0 0 1.532.14C.16 1.728-.265 6.023.442 8.172c.236.716.3 1.472.16 2.207-.137.73-.276 1.61-.276 2.223C.326 18.898 5.553 24 11.997 24c6.447 0 11.671-5.105 11.671-11.398 0-.613-.138-1.494-.276-2.223a4.47 4.47 0 0 1 .166-2.207m-11.56 13.284c-4.984 0-9.036-3.96-9.036-8.827q0-.239.014-.473c.18-3.316 2.243-6.15 5.16-7.5 1.17-.546 2.481-.848 3.864-.848s2.69.302 3.864.85c2.917 1.351 4.98 4.187 5.16 7.501q.013.236.014.473c-.003 4.864-4.057 8.824-9.04 8.824m3.915-5.43-2.31-3.91-1.98-3.26a.26.26 0 0 0-.223-.125H9.508a.26.26 0 0 0-.227.13.25.25 0 0 0 .003.254l1.895 3.109-2.542 3.787a.25.25 0 0 0-.011.259.26.26 0 0 0 .23.132h1.905a.26.26 0 0 0 .218-.116l1.375-2.096 1.233 2.088a.26.26 0 0 0 .224.127h1.878c.094 0 .18-.049.224-.127a.24.24 0 0 0 0-.251z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#ea4b71" viewBox="0 0 24 24"><title>n8n</title><path d="M21.474 5.684a2.53 2.53 0 0 0-2.447 1.895H16.13a2.526 2.526 0 0 0-2.492 2.11l-.103.624a1.26 1.26 0 0 1-1.246 1.055h-1.001a2.527 2.527 0 0 0-4.893 0H4.973a2.527 2.527 0 1 0 0 1.264h1.422a2.527 2.527 0 0 0 4.894 0h1a1.26 1.26 0 0 1 1.247 1.055l.103.623a2.526 2.526 0 0 0 2.492 2.111h.37a2.527 2.527 0 1 0 0-1.263h-.37a1.26 1.26 0 0 1-1.246-1.056l-.103-.623A2.52 2.52 0 0 0 13.96 12a2.52 2.52 0 0 0 .82-1.48l.104-.622a1.26 1.26 0 0 1 1.246-1.056h2.896a2.527 2.527 0 1 0 2.447-3.158m0 1.263a1.263 1.263 0 0 1 1.263 1.263 1.263 1.263 0 0 1-1.263 1.264A1.263 1.263 0 0 1 20.21 8.21a1.263 1.263 0 0 1 1.264-1.263m-18.948 3.79A1.263 1.263 0 0 1 3.79 12a1.263 1.263 0 0 1-1.264 1.263A1.263 1.263 0 0 1 1.263 12a1.263 1.263 0 0 1 1.263-1.263m6.316 0A1.263 1.263 0 0 1 10.105 12a1.263 1.263 0 0 1-1.263 1.263A1.263 1.263 0 0 1 7.58 12a1.263 1.263 0 0 1 1.263-1.263m10.106 3.79a1.263 1.263 0 0 1 1.263 1.263 1.263 1.263 0 0 1-1.263 1.263 1.263 1.263 0 0 1-1.264-1.263 1.263 1.263 0 0 1 1.263-1.264"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -1,9 +1,8 @@
import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { matchPath, Redirect, useLocation } from 'react-router-dom';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import getAll from 'api/v1/user/get';
import { useListUsers } from 'api/generated/services/users';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
@@ -12,12 +11,9 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
import { Organization } from 'types/api/user/getOrganization';
import { UserResponse } from 'types/api/user/getUser';
import { USER_ROLES } from 'types/roles';
import { routePermission } from 'utils/permission';
@@ -63,18 +59,10 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const [orgData, setOrgData] = useState<Organization | undefined>(undefined);
const { data: usersData, isFetching: isFetchingUsers } = useQuery<
SuccessResponseV2<UserResponse[]> | undefined,
APIError
>({
queryFn: () => {
if (orgData && orgData.id !== undefined) {
return getAll();
}
return undefined;
const { data: usersData, isFetching: isFetchingUsers } = useListUsers({
query: {
enabled: !isEmpty(orgData) && user.role === 'ADMIN',
},
queryKey: ['getOrgUser'],
enabled: !isEmpty(orgData) && user.role === 'ADMIN',
});
const checkFirstTimeUser = useCallback((): boolean => {

View File

@@ -67,9 +67,12 @@ jest.mock('hooks/useGetTenantLicense', () => ({
// Mock react-query for users fetch
let mockUsersData: { email: string }[] = [];
jest.mock('api/v1/user/get', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve({ data: mockUsersData })),
jest.mock('api/generated/services/users', () => ({
...jest.requireActual('api/generated/services/users'),
useListUsers: jest.fn(() => ({
data: { data: mockUsersData },
isFetching: false,
})),
}));
const queryClient = new QueryClient({

View File

@@ -18,7 +18,7 @@ import AppLayout from 'container/AppLayout';
import Hex from 'crypto-js/enc-hex';
import HmacSHA256 from 'crypto-js/hmac-sha256';
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useThemeConfig } from 'hooks/useDarkMode';
import { useIsDarkMode, useThemeConfig } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { NotificationProvider } from 'hooks/useNotifications';
import { ResourceProvider } from 'hooks/useResourceAttribute';
@@ -212,6 +212,12 @@ function App(): JSX.Element {
activeLicenseFetchError,
]);
const isDarkMode = useIsDarkMode();
useEffect(() => {
window.Pylon?.('setTheme', isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
useEffect(() => {
if (
pathname === ROUTES.ONBOARDING ||

View File

@@ -425,39 +425,6 @@ export interface AuthtypesSessionContextDTO {
orgs?: AuthtypesOrgSessionContextDTO[] | null;
}
export interface AuthtypesStorableRoleDTO {
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
orgId?: string;
/**
* @type string
*/
type?: string;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
}
export interface AuthtypesTransactionDTO {
object: AuthtypesObjectDTO;
/**
@@ -475,25 +442,25 @@ export interface AuthtypesUserRoleDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt: Date;
/**
* @type string
*/
id: string;
role?: AuthtypesStorableRoleDTO;
role: AuthtypesRoleDTO;
/**
* @type string
*/
roleId?: string;
roleId: string;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt: Date;
/**
* @type string
*/
userId?: string;
userId: string;
}
export interface AuthtypesUserWithRolesDTO {
@@ -2710,14 +2677,6 @@ export interface RenderErrorResponseDTO {
status: string;
}
export enum RulestatehistorytypesAlertStateDTO {
inactive = 'inactive',
pending = 'pending',
recovering = 'recovering',
firing = 'firing',
nodata = 'nodata',
disabled = 'disabled',
}
export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
/**
* @type integer
@@ -2729,7 +2688,7 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
* @nullable true
*/
labels: Querybuildertypesv5LabelDTO[] | null;
overallState: RulestatehistorytypesAlertStateDTO;
overallState: RuletypesAlertStateDTO;
/**
* @type boolean
*/
@@ -2737,12 +2696,12 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
/**
* @type string
*/
ruleID: string;
ruleId: string;
/**
* @type string
*/
ruleName: string;
state: RulestatehistorytypesAlertStateDTO;
state: RuletypesAlertStateDTO;
/**
* @type boolean
*/
@@ -2840,9 +2799,17 @@ export interface RulestatehistorytypesGettableRuleStateWindowDTO {
* @format int64
*/
start: number;
state: RulestatehistorytypesAlertStateDTO;
state: RuletypesAlertStateDTO;
}
export enum RuletypesAlertStateDTO {
inactive = 'inactive',
pending = 'pending',
recovering = 'recovering',
firing = 'firing',
nodata = 'nodata',
disabled = 'disabled',
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -4613,7 +4580,7 @@ export type GetRuleHistoryTimelineParams = {
/**
* @description undefined
*/
state?: RulestatehistorytypesAlertStateDTO;
state?: RuletypesAlertStateDTO;
/**
* @type string
* @description undefined

View File

@@ -1,26 +0,0 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/editOrg';
const editOrg = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put(`/orgs/me`, {
displayName: props.displayName,
});
return {
statusCode: 204,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default editOrg;

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps } from 'types/api/user/getOrganization';
const getOrganization = async (
token?: string,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(`/org`, {
headers: {
Authorization: `bearer ${token}`,
},
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getOrganization;

View File

@@ -1,21 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { UserResponse } from 'types/api/user/getUser';
import { PayloadProps } from 'types/api/user/getUsers';
const getAll = async (): Promise<SuccessResponseV2<UserResponse[]>> => {
try {
const response = await axios.get<PayloadProps>(`/user`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getAll;

View File

@@ -1,22 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props, UserResponse } from 'types/api/user/getUser';
const getUser = async (
props: Props,
): Promise<SuccessResponseV2<UserResponse>> => {
try {
const response = await axios.get<PayloadProps>(`/user/${props.userId}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getUser;

View File

@@ -1,23 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Props } from 'types/api/user/editUser';
const update = async (props: Props): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.put(`/user/${props.userId}`, {
displayName: props.displayName,
role: props.role,
});
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default update;

View File

@@ -1,20 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, UserResponse } from 'types/api/user/getUser';
const get = async (): Promise<SuccessResponseV2<UserResponse>> => {
try {
const response = await axios.get<PayloadProps>(`/user/me`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default get;

View File

@@ -1,97 +0,0 @@
.announcement-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--padding-2) var(--padding-4);
height: 40px;
font-family: var(--font-sans), sans-serif;
font-size: var(--label-base-500-font-size);
line-height: var(--label-base-500-line-height);
font-weight: var(--label-base-500-font-weight);
letter-spacing: -0.065px;
&--warning {
background-color: var(--callout-warning-background);
color: var(--callout-warning-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-warning-border);
}
}
&--info {
background-color: var(--callout-primary-background);
color: var(--callout-primary-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-primary-border);
}
}
&--error {
background-color: var(--callout-error-background);
color: var(--callout-error-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-error-border);
}
}
&--success {
background-color: var(--callout-success-background);
color: var(--callout-success-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-success-border);
}
}
&__body {
display: flex;
align-items: center;
gap: var(--spacing-4);
flex: 1;
min-width: 0;
}
&__icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
&__message {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: var(--line-height-normal);
strong {
font-weight: var(--font-weight-semibold);
}
}
&__action {
height: 24px;
font-size: var(--label-small-500-font-size);
color: currentColor;
&:hover {
opacity: 0.8;
}
}
&__dismiss {
width: 24px;
height: 24px;
padding: 0;
color: currentColor;
&:hover {
opacity: 0.8;
}
}
}

View File

@@ -1,89 +0,0 @@
import { render, screen, userEvent } from 'tests/test-utils';
import {
AnnouncementBanner,
AnnouncementBannerProps,
PersistedAnnouncementBanner,
} from './index';
const STORAGE_KEY = 'test-banner-dismissed';
function renderBanner(props: Partial<AnnouncementBannerProps> = {}): void {
render(<AnnouncementBanner message="Test message" {...props} />);
}
afterEach(() => {
localStorage.removeItem(STORAGE_KEY);
});
describe('AnnouncementBanner', () => {
it('renders message and default warning variant', () => {
renderBanner({ message: <strong>Heads up</strong> });
const alert = screen.getByRole('alert');
expect(alert).toHaveClass('announcement-banner--warning');
expect(alert).toHaveTextContent('Heads up');
});
it.each(['warning', 'info', 'success', 'error'] as const)(
'renders %s variant correctly',
(type) => {
renderBanner({ type, message: 'Test message' });
const alert = screen.getByRole('alert');
expect(alert).toHaveClass(`announcement-banner--${type}`);
},
);
it('calls action onClick when action button is clicked', async () => {
const onClick = jest.fn() as jest.MockedFunction<() => void>;
renderBanner({ action: { label: 'Go to Settings', onClick } });
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /go to settings/i }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('hides dismiss button when onClose is not provided and hides icon when icon is null', () => {
renderBanner({ onClose: undefined, icon: null });
expect(
screen.queryByRole('button', { name: /dismiss/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('alert')?.querySelector('.announcement-banner__icon'),
).not.toBeInTheDocument();
});
});
describe('PersistedAnnouncementBanner', () => {
it('dismisses on click, calls onDismiss, and persists to localStorage', async () => {
const onDismiss = jest.fn() as jest.MockedFunction<() => void>;
render(
<PersistedAnnouncementBanner
message="Test message"
storageKey={STORAGE_KEY}
onDismiss={onDismiss}
/>,
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /dismiss/i }));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(STORAGE_KEY)).toBe('true');
});
it('does not render when storageKey is already set in localStorage', () => {
localStorage.setItem(STORAGE_KEY, 'true');
render(
<PersistedAnnouncementBanner
message="Test message"
storageKey={STORAGE_KEY}
/>,
);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});

View File

@@ -1,84 +0,0 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/button';
import {
CircleAlert,
CircleCheckBig,
Info,
TriangleAlert,
X,
} from '@signozhq/icons';
import cx from 'classnames';
import './AnnouncementBanner.styles.scss';
export type AnnouncementBannerType = 'warning' | 'info' | 'error' | 'success';
export interface AnnouncementBannerAction {
label: string;
onClick: () => void;
}
export interface AnnouncementBannerProps {
message: ReactNode;
type?: AnnouncementBannerType;
icon?: ReactNode | null;
action?: AnnouncementBannerAction;
onClose?: () => void;
className?: string;
}
const DEFAULT_ICONS: Record<AnnouncementBannerType, ReactNode> = {
warning: <TriangleAlert size={14} />,
info: <Info size={14} />,
error: <CircleAlert size={14} />,
success: <CircleCheckBig size={14} />,
};
export default function AnnouncementBanner({
message,
type = 'warning',
icon,
action,
onClose,
className,
}: AnnouncementBannerProps): JSX.Element {
const resolvedIcon = icon === null ? null : icon ?? DEFAULT_ICONS[type];
return (
<div
role="alert"
className={cx(
'announcement-banner',
`announcement-banner--${type}`,
className,
)}
>
<div className="announcement-banner__body">
{resolvedIcon && (
<span className="announcement-banner__icon">{resolvedIcon}</span>
)}
<span className="announcement-banner__message">{message}</span>
{action && (
<Button
type="button"
className="announcement-banner__action"
onClick={action.onClick}
>
{action.label}
</Button>
)}
</div>
{onClose && (
<Button
type="button"
aria-label="Dismiss"
className="announcement-banner__dismiss"
onClick={onClose}
>
<X size={14} />
</Button>
)}
</div>
);
}

View File

@@ -1,34 +0,0 @@
import { useState } from 'react';
import AnnouncementBanner, {
AnnouncementBannerProps,
} from './AnnouncementBanner';
interface PersistedAnnouncementBannerProps extends AnnouncementBannerProps {
storageKey: string;
onDismiss?: () => void;
}
function isDismissed(storageKey: string): boolean {
return localStorage.getItem(storageKey) === 'true';
}
export default function PersistedAnnouncementBanner({
storageKey,
onDismiss,
...props
}: PersistedAnnouncementBannerProps): JSX.Element | null {
const [visible, setVisible] = useState(() => !isDismissed(storageKey));
if (!visible) {
return null;
}
const handleClose = (): void => {
localStorage.setItem(storageKey, 'true');
setVisible(false);
onDismiss?.();
};
return <AnnouncementBanner {...props} onClose={handleClose} />;
}

View File

@@ -1,12 +0,0 @@
import AnnouncementBanner from './AnnouncementBanner';
import PersistedAnnouncementBanner from './PersistedAnnouncementBanner';
export type {
AnnouncementBannerAction,
AnnouncementBannerProps,
AnnouncementBannerType,
} from './AnnouncementBanner';
export { AnnouncementBanner, PersistedAnnouncementBanner };
export default AnnouncementBanner;

View File

@@ -0,0 +1,75 @@
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { Trash2, X } from '@signozhq/icons';
import { MemberRow } from 'components/MembersTable/MembersTable';
interface DeleteMemberDialogProps {
open: boolean;
isInvited: boolean;
member: MemberRow | null;
isDeleting: boolean;
onClose: () => void;
onConfirm: () => void;
}
function DeleteMemberDialog({
open,
isInvited,
member,
isDeleting,
onClose,
onConfirm,
}: DeleteMemberDialogProps): JSX.Element {
const title = isInvited ? 'Revoke Invite' : 'Delete Member';
const body = isInvited ? (
<>
Are you sure you want to revoke the invite for{' '}
<strong>{member?.email}</strong>? They will no longer be able to join the
workspace using this invite.
</>
) : (
<>
Are you sure you want to delete{' '}
<strong>{member?.name || member?.email}</strong>? This will remove their
access to the workspace.
</>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={title}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="delete-dialog__body">{body}</p>
<DialogFooter className="delete-dialog__footer">
<Button variant="solid" color="secondary" size="sm" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isDeleting}
onClick={onConfirm}
>
<Trash2 size={12} />
{isDeleting ? 'Processing...' : title}
</Button>
</DialogFooter>
</DialogWrapper>
);
}
export default DeleteMemberDialog;

View File

@@ -45,8 +45,8 @@
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 var(--padding-2);
min-height: 32px;
padding: var(--padding-1) var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--border);
@@ -57,6 +57,13 @@
}
}
&__disabled-roles {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
flex: 1;
}
&__email-text {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
@@ -78,21 +85,23 @@
&__role-select {
width: 100%;
height: 32px;
.ant-select-selector {
background-color: var(--l2-background) !important;
border-color: var(--border) !important;
border-radius: 2px;
padding: 0 var(--padding-2) !important;
padding: var(--padding-1) var(--padding-2) !important;
display: flex;
align-items: center;
flex-wrap: wrap;
min-height: 32px;
height: auto !important;
}
.ant-select-selection-item {
font-size: var(--font-size-sm);
color: var(--l1-foreground);
line-height: 32px;
line-height: 22px;
letter-spacing: -0.07px;
}
@@ -168,6 +177,10 @@
flex-shrink: 0;
}
&__tooltip-wrapper {
display: inline-flex;
}
&__footer-btn {
display: inline-flex;
align-items: center;

View File

@@ -2,38 +2,69 @@ import { useCallback, useEffect, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { DrawerWrapper } from '@signozhq/drawer';
import {
Check,
ChevronDown,
Copy,
LockKeyhole,
RefreshCw,
Trash2,
X,
} from '@signozhq/icons';
import { LockKeyhole, RefreshCw, Trash2, X } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { Select } from 'antd';
import { Skeleton, Tooltip } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import {
getResetPasswordToken,
useDeleteUser,
useUpdateUserDeprecated,
useGetUser,
useUpdateMyUserV2,
useUpdateUser,
} from 'api/generated/services/users';
import { AxiosError } from 'axios';
import { MemberRow } from 'components/MembersTable/MembersTable';
import RolesSelect, { useRoles } from 'components/RolesSelect';
import SaveErrorItem from 'components/ServiceAccountDrawer/SaveErrorItem';
import type { SaveError } from 'components/ServiceAccountDrawer/utils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { MemberStatus } from 'container/MembersSettings/utils';
import { capitalize } from 'lodash-es';
import {
MemberRoleUpdateFailure,
useMemberRoleManager,
} from 'hooks/member/useMemberRoleManager';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import { ROLES } from 'types/roles';
import { popupContainer } from 'utils/selectPopupContainer';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import DeleteMemberDialog from './DeleteMemberDialog';
import ResetLinkDialog from './ResetLinkDialog';
import './EditMemberDrawer.styles.scss';
const ROOT_USER_TOOLTIP = 'This operation is not supported for the root user';
const SELF_DELETE_TOOLTIP =
'You cannot perform this action on your own account';
function getDeleteTooltip(
isRootUser: boolean,
isSelf: boolean,
): string | undefined {
if (isRootUser) {
return ROOT_USER_TOOLTIP;
}
if (isSelf) {
return SELF_DELETE_TOOLTIP;
}
return undefined;
}
function toSaveApiError(err: unknown): APIError {
return (
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
toAPIError(err as AxiosError<RenderErrorResponseDTO>)
);
}
function areSortedArraysEqual(a: string[], b: string[]): boolean {
return JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
}
export interface EditMemberDrawerProps {
member: MemberRow | null;
open: boolean;
@@ -49,9 +80,12 @@ function EditMemberDrawer({
onComplete,
}: EditMemberDrawerProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { user: currentUser } = useAppContext();
const [displayName, setDisplayName] = useState('');
const [selectedRole, setSelectedRole] = useState<ROLES>('VIEWER');
const [localDisplayName, setLocalDisplayName] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [resetLink, setResetLink] = useState<string | null>(null);
@@ -60,32 +94,63 @@ function EditMemberDrawer({
const [linkType, setLinkType] = useState<'invite' | 'reset' | null>(null);
const isInvited = member?.status === MemberStatus.Invited;
const isSelf = !!member?.id && member.id === currentUser?.id;
const { mutate: updateUser, isLoading: isSaving } = useUpdateUserDeprecated({
mutation: {
onSuccess: (): void => {
toast.success('Member details updated successfully', { richColors: true });
onComplete();
onClose();
},
onError: (err): void => {
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
toast.error(`Failed to update member details: ${errMessage}`, {
richColors: true,
});
},
},
});
const {
data: fetchedUser,
isLoading: isFetchingUser,
refetch: refetchUser,
} = useGetUser(
{ id: member?.id ?? '' },
{ query: { enabled: open && !!member?.id } },
);
const isRootUser = !!fetchedUser?.data?.isRoot;
const {
roles: availableRoles,
isLoading: rolesLoading,
isError: rolesError,
error: rolesErrorObj,
refetch: refetchRoles,
} = useRoles();
const { fetchedRoleIds, applyDiff } = useMemberRoleManager(
member?.id ?? '',
open && !!member?.id,
);
const fetchedDisplayName =
fetchedUser?.data?.displayName ?? member?.name ?? '';
const fetchedUserId = fetchedUser?.data?.id;
const fetchedUserDisplayName = fetchedUser?.data?.displayName;
useEffect(() => {
if (fetchedUserId) {
setLocalDisplayName(fetchedUserDisplayName ?? member?.name ?? '');
}
setSaveErrors([]);
}, [fetchedUserId, fetchedUserDisplayName, member?.name]);
useEffect(() => {
setLocalRoles(fetchedRoleIds);
}, [fetchedRoleIds]);
const isDirty =
member !== null &&
fetchedUser != null &&
(localDisplayName !== fetchedDisplayName ||
!areSortedArraysEqual(localRoles, fetchedRoleIds));
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
const { mutateAsync: updateUser } = useUpdateUser();
const { mutate: deleteUser, isLoading: isDeleting } = useDeleteUser({
mutation: {
onSuccess: (): void => {
toast.success(
isInvited ? 'Invite revoked successfully' : 'Member deleted successfully',
{ richColors: true },
{ richColors: true, position: 'top-right' },
);
setShowDeleteConfirm(false);
onComplete();
@@ -99,53 +164,163 @@ function EditMemberDrawer({
const prefix = isInvited
? 'Failed to revoke invite'
: 'Failed to delete member';
toast.error(`${prefix}: ${errMessage}`, { richColors: true });
toast.error(`${prefix}: ${errMessage}`, {
richColors: true,
position: 'top-right',
});
},
},
});
useEffect(() => {
if (member) {
setDisplayName(member.name ?? '');
setSelectedRole(member.role);
}
}, [member]);
const isDirty =
member !== null &&
(displayName !== (member.name ?? '') || selectedRole !== member.role);
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
if (!ts) {
return '—';
const makeRoleRetry = useCallback(
(
context: string,
rawRetry: () => Promise<void>,
) => async (): Promise<void> => {
try {
await rawRetry();
setSaveErrors((prev) => prev.filter((e) => e.context !== context));
refetchUser();
} catch (err) {
setSaveErrors((prev) =>
prev.map((e) =>
e.context === context ? { ...e, apiError: toSaveApiError(err) } : e,
),
);
}
const d = new Date(ts);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
},
[formatTimezoneAdjustedTimestamp],
[refetchUser],
);
const handleSave = useCallback((): void => {
const retryNameUpdate = useCallback(async (): Promise<void> => {
if (!member) {
return;
}
try {
if (isSelf) {
await updateMyUser({ data: { displayName: localDisplayName } });
} else {
await updateUser({
pathParams: { id: member.id },
data: { displayName: localDisplayName },
});
}
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Name update'));
refetchUser();
} catch (err) {
setSaveErrors((prev) =>
prev.map((e) =>
e.context === 'Name update' ? { ...e, apiError: toSaveApiError(err) } : e,
),
);
}
}, [member, isSelf, localDisplayName, updateMyUser, updateUser, refetchUser]);
const handleSave = useCallback(async (): Promise<void> => {
if (!member || !isDirty) {
return;
}
updateUser({
pathParams: { id: member.id },
data: { id: member.id, displayName, role: selectedRole },
});
}, [member, isDirty, displayName, selectedRole, updateUser]);
setSaveErrors([]);
setIsSaving(true);
try {
const nameChanged = localDisplayName !== fetchedDisplayName;
const rolesChanged = !areSortedArraysEqual(localRoles, fetchedRoleIds);
const namePromise = nameChanged
? isSelf
? updateMyUser({ data: { displayName: localDisplayName } })
: updateUser({
pathParams: { id: member.id },
data: { displayName: localDisplayName },
})
: Promise.resolve();
const [nameResult, rolesResult] = await Promise.allSettled([
namePromise,
rolesChanged ? applyDiff(localRoles, availableRoles) : Promise.resolve([]),
]);
const errors: SaveError[] = [];
if (nameResult.status === 'rejected') {
errors.push({
context: 'Name update',
apiError: toSaveApiError(nameResult.reason),
onRetry: retryNameUpdate,
});
}
if (rolesResult.status === 'rejected') {
errors.push({
context: 'Roles update',
apiError: toSaveApiError(rolesResult.reason),
onRetry: async (): Promise<void> => {
const failures = await applyDiff(localRoles, availableRoles);
setSaveErrors((prev) => {
const rest = prev.filter((e) => e.context !== 'Roles update');
return [
...rest,
...failures.map((f: MemberRoleUpdateFailure) => {
const ctx = `Role '${f.roleName}'`;
return {
context: ctx,
apiError: toSaveApiError(f.error),
onRetry: makeRoleRetry(ctx, f.onRetry),
};
}),
];
});
refetchUser();
},
});
} else {
for (const failure of rolesResult.value ?? []) {
const context = `Role '${failure.roleName}'`;
errors.push({
context,
apiError: toSaveApiError(failure.error),
onRetry: makeRoleRetry(context, failure.onRetry),
});
}
}
if (errors.length > 0) {
setSaveErrors(errors);
} else {
toast.success('Member details updated successfully', {
richColors: true,
position: 'top-right',
});
onComplete();
}
refetchUser();
} finally {
setIsSaving(false);
}
}, [
member,
isDirty,
isSelf,
localDisplayName,
localRoles,
fetchedDisplayName,
fetchedRoleIds,
updateMyUser,
updateUser,
applyDiff,
availableRoles,
refetchUser,
retryNameUpdate,
makeRoleRetry,
onComplete,
]);
const handleDelete = useCallback((): void => {
if (!member) {
return;
}
deleteUser({
pathParams: { id: member.id },
});
deleteUser({ pathParams: { id: member.id } });
}, [member, deleteUser]);
const handleGenerateResetLink = useCallback(async (): Promise<void> => {
@@ -176,29 +351,28 @@ function EditMemberDrawer({
} finally {
setIsGeneratingLink(false);
}
}, [member, isInvited, setLinkType, onClose]);
}, [member, isInvited, onClose]);
const [copyState, copyToClipboard] = useCopyToClipboard();
const handleCopyResetLink = useCallback(async (): Promise<void> => {
const handleCopyResetLink = useCallback((): void => {
if (!resetLink) {
return;
}
copyToClipboard(resetLink);
setHasCopiedResetLink(true);
setTimeout(() => setHasCopiedResetLink(false), 2000);
toast.success(
const message =
linkType === 'invite'
? 'Invite link copied to clipboard'
: 'Reset link copied to clipboard',
{ richColors: true },
);
: 'Reset link copied to clipboard';
toast.success(message, { richColors: true, position: 'top-right' });
}, [resetLink, copyToClipboard, linkType]);
useEffect(() => {
if (copyState.error) {
toast.error('Failed to copy link', {
richColors: true,
position: 'top-right',
});
}
}, [copyState.error]);
@@ -210,102 +384,183 @@ function EditMemberDrawer({
const joinedOnLabel = isInvited ? 'Invited On' : 'Joined On';
const drawerContent = (
<div className="edit-member-drawer__layout">
<div className="edit-member-drawer__body">
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-name">
Name
</label>
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
if (!ts) {
return '—';
}
const d = new Date(ts);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
},
[formatTimezoneAdjustedTimestamp],
);
const drawerBody = isFetchingUser ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : (
<>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-name">
Name
</label>
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<Input
id="member-name"
value={displayName}
onChange={(e): void => setDisplayName(e.target.value)}
value={localDisplayName}
onChange={(e): void => {
setLocalDisplayName(e.target.value);
setSaveErrors((prev) =>
prev.filter((err) => err.context !== 'Name update'),
);
}}
className="edit-member-drawer__input"
placeholder="Enter name"
disabled={isRootUser}
/>
</div>
</Tooltip>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-email">
Email Address
</label>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<span className="edit-member-drawer__email-text">
{member?.email || '—'}
</span>
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
</div>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-role">
Roles
</label>
<Select
id="member-role"
value={selectedRole}
onChange={(role): void => setSelectedRole(role as ROLES)}
className="edit-member-drawer__role-select"
suffixIcon={<ChevronDown size={14} />}
getPopupContainer={popupContainer}
>
<Select.Option value="ADMIN">{capitalize('ADMIN')}</Select.Option>
<Select.Option value="EDITOR">{capitalize('EDITOR')}</Select.Option>
<Select.Option value="VIEWER">{capitalize('VIEWER')}</Select.Option>
</Select>
</div>
<div className="edit-member-drawer__meta">
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Status</span>
{member?.status === MemberStatus.Active ? (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : (
<Badge color="amber" variant="outline">
INVITED
</Badge>
)}
</div>
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
</div>
{!isInvited && (
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Last Modified</span>
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
</div>
)}
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-email">
Email Address
</label>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<span className="edit-member-drawer__email-text">
{member?.email || '—'}
</span>
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
</div>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-role">
Roles
</label>
{isSelf || isRootUser ? (
<Tooltip
title={isRootUser ? ROOT_USER_TOOLTIP : 'You cannot modify your own role'}
>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<div className="edit-member-drawer__disabled-roles">
{localRoles.length > 0 ? (
localRoles.map((roleId) => {
const role = availableRoles.find((r) => r.id === roleId);
return (
<Badge key={roleId} color="vanilla">
{role?.name ?? roleId}
</Badge>
);
})
) : (
<span className="edit-member-drawer__email-text"></span>
)}
</div>
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
</div>
</Tooltip>
) : (
<RolesSelect
id="member-role"
mode="multiple"
roles={availableRoles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={refetchRoles}
value={localRoles}
onChange={(roles): void => {
setLocalRoles(roles);
setSaveErrors((prev) =>
prev.filter(
(err) =>
err.context !== 'Roles update' && !err.context.startsWith("Role '"),
),
);
}}
className="edit-member-drawer__role-select"
placeholder="Select roles"
/>
)}
</div>
<div className="edit-member-drawer__meta">
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Status</span>
{member?.status === MemberStatus.Active ? (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : (
<Badge color="amber" variant="outline">
INVITED
</Badge>
)}
</div>
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
</div>
{!isInvited && (
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Last Modified</span>
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
</div>
)}
</div>
{saveErrors.length > 0 && (
<div className="edit-member-drawer__save-errors">
{saveErrors.map((e) => (
<SaveErrorItem
key={e.context}
context={e.context}
apiError={e.apiError}
onRetry={e.onRetry}
/>
))}
</div>
)}
</>
);
const drawerContent = (
<div className="edit-member-drawer__layout">
<div className="edit-member-drawer__body">{drawerBody}</div>
<div className="edit-member-drawer__footer">
<div className="edit-member-drawer__footer-left">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => setShowDeleteConfirm(true)}
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
</span>
</Tooltip>
<div className="edit-member-drawer__footer-divider" />
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleGenerateResetLink}
disabled={isGeneratingLink}
>
<RefreshCw size={12} />
{isGeneratingLink
? 'Generating...'
: isInvited
? 'Copy Invite Link'
: 'Generate Password Reset Link'}
</Button>
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser}
>
<RefreshCw size={12} />
{isGeneratingLink && 'Generating...'}
{!isGeneratingLink && isInvited && 'Copy Invite Link'}
{!isGeneratingLink && !isInvited && 'Generate Password Reset Link'}
</Button>
</span>
</Tooltip>
</div>
<div className="edit-member-drawer__footer-right">
@@ -318,7 +573,7 @@ function EditMemberDrawer({
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving}
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
@@ -328,22 +583,6 @@ function EditMemberDrawer({
</div>
);
const deleteDialogTitle = isInvited ? 'Revoke Invite' : 'Delete Member';
const deleteDialogBody = isInvited ? (
<>
Are you sure you want to revoke the invite for{' '}
<strong>{member?.email}</strong>? They will no longer be able to join the
workspace using this invite.
</>
) : (
<>
Are you sure you want to delete{' '}
<strong>{member?.name || member?.email}</strong>? This will remove their
access to the workspace.
</>
);
const deleteConfirmLabel = isInvited ? 'Revoke Invite' : 'Delete Member';
return (
<>
<DrawerWrapper
@@ -363,82 +602,25 @@ function EditMemberDrawer({
className="edit-member-drawer"
/>
<DialogWrapper
<ResetLinkDialog
open={showResetLinkDialog}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setShowResetLinkDialog(false);
setLinkType(null);
}
linkType={linkType}
resetLink={resetLink}
hasCopied={hasCopiedResetLink}
onClose={(): void => {
setShowResetLinkDialog(false);
}}
title={linkType === 'invite' ? 'Invite Link' : 'Password Reset Link'}
showCloseButton
width="base"
className="reset-link-dialog"
>
<div className="reset-link-dialog__content">
<p className="reset-link-dialog__description">
{linkType === 'invite'
? 'Share this one-time link with the team member to complete their account setup.'
: 'This creates a one-time link the team member can use to set a new password for their SigNoz account.'}
</p>
<div className="reset-link-dialog__link-row">
<div className="reset-link-dialog__link-text-wrap">
<span className="reset-link-dialog__link-text">{resetLink}</span>
</div>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={handleCopyResetLink}
prefixIcon={
hasCopiedResetLink ? <Check size={12} /> : <Copy size={12} />
}
className="reset-link-dialog__copy-btn"
>
{hasCopiedResetLink ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
</DialogWrapper>
onCopy={handleCopyResetLink}
/>
<DialogWrapper
<DeleteMemberDialog
open={showDeleteConfirm}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setShowDeleteConfirm(false);
}
}}
title={deleteDialogTitle}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="delete-dialog__body">{deleteDialogBody}</p>
<DialogFooter className="delete-dialog__footer">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setShowDeleteConfirm(false)}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isDeleting}
onClick={handleDelete}
>
<Trash2 size={12} />
{isDeleting ? 'Processing...' : deleteConfirmLabel}
</Button>
</DialogFooter>
</DialogWrapper>
isInvited={isInvited}
member={member}
isDeleting={isDeleting}
onClose={(): void => setShowDeleteConfirm(false)}
onConfirm={handleDelete}
/>
</>
);
}

View File

@@ -0,0 +1,61 @@
import { Button } from '@signozhq/button';
import { DialogWrapper } from '@signozhq/dialog';
import { Check, Copy } from '@signozhq/icons';
interface ResetLinkDialogProps {
open: boolean;
linkType: 'invite' | 'reset' | null;
resetLink: string | null;
hasCopied: boolean;
onClose: () => void;
onCopy: () => void;
}
function ResetLinkDialog({
open,
linkType,
resetLink,
hasCopied,
onClose,
onCopy,
}: ResetLinkDialogProps): JSX.Element {
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={linkType === 'invite' ? 'Invite Link' : 'Password Reset Link'}
showCloseButton
width="base"
className="reset-link-dialog"
>
<div className="reset-link-dialog__content">
<p className="reset-link-dialog__description">
{linkType === 'invite'
? 'Share this one-time link with the team member to complete their account setup.'
: 'This creates a one-time link the team member can use to set a new password for their SigNoz account.'}
</p>
<div className="reset-link-dialog__link-row">
<div className="reset-link-dialog__link-text-wrap">
<span className="reset-link-dialog__link-text">{resetLink}</span>
</div>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={onCopy}
prefixIcon={hasCopied ? <Check size={12} /> : <Copy size={12} />}
className="reset-link-dialog__copy-btn"
>
{hasCopied ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
</DialogWrapper>
);
}
export default ResetLinkDialog;

View File

@@ -4,11 +4,19 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getResetPasswordToken,
useDeleteUser,
useUpdateUserDeprecated,
useGetUser,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
useUpdateMyUserV2,
useUpdateUser,
} from 'api/generated/services/users';
import { MemberStatus } from 'container/MembersSettings/utils';
import {
listRolesSuccessResponse,
managedRoles,
} from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { ROLES } from 'types/roles';
import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
@@ -44,7 +52,11 @@ jest.mock('@signozhq/dialog', () => ({
jest.mock('api/generated/services/users', () => ({
useDeleteUser: jest.fn(),
useUpdateUserDeprecated: jest.fn(),
useGetUser: jest.fn(),
useUpdateUser: jest.fn(),
useUpdateMyUserV2: jest.fn(),
useSetRoleByUserID: jest.fn(),
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
getResetPasswordToken: jest.fn(),
}));
@@ -69,25 +81,53 @@ jest.mock('react-use', () => ({
],
}));
const mockUpdateMutate = jest.fn();
const ROLES_ENDPOINT = '*/api/v1/roles';
const mockDeleteMutate = jest.fn();
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
const mockFetchedUser = {
data: {
id: 'user-1',
displayName: 'Alice Smith',
email: 'alice@signoz.io',
status: 'active',
userRoles: [
{
id: 'ur-1',
roleId: managedRoles[0].id,
role: managedRoles[0], // signoz-admin
},
],
},
};
const activeMember = {
id: 'user-1',
name: 'Alice Smith',
email: 'alice@signoz.io',
role: 'ADMIN' as ROLES,
status: MemberStatus.Active,
joinedOn: '1700000000000',
updatedAt: '1710000000000',
};
const selfMember = {
...activeMember,
id: 'some-user-id',
};
const rootMockFetchedUser = {
data: {
...mockFetchedUser.data,
id: 'root-user-1',
isRoot: true,
},
};
const invitedMember = {
id: 'abc123',
name: '',
email: 'bob@signoz.io',
role: 'VIEWER' as ROLES,
status: MemberStatus.Invited,
joinedOn: '1700000000000',
};
@@ -109,8 +149,30 @@ function renderDrawer(
describe('EditMemberDrawer', () => {
beforeEach(() => {
jest.clearAllMocks();
(useUpdateUserDeprecated as jest.Mock).mockReturnValue({
mutate: mockUpdateMutate,
server.use(
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
(useGetUser as jest.Mock).mockReturnValue({
data: mockFetchedUser,
isLoading: false,
refetch: jest.fn(),
});
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useUpdateMyUserV2 as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useDeleteUser as jest.Mock).mockReturnValue({
@@ -119,6 +181,10 @@ describe('EditMemberDrawer', () => {
});
});
afterEach(() => {
server.resetHandlers();
});
it('renders active member details and disables Save when form is not dirty', () => {
renderDrawer();
@@ -130,16 +196,15 @@ describe('EditMemberDrawer', () => {
).toBeDisabled();
});
it('enables Save after editing name and calls update API on confirm', async () => {
it('enables Save after editing name and calls updateUser on confirm', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockMutateAsync = jest.fn().mockResolvedValue({});
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
mutate: mockUpdateMutate.mockImplementation(() => {
options?.mutation?.onSuccess?.();
}),
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: mockMutateAsync,
isLoading: false,
}));
});
renderDrawer({ onComplete });
@@ -153,12 +218,92 @@ describe('EditMemberDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(mockUpdateMutate).toHaveBeenCalledWith(
expect.objectContaining({
pathParams: { id: 'user-1' },
data: expect.objectContaining({ displayName: 'Alice Updated' }),
}),
);
expect(mockMutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
data: { displayName: 'Alice Updated' },
});
expect(onComplete).toHaveBeenCalled();
});
});
it('does not close the drawer after a successful save', async () => {
const onClose = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer({ onClose });
const nameInput = screen.getByDisplayValue('Alice Smith');
await user.clear(nameInput);
await user.type(nameInput, 'Alice Updated');
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(
screen.getByRole('button', { name: /save member details/i }),
).toBeInTheDocument();
});
expect(onClose).not.toHaveBeenCalled();
});
it('calls setRole when a new role is added', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: mockSet,
isLoading: false,
});
renderDrawer({ onComplete });
// Open the roles dropdown and select signoz-editor
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-editor'));
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
data: { name: 'signoz-editor' },
});
expect(onComplete).toHaveBeenCalled();
});
});
it('calls removeRole when an existing role is removed', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockRemove = jest.fn().mockResolvedValue({});
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
mutateAsync: mockRemove,
isLoading: false,
});
renderDrawer({ onComplete });
// Wait for the signoz-admin tag to appear, then click its remove button
const adminTag = await screen.findByTitle('signoz-admin');
const removeBtn = adminTag.querySelector(
'.ant-select-selection-item-remove',
) as Element;
await user.click(removeBtn);
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(mockRemove).toHaveBeenCalledWith({
pathParams: { id: 'user-1', roleId: managedRoles[0].id },
});
expect(onComplete).toHaveBeenCalled();
});
});
@@ -239,16 +384,33 @@ describe('EditMemberDrawer', () => {
});
});
it('calls update API when saving changes for an invited member', async () => {
it('calls updateUser when saving name change for an invited member', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockMutateAsync = jest.fn().mockResolvedValue({});
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
mutate: mockUpdateMutate.mockImplementation(() => {
options?.mutation?.onSuccess?.();
}),
(useGetUser as jest.Mock).mockReturnValue({
data: {
data: {
...mockFetchedUser.data,
id: 'abc123',
displayName: 'Bob',
userRoles: [
{
id: 'ur-2',
roleId: managedRoles[2].id,
role: managedRoles[2], // signoz-viewer
},
],
},
},
isLoading: false,
}));
refetch: jest.fn(),
});
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: mockMutateAsync,
isLoading: false,
});
renderDrawer({ member: { ...invitedMember, name: 'Bob' }, onComplete });
@@ -261,12 +423,10 @@ describe('EditMemberDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(mockUpdateMutate).toHaveBeenCalledWith(
expect.objectContaining({
pathParams: { id: 'abc123' },
data: expect.objectContaining({ displayName: 'Bob Updated' }),
}),
);
expect(mockMutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'abc123' },
data: { displayName: 'Bob Updated' },
});
expect(onComplete).toHaveBeenCalled();
});
});
@@ -280,16 +440,13 @@ describe('EditMemberDrawer', () => {
} as ReturnType<typeof convertToApiError>);
});
it('shows API error message when updateUser fails', async () => {
it('shows SaveErrorItem when updateUser fails for name change', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
mutate: mockUpdateMutate.mockImplementation(() => {
options?.mutation?.onError?.({});
}),
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockRejectedValue(new Error('server error')),
isLoading: false,
}));
});
renderDrawer();
@@ -302,10 +459,9 @@ describe('EditMemberDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to update member details: Something went wrong on server',
expect.anything(),
);
expect(
screen.getByText('Name update: Something went wrong on server'),
).toBeInTheDocument();
});
});
@@ -364,6 +520,96 @@ describe('EditMemberDrawer', () => {
});
});
describe('self user (isSelf)', () => {
it('disables Delete button when viewing own profile', () => {
renderDrawer({ member: selfMember });
expect(
screen.getByRole('button', { name: /delete member/i }),
).toBeDisabled();
});
it('does not open delete confirm dialog when Delete is clicked while disabled (isSelf)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer({ member: selfMember });
await user.click(screen.getByRole('button', { name: /delete member/i }));
expect(
screen.queryByText(/are you sure you want to delete/i),
).not.toBeInTheDocument();
});
it('keeps name input enabled when viewing own profile', () => {
renderDrawer({ member: selfMember });
expect(screen.getByDisplayValue('Alice Smith')).not.toBeDisabled();
});
it('keeps Reset Link button enabled when viewing own profile', () => {
renderDrawer({ member: selfMember });
expect(
screen.getByRole('button', { name: /generate password reset link/i }),
).not.toBeDisabled();
});
});
describe('root user', () => {
beforeEach(() => {
(useGetUser as jest.Mock).mockReturnValue({
data: rootMockFetchedUser,
isLoading: false,
refetch: jest.fn(),
});
});
it('disables name input for root user', () => {
renderDrawer();
expect(screen.getByDisplayValue('Alice Smith')).toBeDisabled();
});
it('disables Delete button for root user', () => {
renderDrawer();
expect(
screen.getByRole('button', { name: /delete member/i }),
).toBeDisabled();
});
it('disables Reset Link button for root user', () => {
renderDrawer();
expect(
screen.getByRole('button', { name: /generate password reset link/i }),
).toBeDisabled();
});
it('disables Save button for root user', () => {
renderDrawer();
expect(
screen.getByRole('button', { name: /save member details/i }),
).toBeDisabled();
});
it('does not open delete confirm dialog when Delete is clicked while disabled (root)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer();
await user.click(screen.getByRole('button', { name: /delete member/i }));
expect(
screen.queryByText(/are you sure you want to delete/i),
).not.toBeInTheDocument();
});
it('does not call getResetPasswordToken when Reset Link is clicked while disabled (root)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer();
await user.click(
screen.getByRole('button', { name: /generate password reset link/i }),
);
expect(mockGetResetPasswordToken).not.toHaveBeenCalled();
});
});
describe('Generate Password Reset Link', () => {
beforeEach(() => {
mockCopyToClipboard.mockClear();

View File

@@ -0,0 +1,52 @@
import { useEffect } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { useGlobalTimeStore } from 'store/globalTime/globalTimeStore';
import { createCustomTimeRange } from 'store/globalTime/utils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
/**
* Adapter component that syncs Redux global time state to Zustand store.
* This component should be rendered once at the app level.
*
* It reads from the Redux globalTime reducer and updates the Zustand store
* to provide a migration path from Redux to Zustand.
*/
export function GlobalTimeStoreAdapter(): null {
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const setSelectedTime = useGlobalTimeStore((s) => s.setSelectedTime);
useEffect(() => {
// Convert the selectedTime to the new format
// If it's 'custom', store the min/max times in the custom format
const selectedTime =
globalTime.selectedTime === 'custom'
? createCustomTimeRange(globalTime.minTime, globalTime.maxTime)
: (globalTime.selectedTime as Time);
// Find refresh interval from Redux state
const refreshOption = refreshIntervalOptions.find(
(option) => option.key === globalTime.selectedAutoRefreshInterval,
);
const refreshInterval =
!globalTime.isAutoRefreshDisabled && refreshOption ? refreshOption.value : 0;
setSelectedTime(selectedTime, refreshInterval);
}, [
globalTime.selectedTime,
globalTime.isAutoRefreshDisabled,
globalTime.selectedAutoRefreshInterval,
globalTime.minTime,
globalTime.maxTime,
setSelectedTime,
]);
return null;
}

View File

@@ -0,0 +1,227 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { act, render, renderHook } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
import { useGlobalTimeStore } from 'store/globalTime/globalTimeStore';
import { createCustomTimeRange } from 'store/globalTime/utils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { GlobalTimeStoreAdapter } from '../GlobalTimeStoreAdapter';
const mockStore = configureStore<Partial<AppState>>([]);
const randomTime = 1700000000000000000;
describe('GlobalTimeStoreAdapter', () => {
let store: MockStoreEnhanced<Partial<AppState>>;
const createGlobalTimeState = (
overrides: Partial<GlobalReducer> = {},
): GlobalReducer => ({
minTime: randomTime,
maxTime: randomTime,
loading: false,
selectedTime: '15m',
isAutoRefreshDisabled: true,
selectedAutoRefreshInterval: 'off',
...overrides,
});
beforeEach(() => {
// Reset Zustand store before each test
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
it('should render null because it just an adapter', () => {
store = mockStore({
globalTime: createGlobalTimeState(),
});
const { container } = render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
expect(container.firstChild).toBeNull();
});
it('should sync relative time from Redux to Zustand store', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: true,
selectedAutoRefreshInterval: 'off',
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe('15m');
expect(result.current.refreshInterval).toBe(0);
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should sync custom time from Redux to Zustand store', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: 'custom',
minTime: randomTime,
maxTime: randomTime,
isAutoRefreshDisabled: true,
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(
createCustomTimeRange(randomTime, randomTime),
);
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should sync refresh interval when auto refresh is enabled', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '5s',
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe('15m');
expect(result.current.refreshInterval).toBe(5000); // 5s = 5000ms
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should set refreshInterval to 0 when auto refresh is disabled', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: true,
selectedAutoRefreshInterval: '5s', // Even with interval set, should be 0 when disabled
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should update Zustand store when Redux state changes', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: true,
}),
});
const { rerender } = render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
// Verify initial state
let zustandState = renderHook(() => useGlobalTimeStore());
expect(zustandState.result.current.selectedTime).toBe('15m');
// Update Redux store
const newStore = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '1h',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '30s',
}),
});
rerender(
<Provider store={newStore}>
<GlobalTimeStoreAdapter />
</Provider>,
);
// Verify updated state
zustandState = renderHook(() => useGlobalTimeStore());
expect(zustandState.result.current.selectedTime).toBe('1h');
expect(zustandState.result.current.refreshInterval).toBe(30000); // 30s = 30000ms
expect(zustandState.result.current.isRefreshEnabled).toBe(true);
});
it('should handle various refresh interval options', () => {
const testCases = [
{ key: '5s', expectedValue: 5000 },
{ key: '10s', expectedValue: 10000 },
{ key: '30s', expectedValue: 30000 },
{ key: '1m', expectedValue: 60000 },
{ key: '5m', expectedValue: 300000 },
];
testCases.forEach(({ key, expectedValue }) => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: key,
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(expectedValue);
});
});
it('should handle unknown refresh interval by setting 0', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: 'unknown-interval',
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
expect(result.current.isRefreshEnabled).toBe(false);
});
});

View File

@@ -197,13 +197,16 @@ function InviteMembersModal({
})),
});
}
toast.success('Invites sent successfully', { richColors: true });
toast.success('Invites sent successfully', {
richColors: true,
position: 'top-right',
});
resetAndClose();
onComplete?.();
} catch (err) {
const apiErr = err as APIError;
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
toast.error(errorMessage, { richColors: true });
toast.error(errorMessage, { richColors: true, position: 'top-right' });
} finally {
setIsSubmitting(false);
}

View File

@@ -28,14 +28,22 @@
}
}
// In table/column view, keep action buttons visible at the viewport's right edge
.log-line-action-buttons.table-view-log-actions {
position: absolute;
top: 50%;
right: 8px;
left: auto;
transform: translateY(-50%);
margin: 0;
z-index: 5;
}
.lightMode {
.log-line-action-buttons {
border: 1px solid var(--bg-vanilla-400);
background: var(--bg-vanilla-400);
.ant-btn-default {
}
.copy-log-btn {
border-left: 1px solid var(--bg-vanilla-400);
border-color: var(--bg-vanilla-400) !important;

View File

@@ -15,13 +15,12 @@ export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
letterSpacing: '-0.07px',
marginBottom: '0px',
minWidth: '10rem',
width: '10rem',
width: 'auto',
};
}
export const defaultTableStyle: CSSProperties = {
minWidth: '40rem',
maxWidth: '90rem',
};
export const defaultListViewPanelStyle: CSSProperties = {

View File

@@ -33,7 +33,7 @@
display: flex;
align-items: center;
.ant-typography {
p {
margin-bottom: 0;
}
}
@@ -45,6 +45,7 @@
}
.paragraph {
margin: 0;
padding: 0px !important;
&.small {
font-size: 11px !important;

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { TableColumnsType as ColumnsType, Typography } from 'antd';
import { TableColumnsType as ColumnsType } from 'antd';
import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
@@ -43,7 +43,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
const bodyColumnStyle = useMemo(
() => ({
...defaultTableStyle,
...(fields.length > 2 ? { width: '50rem' } : {}),
...(fields.length > 2 ? { width: 'auto' } : {}),
}),
[fields.length],
);
@@ -59,18 +59,18 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
key: name,
render: (field): ColumnTypeRender<Record<string, unknown>> => ({
props: {
style: isListViewPanel
? defaultListViewPanelStyle
: getDefaultCellStyle(isDarkMode),
style: {
...(isListViewPanel
? defaultListViewPanelStyle
: getDefaultCellStyle(isDarkMode)),
display: '-webkit-box',
WebkitLineClamp: linesPerRow,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-all',
},
},
children: (
<Typography.Paragraph
ellipsis={{ rows: linesPerRow }}
className={cx('paragraph', fontSize)}
>
{field}
</Typography.Paragraph>
),
children: <p className={cx('paragraph', fontSize)}>{field}</p>,
}),
}));
@@ -123,9 +123,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
return {
children: (
<div className="table-timestamp">
<Typography.Paragraph ellipsis className={cx('text', fontSize)}>
{date}
</Typography.Paragraph>
<p className={cx('text', fontSize)}>{date}</p>
</div>
),
};

View File

@@ -4,9 +4,7 @@ import { Table, Tooltip } from 'antd';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { MemberStatus } from 'container/MembersSettings/utils';
import { capitalize } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { ROLES } from 'types/roles';
import './MembersTable.styles.scss';
@@ -14,7 +12,6 @@ export interface MemberRow {
id: string;
name?: string;
email: string;
role: ROLES;
status: MemberStatus;
joinedOn: string | null;
updatedAt?: string | null;
@@ -141,17 +138,6 @@ function MembersTable({
<NameEmailCell name={record.name} email={record.email} />
),
},
{
title: 'Roles',
dataIndex: 'role',
key: 'role',
width: 180,
sorter: (a, b): number => a.role.localeCompare(b.role),
render: (role: ROLES): JSX.Element => (
<Badge color="vanilla">{capitalize(role)}</Badge>
),
},
{
title: 'Status',
dataIndex: 'status',

View File

@@ -1,6 +1,5 @@
import { MemberStatus } from 'container/MembersSettings/utils';
import { render, screen, userEvent } from 'tests/test-utils';
import { ROLES } from 'types/roles';
import MembersTable, { MemberRow } from '../MembersTable';
@@ -9,7 +8,6 @@ const mockActiveMembers: MemberRow[] = [
id: 'user-1',
name: 'Alice Smith',
email: 'alice@signoz.io',
role: 'ADMIN' as ROLES,
status: MemberStatus.Active,
joinedOn: '1700000000000',
},
@@ -17,7 +15,6 @@ const mockActiveMembers: MemberRow[] = [
id: 'user-2',
name: 'Bob Jones',
email: 'bob@signoz.io',
role: 'VIEWER' as ROLES,
status: MemberStatus.Active,
joinedOn: null,
},
@@ -27,7 +24,6 @@ const mockInvitedMember: MemberRow = {
id: 'inv-abc',
name: '',
email: 'charlie@signoz.io',
role: 'EDITOR' as ROLES,
status: MemberStatus.Invited,
joinedOn: null,
};
@@ -47,12 +43,11 @@ describe('MembersTable', () => {
jest.clearAllMocks();
});
it('renders member rows with name, email, role badge, and ACTIVE status', () => {
it('renders member rows with name, email, and ACTIVE status', () => {
render(<MembersTable {...defaultProps} data={mockActiveMembers} />);
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
expect(screen.getByText('alice@signoz.io')).toBeInTheDocument();
expect(screen.getByText('Admin')).toBeInTheDocument();
expect(screen.getAllByText('ACTIVE')).toHaveLength(2);
});
@@ -67,7 +62,6 @@ describe('MembersTable', () => {
expect(screen.getByText('INVITED')).toBeInTheDocument();
expect(screen.getByText('charlie@signoz.io')).toBeInTheDocument();
expect(screen.getByText('Editor')).toBeInTheDocument();
});
it('calls onRowClick with the member data when a row is clicked', async () => {
@@ -99,7 +93,6 @@ describe('MembersTable', () => {
id: 'user-del',
name: 'Dave Deleted',
email: 'dave@signoz.io',
role: 'VIEWER' as ROLES,
status: MemberStatus.Deleted,
joinedOn: null,
};

View File

@@ -1,21 +1,35 @@
.quick-filters-container {
display: flex;
flex-direction: row;
height: 100%;
min-height: 0;
position: relative;
.quick-filters-settings-container {
flex: 0 0 0;
width: 0;
min-width: 0;
overflow: visible;
position: relative;
align-self: stretch;
}
}
.quick-filters {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
height: 100%;
width: 100%;
min-height: 0;
border-right: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
.overlay-scrollbar {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;

View File

@@ -2,6 +2,8 @@
display: flex;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
z-index: 999;
width: 342px;
background: var(--bg-slate-500);

View File

@@ -165,7 +165,17 @@ function KeysTab({
return (
<div className="keys-tab__empty">
<KeyRound size={24} className="keys-tab__empty-icon" />
<p className="keys-tab__empty-text">No keys. Start by creating one.</p>
<p className="keys-tab__empty-text">
No keys. Start by creating one.{' '}
<a
href="https://signoz.io/docs/manage/administrator-guide/iam/service-accounts/#step-3-generate-an-api-key"
target="_blank"
rel="noopener noreferrer"
className="keys-tab__learn-more"
>
Learn more
</a>
</p>
<Button
type="button"
className="keys-tab__learn-more"

View File

@@ -294,6 +294,7 @@ function ServiceAccountDrawer({
} else {
toast.success('Service account updated successfully', {
richColors: true,
position: 'top-right',
});
onSuccess({ closeDrawer: false });
}

View File

@@ -12,6 +12,7 @@ export enum LOCALSTORAGE {
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
LOGGED_IN_USER_EMAIL = 'LOGGED_IN_USER_EMAIL',
CHAT_SUPPORT = 'CHAT_SUPPORT',

View File

@@ -1,4 +1,11 @@
export const REACT_QUERY_KEY = {
/**
* For any query that should support AutoRefresh and min/max time is from DateTimeSelectionV2
* You can prefix the query with this KEY, it will allow the queries to be automatically refreshed
* when the user clicks in the refresh button, or alert the user when the data is being refreshed.
*/
AUTO_REFRESH_QUERY: 'AUTO_REFRESH_QUERY',
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',

View File

@@ -248,5 +248,35 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
roles: ['ADMIN', 'EDITOR'],
perform: (): void => navigate(ROUTES.BILLING),
},
{
id: 'my-settings-service-accounts',
name: 'Go to Service Accounts',
shortcut: [GlobalShortcutsName.NavigateToSettingsServiceAccounts],
keywords: 'settings service accounts',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN'],
perform: (): void => navigate(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
},
{
id: 'my-settings-roles',
name: 'Go to Roles',
shortcut: [GlobalShortcutsName.NavigateToSettingsRoles],
keywords: 'settings roles',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN'],
perform: (): void => navigate(ROUTES.ROLES_SETTINGS),
},
{
id: 'my-settings-members',
name: 'Go to Members',
shortcut: [GlobalShortcutsName.NavigateToSettingsMembers],
keywords: 'settings members',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN'],
perform: (): void => navigate(ROUTES.MEMBERS_SETTINGS),
},
];
}

View File

@@ -27,6 +27,9 @@ export const GlobalShortcuts = {
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
};
export const GlobalShortcutsName = {
@@ -47,6 +50,9 @@ export const GlobalShortcutsName = {
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
NavigateToLogs: 'shift+l',
NavigateToLogsPipelines: 'shift+l+p',
NavigateToLogsViews: 'shift+l+v',
@@ -74,4 +80,7 @@ export const GlobalShortcutsDescription = {
'Navigate to Notification Channels Settings',
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
NavigateToLogsViews: 'Navigate to Logs Views',
NavigateToSettingsServiceAccounts: 'Navigate to Service Accounts Settings',
NavigateToSettingsRoles: 'Navigate to Roles Settings',
NavigateToSettingsMembers: 'Navigate to Members Settings',
};

View File

@@ -3,12 +3,12 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Compass, Dot, House, Plus, Wrench } from '@signozhq/icons';
import { PersistedAnnouncementBanner } from '@signozhq/ui';
import { Button, Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetMetricsOnboardingStatus } from 'api/generated/services/metrics';
import listUserPreferences from 'api/v1/user/preferences/list';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { PersistedAnnouncementBanner } from 'components/AnnouncementBanner';
import Header from 'components/Header/Header';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -265,20 +265,19 @@ export default function Home(): JSX.Element {
return (
<div className="home-container">
<PersistedAnnouncementBanner
type="warning"
type="info"
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
message={
<>
<strong>API Keys</strong> have been deprecated and replaced by{' '}
<strong>Service Accounts</strong>. Please migrate to Service Accounts for
programmatic API access.
</>
}
action={{
label: 'Go to Service Accounts',
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
}}
/>
>
<>
<strong>API keys</strong> have been deprecated in favour of{' '}
<strong>Service accounts</strong>. The existing API Keys have been migrated
to service accounts.
</>
</PersistedAnnouncementBanner>
<div className="sticky-header">
<Header

View File

@@ -1,3 +1,16 @@
.live-logs-container {
display: flex;
flex: 1;
min-height: 0;
}
.live-logs-content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.live-logs-chart-container {
height: 200px;
min-height: 200px;
@@ -5,6 +18,12 @@
border-right: none;
}
.live-logs-list-container {
display: flex;
flex: 1;
min-height: 0;
}
.live-logs-settings-panel {
display: flex;
align-items: center;

View File

@@ -18,6 +18,19 @@
}
.live-logs-list {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
.ant-card,
.ant-card-body {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
}
.live-logs-list-loading {
padding: 16px;
text-align: center;

View File

@@ -8,8 +8,8 @@ import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
import InfinityTableView from 'container/LogsExplorerList/InfinityTableView';
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
import TanStackTableView from 'container/LogsExplorerList/TanStackTableView';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
@@ -50,7 +50,7 @@ function LiveLogsList({
[logs],
);
const { options } = useOptionsMenu({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator: StringOperators.NOOP,
@@ -158,7 +158,7 @@ function LiveLogsList({
{formattedLogs.length !== 0 && (
<InfinityWrapperStyled>
{options.format === OptionFormatTypes.TABLE ? (
<InfinityTableView
<TanStackTableView
ref={ref}
isLoading={false}
tableViewProps={{
@@ -174,6 +174,7 @@ function LiveLogsList({
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
activeLog={activeLog}
onRemoveColumn={config.addColumn?.onRemove}
/>
) : (
<Card style={{ width: '100%' }} bodyStyle={CARD_BODY_STYLE}>

View File

@@ -284,6 +284,15 @@ export default function TableViewActions(
error,
);
}
// If the value is valid JSON (object or array), pretty-print it for copying
try {
const parsed = JSON.parse(text);
if (typeof parsed === 'object' && parsed !== null) {
return JSON.stringify(parsed, null, 2);
}
} catch {
// not JSON, return as-is
}
return text;
}, [fieldData.value]);

View File

@@ -4,18 +4,11 @@ import { FontSize } from 'container/OptionsMenu/types';
export const infinityDefaultStyles: CSSProperties = {
width: '100%',
overflowX: 'scroll',
marginTop: '15px',
};
export function getInfinityDefaultStyles(fontSize: FontSize): CSSProperties {
export function getInfinityDefaultStyles(_fontSize: FontSize): CSSProperties {
return {
width: '100%',
overflowX: 'scroll',
marginTop:
fontSize === FontSize.SMALL
? '10px'
: fontSize === FontSize.MEDIUM
? '12px'
: '15px',
};
}

View File

@@ -14,6 +14,78 @@ interface TableHeaderCellStyledProps {
export const TableStyled = styled.table`
width: 100%;
border-collapse: separate;
border-spacing: 0;
`;
/**
* TanStack column sizing uses table-layout:fixed + colgroup widths; without clipping,
* cell content overflows visually on top of neighbouring columns (overlap / "ghost" text).
*/
export const TanStackTableStyled = styled(TableStyled)`
table-layout: fixed;
width: 100%;
min-width: 100%;
max-width: 100%;
& td,
& th {
overflow: hidden;
min-width: 0;
box-sizing: border-box;
vertical-align: middle;
}
& td.table-actions-cell {
overflow: visible;
}
& td.body {
word-break: break-word;
overflow-wrap: anywhere;
}
/* Let nested body HTML / line-clamp shrink inside fixed columns */
& td.body > * {
min-width: 0;
max-width: 100%;
}
/* Long column titles: ellipsis when wider than the column (TanStackHeaderRow) */
& thead th .tanstack-header-title {
min-width: 0;
flex: 1 1 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
& thead th .tanstack-header-title > * {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
& td.logs-table-filler-cell,
& th.logs-table-filler-header {
padding: 0 !important;
min-width: 0;
border-left: none;
}
& th.logs-table-actions-header {
position: sticky;
right: 0;
z-index: 2;
width: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
padding: 0 !important;
overflow: visible;
white-space: nowrap;
border-left: none;
}
`;
const getTimestampColumnWidth = (
@@ -46,6 +118,19 @@ export const TableCellStyled = styled.td<TableHeaderCellStyledProps>`
${({ columnKey, $hasSingleColumn }): string =>
getTimestampColumnWidth(columnKey, $hasSingleColumn)}
&.table-actions-cell {
position: sticky;
right: 0;
z-index: 2;
width: 0;
min-width: 0;
max-width: 0;
padding: 0 !important;
white-space: nowrap;
overflow: visible;
background-color: inherit;
}
`;
export const TableRowStyled = styled.tr<{
@@ -64,7 +149,10 @@ export const TableRowStyled = styled.tr<{
position: relative;
.log-line-action-buttons {
display: none;
display: flex;
opacity: 0;
pointer-events: none;
transition: opacity 80ms linear;
}
&:hover {
@@ -73,13 +161,25 @@ export const TableRowStyled = styled.tr<{
getActiveLogBackground(true, $isDarkMode, $logType)}
}
.log-line-action-buttons {
display: flex;
opacity: 1;
pointer-events: auto;
}
}
${({ $isActiveLog }): string =>
$isActiveLog
? `
.log-line-action-buttons {
opacity: 1;
pointer-events: auto;
}
`
: ''}
`;
export const TableHeaderCellStyled = styled.th<TableHeaderCellStyledProps>`
padding: 0.5rem;
height: 36px;
text-align: left;
font-size: 14px;
font-style: normal;
font-weight: 400;
@@ -98,6 +198,12 @@ export const TableHeaderCellStyled = styled.th<TableHeaderCellStyledProps>`
: ``};
${({ $isLogIndicator }): string =>
$isLogIndicator ? 'padding: 0px; width: 1%;' : ''}
border-top: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
box-shadow: inset 0 -1px 0 var(--l2-border);
&:first-child {
border-left: 1px solid var(--l2-border);
}
color: ${(props): string =>
props.$isDarkMode ? 'var(--bg-vanilla-100, #fff)' : themeColors.bckgGrey};

View File

@@ -5,6 +5,8 @@ import { ILog } from 'types/api/logs/log';
export type InfinityTableProps = {
isLoading?: boolean;
isFetching?: boolean;
onRemoveColumn?: (columnKey: string) => void;
tableViewProps: Omit<UseTableViewProps, 'onOpenLogsContext' | 'onClickExpand'>;
infitiyTableProps?: {
onEndReached: (index: number) => void;

View File

@@ -8,7 +8,7 @@
line-height: 18px;
letter-spacing: -0.005em;
text-align: left;
min-height: 500px;
min-height: 0;
.logs-list-table-view-container {
.data-table-container {
@@ -24,11 +24,11 @@
color: white !important;
.cursor-col-resize {
width: 3px !important;
width: 24px !important;
cursor: col-resize !important;
opacity: 0.5 !important;
background-color: var(--bg-ink-500) !important;
border: 1px solid var(--bg-ink-500) !important;
opacity: 1 !important;
background-color: transparent !important;
border: none !important;
&:hover {
opacity: 1 !important;
@@ -85,7 +85,7 @@
}
thead {
z-index: 0 !important;
z-index: 2 !important;
}
.log-state-indicator {

View File

@@ -0,0 +1,8 @@
// eslint-disable-next-line no-restricted-imports
import { createContext, useContext } from 'react';
const RowHoverContext = createContext(false);
export const useRowHover = (): boolean => useContext(RowHoverContext);
export default RowHoverContext;

View File

@@ -0,0 +1,84 @@
import { ComponentProps, memo, useCallback, useState } from 'react';
import { TableComponents } from 'react-virtuoso';
import {
getLogIndicatorType,
getLogIndicatorTypeForTable,
} from 'components/Logs/LogStateIndicator/utils';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ILog } from 'types/api/logs/log';
import { TableRowStyled } from '../InfinityTableView/styles';
import RowHoverContext from '../RowHoverContext';
import { TanStackTableRowData } from './types';
export type TableRowContext = {
activeLog?: ILog | null;
activeContextLog?: ILog | null;
logsById: Map<string, ILog>;
};
type VirtuosoTableRowProps = ComponentProps<
NonNullable<TableComponents<TanStackTableRowData, TableRowContext>['TableRow']>
>;
type TanStackCustomTableRowProps = VirtuosoTableRowProps;
function TanStackCustomTableRow({
children,
item,
context,
...props
}: TanStackCustomTableRowProps): JSX.Element {
const { isHighlighted } = useCopyLogLink(item.currentLog.id);
const isDarkMode = useIsDarkMode();
const [hasHovered, setHasHovered] = useState(false);
const rowId = String(item.currentLog.id ?? '');
const activeLog = context?.activeLog;
const activeContextLog = context?.activeContextLog;
const logsById = context?.logsById;
const rowLog = logsById?.get(rowId) || item.currentLog;
const logType = rowLog
? getLogIndicatorType(rowLog)
: getLogIndicatorTypeForTable(item.log);
const handleMouseEnter = useCallback(() => {
if (!hasHovered) {
setHasHovered(true);
}
}, [hasHovered]);
return (
<RowHoverContext.Provider value={hasHovered}>
<TableRowStyled
{...props}
$isDarkMode={isDarkMode}
$isActiveLog={
isHighlighted ||
rowId === String(activeLog?.id ?? '') ||
rowId === String(activeContextLog?.id ?? '')
}
$logType={logType}
onMouseEnter={handleMouseEnter}
>
{children}
</TableRowStyled>
</RowHoverContext.Provider>
);
}
export default memo(TanStackCustomTableRow, (prev, next) => {
const prevId = String(prev.item.currentLog.id ?? '');
const nextId = String(next.item.currentLog.id ?? '');
if (prevId !== nextId) {
return false;
}
const prevIsActive =
prevId === String(prev.context?.activeLog?.id ?? '') ||
prevId === String(prev.context?.activeContextLog?.id ?? '');
const nextIsActive =
nextId === String(next.context?.activeLog?.id ?? '') ||
nextId === String(next.context?.activeContextLog?.id ?? '');
return prevIsActive === nextIsActive;
});

View File

@@ -0,0 +1,193 @@
import type {
CSSProperties,
MouseEvent as ReactMouseEvent,
TouchEvent as ReactTouchEvent,
} from 'react';
import { useMemo } from 'react';
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
import { useSortable } from '@dnd-kit/sortable';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/popover';
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
import { GripVertical } from 'lucide-react';
import { TableHeaderCellStyled } from '../InfinityTableView/styles';
import { InfinityTableProps } from '../InfinityTableView/types';
import { OrderedColumn, TanStackTableRowData } from './types';
import { getColumnId } from './utils';
import './styles/TanStackHeaderRow.styles.scss';
type TanStackHeaderRowProps = {
column: OrderedColumn;
header?: TanStackHeader<TanStackTableRowData, unknown>;
isDarkMode: boolean;
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
hasSingleColumn: boolean;
canRemoveColumn?: boolean;
onRemoveColumn?: (columnKey: string) => void;
};
const GRIP_ICON_SIZE = 12;
// eslint-disable-next-line sonarjs/cognitive-complexity
function TanStackHeaderRow({
column,
header,
isDarkMode,
fontSize,
hasSingleColumn,
canRemoveColumn = false,
onRemoveColumn,
}: TanStackHeaderRowProps): JSX.Element {
const columnId = getColumnId(column);
const isDragColumn =
column.key !== 'expand' && column.key !== 'state-indicator';
const isResizableColumn = Boolean(header?.column.getCanResize());
const isColumnRemovable = Boolean(
canRemoveColumn &&
onRemoveColumn &&
column.key !== 'expand' &&
column.key !== 'state-indicator',
);
const isResizing = Boolean(header?.column.getIsResizing());
const resizeHandler = header?.getResizeHandler();
const headerText =
typeof column.title === 'string' && column.title
? column.title
: String(header?.id ?? columnId);
const headerTitleAttr = headerText.replace(/^\w/, (c) => c.toUpperCase());
const handleResizeStart = (
event: ReactMouseEvent<HTMLElement> | ReactTouchEvent<HTMLElement>,
): void => {
event.preventDefault();
event.stopPropagation();
resizeHandler?.(event);
};
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: columnId,
disabled: !isDragColumn,
});
const headerCellStyle = useMemo(
() =>
({
'--tanstack-header-translate-x': `${Math.round(transform?.x ?? 0)}px`,
'--tanstack-header-translate-y': `${Math.round(transform?.y ?? 0)}px`,
'--tanstack-header-transition': isResizing ? 'none' : transition || 'none',
} as CSSProperties),
[isResizing, transform?.x, transform?.y, transition],
);
const headerCellClassName = [
'tanstack-header-cell',
isDragging ? 'is-dragging' : '',
isResizing ? 'is-resizing' : '',
]
.filter(Boolean)
.join(' ');
const headerContentClassName = [
'tanstack-header-content',
isResizableColumn ? 'has-resize-control' : '',
isColumnRemovable ? 'has-action-control' : '',
]
.filter(Boolean)
.join(' ');
return (
<TableHeaderCellStyled
ref={setNodeRef}
$isLogIndicator={column.key === 'state-indicator'}
$isDarkMode={isDarkMode}
$isDragColumn={false}
className={headerCellClassName}
key={columnId}
fontSize={fontSize}
$hasSingleColumn={hasSingleColumn}
style={headerCellStyle}
>
<span className={headerContentClassName}>
{isDragColumn ? (
<span className="tanstack-grip-slot">
<span
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
role="button"
aria-label={`Drag ${String(
column.title || header?.id || columnId,
)} column`}
className="tanstack-grip-activator"
>
<GripVertical size={GRIP_ICON_SIZE} />
</span>
</span>
) : null}
<span className="tanstack-header-title" title={headerTitleAttr}>
{header
? flexRender(header.column.columnDef.header, header.getContext())
: String(column.title || '').replace(/^\w/, (c) => c.toUpperCase())}
</span>
{isColumnRemovable && (
<Popover>
<PopoverTrigger asChild>
<span
role="button"
aria-label={`Column actions for ${headerTitleAttr}`}
className="tanstack-header-action-trigger"
onMouseDown={(event): void => {
event.stopPropagation();
}}
>
<MoreOutlined />
</span>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={6}
className="tanstack-column-actions-content"
>
<button
type="button"
className="tanstack-remove-column-action"
onClick={(event): void => {
event.preventDefault();
event.stopPropagation();
onRemoveColumn?.(String(column.key));
}}
>
<CloseOutlined className="tanstack-remove-column-action-icon" />
Remove column
</button>
</PopoverContent>
</Popover>
)}
</span>
{isResizableColumn && (
<span
role="presentation"
className="cursor-col-resize"
title="Drag to resize column"
onClick={(event): void => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event): void => {
handleResizeStart(event);
}}
onTouchStart={(event): void => {
handleResizeStart(event);
}}
>
<span className="tanstack-resize-handle-line" />
</span>
)}
</TableHeaderCellStyled>
);
}
export default TanStackHeaderRow;

View File

@@ -0,0 +1,95 @@
import {
MouseEvent as ReactMouseEvent,
MouseEventHandler,
useCallback,
} from 'react';
import { flexRender, Row as TanStackRowModel } from '@tanstack/react-table';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
import { TableCellStyled } from '../InfinityTableView/styles';
import { InfinityTableProps } from '../InfinityTableView/types';
import { useRowHover } from '../RowHoverContext';
import { TanStackTableRowData } from './types';
type TanStackRowCellsProps = {
row: TanStackRowModel<TanStackTableRowData>;
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
onSetActiveLog?: InfinityTableProps['onSetActiveLog'];
onClearActiveLog?: InfinityTableProps['onClearActiveLog'];
isActiveLog?: boolean;
isDarkMode: boolean;
onLogCopy: (logId: string, event: ReactMouseEvent<HTMLElement>) => void;
isLogsExplorerPage: boolean;
};
function TanStackRowCells({
row,
fontSize,
onSetActiveLog,
onClearActiveLog,
isActiveLog = false,
isDarkMode,
onLogCopy,
isLogsExplorerPage,
}: TanStackRowCellsProps): JSX.Element {
const { currentLog } = row.original;
const hasHovered = useRowHover();
const handleShowContext: MouseEventHandler<HTMLElement> = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
onSetActiveLog?.(currentLog, VIEW_TYPES.CONTEXT);
},
[currentLog, onSetActiveLog],
);
const handleShowLogDetails = useCallback(() => {
if (!currentLog) {
return;
}
if (isActiveLog && onClearActiveLog) {
onClearActiveLog();
return;
}
onSetActiveLog?.(currentLog);
}, [currentLog, isActiveLog, onClearActiveLog, onSetActiveLog]);
const visibleCells = row.getVisibleCells();
const lastCellIndex = visibleCells.length - 1;
return (
<>
{visibleCells.map((cell, index) => {
const columnKey = cell.column.id;
const isLastCell = index === lastCellIndex;
return (
<TableCellStyled
$isDragColumn={false}
$isLogIndicator={columnKey === 'state-indicator'}
$hasSingleColumn={visibleCells.length <= 2}
$isDarkMode={isDarkMode}
key={cell.id}
fontSize={fontSize}
className={columnKey}
onClick={handleShowLogDetails}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{isLastCell && isLogsExplorerPage && hasHovered && (
<LogLinesActionButtons
handleShowContext={handleShowContext}
onLogCopy={(event): void => onLogCopy(currentLog.id, event)}
customClassName="table-view-log-actions"
/>
)}
</TableCellStyled>
);
})}
</>
);
}
export default TanStackRowCells;

View File

@@ -0,0 +1,105 @@
import { render, screen } from '@testing-library/react';
import TanStackCustomTableRow, {
TableRowContext,
} from '../TanStackCustomTableRow';
import type { TanStackTableRowData } from '../types';
jest.mock('../../InfinityTableView/styles', () => ({
TableRowStyled: 'tr',
}));
jest.mock('hooks/logs/useCopyLogLink', () => ({
useCopyLogLink: (): { isHighlighted: boolean } => ({ isHighlighted: false }),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('components/Logs/LogStateIndicator/utils', () => ({
getLogIndicatorType: (): string => 'info',
getLogIndicatorTypeForTable: (): string => 'info',
}));
const item: TanStackTableRowData = {
log: {},
currentLog: { id: 'row-1' } as TanStackTableRowData['currentLog'],
rowIndex: 0,
};
const virtuosoTableRowAttrs = {
'data-index': 0,
'data-item-index': 0,
'data-known-size': 40,
} as const;
const defaultContext: TableRowContext = {
activeLog: null,
activeContextLog: null,
logsById: new Map(),
};
describe('TanStackCustomTableRow', () => {
it('renders children inside TableRowStyled', () => {
render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoTableRowAttrs}
item={item}
context={defaultContext}
>
<td>cell</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(screen.getByText('cell')).toBeInTheDocument();
});
it('marks row active when activeLog matches item id', () => {
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoTableRowAttrs}
item={item}
context={{
...defaultContext,
activeLog: { id: 'row-1' } as never,
}}
>
<td>x</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
const row = container.querySelector('tr');
expect(row).toBeTruthy();
});
it('uses logsById entry when present for indicator type', () => {
const logFromMap = { id: 'row-1', severity_text: 'error' } as never;
render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoTableRowAttrs}
item={item}
context={{
...defaultContext,
logsById: new Map([['row-1', logFromMap]]),
}}
>
<td>x</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(screen.getByText('x')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,152 @@
import type { Header } from '@tanstack/react-table';
import { render, screen } from '@testing-library/react';
import { FontSize } from 'container/OptionsMenu/types';
import TanStackHeaderRow from '../TanStackHeaderRow';
import type { OrderedColumn, TanStackTableRowData } from '../types';
jest.mock('../../InfinityTableView/styles', () => ({
TableHeaderCellStyled: 'th',
}));
const mockUseSortable = jest.fn((_args?: unknown) => ({
attributes: {},
listeners: {},
setNodeRef: jest.fn(),
setActivatorNodeRef: jest.fn(),
transform: null,
transition: undefined,
isDragging: false,
}));
jest.mock('@dnd-kit/sortable', () => ({
useSortable: (args: unknown): ReturnType<typeof mockUseSortable> =>
mockUseSortable(args),
}));
jest.mock('@tanstack/react-table', () => ({
flexRender: (def: unknown, ctx: unknown): unknown => {
if (typeof def === 'string') {
return def;
}
if (typeof def === 'function') {
return (def as (c: unknown) => unknown)(ctx);
}
return def;
},
}));
const column = (key: string): OrderedColumn =>
({ key, title: key } as OrderedColumn);
const mockHeader = (
id: string,
canResize = true,
): Header<TanStackTableRowData, unknown> =>
(({
id,
column: {
getCanResize: (): boolean => canResize,
getIsResizing: (): boolean => false,
columnDef: { header: id },
},
getContext: (): unknown => ({}),
getResizeHandler: (): (() => void) => jest.fn(),
flexRender: undefined,
} as unknown) as Header<TanStackTableRowData, unknown>);
describe('TanStackHeaderRow', () => {
beforeEach(() => {
mockUseSortable.mockClear();
});
it('renders column title when header is undefined', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('timestamp')}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(screen.getByText('Timestamp')).toBeInTheDocument();
});
it('enables useSortable for draggable columns', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('body')}
header={mockHeader('body')}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(mockUseSortable).toHaveBeenCalledWith(
expect.objectContaining({
id: 'body',
disabled: false,
}),
);
});
it('disables sortable for expand column', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('expand')}
header={mockHeader('expand', false)}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(mockUseSortable).toHaveBeenCalledWith(
expect.objectContaining({
disabled: true,
}),
);
});
it('shows drag grip for draggable columns', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('body')}
header={mockHeader('body')}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(
screen.getByRole('button', { name: /Drag body column/i }),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,193 @@
import { fireEvent, render, screen } from '@testing-library/react';
import RowHoverContext from 'container/LogsExplorerList/RowHoverContext';
import { FontSize } from 'container/OptionsMenu/types';
import TanStackRowCells from '../TanStackRow';
import type { TanStackTableRowData } from '../types';
jest.mock('../../InfinityTableView/styles', () => ({
TableCellStyled: 'td',
}));
jest.mock(
'components/Logs/LogLinesActionButtons/LogLinesActionButtons',
() => ({
__esModule: true,
default: ({
onLogCopy,
}: {
onLogCopy: (e: React.MouseEvent) => void;
}): JSX.Element => (
<button type="button" data-testid="copy-btn" onClick={onLogCopy}>
copy
</button>
),
}),
);
const flexRenderMock = jest.fn((def: unknown, _ctx?: unknown) =>
typeof def === 'function' ? def({}) : def,
);
jest.mock('@tanstack/react-table', () => ({
flexRender: (def: unknown, ctx: unknown): unknown => flexRenderMock(def, ctx),
}));
function buildMockRow(
visibleCells: Array<{ columnId: string }>,
): Parameters<typeof TanStackRowCells>[0]['row'] {
return {
original: {
currentLog: { id: 'log-1' } as TanStackTableRowData['currentLog'],
log: {},
rowIndex: 0,
},
getVisibleCells: () =>
visibleCells.map((cell, index) => ({
id: `cell-${index}`,
column: {
id: cell.columnId,
columnDef: {
cell: (): string => `content-${cell.columnId}`,
},
},
getContext: (): Record<string, unknown> => ({}),
})),
} as never;
}
describe('TanStackRowCells', () => {
beforeEach(() => {
flexRenderMock.mockClear();
});
it('renders a cell per visible column and calls flexRender', () => {
const row = buildMockRow([
{ columnId: 'state-indicator' },
{ columnId: 'body' },
]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
expect(screen.getAllByRole('cell')).toHaveLength(2);
expect(flexRenderMock).toHaveBeenCalled();
});
it('applies state-indicator styling class on the indicator cell', () => {
const row = buildMockRow([{ columnId: 'state-indicator' }]);
const { container } = render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
expect(container.querySelector('td.state-indicator')).toBeInTheDocument();
});
it('renders row actions on logs explorer page after hover', () => {
const row = buildMockRow([{ columnId: 'body' }]);
render(
<RowHoverContext.Provider value>
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onLogCopy={jest.fn()}
isLogsExplorerPage
/>
</tr>
</tbody>
</table>
</RowHoverContext.Provider>,
);
expect(screen.getByTestId('copy-btn')).toBeInTheDocument();
});
it('click on a data cell calls onSetActiveLog with current log', () => {
const onSetActiveLog = jest.fn();
const row = buildMockRow([{ columnId: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onSetActiveLog={onSetActiveLog}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0]);
expect(onSetActiveLog).toHaveBeenCalledWith(
expect.objectContaining({ id: 'log-1' }),
);
});
it('when row is active log, click on cell clears active log', () => {
const onSetActiveLog = jest.fn();
const onClearActiveLog = jest.fn();
const row = buildMockRow([{ columnId: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
isActiveLog
onSetActiveLog={onSetActiveLog}
onClearActiveLog={onClearActiveLog}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0]);
expect(onClearActiveLog).toHaveBeenCalled();
expect(onSetActiveLog).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,104 @@
import { forwardRef } from 'react';
import { render, screen } from '@testing-library/react';
import { FontSize } from 'container/OptionsMenu/types';
import type { InfinityTableProps } from '../../InfinityTableView/types';
import TanStackTableView from '../index';
jest.mock('react-virtuoso', () => ({
TableVirtuoso: forwardRef<
unknown,
{
fixedHeaderContent?: () => JSX.Element;
itemContent: (i: number) => JSX.Element;
}
>(function MockVirtuoso({ fixedHeaderContent, itemContent }, _ref) {
return (
<div data-testid="virtuoso">
{fixedHeaderContent?.()}
{itemContent(0)}
</div>
);
}),
}));
jest.mock('components/Logs/TableView/useTableView', () => ({
useTableView: (): {
dataSource: Record<string, string>[];
columns: unknown[];
} => ({
dataSource: [{ id: '1' }],
columns: [
{ key: 'body', title: 'body', render: (): string => 'x' },
{ key: 'state-indicator', title: 's', render: (): string => 'y' },
],
}),
}));
jest.mock('hooks/useDragColumns', () => ({
__esModule: true,
default: (): {
draggedColumns: unknown[];
onColumnOrderChange: () => void;
} => ({
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
}));
jest.mock('hooks/logs/useActiveLog', () => ({
useActiveLog: (): { activeLog: null } => ({ activeLog: null }),
}));
jest.mock('hooks/logs/useCopyLogLink', () => ({
useCopyLogLink: (): { activeLogId: null } => ({ activeLogId: null }),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('react-router-dom', () => ({
useLocation: (): { pathname: string } => ({ pathname: '/logs' }),
}));
jest.mock('react-use', () => ({
useCopyToClipboard: (): [unknown, () => void] => [null, jest.fn()],
}));
jest.mock('@signozhq/sonner', () => ({
toast: { success: jest.fn() },
}));
jest.mock('components/Spinner', () => ({
__esModule: true,
default: ({ tip }: { tip: string }): JSX.Element => (
<div data-testid="spinner">{tip}</div>
),
}));
const baseProps: InfinityTableProps = {
isLoading: false,
tableViewProps: {
logs: [{ id: '1' } as never],
fields: [],
linesPerRow: 3,
fontSize: FontSize.SMALL,
appendTo: 'end',
activeLogIndex: 0,
},
};
describe('TanStackTableView', () => {
it('shows spinner while loading', () => {
render(<TanStackTableView {...baseProps} isLoading />);
expect(screen.getByTestId('spinner')).toHaveTextContent('Getting Logs');
});
it('renders virtuoso when not loading', () => {
render(<TanStackTableView {...baseProps} />);
expect(screen.getByTestId('virtuoso')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,173 @@
import { act, renderHook } from '@testing-library/react';
import { LOCALSTORAGE } from 'constants/localStorage';
import type { OrderedColumn } from '../types';
import { useColumnSizingPersistence } from '../useColumnSizingPersistence';
const mockGet = jest.fn();
const mockSet = jest.fn();
jest.mock('api/browser/localstorage/get', () => ({
__esModule: true,
default: (key: string): string | null => mockGet(key),
}));
jest.mock('api/browser/localstorage/set', () => ({
__esModule: true,
default: (key: string, value: string): void => {
mockSet(key, value);
},
}));
const col = (key: string): OrderedColumn =>
({ key, title: key } as OrderedColumn);
describe('useColumnSizingPersistence', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGet.mockReturnValue(null);
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('initializes with empty sizing when localStorage is empty', () => {
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('timestamp')]),
);
expect(result.current.columnSizing).toEqual({});
});
it('parses flat ColumnSizingState from localStorage', () => {
mockGet.mockReturnValue(JSON.stringify({ body: 400, timestamp: 180 }));
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('timestamp')]),
);
expect(result.current.columnSizing).toEqual({ body: 400, timestamp: 180 });
});
it('parses PersistedColumnSizing wrapper with sizing + columnIdsSignature', () => {
mockGet.mockReturnValue(
JSON.stringify({
version: 1,
columnIdsSignature: 'body|timestamp',
sizing: { body: 300 },
}),
);
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('timestamp')]),
);
expect(result.current.columnSizing).toEqual({ body: 300 });
});
it('drops invalid numeric entries when reading from localStorage', () => {
mockGet.mockReturnValue(
JSON.stringify({
body: 200,
bad: NaN,
zero: 0,
neg: -1,
str: 'wide',
}),
);
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('bad'), col('zero')]),
);
expect(result.current.columnSizing).toEqual({ body: 200 });
});
it('returns empty sizing when JSON is invalid', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockGet.mockReturnValue('not-json');
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body')]),
);
expect(result.current.columnSizing).toEqual({});
spy.mockRestore();
});
it('prunes sizing for columns not in orderedColumns and strips fixed columns', () => {
mockGet.mockReturnValue(JSON.stringify({ body: 400, expand: 32, gone: 100 }));
const { result, rerender } = renderHook(
({ columns }: { columns: OrderedColumn[] }) =>
useColumnSizingPersistence(columns),
{
initialProps: {
columns: [
col('body'),
col('expand'),
col('state-indicator'),
] as OrderedColumn[],
},
},
);
expect(result.current.columnSizing).toEqual({ body: 400 });
act(() => {
rerender({
columns: [col('body'), col('expand'), col('state-indicator')],
});
});
expect(result.current.columnSizing).toEqual({ body: 400 });
});
it('updates setColumnSizing manually', () => {
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body')]),
);
act(() => {
result.current.setColumnSizing({ body: 500 });
});
expect(result.current.columnSizing).toEqual({ body: 500 });
});
it('debounces writes to localStorage', () => {
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body')]),
);
act(() => {
result.current.setColumnSizing({ body: 600 });
});
expect(mockSet).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(250);
});
expect(mockSet).toHaveBeenCalledWith(
LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING,
expect.stringContaining('"body":600'),
);
});
it('does not persist when ordered columns signature effect runs with empty ids early — still debounces empty sizing', () => {
const { result } = renderHook(() => useColumnSizingPersistence([]));
expect(result.current.columnSizing).toEqual({});
act(() => {
jest.advanceTimersByTime(250);
});
expect(mockSet).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,222 @@
import { act, renderHook } from '@testing-library/react';
import type { OrderedColumn } from '../types';
import { useOrderedColumns } from '../useOrderedColumns';
const mockGetDraggedColumns = jest.fn();
jest.mock('hooks/useDragColumns/utils', () => ({
getDraggedColumns: <T,>(current: unknown[], dragged: unknown[]): T[] =>
mockGetDraggedColumns(current, dragged) as T[],
}));
const col = (key: string, title?: string): OrderedColumn =>
({ key, title: title ?? key } as OrderedColumn);
describe('useOrderedColumns', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns columns from getDraggedColumns filtered to keys with string or number', () => {
mockGetDraggedColumns.mockReturnValue([
col('body'),
col('timestamp'),
{ title: 'no-key' },
]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.orderedColumns).toEqual([
col('body'),
col('timestamp'),
]);
expect(result.current.orderedColumnIds).toEqual(['body', 'timestamp']);
});
it('hasSingleColumn is true when exactly one column is not state-indicator', () => {
mockGetDraggedColumns.mockReturnValue([col('state-indicator'), col('body')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.hasSingleColumn).toBe(true);
});
it('hasSingleColumn is false when more than one non-state-indicator column exists', () => {
mockGetDraggedColumns.mockReturnValue([
col('state-indicator'),
col('body'),
col('timestamp'),
]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.hasSingleColumn).toBe(false);
});
it('handleDragEnd reorders columns and calls onColumnOrderChange', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b'), col('c')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
act(() => {
result.current.handleDragEnd({
active: { id: 'a' },
over: { id: 'c' },
} as never);
});
expect(onColumnOrderChange).toHaveBeenCalledWith([
expect.objectContaining({ key: 'b' }),
expect.objectContaining({ key: 'c' }),
expect.objectContaining({ key: 'a' }),
]);
// Derived-only: orderedColumns should remain until draggedColumns (URL/localStorage) updates.
expect(result.current.orderedColumns.map((c) => c.key)).toEqual([
'a',
'b',
'c',
]);
});
it('handleDragEnd no-ops when over is null', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
const before = result.current.orderedColumns;
act(() => {
result.current.handleDragEnd({
active: { id: 'a' },
over: null,
} as never);
});
expect(result.current.orderedColumns).toBe(before);
expect(onColumnOrderChange).not.toHaveBeenCalled();
});
it('handleDragEnd no-ops when active.id equals over.id', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
act(() => {
result.current.handleDragEnd({
active: { id: 'a' },
over: { id: 'a' },
} as never);
});
expect(onColumnOrderChange).not.toHaveBeenCalled();
});
it('handleDragEnd no-ops when indices cannot be resolved', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
act(() => {
result.current.handleDragEnd({
active: { id: 'missing' },
over: { id: 'a' },
} as never);
});
expect(onColumnOrderChange).not.toHaveBeenCalled();
});
it('exposes sensors from useSensors', () => {
mockGetDraggedColumns.mockReturnValue([col('a')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.sensors).toBeDefined();
});
it('syncs ordered columns when base order changes externally (e.g. URL / localStorage)', () => {
mockGetDraggedColumns.mockReturnValue([col('a'), col('b'), col('c')]);
const { result, rerender } = renderHook(
({ draggedColumns }: { draggedColumns: unknown[] }) =>
useOrderedColumns({
columns: [],
draggedColumns,
onColumnOrderChange: jest.fn(),
}),
{ initialProps: { draggedColumns: [] as unknown[] } },
);
expect(result.current.orderedColumns.map((column) => column.key)).toEqual([
'a',
'b',
'c',
]);
mockGetDraggedColumns.mockReturnValue([col('c'), col('b'), col('a')]);
act(() => {
rerender({ draggedColumns: [{ title: 'from-url' }] as unknown[] });
});
expect(result.current.orderedColumns.map((column) => column.key)).toEqual([
'c',
'b',
'a',
]);
});
});

View File

@@ -0,0 +1,433 @@
import {
forwardRef,
memo,
MouseEvent as ReactMouseEvent,
ReactElement,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
import { DndContext, pointerWithin } from '@dnd-kit/core';
import {
horizontalListSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import { toast } from '@signozhq/sonner';
import {
ColumnDef,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { ColumnTypeRender } from 'components/Logs/TableView/types';
import { useTableView } from 'components/Logs/TableView/useTableView';
import Spinner from 'components/Spinner';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDragColumns from 'hooks/useDragColumns';
import { infinityDefaultStyles } from '../InfinityTableView/config';
import { TanStackTableStyled } from '../InfinityTableView/styles';
import { InfinityTableProps } from '../InfinityTableView/types';
import TanStackCustomTableRow from './TanStackCustomTableRow';
import TanStackHeaderRow from './TanStackHeaderRow';
import TanStackRowCells from './TanStackRow';
import { TableRecord, TanStackTableRowData } from './types';
import { useColumnSizingPersistence } from './useColumnSizingPersistence';
import { useOrderedColumns } from './useOrderedColumns';
import {
getColumnId,
getColumnMinWidthPx,
resolveColumnTypeRender,
} from './utils';
import '../logsTableVirtuosoScrollbar.scss';
import './styles/TanStackTableView.styles.scss';
const COLUMN_DND_AUTO_SCROLL = {
layoutShiftCompensation: false as const,
threshold: { x: 0.2, y: 0 },
};
const TanStackTableView = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
function TanStackTableView(
{
isLoading,
isFetching,
onRemoveColumn,
tableViewProps,
infitiyTableProps,
onSetActiveLog,
onClearActiveLog,
activeLog,
}: InfinityTableProps,
forwardedRef,
): JSX.Element {
const { pathname } = useLocation();
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
// could avoid this if directly use forwardedRef in TableVirtuoso, but need to verify if it causes any issue with react-virtuoso internal ref handling
useImperativeHandle(
forwardedRef,
() => virtuosoRef.current as TableVirtuosoHandle,
[],
);
const [, setCopy] = useCopyToClipboard();
const isDarkMode = useIsDarkMode();
const isLogsExplorerPage = pathname === ROUTES.LOGS_EXPLORER;
const { activeLog: activeContextLog } = useActiveLog();
// Column definitions (shared with existing logs table)
const { dataSource, columns } = useTableView({
...tableViewProps,
onClickExpand: onSetActiveLog,
onOpenLogsContext: (log): void => onSetActiveLog?.(log, VIEW_TYPES.CONTEXT),
});
// Column order (drag + persisted order)
const { draggedColumns, onColumnOrderChange } = useDragColumns<TableRecord>(
LOCALSTORAGE.LOGS_LIST_COLUMNS,
);
const {
orderedColumns,
orderedColumnIds,
hasSingleColumn,
handleDragEnd,
sensors,
} = useOrderedColumns({
columns,
draggedColumns,
onColumnOrderChange: onColumnOrderChange as (columns: unknown[]) => void,
});
// Column sizing (persisted). stored to localStorage.
const { columnSizing, setColumnSizing } = useColumnSizingPersistence(
orderedColumns,
);
// don't allow "remove column" when only state-indicator + one data col remain
const isAtMinimumRemovableColumns = useMemo(
() =>
orderedColumns.filter(
(column) => column.key !== 'state-indicator' && column.key !== 'expand',
).length <= 1,
[orderedColumns],
);
// Table data (TanStack row data shape)
// useTableView sends flattened log data. this would not be needed once we move to new log details view
const tableData = useMemo<TanStackTableRowData[]>(
() =>
dataSource
.map((log, rowIndex) => {
const currentLog = tableViewProps.logs[rowIndex];
if (!currentLog) {
return null;
}
return { log, currentLog, rowIndex };
})
.filter(Boolean) as TanStackTableRowData[],
[dataSource, tableViewProps.logs],
);
// TanStack columns + table instance
const tanstackColumns = useMemo<ColumnDef<TanStackTableRowData>[]>(
() =>
orderedColumns.map((column, index) => {
const isStateIndicator = column.key === 'state-indicator';
const isExpand = column.key === 'expand';
const isFixedColumn = isStateIndicator || isExpand;
const fixedWidth = isFixedColumn ? 32 : undefined;
const minWidthPx = getColumnMinWidthPx(column, orderedColumns);
const headerTitle = String(column.title || '');
return {
id: getColumnId(column),
header: headerTitle.replace(/^\w/, (character) =>
character.toUpperCase(),
),
accessorFn: (row): unknown => row.log[column.key as keyof TableRecord],
enableResizing: !isFixedColumn && index !== orderedColumns.length - 1,
minSize: fixedWidth ?? minWidthPx,
size: fixedWidth, // last column gets remaining space, so don't set initial size to avoid conflict with resizing
maxSize: fixedWidth,
cell: ({ row, getValue }): ReactElement | string | number | null => {
if (!column.render) {
return null;
}
return resolveColumnTypeRender(
column.render(
getValue(),
row.original.log,
row.original.rowIndex,
) as ColumnTypeRender<Record<string, unknown>>,
);
},
};
}),
[orderedColumns],
);
const table = useReactTable({
data: tableData,
columns: tanstackColumns,
enableColumnResizing: true,
getCoreRowModel: getCoreRowModel(),
columnResizeMode: 'onChange',
onColumnSizingChange: setColumnSizing,
state: {
columnSizing,
},
});
const tableRows = table.getRowModel().rows;
// Infinite-scroll footer UI state
const [loadMoreState, setLoadMoreState] = useState<{
active: boolean;
startCount: number;
}>({
active: false,
startCount: 0,
});
// Map to resolve full log object by id (row highlighting + indicator)
const logsById = useMemo(
() => new Map(tableViewProps.logs.map((log) => [String(log.id), log])),
[tableViewProps.logs],
);
// this is already written in parent. Check if this is needed.
useEffect(() => {
const activeLogIndex = tableViewProps.activeLogIndex ?? -1;
if (activeLogIndex < 0 || activeLogIndex >= tableRows.length) {
return;
}
virtuosoRef.current?.scrollToIndex({
index: activeLogIndex,
align: 'center',
behavior: 'auto',
});
}, [tableRows.length, tableViewProps.activeLogIndex]);
useEffect(() => {
if (!loadMoreState.active) {
return;
}
if (!isFetching || tableRows.length > loadMoreState.startCount) {
setLoadMoreState((prev) =>
prev.active ? { active: false, startCount: prev.startCount } : prev,
);
}
}, [isFetching, loadMoreState, tableRows.length]);
const handleLogCopy = useCallback(
(logId: string, event: ReactMouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
const urlQuery = new URLSearchParams(window.location.search);
urlQuery.delete(QueryParams.activeLogId);
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
setCopy(link);
toast.success('Copied to clipboard', { position: 'top-right' });
},
[pathname, setCopy],
);
const itemContent = useCallback(
(index: number): JSX.Element | null => {
const row = tableRows[index];
if (!row) {
return null;
}
return (
<TanStackRowCells
row={row}
fontSize={tableViewProps.fontSize}
onSetActiveLog={onSetActiveLog}
onClearActiveLog={onClearActiveLog}
isActiveLog={
String(activeLog?.id ?? '') === String(row.original.currentLog.id ?? '')
}
isDarkMode={isDarkMode}
onLogCopy={handleLogCopy}
isLogsExplorerPage={isLogsExplorerPage}
/>
);
},
[
activeLog?.id,
handleLogCopy,
isDarkMode,
isLogsExplorerPage,
onClearActiveLog,
onSetActiveLog,
tableRows,
tableViewProps.fontSize,
],
);
const flatHeaders = useMemo(
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tanstackColumns],
);
const tableHeader = useCallback(() => {
const orderedColumnsById = new Map(
orderedColumns.map((column) => [getColumnId(column), column] as const),
);
return (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragEnd={handleDragEnd}
autoScroll={COLUMN_DND_AUTO_SCROLL}
>
<SortableContext
items={orderedColumnIds}
strategy={horizontalListSortingStrategy}
>
<tr>
{flatHeaders.map((header) => {
const column = orderedColumnsById.get(header.id);
if (!column) {
return null;
}
return (
<TanStackHeaderRow
key={header.id}
column={column}
header={header}
isDarkMode={isDarkMode}
fontSize={tableViewProps.fontSize}
hasSingleColumn={hasSingleColumn}
onRemoveColumn={onRemoveColumn}
canRemoveColumn={!isAtMinimumRemovableColumns}
/>
);
})}
</tr>
</SortableContext>
</DndContext>
);
}, [
flatHeaders,
handleDragEnd,
hasSingleColumn,
isDarkMode,
orderedColumnIds,
orderedColumns,
onRemoveColumn,
isAtMinimumRemovableColumns,
sensors,
tableViewProps.fontSize,
]);
const handleEndReached = useCallback(
(index: number): void => {
if (!infitiyTableProps?.onEndReached) {
return;
}
setLoadMoreState({
active: true,
startCount: tableRows.length,
});
infitiyTableProps.onEndReached(index);
},
[infitiyTableProps, tableRows.length],
);
if (isLoading) {
return <Spinner height="35px" tip="Getting Logs" />;
}
return (
<div className="tanstack-table-view-wrapper">
<TableVirtuoso
className="logs-table-virtuoso-scroll"
ref={virtuosoRef}
style={infinityDefaultStyles}
data={tableData}
totalCount={tableRows.length}
increaseViewportBy={{ top: 500, bottom: 500 }}
initialTopMostItemIndex={
tableViewProps.activeLogIndex !== -1 ? tableViewProps.activeLogIndex : 0
}
context={{ activeLog, activeContextLog, logsById }}
fixedHeaderContent={tableHeader}
itemContent={itemContent}
components={{
Table: ({ style, children }): JSX.Element => (
<TanStackTableStyled style={style}>
<colgroup>
{orderedColumns.map((column, colIndex) => {
const columnId = getColumnId(column);
const isFixedColumn =
column.key === 'expand' || column.key === 'state-indicator';
const minWidthPx = getColumnMinWidthPx(column, orderedColumns);
const persistedWidth = columnSizing[columnId];
const computedWidth = table.getColumn(columnId)?.getSize();
const effectiveWidth = persistedWidth ?? computedWidth;
if (isFixedColumn) {
return <col key={columnId} className="tanstack-fixed-col" />;
}
// Last data column should stretch to fill remaining space
const isLastColumn = colIndex === orderedColumns.length - 1;
if (isLastColumn) {
return (
<col
key={columnId}
style={{ width: '100%', minWidth: `${minWidthPx}px` }}
/>
);
}
const widthPx =
effectiveWidth != null
? Math.max(effectiveWidth, minWidthPx)
: minWidthPx;
return (
<col
key={columnId}
style={{ width: `${widthPx}px`, minWidth: `${minWidthPx}px` }}
/>
);
})}
</colgroup>
{children}
</TanStackTableStyled>
),
TableRow: TanStackCustomTableRow,
}}
{...(infitiyTableProps?.onEndReached
? { endReached: handleEndReached }
: {})}
/>
{loadMoreState.active && (
<div className="tanstack-load-more-container">
<Spinner height="20px" tip="Getting Logs" />
</div>
)}
</div>
);
},
);
export default memo(TanStackTableView);

View File

@@ -0,0 +1,153 @@
.tanstack-header-cell {
position: sticky;
top: 0;
z-index: 2;
padding: 0;
transform: translate3d(
var(--tanstack-header-translate-x, 0px),
var(--tanstack-header-translate-y, 0px),
0
);
transition: var(--tanstack-header-transition, none);
&.is-dragging {
opacity: 0.85;
}
&.is-resizing {
background: var(--l2-background-hover);
}
}
.tanstack-header-content {
display: flex;
align-items: center;
height: 100%;
min-width: 0;
width: 100%;
cursor: default;
max-width: 100%;
&.has-resize-control {
max-width: calc(100% - 5px);
}
&.has-action-control {
max-width: calc(100% - 5px);
}
&.has-resize-control.has-action-control {
max-width: calc(100% - 10px);
}
}
.tanstack-grip-slot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin-right: 4px;
flex-shrink: 0;
}
.tanstack-grip-activator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
cursor: grab;
color: var(--l2-foreground);
opacity: 1;
touch-action: none;
}
.tanstack-header-action-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
cursor: pointer;
flex-shrink: 0;
color: var(--l2-foreground);
}
.tanstack-column-actions-content {
width: 140px;
padding: 0;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
box-shadow: none;
}
.tanstack-remove-column-action {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 32px;
padding: 0 8px;
border: none;
border-radius: 0;
background: transparent;
justify-content: flex-start;
cursor: pointer;
color: var(--l2-foreground);
font-size: 12px;
line-height: 16px;
font-weight: 500;
transition: background-color 120ms ease, color 120ms ease;
&:hover {
background: var(--l2-background-hover);
color: var(--l2-foreground);
.tanstack-remove-column-action-icon {
color: var(--l2-foreground);
}
}
}
.tanstack-remove-column-action-icon {
font-size: 11px;
color: var(--l2-foreground);
opacity: 0.95;
}
.tanstack-header-cell .cursor-col-resize {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 5px;
cursor: col-resize;
z-index: 10;
touch-action: none;
background: transparent;
}
.tanstack-header-cell.is-resizing .cursor-col-resize {
background: var(--bg-robin-300);
}
.tanstack-resize-handle-line {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 4px;
transform: translateX(-50%);
background: var(--l2-border);
opacity: 1;
pointer-events: none;
transition: background 120ms ease, width 120ms ease;
}
.tanstack-header-cell.is-resizing .tanstack-resize-handle-line {
width: 2px;
background: var(--bg-robin-500);
transition: none;
}

View File

@@ -0,0 +1,55 @@
.tanstack-table-view-wrapper {
display: flex;
flex-direction: column;
width: 100%;
min-height: 0;
}
.tanstack-fixed-col {
width: 32px;
min-width: 32px;
max-width: 32px;
}
.tanstack-filler-col {
width: 100%;
min-width: 0;
}
.tanstack-actions-col {
width: 0;
min-width: 0;
max-width: 0;
}
.tanstack-load-more-container {
width: 100%;
min-height: 56px;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 0 12px;
flex-shrink: 0;
}
.tanstack-table-virtuoso {
width: 100%;
overflow-x: scroll;
}
.tanstack-fontSize-small {
font-size: 11px;
}
.tanstack-fontSize-medium {
font-size: 13px;
}
.tanstack-fontSize-large {
font-size: 14px;
}
.tanstack-table-foot-loader-cell {
text-align: center;
padding: 8px 0;
}

View File

@@ -0,0 +1,29 @@
import { ColumnSizingState } from '@tanstack/react-table';
import { ColumnTypeRender } from 'components/Logs/TableView/types';
import { ILog } from 'types/api/logs/log';
export type TableRecord = Record<string, unknown>;
export type LogsTableColumnDef = {
key?: string | number;
title?: string;
render?: (
value: unknown,
record: TableRecord,
index: number,
) => ColumnTypeRender<Record<string, unknown>>;
};
export type OrderedColumn = LogsTableColumnDef & {
key: string | number;
};
export type TanStackTableRowData = {
log: TableRecord;
currentLog: ILog;
rowIndex: number;
};
export type PersistedColumnSizing = {
sizing: ColumnSizingState;
};

View File

@@ -0,0 +1,111 @@
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
import { ColumnSizingState } from '@tanstack/react-table';
import getFromLocalstorage from 'api/browser/localstorage/get';
import setToLocalstorage from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { OrderedColumn, PersistedColumnSizing } from './types';
import { getColumnId } from './utils';
const COLUMN_SIZING_PERSIST_DEBOUNCE_MS = 250;
const sanitizeSizing = (input: unknown): ColumnSizingState => {
if (!input || typeof input !== 'object') {
return {};
}
return Object.entries(
input as Record<string, unknown>,
).reduce<ColumnSizingState>((acc, [key, value]) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return acc;
}
acc[key] = value;
return acc;
}, {});
};
const readPersistedColumnSizing = (): ColumnSizingState => {
const rawSizing = getFromLocalstorage(LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING);
if (!rawSizing) {
return {};
}
try {
const parsed = JSON.parse(rawSizing) as
| PersistedColumnSizing
| ColumnSizingState;
const sizing = ('sizing' in parsed
? parsed.sizing
: parsed) as ColumnSizingState;
return sanitizeSizing(sizing);
} catch (error) {
console.error('Failed to parse persisted log column sizing', error);
return {};
}
};
type UseColumnSizingPersistenceResult = {
columnSizing: ColumnSizingState;
setColumnSizing: Dispatch<SetStateAction<ColumnSizingState>>;
};
export const useColumnSizingPersistence = (
orderedColumns: OrderedColumn[],
): UseColumnSizingPersistenceResult => {
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(() =>
readPersistedColumnSizing(),
);
const orderedColumnIds = useMemo(
() => orderedColumns.map((column) => getColumnId(column)),
[orderedColumns],
);
useEffect(() => {
if (orderedColumnIds.length === 0) {
return;
}
const validColumnIds = new Set(orderedColumnIds);
const nonResizableColumnIds = new Set(
orderedColumns
.filter(
(column) => column.key === 'expand' || column.key === 'state-indicator',
)
.map((column) => getColumnId(column)),
);
setColumnSizing((previousSizing) => {
const nextSizing = Object.entries(previousSizing).reduce<ColumnSizingState>(
(acc, [columnId, size]) => {
if (!validColumnIds.has(columnId) || nonResizableColumnIds.has(columnId)) {
return acc;
}
acc[columnId] = size;
return acc;
},
{},
);
const hasChanged =
Object.keys(nextSizing).length !== Object.keys(previousSizing).length ||
Object.entries(nextSizing).some(
([columnId, size]) => previousSizing[columnId] !== size,
);
return hasChanged ? nextSizing : previousSizing;
});
}, [orderedColumnIds, orderedColumns]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
const persistedSizing = { sizing: columnSizing };
setToLocalstorage(
LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING,
JSON.stringify(persistedSizing),
);
}, COLUMN_SIZING_PERSIST_DEBOUNCE_MS);
return (): void => window.clearTimeout(timeoutId);
}, [columnSizing]);
return { columnSizing, setColumnSizing };
};

View File

@@ -0,0 +1,108 @@
import { useCallback, useMemo } from 'react';
import {
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable';
import { getDraggedColumns } from 'hooks/useDragColumns/utils';
import { OrderedColumn, TableRecord } from './types';
import { getColumnId } from './utils';
type UseOrderedColumnsProps = {
columns: unknown[];
draggedColumns: unknown[];
onColumnOrderChange: (columns: unknown[]) => void;
};
type UseOrderedColumnsResult = {
orderedColumns: OrderedColumn[];
orderedColumnIds: string[];
hasSingleColumn: boolean;
handleDragEnd: (event: DragEndEvent) => void;
sensors: ReturnType<typeof useSensors>;
};
export const useOrderedColumns = ({
columns,
draggedColumns,
onColumnOrderChange,
}: UseOrderedColumnsProps): UseOrderedColumnsResult => {
const baseColumns = useMemo<OrderedColumn[]>(
() =>
getDraggedColumns<TableRecord>(
columns as never[],
draggedColumns as never[],
).filter(
(column): column is OrderedColumn =>
typeof column.key === 'string' || typeof column.key === 'number',
),
[columns, draggedColumns],
);
const orderedColumns = useMemo(() => {
const stateIndicatorIndex = baseColumns.findIndex(
(column) => column.key === 'state-indicator',
);
if (stateIndicatorIndex <= 0) {
return baseColumns;
}
const pinned = baseColumns[stateIndicatorIndex];
const rest = baseColumns.filter((_, i) => i !== stateIndicatorIndex);
return [pinned, ...rest];
}, [baseColumns]);
const handleDragEnd = useCallback(
(event: DragEndEvent): void => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
// Don't allow moving the state-indicator column
if (String(active.id) === 'state-indicator') {
return;
}
const oldIndex = orderedColumns.findIndex(
(column) => getColumnId(column) === String(active.id),
);
const newIndex = orderedColumns.findIndex(
(column) => getColumnId(column) === String(over.id),
);
if (oldIndex === -1 || newIndex === -1) {
return;
}
const nextColumns = arrayMove(orderedColumns, oldIndex, newIndex);
onColumnOrderChange(nextColumns as unknown[]);
},
[onColumnOrderChange, orderedColumns],
);
const orderedColumnIds = useMemo(
() => orderedColumns.map((column) => getColumnId(column)),
[orderedColumns],
);
const hasSingleColumn = useMemo(
() =>
orderedColumns.filter((column) => column.key !== 'state-indicator')
.length === 1,
[orderedColumns],
);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 4 },
}),
);
return {
orderedColumns,
orderedColumnIds,
hasSingleColumn,
handleDragEnd,
sensors,
};
};

View File

@@ -0,0 +1,61 @@
import { cloneElement, isValidElement, ReactElement } from 'react';
import { ColumnTypeRender } from 'components/Logs/TableView/types';
import { OrderedColumn } from './types';
export const getColumnId = (column: OrderedColumn): string =>
String(column.key);
/** Browser default root font size; TanStack column sizing uses px. */
const REM_PX = 16;
const MIN_WIDTH_OTHER_REM = 12;
const MIN_WIDTH_BODY_REM = 40;
/** When total column count is below this, body column min width is doubled (more horizontal space for few columns). */
export const FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD = 4;
/**
* Minimum width (px) for TanStack column defs + colgroup.
* Design: state/expand 32px; body min 40rem (doubled when fewer than
* {@link FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD} total columns); other columns use rem→px (16px root).
*/
export const getColumnMinWidthPx = (
column: OrderedColumn,
orderedColumns?: OrderedColumn[],
): number => {
const key = String(column.key);
if (key === 'state-indicator' || key === 'expand') {
return 32;
}
if (key === 'body') {
const base = MIN_WIDTH_BODY_REM * REM_PX;
const fewColumns =
orderedColumns != null &&
orderedColumns.length < FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD;
return fewColumns ? base * 1.5 : base;
}
return MIN_WIDTH_OTHER_REM * REM_PX;
};
export const resolveColumnTypeRender = (
rendered: ColumnTypeRender<Record<string, unknown>>,
): ReactElement | string | number | null => {
if (
rendered &&
typeof rendered === 'object' &&
'children' in rendered &&
isValidElement(rendered.children)
) {
const { children, props } = rendered as {
children: ReactElement;
props?: Record<string, unknown>;
};
return cloneElement(children, props || {});
}
if (rendered && typeof rendered === 'object' && isValidElement(rendered)) {
return rendered;
}
return typeof rendered === 'string' || typeof rendered === 'number'
? rendered
: null;
};

View File

@@ -25,9 +25,9 @@ import { ILog } from 'types/api/logs/log';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import NoLogs from '../NoLogs/NoLogs';
import InfinityTableView from './InfinityTableView';
import { LogsExplorerListProps } from './LogsExplorerList.interfaces';
import { InfinityWrapperStyled } from './styles';
import TanStackTableView from './TanStackTableView';
import {
convertKeysToColumnFields,
getEmptyLogsListConfig,
@@ -61,7 +61,7 @@ function LogsExplorerList({
handleCloseLogDetail,
} = useLogDetailHandlers();
const { options } = useOptionsMenu({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator:
@@ -155,9 +155,10 @@ function LogsExplorerList({
if (options.format === 'table') {
return (
<InfinityTableView
<TanStackTableView
ref={ref}
isLoading={isLoading}
isFetching={isFetching}
tableViewProps={{
logs,
fields: selectedFields,
@@ -172,6 +173,7 @@ function LogsExplorerList({
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
activeLog={activeLog}
onRemoveColumn={config.addColumn?.onRemove}
/>
);
}
@@ -216,11 +218,13 @@ function LogsExplorerList({
logs,
onEndReached,
getItemContent,
isFetching,
selectedFields,
handleChangeSelectedView,
handleSetActiveLog,
handleCloseLogDetail,
activeLog,
config.addColumn?.onRemove,
]);
const isTraceToLogsNavigation = useMemo(() => {

View File

@@ -0,0 +1,38 @@
.logs-table-virtuoso-scroll {
scrollbar-width: thin;
scrollbar-color: var(--bg-slate-300) transparent;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
border-radius: 9999px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
}
.lightMode .logs-table-virtuoso-scroll {
scrollbar-color: var(--bg-vanilla-300) transparent;
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-100);
}
}

View File

@@ -2,7 +2,7 @@ import styled from 'styled-components';
export const InfinityWrapperStyled = styled.div`
flex: 1;
height: 40rem !important;
display: flex;
height: 100%;
min-height: 0;
`;

View File

@@ -1,6 +1,7 @@
.logs-explorer-views-container {
margin-bottom: 24px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
@@ -9,6 +10,7 @@
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
padding-bottom: 10px;
.views-tabs-container {
@@ -195,6 +197,7 @@
.logs-explorer-views-type-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
@@ -210,12 +213,32 @@
}
}
.table-view-container {
flex: 1;
min-height: 0;
overflow-y: visible;
}
.time-series-view-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: visible;
.time-series-view-container-header {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 12px;
flex-shrink: 0;
}
.time-series-view {
flex-shrink: 0;
height: 65vh;
min-height: 450px;
padding-bottom: 140px;
}
}
}

View File

@@ -499,16 +499,18 @@ function LogsExplorerViewsContainer({
</div>
)}
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
<LogsExplorerTable
data={
(data?.payload?.data?.newResult?.data?.result ||
data?.payload?.data?.result ||
[]) as QueryDataV3[]
}
isLoading={isLoading || isFetching}
isError={isError}
error={error as APIError}
/>
<div className="table-view-container">
<LogsExplorerTable
data={
(data?.payload?.data?.newResult?.data?.result ||
data?.payload?.data?.result ||
[]) as QueryDataV3[]
}
isLoading={isLoading || isFetching}
isError={isError}
error={error as APIError}
/>
</div>
)}
</div>
</div>

View File

@@ -1,17 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/button';
import { Check, ChevronDown, Plus } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import type { MenuProps } from 'antd';
import { Dropdown } from 'antd';
import getAll from 'api/v1/user/get';
import { useListUsers } from 'api/generated/services/users';
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
import useUrlQuery from 'hooks/useUrlQuery';
import { useAppContext } from 'providers/App/App';
import { toISOString } from 'utils/app';
import { FilterMode, MemberStatus, toMemberStatus } from './utils';
@@ -21,7 +19,6 @@ import './MembersSettings.styles.scss';
const PAGE_SIZE = 20;
function MembersSettings(): JSX.Element {
const { org } = useAppContext();
const history = useHistory();
const urlQuery = useUrlQuery();
@@ -34,18 +31,14 @@ function MembersSettings(): JSX.Element {
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [selectedMember, setSelectedMember] = useState<MemberRow | null>(null);
const { data: usersData, isLoading, refetch: refetchUsers } = useQuery({
queryFn: getAll,
queryKey: ['getOrgUser', org?.[0]?.id],
});
const { data: usersData, isLoading, refetch: refetchUsers } = useListUsers();
const allMembers = useMemo(
(): MemberRow[] =>
(usersData?.data ?? []).map((user) => ({
id: user.id,
name: user.displayName,
email: user.email,
role: user.role,
email: user.email ?? '',
status: toMemberStatus(user.status ?? ''),
joinedOn: toISOString(user.createdAt),
updatedAt: toISOString(user?.updatedAt),
@@ -64,9 +57,7 @@ function MembersSettings(): JSX.Element {
const q = searchQuery.toLowerCase();
result = result.filter(
(m) =>
m?.name?.toLowerCase().includes(q) ||
m.email.toLowerCase().includes(q) ||
m.role.toLowerCase().includes(q),
m?.name?.toLowerCase().includes(q) || m.email.toLowerCase().includes(q),
);
}
@@ -148,7 +139,6 @@ function MembersSettings(): JSX.Element {
const handleMemberEditComplete = useCallback((): void => {
refetchUsers();
setSelectedMember(null);
}, [refetchUsers]);
return (
@@ -181,7 +171,7 @@ function MembersSettings(): JSX.Element {
<div className="members-settings__search">
<Input
type="search"
placeholder="Search by name, email, or role..."
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e): void => {
setSearchQuery(e.target.value);

View File

@@ -1,6 +1,6 @@
import type { TypesUserDTO } from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
import { UserResponse } from 'types/api/user/getUser';
import MembersSettings from '../MembersSettings';
@@ -11,47 +11,39 @@ jest.mock('@signozhq/sonner', () => ({
},
}));
const USERS_ENDPOINT = '*/api/v1/user';
const USERS_ENDPOINT = '*/api/v2/users';
const mockUsers: UserResponse[] = [
const mockUsers: TypesUserDTO[] = [
{
id: 'user-1',
displayName: 'Alice Smith',
email: 'alice@signoz.io',
role: 'ADMIN',
status: 'active',
createdAt: '2024-01-01T00:00:00.000Z',
organization: 'TestOrg',
createdAt: new Date('2024-01-01T00:00:00.000Z'),
orgId: 'org-1',
},
{
id: 'user-2',
displayName: 'Bob Jones',
email: 'bob@signoz.io',
role: 'VIEWER',
status: 'active',
createdAt: '2024-01-02T00:00:00.000Z',
organization: 'TestOrg',
createdAt: new Date('2024-01-02T00:00:00.000Z'),
orgId: 'org-1',
},
{
id: 'inv-1',
displayName: '',
email: 'charlie@signoz.io',
role: 'EDITOR',
status: 'pending_invite',
createdAt: '2024-01-03T00:00:00.000Z',
organization: 'TestOrg',
createdAt: new Date('2024-01-03T00:00:00.000Z'),
orgId: 'org-1',
},
{
id: 'user-3',
displayName: 'Dave Deleted',
email: 'dave@signoz.io',
role: 'VIEWER',
status: 'deleted',
createdAt: '2024-01-04T00:00:00.000Z',
organization: 'TestOrg',
createdAt: new Date('2024-01-04T00:00:00.000Z'),
orgId: 'org-1',
},
];
@@ -106,7 +98,7 @@ describe('MembersSettings (integration)', () => {
await screen.findByText('Alice Smith');
await user.type(
screen.getByPlaceholderText(/Search by name, email, or role/i),
screen.getByPlaceholderText(/Search by name or email/i),
'bob',
);

View File

@@ -2,8 +2,8 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input, Modal, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { useUpdateMyUserV2 } from 'api/generated/services/users';
import changeMyPassword from 'api/v1/factor_password/changeMyPassword';
import editUser from 'api/v1/user/id/update';
import { useNotifications } from 'hooks/useNotifications';
import { Check, FileTerminal, MailIcon, UserIcon } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
@@ -17,6 +17,7 @@ function UserInfo(): JSX.Element {
const { t } = useTranslation(['routes', 'settings', 'common']);
const { notifications } = useNotifications();
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
const [currentPassword, setCurrentPassword] = useState<string>('');
const [updatePassword, setUpdatePassword] = useState<string>('');
@@ -92,10 +93,7 @@ function UserInfo(): JSX.Element {
);
try {
setIsLoading(true);
await editUser({
displayName: changedName,
userId: user.id,
});
await updateMyUser({ data: { displayName: changedName } });
notifications.success({
message: t('success', {

View File

@@ -22,9 +22,12 @@ jest.mock('react-use', () => ({
],
}));
jest.mock('api/v1/user/id/update', () => ({
__esModule: true,
default: (...args: unknown[]): Promise<unknown> => editUserFn(...args),
jest.mock('api/generated/services/users', () => ({
...jest.requireActual('api/generated/services/users'),
useUpdateMyUserV2: jest.fn(() => ({
mutateAsync: (...args: unknown[]): Promise<unknown> => editUserFn(...args),
isLoading: false,
})),
}));
jest.mock('hooks/useDarkMode', () => ({

View File

@@ -5,7 +5,7 @@
"tags": [
"quickstart"
],
"module": "apm",
"module": "home",
"relatedSearchKeywords": [
"apm",
"application performance monitoring",
@@ -22,6 +22,28 @@
"imgUrl": "/Logos/quickstart.svg",
"link": "/docs/cloud/quickstart/"
},
{
"dataSource": "signoz-mcp-server",
"label": "SigNoz MCP Server",
"tags": [
"quickstart"
],
"module": "home",
"relatedSearchKeywords": [
"agent",
"ai",
"mcp",
"mcp server",
"model context protocol",
"quickstart",
"signoz",
"signoz mcp",
"signoz mcp server",
"setup"
],
"imgUrl": "/Logos/signoz-brand-logo.svg",
"link": "/docs/ai/signoz-mcp-server/"
},
{
"dataSource": "migrate-from-datadog",
"label": "From Datadog",
@@ -1524,18 +1546,24 @@
"link": "/docs/userguide/collect_docker_logs/"
},
{
"dataSource": "vercel-logs",
"label": "Vercel logs",
"dataSource": "vercel",
"label": "Vercel",
"imgUrl": "/Logos/vercel.svg",
"tags": [
"apm/traces",
"logs"
],
"module": "logs",
"module": "home",
"relatedSearchKeywords": [
"collect vercel logs",
"logging",
"logs",
"opentelemetry drains",
"trace drain",
"traces",
"tracing",
"vercel",
"vercel drains",
"vercel functions logs",
"vercel log forwarding",
"vercel log monitoring",
@@ -1545,10 +1573,12 @@
"vercel observability",
"vercel opentelemetry integration",
"vercel to otel",
"vercel trace drain",
"vercel traces",
"vercel-logs"
],
"id": "vercel-logs",
"link": "/docs/userguide/vercel_logs_to_signoz/"
"link": "/docs/userguide/vercel-to-signoz/"
},
{
"dataSource": "heroku-logs",
@@ -4029,6 +4059,57 @@
],
"link": "/docs/pydantic-ai-observability/"
},
{
"dataSource": "qwen-observability",
"label": "Qwen",
"imgUrl": "/Logos/qwen.svg",
"tags": [
"LLM Monitoring"
],
"module": "apm",
"relatedSearchKeywords": [
"alibaba cloud",
"dashscope",
"llm",
"llm monitoring",
"monitoring",
"observability",
"otel qwen",
"qwen",
"qwen logs",
"qwen metrics",
"qwen monitoring",
"qwen observability",
"qwen response time",
"qwen traces"
],
"id": "qwen-observability",
"link": "/docs/qwen-observability/"
},
{
"dataSource": "n8n-cloud",
"label": "n8n Cloud",
"imgUrl": "/Logos/n8n.svg",
"tags": [
"LLM Monitoring"
],
"module": "apm",
"relatedSearchKeywords": [
"llm monitoring",
"monitoring",
"n8n",
"n8n cloud",
"n8n monitoring",
"n8n observability",
"n8n traces",
"observability",
"otel n8n",
"workflow monitoring",
"workflow traces"
],
"id": "n8n-cloud",
"link": "/docs/n8n-monitoring/"
},
{
"dataSource": "mastra-monitoring",
"label": "Mastra",
@@ -5158,6 +5239,31 @@
"id": "microsoft-sql-server",
"link": "/docs/integrations/sql-server/"
},
{
"dataSource": "hasura",
"label": "Hasura",
"imgUrl": "/Logos/hasura.svg",
"tags": [
"database"
],
"module": "apm",
"relatedSearchKeywords": [
"database",
"graphql",
"graphql engine",
"hasura",
"hasura graphql",
"hasura logs",
"hasura metrics",
"hasura monitoring",
"hasura observability",
"hasura traces",
"opentelemetry hasura",
"telemetry"
],
"id": "hasura",
"link": "/docs/integrations/opentelemetry-hasura/"
},
{
"dataSource": "supabase",
"label": "Supabase",

View File

@@ -1,8 +1,10 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from '@signozhq/sonner';
import { Button, Form, Input } from 'antd';
import editOrg from 'api/organization/editOrg';
import { useNotifications } from 'hooks/useNotifications';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useUpdateMyOrganization } from 'api/generated/services/orgs';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { useAppContext } from 'providers/App/App';
import { IUser } from 'providers/App/types';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
@@ -14,42 +16,34 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
const { t } = useTranslation(['organizationsettings', 'common']);
const { org, updateOrg } = useAppContext();
const { displayName } = (org || [])[index];
const [isLoading, setIsLoading] = useState<boolean>(false);
const { notifications } = useNotifications();
const {
mutateAsync: updateMyOrganization,
isLoading,
} = useUpdateMyOrganization({
mutation: {
onSuccess: (_, { data }) => {
toast.success(t('success', { ns: 'common' }), {
richColors: true,
position: 'top-right',
});
updateOrg(orgId, data.displayName ?? '');
},
onError: (error) => {
const apiError = convertToApiError(
error as AxiosError<RenderErrorResponseDTO>,
);
toast.error(
apiError?.getErrorMessage() ?? t('something_went_wrong', { ns: 'common' }),
{ richColors: true, position: 'top-right' },
);
},
},
});
const onSubmit = async (values: FormValues): Promise<void> => {
try {
setIsLoading(true);
const { displayName } = values;
const { statusCode, error } = await editOrg({
displayName,
orgId,
});
if (statusCode === 204) {
notifications.success({
message: t('success', {
ns: 'common',
}),
});
updateOrg(orgId, displayName);
} else {
notifications.error({
message:
error ||
t('something_went_wrong', {
ns: 'common',
}),
});
}
setIsLoading(false);
} catch (error) {
setIsLoading(false);
notifications.error({
message: t('something_went_wrong', {
ns: 'common',
}),
});
}
const { displayName } = values;
await updateMyOrganization({ data: { id: orgId, displayName } });
};
if (!org) {

View File

@@ -198,15 +198,14 @@ function ServiceAccountsSettings(): JSX.Element {
<h1 className="sa-settings__title">Service Accounts</h1>
<p className="sa-settings__subtitle">
Overview of service accounts added to this workspace.{' '}
{/* Todo: to add doc links */}
{/* <a
href="https://signoz.io/docs/service-accounts"
<a
href="https://signoz.io/docs/manage/administrator-guide/iam/service-accounts"
target="_blank"
rel="noopener noreferrer"
className="sa-settings__learn-more"
>
Learn more
</a> */}
</a>
</p>
</div>

View File

@@ -695,6 +695,15 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
registerShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels, () =>
onClickHandler(ROUTES.ALL_CHANNELS, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts, () =>
onClickHandler(ROUTES.SERVICE_ACCOUNTS_SETTINGS, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettingsRoles, () =>
onClickHandler(ROUTES.ROLES_SETTINGS, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettingsMembers, () =>
onClickHandler(ROUTES.MEMBERS_SETTINGS, null),
);
registerShortcut(GlobalShortcuts.NavigateToLogsPipelines, () =>
onClickHandler(ROUTES.LOGS_PIPELINES, null),
);
@@ -718,6 +727,9 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
deregisterShortcut(GlobalShortcuts.NavigateToSettingsIngestion);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsBilling);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsRoles);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsMembers);
deregisterShortcut(GlobalShortcuts.NavigateToLogsPipelines);
deregisterShortcut(GlobalShortcuts.NavigateToLogsViews);
deregisterShortcut(GlobalShortcuts.NavigateToTracesViews);

View File

@@ -14,6 +14,10 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import NewExplorerCTA from 'container/NewExplorerCTA';
import dayjs, { Dayjs } from 'dayjs';
import {
useGlobalTimeQueryInvalidate,
useIsGlobalTimeQueryRefreshing,
} from 'hooks/globalTime';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -352,7 +356,10 @@ function DateTimeSelection({
],
);
const isRefreshingQueries = useIsGlobalTimeQueryRefreshing();
const invalidateQueries = useGlobalTimeQueryInvalidate();
const onRefreshHandler = (): void => {
invalidateQueries();
onSelectHandler(selectedTime);
onLastRefreshHandler();
};
@@ -732,7 +739,11 @@ function DateTimeSelection({
{showAutoRefresh && selectedTime !== 'custom' && (
<div className="refresh-actions">
<FormItem hidden={refreshButtonHidden} className="refresh-btn">
<Button icon={<SyncOutlined />} onClick={onRefreshHandler} />
<Button
icon={<SyncOutlined />}
loading={!!isRefreshingQueries}
onClick={onRefreshHandler}
/>
</FormItem>
<FormItem>

View File

@@ -0,0 +1,2 @@
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';

View File

@@ -0,0 +1,16 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
/**
* Use when you want to invalida any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
*/
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
const queryClient = useQueryClient();
return useCallback(async () => {
return await queryClient.invalidateQueries({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
});
}, [queryClient]);
}

View File

@@ -0,0 +1,13 @@
import { useIsFetching } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
/**
* Use when you want to know if any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY} is refreshing
*/
export function useIsGlobalTimeQueryRefreshing(): boolean {
return (
useIsFetching({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
}) > 0
);
}

View File

@@ -0,0 +1,101 @@
import { useCallback, useMemo } from 'react';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import {
useGetUser,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
} from 'api/generated/services/users';
export interface MemberRoleUpdateFailure {
roleName: string;
error: unknown;
onRetry: () => Promise<void>;
}
interface UseMemberRoleManagerResult {
fetchedRoleIds: string[];
isLoading: boolean;
applyDiff: (
localRoleIds: string[],
availableRoles: AuthtypesRoleDTO[],
) => Promise<MemberRoleUpdateFailure[]>;
}
export function useMemberRoleManager(
userId: string,
enabled: boolean,
): UseMemberRoleManagerResult {
const { data: fetchedUser, isLoading } = useGetUser(
{ id: userId },
{ query: { enabled: !!userId && enabled } },
);
const currentUserRoles = useMemo(() => fetchedUser?.data?.userRoles ?? [], [
fetchedUser,
]);
const fetchedRoleIds = useMemo(
() =>
currentUserRoles
.map((ur) => ur.role?.id ?? ur.roleId)
.filter((id): id is string => Boolean(id)),
[currentUserRoles],
);
const { mutateAsync: setRole } = useSetRoleByUserID();
const { mutateAsync: removeRole } = useRemoveUserRoleByUserIDAndRoleID();
const applyDiff = useCallback(
async (
localRoleIds: string[],
availableRoles: AuthtypesRoleDTO[],
): Promise<MemberRoleUpdateFailure[]> => {
const currentRoleIdSet = new Set(fetchedRoleIds);
const desiredRoleIdSet = new Set(localRoleIds.filter(Boolean));
const toRemove = currentUserRoles.filter((ur) => {
const id = ur.role?.id ?? ur.roleId;
return id && !desiredRoleIdSet.has(id);
});
const toAdd = availableRoles.filter(
(r) => r.id && desiredRoleIdSet.has(r.id) && !currentRoleIdSet.has(r.id),
);
const allOps = [
...toRemove.map((ur) => ({
roleName: ur.role?.name ?? 'unknown',
run: (): ReturnType<typeof removeRole> =>
removeRole({
pathParams: {
id: userId,
roleId: ur.role?.id ?? ur.roleId ?? '',
},
}),
})),
...toAdd.map((role) => ({
roleName: role.name ?? 'unknown',
run: (): ReturnType<typeof setRole> =>
setRole({
pathParams: { id: userId },
data: { name: role.name ?? '' },
}),
})),
];
const results = await Promise.allSettled(allOps.map((op) => op.run()));
const failures: MemberRoleUpdateFailure[] = [];
results.forEach((result, i) => {
if (result.status === 'rejected') {
const { roleName, run } = allOps[i];
failures.push({ roleName, error: result.reason, onRetry: run });
}
});
return failures;
},
[userId, fetchedRoleIds, currentUserRoles, setRole, removeRole],
);
return { fetchedRoleIds, isLoading, applyDiff };
}

View File

@@ -1,15 +0,0 @@
import { useQuery, UseQueryResult } from 'react-query';
import getUser from 'api/v1/user/id/get';
import { SuccessResponseV2 } from 'types/api';
import { UserResponse } from 'types/api/user/getUser';
const useGetUser = (userId: string, isLoggedIn: boolean): UseGetUser =>
useQuery({
queryFn: () => getUser({ userId }),
queryKey: [userId],
enabled: !!userId && !!isLoggedIn,
});
type UseGetUser = UseQueryResult<SuccessResponseV2<UserResponse>, unknown>;
export default useGetUser;

View File

@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import AppRoutes from 'AppRoutes';
import { AxiosError } from 'axios';
import { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
import { ThemeProvider } from 'hooks/useDarkMode';
import { NuqsAdapter } from 'nuqs/adapters/react';
import { AppProvider } from 'providers/App/App';
@@ -51,6 +52,7 @@ if (container) {
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<GlobalTimeStoreAdapter />
<AppProvider>
<AppRoutes />
</AppProvider>

View File

@@ -1,6 +1,9 @@
.logs-module-page {
display: flex;
height: 100%;
min-height: 0;
overflow: hidden;
.log-quick-filter-left-section {
width: 0%;
flex-shrink: 0;
@@ -10,13 +13,19 @@
display: flex;
flex-direction: column;
width: 100%;
min-height: 0;
.log-explorer-query-container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
.logs-explorer-views {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
@@ -26,6 +35,18 @@
&.filter-visible {
.log-quick-filter-left-section {
width: 260px;
height: 100%;
overflow: visible;
min-height: 0;
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
.quick-filters-container {
flex: 1;
min-height: 0;
}
}
.log-module-right-section {

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