Compare commits

..

209 Commits

Author SHA1 Message Date
aks07
48f1a4cbf3 Merge branch 'main' of github.com:SigNoz/signoz into feat/dropdown-items 2026-05-06 20:57:43 +05:30
aks07
bb499973bf feat: remove antd component usage 2026-05-06 20:57:04 +05:30
dasmat
72036b42e3 fix(frontend): open trace details in new tab from funnel results (#10999)
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-05-06 15:06:48 +00:00
aks07
13c087d34d feat: remove common component 2026-05-06 19:54:09 +05:30
aks07
feb9031bcd feat: packages remove 2026-05-06 19:17:51 +05:30
aks07
bc4a6b7ded feat: packages remove 2026-05-06 18:01:18 +05:30
aks07
9216bb5f34 Merge branch 'main' of github.com:SigNoz/signoz into feat/dropdown-items 2026-05-06 17:26:48 +05:30
aks07
18d2806f95 feat: minor refactors 2026-05-06 17:03:36 +05:30
aks07
8d666471e1 feat: v3 behind feature flag 2026-05-06 17:00:16 +05:30
aks07
9d022772b7 feat: minor refactors 2026-05-06 16:28:47 +05:30
aks07
648a48cbaa feat: minor refactors 2026-05-06 15:39:44 +05:30
Tushar Vats
9301b2fb1c fix: dashboard date refresh (#11201)
* fix: dashboard invalid date state upon refresh

* fix: dashboard invalid date state upon refresh

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2026-05-06 09:25:47 +00:00
primus-bot[bot]
6d0e60822c chore(release): bump to v0.122.0 (#11204)
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
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-05-06 08:01:25 +00:00
Abhi kumar
acdaef6c2e chore: added reference to crosspanel sync docs (#11200)
* chore: added reference to crosspanel sync docs

* chore: minor changes
2026-05-06 05:44:03 +00:00
Abhi kumar
a7690bdaa2 fix: added fix for all series in tooltip sync mode (#11197)
* fix: added fix for all series in tooltip sync mode

* chore: minor cleanup
2026-05-06 04:24:33 +00:00
Nityananda Gohain
a7fde606ca fix: use ms in prepareFillZeroArgsWithStep (#11196)
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-05-05 18:27:19 +00:00
Vikrant Gupta
0aaf556137 Update CODEOWNERS (#11192)
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-05-05 14:11:58 +00:00
Vinicius Lourenço
582ba1c677 chore(oxfmt): add more patterns to ignore (#11173) 2026-05-05 13:37:53 +00:00
Pandey
3d8cddf84e refactor: split typeable infrastructure into pkg/types/coretypes (#11105)
* refactor: move authtypes to coretypes

* refactor: migrate downstream consumers to coretypes Kind/Type/Relation

Wire all consumers of the typeable infrastructure through coretypes:
- Replace authtypes.Name/Type/Relation references with coretypes equivalents
- Switch Typeable singletons to constructor calls (authtypes.NewTypeableUser
  etc.), with the embedded coretypes.Typeable populated so Kind/Type/Prefix/
  Scope dispatch correctly through the embed
- Update dashboardtypes meta-resource declarations to use authtypes
  constructors so they expose Tuples (authz callers need it)
- Rename Resource.Name field accesses to Resource.Kind to match the field
  rename in authtypes.Resource
- Fix typeable_metaresource.go calling the plural NewTypeableMetaResources
  helper — should be the singular NewTypeableMetaResource

go build ./... and go vet ./... clean (parser-generated unreachable-code
warnings are pre-existing). Authz unit tests pass.

* refactor(audittypes): unify Action with coretypes.Relation

Drop the duplicate Action enum from audittypes — the verbs (create/update/
delete) match coretypes.Relation exactly. Move PastTense onto Relation so
audit EventName derivation continues to work without a parallel hierarchy.

Also retypes AuditDef.ResourceKind from string to coretypes.Kind so audit
declarations get the same regex validation that authz already enforces.

* refactor(retentiontypes): extract TTLSetting into its own package

TTLSetting is the bun model for ClickHouse TTL settings — has nothing to do
with the Organization domain it was previously co-located with in
pkg/types/organization.go. Moved to pkg/types/retentiontypes/ alongside the
ClickHouse reader that's its sole consumer.

No schema change; the bun table tag (table:ttl_setting) is unchanged.

* chore(openapi): regenerate spec for coretypes.Relation and Resource.Kind

* chore(frontend): regenerate API client and migrate Resource.name → Resource.kind

Regenerated TypeScript API types after the AuthtypesResource field rename
and the new CoretypesRelation enum. Updated:

- frontend/scripts/generate-permissions-type.cjs to read `r.kind` from the
  /api/v1/authz/resources response and emit `kind:` in the static
  permissions.type.ts file.
- frontend/src/hooks/useAuthZ/{permissions.type,types,utils,useAuthZ}.tsx:
  Resource.name → Resource.kind throughout.
- frontend/src/container/RolesSettings/{utils.tsx,__tests__/utils.test.ts}:
  same field migration.
- frontend/src/components/createGuardedRoute/createGuardedRoute.test.tsx:
  same.
- useAuthZ/utils.ts: cast string relations to CoretypesRelationDTO at the
  AuthtypesTransactionDTO boundary now that relation is an enum, not a raw
  string.

yarn generate:api passes (orval generation + lint + typecheck).

* refactor: migrate downstream consumers to Resource/Verb rename

* chore(openapi): regenerate spec for Resource/Verb rename

* feat(coretypes): add ListResources accessor with stable sort

* feat(cmd): add 'generate authz' subcommand for permissions type

* refactor(authz): drop runtime authz/resources endpoint

* refactor(frontend): consume static permissions.type.ts directly

* chore(frontend): regenerate Orval client without authz/resources

* ci: move authz schema check from jsci to goci

* refactor(coretypes): move Selector/Object/Transaction from authtypes

* feat(coretypes): add managed role names and permission policy

* feat(coretypes): add Registry assembling resources, types, and managed-role transactions

* refactor(authz): wire *coretypes.Registry; drop RegisterTypeable

* refactor(cmd): wire coretypes.NewRegistry into server bootstraps

* chore: regenerate openapi spec for authtypes -> coretypes type moves

* chore(frontend): regenerate API client for Authtypes -> Coretypes type moves

* refactor(coretypes): rename GettableResource to ResourceRef

* refactor(authz): collapse Registry around static data; bridge once at construction

* refactor(coretypes): tighten Registry, restore anonymous public-dashboard grant

Drops passthrough fields from coretypes.Registry; adds an O(1) lookup map
for NewResourceFromTypeAndKind; replaces stringly-typed Type compares with
Type.Equals; removes the now-redundant getUniqueTypes helper. Restores the
signoz-anonymous read grant on metaresource/public-dashboard that was
silently dropped, and removes the invalid signoz-admin/VerbCreate/TypeUser
entry that panicked at startup.

* chore: regenerate openapi spec for coretypes -> authtypes type moves

* chore(frontend): regenerate API client for Coretypes -> Authtypes type moves

* fix(authz): disambiguate kind→type by relation, preserve multi-part selectors

permissions.type.ts now lists the same kind (dashboard, role,
public-dashboard) under both metaresource and metaresources, so the prior
kind→type map silently overwrote one with the other. Resolve the type
using the requesting relation's allowed types, and slice the selector at
the first colon so multi-part selectors (e.g. id:version) round-trip
correctly. Updates useAuthZ.test.tsx to use the regenerated kind field.

* refactor(authtypes): introduce Relation wrapper over coretypes.Verb

The authz layer modeled relations as raw coretypes.Verb everywhere, which
forced authz-level concepts (action, role-binding) to share a type with
schema-level enumerations. Introduce authtypes.Relation as a thin wrapper
over coretypes.Verb so the authz APIs (CheckWithTupleCreation, ListObjects,
GetObjects, PatchObjects, NewTuples, Transaction.Relation, etc.) can grow
authz-specific affordances without leaking back into coretypes.

Also reshuffles the static coretypes data into dedicated registry_*.go files
(types, kinds, verbs, resources, managed roles) to keep the schema declarations
isolated from the value types they configure.

* refactor(authtypes): expose Relation.Enum() and regenerate openapi spec

Without an Enum() method on Relation the openapi generator emitted an
empty AuthtypesRelation schema (no allowed values). Forward the enum
from the embedded coretypes.Verb so the wire contract is faithful.

* refactor(ee/authz): drop always-nil error returns from managed-role tuple helpers

getManagedRoleGrantTuples and getManagedRoleTransactionTuples never
returned a non-nil error, which the linter (unparam) had flagged. Drop
the unused error return; callers no longer need the err check either.

* chore(frontend): regenerate API client for authtypes.Relation

* fix(authz): satisfy go-lint — keyed Relation literal, drop redundant Verb selector

* refactor(coretypes): sync Kinds slice with full registry_kind declarations

* feat(coretypes): register metaresource and metaresources for all new kinds

Adds 21 metaresource and 21 metaresources entries (covering notification-channel,
route-policy, apdex-setting, auth-domain, session, cloud-integration,
cloud-integration-service, ingestion-key, ingestion-limit, pipeline,
user-preference, org-preference, quick-filter, ttl-setting, rule,
planned-maintenance, saved-view, trace-funnel, factor-password, factor-api-key,
license) so the authz schema covers every resource Kind declared in
registry_kind. Regenerates the static frontend permissions.type.ts to match.

* feat(coretypes): populate ManagedRoleToTransactions from signozapiserver routes

Enumerates every (verb, resource) tuple each managed role holds, derived
from the AdminAccess/EditAccess/ViewAccess middleware on routes in
pkg/apiserver/signozapiserver and the legacy http_handler in
pkg/query-service/app. Admin gets 123 transactions, editor 53, viewer 25,
anonymous keeps the single public-dashboard read.

* feat(coretypes): add integration kind with full CRUD for viewer/editor/admin

Install/uninstall/list integration routes (legacy /api/v1/integrations) all
sit behind ViewAccess, so every authenticated role gets the full CRUD
surface on (metaresource, integration) and (metaresources, integration).
Regenerates the static frontend permissions.type.ts to match.

* feat(coretypes): add subscription kind alongside license, document LCRUD shape

License covers the in-product license resource (Activate/Refresh/GetActive).
Subscription is the billing lifecycle (checkout/portal/billing) served by
ee/query-service routes. Both are admin-only and modeled with a uniform
LCRUD shape; comments call out which verbs actually map to routes versus
which are placeholders for shape parity (e.g. cancellation flows through
Stripe's portal, not an in-process delete).

* feat(coretypes): model telemetryresource for logs, traces, metrics

Mirrors the telemetryresource type from ee/authz/openfgaschema/base.fga
into coretypes: a read-only Type with three Kinds (logs, traces, metrics)
matching telemetrytypes.Signal. Selector is wildcard-only for v1; future
work can narrow per-service or per-environment when the use case lands.
Every managed role (admin/editor/viewer) gets read on each signal,
matching the schema's role#assignee grant. Anonymous stays unchanged.
Regenerates the static frontend permissions.type.ts.

* feat(coretypes): add audit-logs and meter-metrics kinds under telemetryresource

Audit logs (signal=logs, source=audit) and meter metrics (signal=metrics,
source=meter) are sensitive source-qualified telemetry streams that don't
belong under the broad read-grant every role gets on regular logs/traces/
metrics. Modeled as distinct Kinds so they can be permissioned
independently. Admin-only read for now; widen on explicit ask (e.g. an
auditor flow that needs viewer access to audit-logs). Regenerates the
static frontend permissions.type.ts.

* feat(coretypes): add logs-field and traces-field kinds for stored field config

GET/POST /logs/fields and /api/v2/traces/fields manage stored, mutable
field metadata (indexed/promoted columns) over each signal. They're
configuration, not telemetry data, so they sit under metaresource rather
than telemetryresource. Viewer reads, editor/admin update; no
create/delete since POST overwrites. Plural prefix (logs-field /
traces-field) matches the signal naming.

* chore(frontend): regenerate permissions.type.ts to match generate authz output

* feat(authz): add attach permissions to fga model

* fix(tests): use role permissions instead of dashboards

* fix(authz): couple of issues with register flow

* fix(authz): public dashboard read should be anomymous

* fix(tests): integration test for public dashboard access

---------

Co-authored-by: vikrantgupta25 <vikrant@signoz.io>
2026-05-05 19:13:09 +05:30
Nikhil Soni
ac46cd8e80 fix: return span start time similar to waterfall v2 (#11183)
* fix: return span start time similar to waterfall v2

* chore: update openapi specs

* chore: rename timestamp field to match style of other fields

* chore: rename the struct field to keep json and field same
2026-05-05 11:50:18 +00:00
aks07
eb22c57a67 feat: minor change 2026-05-04 17:43:31 +05:30
aks07
896379b680 feat: minor change 2026-05-04 17:34:32 +05:30
aks07
f041b16e4b feat: delete waterfall go 2026-05-04 17:04:39 +05:30
aks07
9ca3a7fd3e Merge branch 'main' of github.com:SigNoz/signoz into feat/dropdown-items 2026-05-04 16:57:14 +05:30
aks07
43e122367c Merge branch 'main' of github.com:SigNoz/signoz into feat/dropdown-items
# Conflicts:
#	docs/api/openapi.yml
#	frontend/package.json
#	frontend/src/api/generated/services/sigNoz.schemas.ts
#	frontend/src/api/generated/services/tracedetail/index.ts
#	frontend/src/auto-import-registry.d.ts
#	frontend/src/components/LogDetail/index.tsx
#	frontend/src/hooks/trace/useCopySpanLink.ts
#	frontend/src/styles.scss
#	frontend/yarn.lock
#	pkg/apiserver/signozapiserver/provider.go
#	pkg/apiserver/signozapiserver/tracedetail.go
#	pkg/modules/tracedetail/impltracedetail/handler.go
#	pkg/modules/tracedetail/impltracedetail/module.go
#	pkg/modules/tracedetail/impltracedetail/store.go
#	pkg/modules/tracedetail/impltracedetail/waterfall_test.go
#	pkg/signoz/module.go
#	pkg/signoz/openapi.go
#	pkg/signoz/provider.go
#	pkg/types/tracedetailtypes/store.go
#	pkg/types/tracedetailtypes/waterfall.go
2026-05-04 16:56:32 +05:30
aks07
603077c575 feat: open in new tab 2026-04-21 10:01:34 +05:30
aks07
7e5c4476f7 feat: add service name 2026-04-21 09:22:17 +05:30
aks07
da648ed3f3 feat: style fix 2026-04-21 01:43:12 +05:30
aks07
9fa56aacd1 feat: uncollapse spans on select from flamegraph 2026-04-21 01:37:22 +05:30
aks07
5acd79419c Merge branch 'main' of github.com:SigNoz/signoz into feat/dropdown-items 2026-04-21 01:30:25 +05:30
aks07
9b7b0f8862 feat: load all spans in waterfall under limit 2026-04-21 01:23:33 +05:30
aks07
c29e8a0136 feat: add status codes 2026-04-20 21:25:49 +05:30
aks07
ebac945ac2 feat: minor fix 2026-04-20 14:04:05 +05:30
aks07
e787497695 feat: fix colors 2026-04-20 13:46:25 +05:30
aks07
eba6bd5f5b feat: disable crosshair in waterfall 2026-04-20 11:26:35 +05:30
aks07
1aeab2718d feat: fix test 2026-04-20 10:44:21 +05:30
aks07
d879af4fb3 Merge branch 'ns/waterfall-v3-2' of github.com:SigNoz/signoz into feat/dropdown-items 2026-04-20 10:19:56 +05:30
Nikhil Soni
ac10be2eb2 Merge remote-tracking branch 'origin/main' into ns/waterfall-v3-2 2026-04-20 10:04:09 +05:30
aks07
113d1544ba feat: add crosshair 2026-04-20 09:46:14 +05:30
aks07
df02da664c feat: use semantic tokens 2026-04-17 21:03:41 +05:30
aks07
d0a491ed8e feat: use semantic tokens 2026-04-17 20:59:46 +05:30
aks07
77c39a9f05 feat: delete unused file 2026-04-17 20:26:50 +05:30
aks07
309a76e5fd feat: rename copy to copy value 2026-04-17 19:13:28 +05:30
aks07
43e80caf09 Merge branch 'feat/filter-search' of github.com:SigNoz/signoz into feat/dropdown-items 2026-04-17 10:31:48 +05:30
aks07
a2d853daf5 Merge branch 'main' of github.com:SigNoz/signoz into feat/filter-search 2026-04-17 10:23:53 +05:30
aks07
3970619afa Merge remote-tracking branch 'origin/ns/waterfall-v3-2' into feat/filter-search 2026-04-17 10:11:31 +05:30
aks07
9dc87761c1 feat: minor fix 2026-04-16 20:22:17 +05:30
aks07
86a44fad42 Merge branch 'feat/filter-search' of github.com:SigNoz/signoz into feat/dropdown-items 2026-04-16 20:20:41 +05:30
aks07
91f74144cb feat: old trace btn added 2026-04-16 20:19:27 +05:30
aks07
0863c5170b feat: no data screen 2026-04-16 18:14:18 +05:30
aks07
837cd2a463 feat: fix color duplications 2026-04-16 16:46:37 +05:30
aks07
c88a2d5d90 feat: dropdown added to span details 2026-04-16 15:42:09 +05:30
Nikhil Soni
c9abc2cb30 chore: use sqlbuilder in clickhouse store where possible 2026-04-16 09:39:36 +05:30
Nikhil Soni
01824b0b62 chore: remove unused duration nano field 2026-04-16 09:39:36 +05:30
Nikhil Soni
d1b378992d refactor: use same method for cache key creation 2026-04-16 09:39:36 +05:30
Nikhil Soni
52ca921d2a refactor: return 404 on missing trace and other cleanup 2026-04-16 09:39:36 +05:30
Nikhil Soni
42f12dfef3 refactor: breakdown GetSelectedSpans for readability 2026-04-16 09:39:36 +05:30
Nikhil Soni
f2a694447e refactor: separate out store calls and computations 2026-04-16 09:39:36 +05:30
Nikhil Soni
2e7dfa739f refactor: move error to types as well 2026-04-16 09:39:36 +05:30
Nikhil Soni
0aa73580a3 refactor: extract ClickHouse queries into a store abstraction
Move GetTraceSummary and GetTraceSpans out of module.go into a
traceStore interface backed by clickhouseTraceStore in store.go.
The module struct now holds a traceStore instead of a raw
telemetrystore.TelemetryStore, keeping DB access separate from
business logic.
2026-04-16 09:39:36 +05:30
Nikhil Soni
2ff1a43bf8 refactor: move type creation and constants to types package
- Move DB/table/cache/windowing constants to tracedetailtypes package
- Add NewWaterfallTrace and NewWaterfallResponse constructors in types
- Use constructors in module.go instead of inline struct literals
- Reorder waterfall.go so public functions precede private ones
2026-04-16 09:39:36 +05:30
Nikhil Soni
c1477c78be chore: avoid returning nil, nil 2026-04-16 09:39:36 +05:30
Nikhil Soni
9807dd5295 refactor: break down GetWaterfall method for readability 2026-04-16 09:39:36 +05:30
Nikhil Soni
2c59eeff26 fix: update openapi specs 2026-04-16 09:39:36 +05:30
Nikhil Soni
8ccfb4efef fix: use int16 for status code as per db schema 2026-04-16 09:39:36 +05:30
Nikhil Soni
87d18160e8 fix: remove timeout since waterfall take longer 2026-04-16 09:39:36 +05:30
Nikhil Soni
bfa7ee96da chore: generate openapi spec for v3 waterfall 2026-04-16 09:39:33 +05:30
Nikhil Soni
5e3eb66d3a fix: use typed paramter field in logs 2026-04-16 09:38:12 +05:30
Nikhil Soni
3d8cdf18bd fix: add timeout to module context 2026-04-16 09:38:12 +05:30
Nikhil Soni
cb4e501047 fix: rename timestamp to milli for readability 2026-04-16 09:38:12 +05:30
Nikhil Soni
cb8b2137ba fix: remove unused fields and rename span type
To avoid confusing with otel span
2026-04-16 09:38:12 +05:30
Nikhil Soni
998315a255 chore: avoid sorting on every traversal 2026-04-16 09:38:12 +05:30
Nikhil Soni
250657e46b chore: add same test cases as for old waterfall api 2026-04-16 09:38:12 +05:30
Nikhil Soni
795ae9ab18 refactor: convert waterfall api to modules format 2026-04-16 09:38:05 +05:30
Nikhil Soni
6a9ea8d9f8 fix: remove unused fields and rename span type
To avoid confusing with otel span
2026-04-16 09:31:48 +05:30
Nikhil Soni
2723e18023 fix: update span.attributes to map of string to any
To support otel format of diffrent types of attributes
2026-04-16 09:31:48 +05:30
Nikhil Soni
6e89d5f6eb chore: add reason for using snake case in response 2026-04-16 09:31:48 +05:30
Nikhil Soni
4c2a815236 refactor: move type conversion logic to types pkg 2026-04-16 09:31:48 +05:30
Nikhil Soni
b1d66b2e5f feat: setup types and interface for waterfall v3
v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service
2026-04-16 09:31:48 +05:30
aks07
ae88edbb5e feat: disable scroll to view for collapse and uncollapse 2026-04-15 22:36:58 +05:30
aks07
7c9484d47b feat: api integration v3 2026-04-15 21:50:02 +05:30
aks07
24128bd394 feat: show caution on sampled flamegraph 2026-04-15 13:35:55 +05:30
aks07
2118916a23 feat: fix flamegraph and waterfall bg color 2026-04-15 00:51:39 +05:30
aks07
52220412a1 fix: align timeline and span length in flamegraph and waterfall 2026-04-15 00:32:35 +05:30
aks07
85abee8476 feat: show child count in waterfall 2026-04-14 21:27:15 +05:30
aks07
650a29d184 feat: set limit to 100k for flamegraph 2026-04-14 21:17:26 +05:30
aks07
d9c7101d22 feat: reduce time 2026-04-14 18:45:14 +05:30
aks07
b1e7c25189 Merge branch 'main' of github.com:SigNoz/signoz into feat/filter-search 2026-04-14 15:33:19 +05:30
aks07
e9904a0558 Merge branch 'feat/filter-search' of github.com:SigNoz/signoz into feat/filter-search 2026-04-14 15:33:04 +05:30
aks07
5cd199f535 feat: automatically scroll left on vertical scroll 2026-04-14 15:32:23 +05:30
aks07
f6f48ca0bc feat: api integration 2026-04-14 14:46:49 +05:30
Aditya Singh
847f91e22e Merge branch 'main' into feat/filter-search 2026-04-14 13:14:46 +05:30
aks07
29d0abe5a8 Merge branch 'ns/waterfall-v3-2' of github.com:SigNoz/signoz into feat/filter-search 2026-04-14 13:11:26 +05:30
Nikhil Soni
c08840a827 fix: update openapi specs 2026-04-14 10:50:36 +05:30
Nikhil Soni
a3e7bb90b0 fix: use int16 for status code as per db schema 2026-04-14 10:50:36 +05:30
Nikhil Soni
8515d2f37c fix: remove timeout since waterfall take longer 2026-04-14 10:50:36 +05:30
Nikhil Soni
07c05ac3a6 chore: generate openapi spec for v3 waterfall 2026-04-14 10:50:36 +05:30
Nikhil Soni
6289f59ba3 fix: use typed paramter field in logs 2026-04-14 10:50:36 +05:30
Nikhil Soni
76371c9fa2 fix: add timeout to module context 2026-04-14 10:50:36 +05:30
Nikhil Soni
f082e396eb fix: rename timestamp to milli for readability 2026-04-14 10:50:36 +05:30
Nikhil Soni
840eb8f228 fix: remove unused fields and rename span type
To avoid confusing with otel span
2026-04-14 10:50:36 +05:30
Nikhil Soni
2911baf6bb chore: avoid sorting on every traversal 2026-04-14 10:50:36 +05:30
Nikhil Soni
fc5be4eeb5 chore: add same test cases as for old waterfall api 2026-04-14 10:50:36 +05:30
Nikhil Soni
a1b92c79a4 refactor: convert waterfall api to modules format 2026-04-14 10:50:31 +05:30
Nikhil Soni
7a0acd5c8b fix: remove unused fields and rename span type
To avoid confusing with otel span
2026-04-14 10:49:58 +05:30
Nikhil Soni
069cbe2c6f fix: update span.attributes to map of string to any
To support otel format of diffrent types of attributes
2026-04-14 10:49:58 +05:30
Nikhil Soni
4c821f9721 chore: add reason for using snake case in response 2026-04-14 10:49:58 +05:30
Nikhil Soni
4eccea92db refactor: move type conversion logic to types pkg 2026-04-14 10:49:58 +05:30
Nikhil Soni
c8d8966a5d feat: setup types and interface for waterfall v3
v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service
2026-04-14 10:49:58 +05:30
aks07
1e52a5603e Merge branch 'ns/waterfall-v3-2' of github.com:SigNoz/signoz into feat/filter-search 2026-04-14 10:33:46 +05:30
aks07
780ba1a359 feat: new filter ui 2026-04-14 10:27:21 +05:30
aks07
3b71abe820 feat: remove trace header 2026-04-13 18:49:38 +05:30
aks07
70b9d0ff02 feat: filter ui fix 2026-04-13 15:49:43 +05:30
aks07
f4657861e1 feat: make filter and search work with flamegraph 2026-04-13 15:49:09 +05:30
Nikhil Soni
66fe5b5240 fix: update openapi specs 2026-04-13 14:04:45 +05:30
Nikhil Soni
c333cecf43 fix: use int16 for status code as per db schema 2026-04-13 13:23:39 +05:30
Nikhil Soni
276e09853e fix: remove timeout since waterfall take longer 2026-04-13 12:31:08 +05:30
aks07
4defd41504 feat: prevent api call on closing span detail 2026-04-13 12:07:28 +05:30
aks07
ab53b29a14 feat: add span details loader 2026-04-13 10:25:49 +05:30
aks07
b58e82efbf feat: disable anaytics span tab for now 2026-04-12 21:33:07 +05:30
aks07
0a1a676877 feat: show total span count 2026-04-12 21:14:19 +05:30
aks07
bb2aa9f77c feat: auto scroll horizontally to span 2026-04-12 20:08:55 +05:30
aks07
04bef4ac06 feat: sync error and loading state for flamegraph for n/w and computation logic 2026-04-11 23:48:47 +05:30
aks07
3bcb2c2c41 feat: added loading to flamegraph and timeout to webworker 2026-04-11 22:37:10 +05:30
aks07
9e77b76122 feat: add icons 2026-04-11 21:28:00 +05:30
aks07
ff4a41d842 feat: analytics 2026-04-11 14:34:57 +05:30
aks07
387deb779d feat: span details ux 2026-04-10 21:31:40 +05:30
aks07
1ec2663d51 feat: lint fix 2026-04-10 13:40:43 +05:30
aks07
1b17370da0 feat: fix test 2026-04-10 13:10:28 +05:30
aks07
c6484a79e2 feat: fix test 2026-04-10 13:09:08 +05:30
Nikhil Soni
16a2c7a1af chore: generate openapi spec for v3 waterfall 2026-04-10 10:54:36 +05:30
aks07
3c4ac0e85e feat: supress click 2026-04-10 09:08:45 +05:30
aks07
87ba729a00 feat: minor change 2026-04-10 02:46:55 +05:30
aks07
f1ed7145e4 feat: add limit 2026-04-10 02:41:22 +05:30
aks07
bc15495e17 Merge branch 'main' of github.com:SigNoz/signoz into feat/span-details 2026-04-10 02:01:59 +05:30
aks07
f7d3012daf feat: api integration 2026-04-10 00:53:25 +05:30
Nikhil Soni
6ec9a2ec41 fix: use typed paramter field in logs 2026-04-09 21:39:29 +05:30
Nikhil Soni
9c056f809a fix: add timeout to module context 2026-04-09 20:03:47 +05:30
Nikhil Soni
c1d4273416 fix: rename timestamp to milli for readability 2026-04-09 16:54:44 +05:30
Nikhil Soni
618fe891d5 fix: remove unused fields and rename span type
To avoid confusing with otel span
2026-04-09 14:02:44 +05:30
Nikhil Soni
549c7e7034 chore: avoid sorting on every traversal 2026-04-09 11:38:55 +05:30
Nikhil Soni
dd65f83c3d chore: add same test cases as for old waterfall api 2026-04-09 11:38:55 +05:30
Nikhil Soni
8463a131fc refactor: convert waterfall api to modules format 2026-04-09 11:38:55 +05:30
Nikhil Soni
2d42518440 fix: remove unused fields and rename span type
To avoid confusing with otel span
2026-04-09 11:38:55 +05:30
Nikhil Soni
43d75a3853 fix: update span.attributes to map of string to any
To support otel format of diffrent types of attributes
2026-04-09 11:38:55 +05:30
Nikhil Soni
c5bb34e385 chore: add reason for using snake case in response 2026-04-09 11:38:54 +05:30
Nikhil Soni
6fd129991d refactor: move type conversion logic to types pkg 2026-04-09 11:38:54 +05:30
Nikhil Soni
9c5cca426a feat: setup types and interface for waterfall v3
v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service
2026-04-09 11:38:54 +05:30
aks07
a467efb97d feat: style fixes 2026-04-09 08:31:12 +05:30
aks07
58e2718090 feat: linked spans 2026-04-08 22:04:42 +05:30
aks07
65fee725c9 feat: key value label style fixes 2026-04-08 14:21:54 +05:30
aks07
ea87174088 feat: fix span details headr 2026-04-08 11:39:47 +05:30
aks07
627c483d86 feat: copy link change and url clear on span close 2026-04-07 20:06:53 +05:30
aks07
2533137db4 feat: use resizable for waterfall table as well 2026-04-07 15:55:06 +05:30
aks07
a774f8a4fe feat: add collapsible sections in trace details 2026-04-07 13:28:59 +05:30
aks07
8487f6cf66 feat: add bound to drags while floating 2026-04-01 19:33:53 +05:30
aks07
6ebe51126e feat: fix pinning. fix drag on top 2026-04-01 16:48:59 +05:30
aks07
ed64d5cd9f feat: replace draggable package 2026-03-31 21:33:42 +05:30
aks07
c04076e664 feat: span details folder rename 2026-03-31 17:50:02 +05:30
aks07
3c129e2c7d feat: span details floating drawer added 2026-03-31 17:44:25 +05:30
aks07
0ba51e2058 feat: json viewer with select dropdown added 2026-03-31 15:17:15 +05:30
aks07
cdc2ab134c feat: style fix 2026-03-26 20:27:41 +05:30
aks07
fb0c05b553 feat: refactor 2026-03-26 20:25:25 +05:30
aks07
68e9707e3b feat: search in pretty view 2026-03-26 20:08:25 +05:30
aks07
17ffaf9ccf feat: minor change 2026-03-26 19:42:48 +05:30
aks07
efec669b76 feat: update yarn lock 2026-03-26 19:31:10 +05:30
aks07
17b9e14d34 feat: added pretty view 2026-03-26 19:25:06 +05:30
aks07
2db9f969c3 feat: key attr section added 2026-03-26 15:01:16 +05:30
aks07
9fa466b124 feat: added span percentile 2026-03-26 14:33:06 +05:30
aks07
0c7768ebff feat: details field component 2026-03-25 03:10:50 +05:30
aks07
58dd51e92f feat: span details header 2026-03-25 02:22:11 +05:30
aks07
870c9bf6dc Merge branch 'main' of github.com:SigNoz/signoz into feat/span-details 2026-03-25 01:08:22 +05:30
aks07
7604956bf0 feat: span details init 2026-03-25 00:34:56 +05:30
aks07
66510e4919 feat: waterfall resizable 2026-03-18 20:58:01 +05:30
aks07
a1bf0e67db feat: event dots in trace details 2026-03-18 18:54:19 +05:30
aks07
a06046612a feat: connector line ux 2026-03-17 18:45:11 +05:30
aks07
31c9d4309b feat: move to service worker 2026-03-17 17:06:05 +05:30
aks07
7bef8b86c4 feat: subtree segregated tree 2026-03-17 14:39:57 +05:30
aks07
d26acd36a3 feat: subtree segregated tree 2026-03-17 13:55:58 +05:30
aks07
1cee595135 feat: subtree segregated tree 2026-03-17 13:29:07 +05:30
aks07
dd1868fcbc feat: row based flamegraph 2026-03-16 23:27:31 +05:30
aks07
a20beb8ba2 feat: span hover popover sync 2026-03-16 14:18:12 +05:30
aks07
998d652feb feat: fix hover option overflow 2026-03-16 10:19:42 +05:30
aks07
3695d3c180 feat: match span style 2026-03-13 15:53:46 +05:30
aks07
da175bafbc feat: add TimelineV3 ruler to waterfall header with padding fix
Add the TimelineV3 component to the sticky header of the waterfall's
right panel so timeline tick marks are visible. Add horizontal padding
to both the timeline header and span duration bars to prevent label
overflow/clipping at the edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:12:49 +05:30
aks07
021b187cbc feat: decouple waterfall left (span tree) and right (timeline bars) panels
Split the waterfall into two independent panels with a shared virtualizer
so deeply nested span names are visible via horizontal scroll in the left
panel. Left panel uses useReactTable + <table> for future column
extensibility; right panel uses plain divs for timeline bars. A draggable
resize handle separates the two panels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:04:32 +05:30
aks07
f42b468597 feat: waterfall init 2026-03-11 17:15:27 +05:30
aks07
7e2cf57819 Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-flamegraph 2026-03-11 12:24:06 +05:30
aks07
dc9ebc5b26 feat: add test utils 2026-03-11 03:31:10 +05:30
aks07
398ab6e9d9 feat: add test cases for flamegraph 2026-03-11 03:28:35 +05:30
aks07
fec60671d8 feat: minor comment added 2026-03-11 03:03:49 +05:30
aks07
99259cc4e8 feat: remove unnecessary props 2026-03-10 16:01:24 +05:30
aks07
ca311717c2 feat: bg color for selected and hover spans 2026-03-06 23:16:35 +05:30
aks07
a614da2c65 fix: update color 2026-03-06 20:22:23 +05:30
aks07
ce18709002 fix: style fix 2026-03-06 20:03:42 +05:30
aks07
2b6977e891 feat: reduce timeline intervals 2026-03-06 20:01:50 +05:30
aks07
3e6eedbcab feat: fix style 2026-03-06 19:57:19 +05:30
aks07
fd9e3f0411 feat: fix style 2026-03-06 19:26:23 +05:30
aks07
e99465e030 Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-flamegraph 2026-03-06 18:45:18 +05:30
aks07
9ad2db4b99 feat: scroll to selected span 2026-03-06 16:00:48 +05:30
aks07
07fd5f70ef feat: fix timerange unit selection when zoomed 2026-03-06 12:55:34 +05:30
aks07
ba79121795 feat: temp change 2026-03-06 12:49:06 +05:30
aks07
6e4e419b5e Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-flamegraph 2026-03-06 10:30:41 +05:30
aks07
2f06afaf27 feat: handle click and hover with tooltip 2026-03-05 23:25:10 +05:30
aks07
f77c3cb23c feat: update span colors 2026-03-05 22:40:06 +05:30
aks07
9e3a8efcfc feat: zoom and drag added 2026-03-05 18:22:55 +05:30
aks07
8e325ba8b3 feat: added timeline v3 2026-03-05 12:31:17 +05:30
aks07
884f516766 feat: add text to spans 2026-03-03 12:20:06 +05:30
aks07
4bcbb4ffc3 feat: flamegraph canvas init 2026-03-02 19:21:23 +05:30
274 changed files with 18939 additions and 3887 deletions

1
.github/CODEOWNERS vendored
View File

@@ -107,6 +107,7 @@ go.mod @therealpandey
/pkg/modules/organization/ @therealpandey
/pkg/modules/authdomain/ @therealpandey
/pkg/modules/role/ @therealpandey
/pkg/types/coretypes/ @therealpandey @vikrantgupta25
# IdentN Owners

View File

@@ -102,3 +102,20 @@ jobs:
run: |
go run cmd/enterprise/*.go generate openapi
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in openapi spec. Run go run cmd/enterprise/*.go generate openapi locally and commit."; exit 1)
authz:
if: |
github.event_name == 'merge_group' ||
(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'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: generate-authz
run: |
go run cmd/enterprise/*.go generate authz
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in authz permissions. Run go run cmd/enterprise/*.go generate authz locally and commit."; exit 1)

View File

@@ -63,46 +63,6 @@ jobs:
uses: actions/checkout@v4
- name: run
run: bash frontend/scripts/validate-md-languages.sh
authz:
if: |
github.event_name == 'merge_group' ||
(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'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v5
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: deps-install
working-directory: ./frontend
run: |
yarn install
- name: uv-install
uses: astral-sh/setup-uv@v5
- name: uv-deps
working-directory: ./tests/integration
run: |
uv sync
- name: setup-test
run: |
make py-test-setup
- name: generate
working-directory: ./frontend
run: |
yarn generate:permissions-type
- name: teardown-test
if: always()
run: |
make py-test-teardown
- name: validate
run: |
if ! git diff --exit-code frontend/src/hooks/useAuthZ/permissions.type.ts; then
echo "::error::frontend/src/hooks/useAuthZ/permissions.type.ts is out of date. Please run the generator locally and commit the changes: npm run generate:permissions-type (from the frontend directory)"
exit 1
fi
openapi:
if: |
github.event_name == 'merge_group' ||

117
cmd/authz.go Normal file
View File

@@ -0,0 +1,117 @@
package cmd
import (
"bytes"
"context"
"os"
"sort"
"text/template"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/spf13/cobra"
)
const permissionsTypePath = "frontend/src/hooks/useAuthZ/permissions.type.ts"
var permissionsTypeTemplate = template.Must(template.New("permissions").Parse(
`// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY cmd/enterprise/*.go generate authz
export default {
status: 'success',
data: {
resources: [
{{- range .Resources }}
{
kind: '{{ .Kind }}',
type: '{{ .Type }}',
},
{{- end }}
],
relations: {
{{- range .Relations }}
{{ .Verb }}: [{{ range $i, $t := .Types }}{{ if $i }}, {{ end }}'{{ $t }}'{{ end }}],
{{- end }}
},
},
} as const;
`))
type permissionsTypeRelation struct {
Verb string
Types []string
}
type permissionsTypeResource struct {
Kind string
Type string
}
type permissionsTypeData struct {
Resources []permissionsTypeResource
Relations []permissionsTypeRelation
}
func registerGenerateAuthz(parentCmd *cobra.Command) {
authzCmd := &cobra.Command{
Use: "authz",
Short: "Generate authz permissions for the frontend",
RunE: func(currCmd *cobra.Command, args []string) error {
return runGenerateAuthz(currCmd.Context())
},
}
parentCmd.AddCommand(authzCmd)
}
func runGenerateAuthz(_ context.Context) error {
registry := coretypes.NewRegistry()
allowedResources := map[string]bool{
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourcesRole).String(): true,
}
allowedTypes := map[string]bool{}
refs := registry.ResourceRefs()
resources := make([]permissionsTypeResource, 0, len(refs))
for _, ref := range refs {
if !allowedResources[ref.String()] {
continue
}
allowedTypes[ref.Type.StringValue()] = true
resources = append(resources, permissionsTypeResource{
Kind: ref.Kind.String(),
Type: ref.Type.StringValue(),
})
}
typesByVerb := registry.TypesByVerb()
verbs := make([]coretypes.Verb, 0, len(typesByVerb))
for verb := range typesByVerb {
verbs = append(verbs, verb)
}
sort.Slice(verbs, func(i, j int) bool { return verbs[i].StringValue() < verbs[j].StringValue() })
relations := make([]permissionsTypeRelation, 0, len(verbs))
for _, verb := range verbs {
types := make([]string, 0, len(typesByVerb[verb]))
for _, t := range typesByVerb[verb] {
if !allowedTypes[t.StringValue()] {
continue
}
types = append(types, t.StringValue())
}
relations = append(relations, permissionsTypeRelation{
Verb: verb.StringValue(),
Types: types,
})
}
var buf bytes.Buffer
if err := permissionsTypeTemplate.Execute(&buf, permissionsTypeData{Resources: resources, Relations: relations}); err != nil {
return err
}
return os.WriteFile(permissionsTypePath, buf.Bytes(), 0o600)
}

View File

@@ -92,13 +92,13 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete, _ dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
func(ctx context.Context, sqlstore sqlstore.SQLStore, config authz.Config, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore, config)
if err != nil {
return nil, err
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore), nil
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, authtypes.NewRegistry()), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)

View File

@@ -137,12 +137,12 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return authNs, nil
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, dashboardModule dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
func(ctx context.Context, sqlstore sqlstore.SQLStore, config authz.Config, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore, config)
if err != nil {
return nil, err
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, dashboardModule), nil
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, authtypes.NewRegistry()), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)

View File

@@ -16,6 +16,7 @@ func RegisterGenerate(parentCmd *cobra.Command, logger *slog.Logger) {
}
registerGenerateOpenAPI(generateCmd)
registerGenerateAuthz(generateCmd)
parentCmd.AddCommand(generateCmd)
}

View File

@@ -428,4 +428,4 @@ authz:
provider: openfga
openfga:
# maximum tuples allowed per openfga write operation.
max_tuples_per_write: 100
max_tuples_per_write: 300

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.121.1
image: signoz/signoz:v0.122.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.121.1
image: signoz/signoz:v0.122.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.121.1}
image: signoz/signoz:${VERSION:-v0.122.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.121.1}
image: signoz/signoz:${VERSION:-v0.122.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -321,35 +321,6 @@ components:
required:
- id
type: object
AuthtypesGettableObjects:
properties:
resource:
$ref: '#/components/schemas/AuthtypesResource'
selectors:
items:
type: string
type: array
required:
- resource
- selectors
type: object
AuthtypesGettableResources:
properties:
relations:
additionalProperties:
items:
type: string
type: array
nullable: true
type: object
resources:
items:
$ref: '#/components/schemas/AuthtypesResource'
type: array
required:
- resources
- relations
type: object
AuthtypesGettableToken:
properties:
accessToken:
@@ -366,9 +337,9 @@ components:
authorized:
type: boolean
object:
$ref: '#/components/schemas/AuthtypesObject'
$ref: '#/components/schemas/CoretypesObject'
relation:
type: string
$ref: '#/components/schemas/AuthtypesRelation'
required:
- relation
- object
@@ -416,16 +387,6 @@ components:
issuerAlias:
type: string
type: object
AuthtypesObject:
properties:
resource:
$ref: '#/components/schemas/AuthtypesResource'
selector:
type: string
required:
- resource
- selector
type: object
AuthtypesOrgSessionContext:
properties:
authNSupport:
@@ -442,22 +403,6 @@ components:
provider:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesPatchableObjects:
properties:
additions:
items:
$ref: '#/components/schemas/AuthtypesGettableObjects'
nullable: true
type: array
deletions:
items:
$ref: '#/components/schemas/AuthtypesGettableObjects'
nullable: true
type: array
required:
- additions
- deletions
type: object
AuthtypesPatchableRole:
properties:
description:
@@ -495,16 +440,15 @@ components:
refreshToken:
type: string
type: object
AuthtypesResource:
properties:
name:
type: string
type:
type: string
required:
- name
- type
type: object
AuthtypesRelation:
enum:
- create
- read
- update
- delete
- list
- assignee
type: string
AuthtypesRole:
properties:
createdAt:
@@ -568,9 +512,9 @@ components:
AuthtypesTransaction:
properties:
object:
$ref: '#/components/schemas/AuthtypesObject'
$ref: '#/components/schemas/CoretypesObject'
relation:
type: string
$ref: '#/components/schemas/AuthtypesRelation'
required:
- relation
- object
@@ -2205,6 +2149,64 @@ components:
to_user:
type: string
type: object
CoretypesObject:
properties:
resource:
$ref: '#/components/schemas/CoretypesResourceRef'
selector:
type: string
required:
- resource
- selector
type: object
CoretypesObjectGroup:
properties:
resource:
$ref: '#/components/schemas/CoretypesResourceRef'
selectors:
items:
type: string
type: array
required:
- resource
- selectors
type: object
CoretypesPatchableObjects:
properties:
additions:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
nullable: true
type: array
deletions:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
nullable: true
type: array
required:
- additions
- deletions
type: object
CoretypesResourceRef:
properties:
kind:
type: string
type:
$ref: '#/components/schemas/CoretypesType'
required:
- type
- kind
type: object
CoretypesType:
enum:
- user
- serviceaccount
- anonymous
- role
- organization
- metaresource
- metaresources
type: string
DashboardtypesDashboard:
properties:
createdAt:
@@ -5321,6 +5323,9 @@ components:
sub_tree_node_count:
minimum: 0
type: integer
time_unix:
minimum: 0
type: integer
trace_id:
type: string
trace_state:
@@ -5701,35 +5706,6 @@ paths:
summary: Check permissions
tags:
- authz
/api/v1/authz/resources:
get:
deprecated: false
description: Gets all the available resources
operationId: AuthzResources
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableResources'
status:
type: string
required:
- status
- data
type: object
description: OK
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
summary: Get resources
tags:
- authz
/api/v1/channels:
get:
deprecated: false
@@ -8953,7 +8929,7 @@ paths:
properties:
data:
items:
$ref: '#/components/schemas/AuthtypesGettableObjects'
$ref: '#/components/schemas/CoretypesObjectGroup'
type: array
status:
type: string
@@ -9026,7 +9002,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPatchableObjects'
$ref: '#/components/schemas/CoretypesPatchableObjects'
responses:
"204":
content:

View File

@@ -2,7 +2,6 @@ package openfgaauthz
import (
"context"
"slices"
"github.com/SigNoz/signoz/ee/authz/openfgaserver"
"github.com/SigNoz/signoz/pkg/authz"
@@ -13,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer"
@@ -25,19 +25,19 @@ type provider struct {
openfgaServer *openfgaserver.Server
licensing licensing.Licensing
store authtypes.RoleStore
registry []authz.RegisterTypeable
registry *authtypes.Registry
settings factory.ScopedProviderSettings
onBeforeRoleDelete []authz.OnBeforeRoleDelete
}
func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, registry ...authz.RegisterTypeable) factory.ProviderFactory[authz.AuthZ, authz.Config] {
func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, registry *authtypes.Registry) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return factory.NewProviderFactory(factory.MustNewName("openfga"), func(ctx context.Context, ps factory.ProviderSettings, config authz.Config) (authz.AuthZ, error) {
return newOpenfgaProvider(ctx, ps, config, sqlstore, openfgaSchema, openfgaDataStore, licensing, onBeforeRoleDelete, registry)
})
}
func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, registry []authz.RegisterTypeable) (authz.AuthZ, error) {
pkgOpenfgaAuthzProvider := pkgopenfgaauthz.NewProviderFactory(sqlstore, openfgaSchema, openfgaDataStore)
func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, registry *authtypes.Registry) (authz.AuthZ, error) {
pkgOpenfgaAuthzProvider := pkgopenfgaauthz.NewProviderFactory(sqlstore, openfgaSchema, openfgaDataStore, registry)
pkgAuthzService, err := pkgOpenfgaAuthzProvider.New(ctx, settings, config)
if err != nil {
return nil, err
@@ -74,11 +74,11 @@ func (provider *provider) Stop(ctx context.Context) error {
return provider.openfgaServer.Stop(ctx)
}
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, roleSelectors []authtypes.Selector) error {
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable coretypes.Resource, selectors []coretypes.Selector, roleSelectors []coretypes.Selector) error {
return provider.openfgaServer.CheckWithTupleCreation(ctx, claims, orgID, relation, typeable, selectors, roleSelectors)
}
func (provider *provider) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, roleSelectors []authtypes.Selector) error {
func (provider *provider) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable coretypes.Resource, selectors []coretypes.Selector, roleSelectors []coretypes.Selector) error {
return provider.openfgaServer.CheckWithTupleCreationWithoutClaims(ctx, orgID, relation, typeable, selectors, roleSelectors)
}
@@ -108,7 +108,7 @@ func (provider *provider) CheckTransactions(ctx context.Context, subject string,
return results, nil
}
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType authtypes.Type) ([]*authtypes.Object, error) {
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
return provider.openfgaServer.ListObjects(ctx, subject, relation, objectType)
}
@@ -159,16 +159,10 @@ func (provider *provider) CreateManagedRoles(ctx context.Context, orgID valuer.U
func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
tuples := make([]*openfgav1.TupleKey, 0)
grantTuples, err := provider.getManagedRoleGrantTuples(orgID, userID)
if err != nil {
return err
}
grantTuples := provider.getManagedRoleGrantTuples(orgID, userID)
tuples = append(tuples, grantTuples...)
managedRoleTuples, err := provider.getManagedRoleTransactionTuples(orgID)
if err != nil {
return err
}
managedRoleTuples := provider.getManagedRoleTransactionTuples(orgID)
tuples = append(tuples, managedRoleTuples...)
return provider.Write(ctx, tuples, nil)
@@ -208,21 +202,7 @@ func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, ro
return role, nil
}
func (provider *provider) GetResources(_ context.Context) []*authtypes.Resource {
resources := make([]*authtypes.Resource, 0)
for _, register := range provider.registry {
for _, typeable := range register.MustGetTypeables() {
resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()})
}
}
for _, typeable := range provider.MustGetTypeables() {
resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()})
}
return resources
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*authtypes.Object, error) {
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -233,16 +213,16 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
return nil, err
}
objects := make([]*authtypes.Object, 0)
for _, objectType := range provider.getUniqueTypes() {
if !slices.Contains(authtypes.TypeableRelations[objectType], relation) {
objects := make([]*coretypes.Object, 0)
for _, objectType := range provider.registry.Types() {
if coretypes.ErrIfVerbNotValidForType(relation.Verb, objectType) != nil {
continue
}
resourceObjects, err := provider.
ListObjects(
ctx,
authtypes.MustNewSubject(authtypes.TypeableRole, storableRole.Name, orgID, &authtypes.RelationAssignee),
authtypes.MustNewSubject(coretypes.NewResourceRole(), storableRole.Name, orgID, &coretypes.VerbAssignee),
relation,
objectType,
)
@@ -265,7 +245,7 @@ func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *au
return provider.store.Update(ctx, orgID, role)
}
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*coretypes.Object) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -318,84 +298,63 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
return provider.store.Delete(ctx, orgID, id)
}
func (provider *provider) MustGetTypeables() []authtypes.Typeable {
return []authtypes.Typeable{authtypes.TypeableRole, authtypes.TypeableResourcesRoles}
}
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) ([]*openfgav1.TupleKey, error) {
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) []*openfgav1.TupleKey {
tuples := []*openfgav1.TupleKey{}
// Grant the admin role to the user
adminSubject := authtypes.MustNewSubject(authtypes.TypeableUser, userID.String(), orgID, nil)
adminTuple, err := authtypes.TypeableRole.Tuples(
adminSubject := authtypes.MustNewSubject(coretypes.NewResourceUser(), userID.String(), orgID, nil)
adminTuple := authtypes.NewTuples(
coretypes.NewResourceRole(),
adminSubject,
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
},
authtypes.Relation{Verb: coretypes.VerbAssignee},
[]coretypes.Selector{coretypes.TypeRole.MustSelector(authtypes.SigNozAdminRoleName)},
orgID,
)
if err != nil {
return nil, err
}
tuples = append(tuples, adminTuple...)
// Grant the admin role to the anonymous user
anonymousSubject := authtypes.MustNewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.String(), orgID, nil)
anonymousTuple, err := authtypes.TypeableRole.Tuples(
anonymousSubject := authtypes.MustNewSubject(coretypes.NewResourceAnonymous(), coretypes.AnonymousUser.String(), orgID, nil)
anonymousTuple := authtypes.NewTuples(
coretypes.NewResourceRole(),
anonymousSubject,
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAnonymousRoleName),
},
authtypes.Relation{Verb: coretypes.VerbAssignee},
[]coretypes.Selector{coretypes.TypeRole.MustSelector(authtypes.SigNozAnonymousRoleName)},
orgID,
)
if err != nil {
return nil, err
}
tuples = append(tuples, anonymousTuple...)
return tuples, nil
return tuples
}
func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
transactionsByRole := make(map[string][]*authtypes.Transaction)
for _, register := range provider.registry {
for roleName, txns := range register.MustGetManagedRoleTransactions() {
transactionsByRole[roleName] = append(transactionsByRole[roleName], txns...)
}
}
func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*openfgav1.TupleKey {
tuples := make([]*openfgav1.TupleKey, 0)
for roleName, transactions := range transactionsByRole {
for roleName, transactions := range provider.registry.ManagedRoleTransactions() {
for _, txn := range transactions {
typeable := authtypes.MustNewTypeableFromType(txn.Object.Resource.Type, txn.Object.Resource.Name)
txnTuples, err := typeable.Tuples(
resource := coretypes.MustNewResourceFromTypeAndKind(txn.Object.Resource.Type, txn.Object.Resource.Kind)
txnTuples := authtypes.NewTuples(
resource,
authtypes.MustNewSubject(
authtypes.TypeableRole,
coretypes.NewResourceRole(),
roleName,
orgID,
&authtypes.RelationAssignee,
&coretypes.VerbAssignee,
),
txn.Relation,
[]authtypes.Selector{txn.Object.Selector},
[]coretypes.Selector{txn.Object.Selector},
orgID,
)
if err != nil {
return nil, err
}
tuples = append(tuples, txnTuples...)
}
}
return tuples, nil
return tuples
}
func (provider *provider) deleteTuples(ctx context.Context, roleName string, orgID valuer.UUID) error {
subject := authtypes.MustNewSubject(authtypes.TypeableRole, roleName, orgID, &authtypes.RelationAssignee)
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
tuples := make([]*openfgav1.TupleKey, 0)
for _, objectType := range provider.getUniqueTypes() {
for _, objectType := range provider.registry.Types() {
typeTuples, err := provider.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
User: subject,
Object: objectType.StringValue() + ":",
@@ -424,28 +383,3 @@ func (provider *provider) deleteTuples(ctx context.Context, roleName string, org
return nil
}
func (provider *provider) getUniqueTypes() []authtypes.Type {
seen := make(map[string]struct{})
uniqueTypes := make([]authtypes.Type, 0)
for _, register := range provider.registry {
for _, typeable := range register.MustGetTypeables() {
typeKey := typeable.Type().StringValue()
if _, ok := seen[typeKey]; ok {
continue
}
seen[typeKey] = struct{}{}
uniqueTypes = append(uniqueTypes, typeable.Type())
}
}
for _, typeable := range provider.MustGetTypeables() {
typeKey := typeable.Type().StringValue()
if _, ok := seen[typeKey]; ok {
continue
}
seen[typeKey] = struct{}{}
uniqueTypes = append(uniqueTypes, typeable.Type())
}
return uniqueTypes
}

View File

@@ -1,6 +1,6 @@
module base
type organisation
type organization
relations
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
@@ -10,12 +10,14 @@ type user
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define attach: [user, serviceaccount, role#assignee]
type serviceaccount
type serviceaccount
relations
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define attach: [user, serviceaccount, role#assignee]
type anonymous
@@ -26,6 +28,7 @@ type role
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define attach: [user, serviceaccount, role#assignee]
type metaresources
relations

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
@@ -33,18 +34,18 @@ func (server *Server) Stop(ctx context.Context) error {
return server.pkgAuthzService.Stop(ctx)
}
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable coretypes.Resource, selectors []coretypes.Selector, _ []coretypes.Selector) error {
subject := ""
switch claims.Principal {
case authtypes.PrincipalUser:
user, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
user, err := authtypes.NewSubject(coretypes.NewResourceUser(), claims.UserID, orgID, nil)
if err != nil {
return err
}
subject = user
case authtypes.PrincipalServiceAccount:
serviceAccount, err := authtypes.NewSubject(authtypes.TypeableServiceAccount, claims.ServiceAccountID, orgID, nil)
serviceAccount, err := authtypes.NewSubject(coretypes.NewResourceServiceAccount(), claims.ServiceAccountID, orgID, nil)
if err != nil {
return err
}
@@ -52,10 +53,7 @@ func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtyp
subject = serviceAccount
}
tupleSlice, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
tupleSlice := authtypes.NewTuples(typeable, subject, relation, selectors, orgID)
tuples := make(map[string]*openfgav1.TupleKey, len(tupleSlice))
for idx, tuple := range tupleSlice {
@@ -76,16 +74,13 @@ func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtyp
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "subjects are not authorized for requested access")
}
func (server *Server) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.String(), orgID, nil)
func (server *Server) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable coretypes.Resource, selectors []coretypes.Selector, _ []coretypes.Selector) error {
subject, err := authtypes.NewSubject(coretypes.NewResourceAnonymous(), coretypes.AnonymousUser.String(), orgID, nil)
if err != nil {
return err
}
tupleSlice, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
tupleSlice := authtypes.NewTuples(typeable, subject, relation, selectors, orgID)
tuples := make(map[string]*openfgav1.TupleKey, len(tupleSlice))
for idx, tuple := range tupleSlice {
@@ -110,7 +105,7 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
return server.pkgAuthzService.BatchCheck(ctx, tupleReq)
}
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType authtypes.Type) ([]*authtypes.Object, error) {
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
return server.pkgAuthzService.ListObjects(ctx, subject, relation, objectType)
}

View File

@@ -2,6 +2,7 @@ package openfgaserver
import (
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/openfga/openfga/pkg/storage"
@@ -10,11 +11,11 @@ import (
"github.com/openfga/openfga/pkg/storage/sqlite"
)
func NewSQLStore(store sqlstore.SQLStore) (storage.OpenFGADatastore, error) {
func NewSQLStore(store sqlstore.SQLStore, config authz.Config) (storage.OpenFGADatastore, error) {
switch store.BunDB().Dialect().Name().String() {
case "sqlite":
return sqlite.NewWithDB(store.SQLDB(), &sqlcommon.Config{
MaxTuplesPerWriteField: 100,
MaxTuplesPerWriteField: config.OpenFGA.MaxTuplesPerWrite,
MaxTypesPerModelField: 100,
})
case "pg":
@@ -24,7 +25,7 @@ func NewSQLStore(store sqlstore.SQLStore) (storage.OpenFGADatastore, error) {
}
return postgres.NewWithDB(pgStore.Pool(), nil, &sqlcommon.Config{
MaxTuplesPerWriteField: 100,
MaxTuplesPerWriteField: config.OpenFGA.MaxTuplesPerWrite,
MaxTypesPerModelField: 100,
})
}

View File

@@ -14,7 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
@@ -88,7 +88,7 @@ func (module *module) GetDashboardByPublicID(ctx context.Context, id valuer.UUID
return dashboardtypes.NewDashboardFromStorableDashboard(storableDashboard), nil
}
func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id valuer.UUID, orgs []*types.Organization) ([]authtypes.Selector, valuer.UUID, error) {
func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id valuer.UUID, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
orgIDs := make([]string, len(orgs))
for idx, org := range orgs {
orgIDs[idx] = org.ID.StringValue()
@@ -99,9 +99,9 @@ func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id
return nil, valuer.UUID{}, err
}
return []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeMetaResource, id.StringValue()),
authtypes.MustNewSelector(authtypes.TypeMetaResource, authtypes.WildCardSelectorString),
return []coretypes.Selector{
coretypes.TypeMetaResource.MustSelector(id.StringValue()),
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
}, storableDashboard.OrgID, nil
}
@@ -217,28 +217,6 @@ func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valu
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) MustGetTypeables() []authtypes.Typeable {
return module.pkgDashboardModule.MustGetTypeables()
}
func (module *module) MustGetManagedRoleTransactions() map[string][]*authtypes.Transaction {
return map[string][]*authtypes.Transaction{
authtypes.SigNozAnonymousRoleName: {
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationRead,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: dashboardtypes.TypeableMetaResourcePublicDashboard.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
},
}
}
func (module *module) deletePublic(ctx context.Context, _ valuer.UUID, dashboardID valuer.UUID) error {
return module.store.DeletePublic(ctx, dashboardID.StringValue())
}

View File

@@ -5,7 +5,12 @@ import (
"fmt"
"net/http"
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/ee/query-service/model"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type DayWiseBreakdown struct {
@@ -65,18 +70,53 @@ func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) {
return
}
data, err := ah.Signoz.Zeus.GetMeters(r.Context(), licenseKey)
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
var billing billingData
if err := json.Unmarshal(data, &billing); err != nil {
orgID := valuer.MustNewUUID(claims.OrgID)
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
useZeus := ah.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureGetMetersFromZeus, evalCtx)
if useZeus {
data, err := ah.Signoz.Zeus.GetMeters(r.Context(), licenseKey)
if err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
var billing billingData
if err := json.Unmarshal(data, &billing); err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
ah.Respond(w, billing)
return
}
billingURL := fmt.Sprintf("%s/usage?licenseKey=%s", constants.LicenseSignozIo, licenseKey)
hClient := &http.Client{}
req, err := http.NewRequest("GET", billingURL, nil)
if err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
req.Header.Add("X-SigNoz-SecretKey", constants.LicenseAPIKey)
billingResp, err := hClient.Do(req)
if err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
ah.Respond(w, billing)
return
var billingResponse billingDetails
if err := json.NewDecoder(billingResp.Body).Decode(&billingResponse); err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
ah.Respond(w, billingResponse.Data)
}

View File

@@ -6,6 +6,8 @@ import (
"io"
"net/http"
"log/slog"
"github.com/SigNoz/signoz/ee/query-service/anomaly"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
@@ -15,7 +17,6 @@ import (
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"log/slog"
)
func (aH *APIHandler) queryRangeV4(w http.ResponseWriter, r *http.Request) {
@@ -137,4 +138,3 @@ func (aH *APIHandler) queryRangeV4(w http.ResponseWriter, r *http.Request) {
aH.QueryRangeV4(w, r)
}
}

View File

@@ -42,8 +42,8 @@ import (
// Server runs HTTP, Mux and a grpc server
type Server struct {
config signoz.Config
signoz *signoz.SigNoz
config signoz.Config
signoz *signoz.SigNoz
// public http router
httpConn net.Listener
@@ -148,7 +148,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
s := &Server{
config: config,
signoz: signoz,
signoz: signoz,
httpHostPort: baseconst.HTTPHostPort,
unavailableChannel: make(chan healthcheck.Status),
usageManager: usageManager,
@@ -317,4 +317,3 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}

View File

@@ -23,6 +23,11 @@
"**/*.md",
"**/*.json",
"src/parser/**",
"src/TraceOperator/parser/**"
"src/TraceOperator/parser/**",
".claude",
".opencode",
"dist",
"playwright-report",
".temp_cache"
]
}

View File

@@ -23,8 +23,7 @@
"commitlint": "commitlint --edit $1",
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
"generate:permissions-type": "node scripts/generate-permissions-type.cjs"
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh"
},
"engines": {
"node": ">=22.0.0"
@@ -121,10 +120,12 @@
"react-helmet-async": "1.3.0",
"react-hook-form": "7.71.2",
"react-i18next": "^11.16.1",
"react-json-tree": "^0.20.0",
"react-lottie": "1.2.10",
"react-markdown": "8.0.7",
"react-query": "3.39.3",
"react-redux": "^7.2.2",
"react-rnd": "^10.5.3",
"react-router-dom": "^5.2.0",
"react-router-dom-v5-compat": "6.27.0",
"react-syntax-highlighter": "15.5.0",

View File

@@ -1,199 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const axios = require('axios');
const PERMISSIONS_TYPE_FILE = path.join(
__dirname,
'../src/hooks/useAuthZ/permissions.type.ts',
);
const SIGNOZ_INTEGRATION_IMAGE = 'signoz:integration';
const LOCAL_BACKEND_URL = 'http://localhost:8080';
function log(message) {
console.log(`[generate-permissions-type] ${message}`);
}
function getBackendUrlFromDocker() {
try {
const output = execSync(
`docker ps --filter "ancestor=${SIGNOZ_INTEGRATION_IMAGE}" --format "{{.Ports}}"`,
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
).trim();
if (!output) {
return null;
}
const portMatch = output.match(/0\.0\.0\.0:(\d+)->8080\/tcp/);
if (portMatch) {
return `http://localhost:${portMatch[1]}`;
}
const ipv6Match = output.match(/:::(\d+)->8080\/tcp/);
if (ipv6Match) {
return `http://localhost:${ipv6Match[1]}`;
}
} catch (err) {
log(`Warning: Could not get port from docker: ${err.message}`);
}
return null;
}
async function checkBackendHealth(url, maxAttempts = 3, delayMs = 1000) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await axios.get(`${url}/api/v1/health`, {
timeout: 5000,
validateStatus: (status) => status === 200,
});
return true;
} catch (err) {
if (attempt < maxAttempts) {
await new Promise((r) => setTimeout(r, delayMs));
}
}
}
return false;
}
async function discoverBackendUrl() {
const dockerUrl = getBackendUrlFromDocker();
if (dockerUrl) {
log(`Found ${SIGNOZ_INTEGRATION_IMAGE} container, trying ${dockerUrl}...`);
if (await checkBackendHealth(dockerUrl)) {
log(`Backend found at ${dockerUrl} (from py-test-setup)`);
return dockerUrl;
}
log(`Backend at ${dockerUrl} is not responding`);
}
log(`Trying local backend at ${LOCAL_BACKEND_URL}...`);
if (await checkBackendHealth(LOCAL_BACKEND_URL)) {
log(`Backend found at ${LOCAL_BACKEND_URL}`);
return LOCAL_BACKEND_URL;
}
return null;
}
async function fetchResources(backendUrl) {
log('Fetching resources from API...');
const resourcesUrl = `${backendUrl}/api/v1/authz/resources`;
const { data: response } = await axios.get(resourcesUrl);
return response;
}
function transformResponse(apiResponse) {
if (!apiResponse.data) {
throw new Error('Invalid API response: missing data field');
}
const { resources, relations } = apiResponse.data;
return {
status: apiResponse.status || 'success',
data: {
resources: resources,
relations: relations,
},
};
}
function generateTypeScriptFile(data) {
const resourcesStr = data.data.resources
.map(
(r) =>
`\t\t\t{\n\t\t\t\tname: '${r.name}',\n\t\t\t\ttype: '${r.type}',\n\t\t\t},`,
)
.join('\n');
const relationsStr = Object.entries(data.data.relations)
.map(
([type, relations]) =>
`\t\t\t${type}: [${relations.map((r) => `'${r}'`).join(', ')}],`,
)
.join('\n');
return `// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY scripts/generate-permissions-type
export default {
\tstatus: '${data.status}',
\tdata: {
\t\tresources: [
${resourcesStr}
\t\t],
\t\trelations: {
${relationsStr}
\t\t},
\t},
} as const;
`;
}
async function main() {
try {
log('Starting permissions type generation...');
const backendUrl = await discoverBackendUrl();
if (!backendUrl) {
console.error('\n' + '='.repeat(80));
console.error('ERROR: No running SigNoz backend found!');
console.error('='.repeat(80));
console.error(
'\nThe permissions type generator requires a running SigNoz backend.',
);
console.error('\nFor local development, start the backend with:');
console.error(' make go-run-enterprise');
console.error(
'\nFor CI or integration testing, start the test environment with:',
);
console.error(' make py-test-setup');
console.error(
'\nIf running in CI and seeing this error, check that the py-test-setup',
);
console.error('step completed successfully before this step runs.');
console.error('='.repeat(80) + '\n');
process.exit(1);
}
log('Fetching resources...');
const apiResponse = await fetchResources(backendUrl);
log('Transforming response...');
const transformed = transformResponse(apiResponse);
log('Generating TypeScript file...');
const content = generateTypeScriptFile(transformed);
log(`Writing to ${PERMISSIONS_TYPE_FILE}...`);
fs.writeFileSync(PERMISSIONS_TYPE_FILE, content, 'utf8');
const rootDir = path.join(__dirname, '../..');
const relativePath = path.relative(
path.join(rootDir, 'frontend'),
PERMISSIONS_TYPE_FILE,
);
log('Linting generated file...');
execSync(`cd frontend && yarn oxlint ${relativePath}`, {
cwd: rootDir,
stdio: 'inherit',
});
log('Successfully generated permissions.type.ts');
} catch (error) {
log(`Error: ${error.message}`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { main };

View File

@@ -65,6 +65,13 @@ export const TraceDetail = Loadable(
),
);
export const TraceDetailV3 = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetailV3 Page" */ 'pages/TraceDetailV3Page/index'
),
);
export const UsageExplorerPage = Loadable(
() => import(/* webpackChunkName: "UsageExplorerPage" */ 'modules/Usage'),
);

View File

@@ -48,6 +48,7 @@ import {
StatusPage,
SupportPage,
TraceDetail,
TraceDetailV3,
TraceFilter,
TracesExplorer,
TracesFunnelDetails,
@@ -138,6 +139,9 @@ const routes: AppRoutes[] = [
exact: true,
key: 'LOGS_SAVE_VIEWS',
},
// V3 trace details is gated until release: /trace serves V2 (public),
// /trace-old serves V3 (URL-only access). Flip the two `component`
// values back to release V3.
{
path: ROUTES.TRACE_DETAIL,
exact: true,
@@ -145,6 +149,13 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'TRACE_DETAIL',
},
{
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
component: TraceDetailV3,
isPrivate: true,
key: 'TRACE_DETAIL_OLD',
},
{
path: ROUTES.SETTINGS,
exact: false,

View File

@@ -4,23 +4,16 @@
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import { useMutation } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
AuthtypesTransactionDTO,
AuthzCheck200,
AuthzResources200,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -110,88 +103,3 @@ export const useAuthzCheck = <
return useMutation(mutationOptions);
};
/**
* Gets all the available resources
* @summary Get resources
*/
export const authzResources = (signal?: AbortSignal) => {
return GeneratedAPIInstance<AuthzResources200>({
url: `/api/v1/authz/resources`,
method: 'GET',
signal,
});
};
export const getAuthzResourcesQueryKey = () => {
return [`/api/v1/authz/resources`] as const;
};
export const getAuthzResourcesQueryOptions = <
TData = Awaited<ReturnType<typeof authzResources>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof authzResources>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getAuthzResourcesQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof authzResources>>> = ({
signal,
}) => authzResources(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof authzResources>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type AuthzResourcesQueryResult = NonNullable<
Awaited<ReturnType<typeof authzResources>>
>;
export type AuthzResourcesQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get resources
*/
export function useAuthzResources<
TData = Awaited<ReturnType<typeof authzResources>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof authzResources>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getAuthzResourcesQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get resources
*/
export const invalidateAuthzResources = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getAuthzResourcesQueryKey() },
options,
);
return queryClient;
};

View File

@@ -18,9 +18,9 @@ import type {
} from 'react-query';
import type {
AuthtypesPatchableObjectsDTO,
AuthtypesPatchableRoleDTO,
AuthtypesPostableRoleDTO,
CoretypesPatchableObjectsDTO,
CreateRole201,
DeleteRolePathParameters,
GetObjects200,
@@ -571,13 +571,13 @@ export const invalidateGetObjects = async (
*/
export const patchObjects = (
{ id, relation }: PatchObjectsPathParameters,
authtypesPatchableObjectsDTO: BodyType<AuthtypesPatchableObjectsDTO>,
coretypesPatchableObjectsDTO: BodyType<CoretypesPatchableObjectsDTO>,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: authtypesPatchableObjectsDTO,
data: coretypesPatchableObjectsDTO,
});
};
@@ -590,7 +590,7 @@ export const getPatchObjectsMutationOptions = <
TError,
{
pathParams: PatchObjectsPathParameters;
data: BodyType<AuthtypesPatchableObjectsDTO>;
data: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
>;
@@ -599,7 +599,7 @@ export const getPatchObjectsMutationOptions = <
TError,
{
pathParams: PatchObjectsPathParameters;
data: BodyType<AuthtypesPatchableObjectsDTO>;
data: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
> => {
@@ -616,7 +616,7 @@ export const getPatchObjectsMutationOptions = <
Awaited<ReturnType<typeof patchObjects>>,
{
pathParams: PatchObjectsPathParameters;
data: BodyType<AuthtypesPatchableObjectsDTO>;
data: BodyType<CoretypesPatchableObjectsDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -630,7 +630,7 @@ export const getPatchObjectsMutationOptions = <
export type PatchObjectsMutationResult = NonNullable<
Awaited<ReturnType<typeof patchObjects>>
>;
export type PatchObjectsMutationBody = BodyType<AuthtypesPatchableObjectsDTO>;
export type PatchObjectsMutationBody = BodyType<CoretypesPatchableObjectsDTO>;
export type PatchObjectsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -645,7 +645,7 @@ export const usePatchObjects = <
TError,
{
pathParams: PatchObjectsPathParameters;
data: BodyType<AuthtypesPatchableObjectsDTO>;
data: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
>;
@@ -654,7 +654,7 @@ export const usePatchObjects = <
TError,
{
pathParams: PatchObjectsPathParameters;
data: BodyType<AuthtypesPatchableObjectsDTO>;
data: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
> => {

View File

@@ -1668,33 +1668,6 @@ export interface AuthtypesGettableAuthDomainDTO {
updatedAt?: Date;
}
export interface AuthtypesGettableObjectsDTO {
resource: AuthtypesResourceDTO;
/**
* @type array
*/
selectors: string[];
}
/**
* @nullable
*/
export type AuthtypesGettableResourcesDTORelations = {
[key: string]: string[];
} | null;
export interface AuthtypesGettableResourcesDTO {
/**
* @type object
* @nullable true
*/
relations: AuthtypesGettableResourcesDTORelations;
/**
* @type array
*/
resources: AuthtypesResourceDTO[];
}
export interface AuthtypesGettableTokenDTO {
/**
* @type string
@@ -1719,11 +1692,8 @@ export interface AuthtypesGettableTransactionDTO {
* @type boolean
*/
authorized: boolean;
object: AuthtypesObjectDTO;
/**
* @type string
*/
relation: string;
object: CoretypesObjectDTO;
relation: AuthtypesRelationDTO;
}
export type AuthtypesGoogleConfigDTODomainToAdminEmail = {
@@ -1797,14 +1767,6 @@ export interface AuthtypesOIDCConfigDTO {
issuerAlias?: string;
}
export interface AuthtypesObjectDTO {
resource: AuthtypesResourceDTO;
/**
* @type string
*/
selector: string;
}
export interface AuthtypesOrgSessionContextDTO {
authNSupport?: AuthtypesAuthNSupportDTO;
/**
@@ -1822,19 +1784,6 @@ export interface AuthtypesPasswordAuthNSupportDTO {
provider?: AuthtypesAuthNProviderDTO;
}
export interface AuthtypesPatchableObjectsDTO {
/**
* @type array
* @nullable true
*/
additions: AuthtypesGettableObjectsDTO[] | null;
/**
* @type array
* @nullable true
*/
deletions: AuthtypesGettableObjectsDTO[] | null;
}
export interface AuthtypesPatchableRoleDTO {
/**
* @type string
@@ -1883,17 +1832,14 @@ export interface AuthtypesPostableRotateTokenDTO {
refreshToken?: string;
}
export interface AuthtypesResourceDTO {
/**
* @type string
*/
name: string;
/**
* @type string
*/
type: string;
export enum AuthtypesRelationDTO {
create = 'create',
read = 'read',
update = 'update',
delete = 'delete',
list = 'list',
assignee = 'assignee',
}
export interface AuthtypesRoleDTO {
/**
* @type string
@@ -1983,11 +1929,8 @@ export interface AuthtypesSessionContextDTO {
}
export interface AuthtypesTransactionDTO {
object: AuthtypesObjectDTO;
/**
* @type string
*/
relation: string;
object: CoretypesObjectDTO;
relation: AuthtypesRelationDTO;
}
export interface AuthtypesUpdatableAuthDomainDTO {
@@ -4173,6 +4116,52 @@ export interface ConfigWechatConfigDTO {
to_user?: string;
}
export interface CoretypesObjectDTO {
resource: CoretypesResourceRefDTO;
/**
* @type string
*/
selector: string;
}
export interface CoretypesObjectGroupDTO {
resource: CoretypesResourceRefDTO;
/**
* @type array
*/
selectors: string[];
}
export interface CoretypesPatchableObjectsDTO {
/**
* @type array
* @nullable true
*/
additions: CoretypesObjectGroupDTO[] | null;
/**
* @type array
* @nullable true
*/
deletions: CoretypesObjectGroupDTO[] | null;
}
export interface CoretypesResourceRefDTO {
/**
* @type string
*/
kind: string;
type: CoretypesTypeDTO;
}
export enum CoretypesTypeDTO {
user = 'user',
serviceaccount = 'serviceaccount',
anonymous = 'anonymous',
role = 'role',
organization = 'organization',
metaresource = 'metaresource',
metaresources = 'metaresources',
}
export interface DashboardtypesDashboardDTO {
/**
* @type string
@@ -7714,6 +7703,11 @@ export interface TracedetailtypesWaterfallSpanDTO {
* @minimum 0
*/
sub_tree_node_count?: number;
/**
* @type integer
* @minimum 0
*/
time_unix?: number;
/**
* @type string
*/
@@ -8105,14 +8099,6 @@ export type AuthzCheck200 = {
status: string;
};
export type AuthzResources200 = {
data: AuthtypesGettableResourcesDTO;
/**
* @type string
*/
status: string;
};
export type ListChannels200 = {
/**
* @type array
@@ -8738,7 +8724,7 @@ export type GetObjects200 = {
/**
* @type array
*/
data: AuthtypesGettableObjectsDTO[];
data: CoretypesObjectGroupDTO[];
/**
* @type string
*/

View File

@@ -0,0 +1,72 @@
import { ApiV3Instance as axios } from 'api';
import { omit } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV3PayloadProps,
GetTraceV3SuccessResponse,
SpanV3,
} from 'types/api/trace/getTraceV3';
const getTraceV3 = async (
props: GetTraceV3PayloadProps,
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
let uncollapsedSpans = [...props.uncollapsedSpans];
if (!props.isSelectedSpanIDUnCollapsed) {
uncollapsedSpans = uncollapsedSpans.filter(
(node) => node !== props.selectedSpanId,
);
} else if (
props.selectedSpanId &&
!uncollapsedSpans.includes(props.selectedSpanId)
) {
// V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets
// isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span
uncollapsedSpans.push(props.selectedSpanId);
}
const postData: GetTraceV3PayloadProps = {
...props,
uncollapsedSpans,
limit: 10000,
};
const response = await axios.post<GetTraceV3SuccessResponse>(
`/traces/${props.traceId}/waterfall`,
omit(postData, 'traceId'),
);
// V3 API wraps response in { status, data }
const rawPayload = (response.data as any).data || response.data;
// Derive 'service.name' from resource for convenience — only derived field
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
...span,
'service.name': span.resource?.['service.name'] || '',
}));
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
// not absolute unix millis like V2. The span timestamps are absolute unix millis.
// Convert by using the first span's timestamp as the base if there's a mismatch.
let { startTimestampMillis, endTimestampMillis } = rawPayload;
if (
spans.length > 0 &&
spans[0].timestamp > 0 &&
startTimestampMillis < spans[0].timestamp / 10
) {
const durationMillis = endTimestampMillis - startTimestampMillis;
startTimestampMillis = spans[0].timestamp;
endTimestampMillis = startTimestampMillis + durationMillis;
}
return {
statusCode: 200,
error: null,
message: 'Success',
payload: {
...rawPayload,
spans,
startTimestampMillis,
endTimestampMillis,
},
};
};
export default getTraceV3;

View File

@@ -0,0 +1,37 @@
.details-header {
// ghost + secondary missing hover bg token in @signozhq/button
--button-ghost-hover-background: var(--l3-background);
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--l1-border);
height: 36px;
background: var(--l2-background);
&__title {
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
&__nav {
display: flex;
align-items: center;
gap: 2px;
}
}

View File

@@ -0,0 +1,57 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/ui';
import { X } from '@signozhq/icons';
import './DetailsHeader.styles.scss';
export interface HeaderAction {
key: string;
component: ReactNode; // check later if we can use direct btn itself or not.
}
export interface DetailsHeaderProps {
title: string;
onClose: () => void;
actions?: HeaderAction[];
closePosition?: 'left' | 'right';
className?: string;
}
function DetailsHeader({
title,
onClose,
actions,
closePosition = 'right',
className,
}: DetailsHeaderProps): JSX.Element {
const closeButton = (
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onClose}
aria-label="Close"
prefix={<X size={14} />}
></Button>
);
return (
<div className={`details-header ${className || ''}`}>
{closePosition === 'left' && closeButton}
<span className="details-header__title">{title}</span>
{actions && (
<div className="details-header__actions">
{actions.map((action) => (
<div key={action.key}>{action.component}</div>
))}
</div>
)}
{closePosition === 'right' && closeButton}
</div>
);
}
export default DetailsHeader;

View File

@@ -0,0 +1,7 @@
.details-panel-drawer {
&__body {
display: flex;
flex-direction: column;
height: 100%;
}
}

View File

@@ -0,0 +1,35 @@
import { DrawerWrapper } from '@signozhq/ui';
import './DetailsPanelDrawer.styles.scss';
interface DetailsPanelDrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
}
function DetailsPanelDrawer({
isOpen,
onClose,
children,
className,
}: DetailsPanelDrawerProps): JSX.Element {
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
showOverlay={false}
className={`details-panel-drawer ${className || ''}`}
>
<div className="details-panel-drawer__body">{children}</div>
</DrawerWrapper>
);
}
export default DetailsPanelDrawer;

View File

@@ -0,0 +1,8 @@
export type {
DetailsHeaderProps,
HeaderAction,
} from './DetailsHeader/DetailsHeader';
export { default as DetailsHeader } from './DetailsHeader/DetailsHeader';
export { default as DetailsPanelDrawer } from './DetailsPanelDrawer';
export type { DetailsPanelState, UseDetailsPanelOptions } from './types';
export { default as useDetailsPanel } from './useDetailsPanel';

View File

@@ -0,0 +1,10 @@
export interface DetailsPanelState {
isOpen: boolean;
open: () => void;
close: () => void;
}
export interface UseDetailsPanelOptions {
entityId: string | undefined;
onClose?: () => void;
}

View File

@@ -0,0 +1,29 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { DetailsPanelState, UseDetailsPanelOptions } from './types';
function useDetailsPanel({
entityId,
onClose,
}: UseDetailsPanelOptions): DetailsPanelState {
const [isOpen, setIsOpen] = useState<boolean>(false);
const prevEntityIdRef = useRef<string>('');
useEffect(() => {
const currentId = entityId || '';
if (currentId && currentId !== prevEntityIdRef.current) {
setIsOpen(true);
}
prevEntityIdRef.current = currentId;
}, [entityId]);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => {
setIsOpen(false);
onClose?.();
}, [onClose]);
return { isOpen, open, close };
}
export default useDetailsPanel;

View File

@@ -55,7 +55,7 @@ describe('GuardAuthZ', () => {
);
render(
<GuardAuthZ relation="read" object="dashboard:*">
<GuardAuthZ relation="read" object="role:*">
<TestChild />
</GuardAuthZ>,
);
@@ -79,7 +79,7 @@ describe('GuardAuthZ', () => {
render(
<GuardAuthZ
relation="read"
object="dashboard:*"
object="role:*"
fallbackOnLoading={<LoadingFallback />}
>
<TestChild />
@@ -102,7 +102,7 @@ describe('GuardAuthZ', () => {
);
const { container } = render(
<GuardAuthZ relation="read" object="dashboard:*">
<GuardAuthZ relation="read" object="role:*">
<TestChild />
</GuardAuthZ>,
);
@@ -121,11 +121,7 @@ describe('GuardAuthZ', () => {
);
render(
<GuardAuthZ
relation="read"
object="dashboard:*"
fallbackOnError={ErrorFallback}
>
<GuardAuthZ relation="read" object="role:*" fallbackOnError={ErrorFallback}>
<TestChild />
</GuardAuthZ>,
);
@@ -155,7 +151,7 @@ describe('GuardAuthZ', () => {
render(
<GuardAuthZ
relation="read"
object="dashboard:*"
object="role:*"
fallbackOnError={errorFallbackWithCapture}
>
<TestChild />
@@ -178,7 +174,7 @@ describe('GuardAuthZ', () => {
);
const { container } = render(
<GuardAuthZ relation="read" object="dashboard:*">
<GuardAuthZ relation="read" object="role:*">
<TestChild />
</GuardAuthZ>,
);
@@ -201,7 +197,7 @@ describe('GuardAuthZ', () => {
render(
<GuardAuthZ
relation="update"
object="dashboard:123"
object="role:123"
fallbackOnNoPermissions={NoPermissionFallback}
>
<TestChild />
@@ -224,7 +220,7 @@ describe('GuardAuthZ', () => {
);
const { container } = render(
<GuardAuthZ relation="update" object="dashboard:123">
<GuardAuthZ relation="update" object="role:123">
<TestChild />
</GuardAuthZ>,
);
@@ -244,7 +240,7 @@ describe('GuardAuthZ', () => {
);
const { container } = render(
<GuardAuthZ relation="read" object="dashboard:*">
<GuardAuthZ relation="read" object="role:*">
<TestChild />
</GuardAuthZ>,
);
@@ -257,7 +253,7 @@ describe('GuardAuthZ', () => {
});
it('should pass requiredPermissionName to fallbackOnNoPermissions', async () => {
const permission = buildPermission('update', 'dashboard:123');
const permission = buildPermission('update', 'role:123');
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
@@ -269,7 +265,7 @@ describe('GuardAuthZ', () => {
render(
<GuardAuthZ
relation="update"
object="dashboard:123"
object="role:123"
fallbackOnNoPermissions={NoPermissionFallbackWithSuggestions}
>
<TestChild />
@@ -299,7 +295,7 @@ describe('GuardAuthZ', () => {
);
const { rerender } = render(
<GuardAuthZ relation="read" object="dashboard:*">
<GuardAuthZ relation="read" object="role:*">
<TestChild />
</GuardAuthZ>,
);
@@ -309,7 +305,7 @@ describe('GuardAuthZ', () => {
});
rerender(
<GuardAuthZ relation="delete" object="dashboard:456">
<GuardAuthZ relation="delete" object="role:456">
<TestChild />
</GuardAuthZ>,
);

View File

@@ -17,7 +17,6 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import JSONView from 'container/LogDetailedView/JsonView';
import Overview from 'container/LogDetailedView/Overview';
import {
aggregateAttributesResourcesToString,
@@ -47,6 +46,7 @@ import {
TextSelect,
X,
} from 'lucide-react';
import { JsonView } from 'periscope/components/JsonView';
import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -562,7 +562,9 @@ function LogDetailInner({
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
{selectedView === VIEW_TYPES.JSON && (
<JsonView data={LogJsonData} height="68vh" />
)}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView

View File

@@ -398,7 +398,7 @@ describe('useTableParams (selective URL mode — partial config object)', () =>
.filter(Boolean)
.pop();
expect(lastExpanded).toBeDefined();
expect(JSON.parse(lastExpanded!)).toStrictEqual(
expect(JSON.parse(lastExpanded!)).toEqual(
expect.arrayContaining(['row-1', 'row-2']),
);

View File

@@ -0,0 +1,20 @@
.timeline-v3-container {
overflow: visible;
position: relative;
}
.timeline-v3-cursor-badge {
position: absolute;
top: 0;
transform: translateX(-50%);
background: var(--l3-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
color: var(--l1-foreground);
white-space: nowrap;
pointer-events: none;
z-index: 1;
}

View File

@@ -0,0 +1,117 @@
import { useEffect, useMemo, useState } from 'react';
import { useMeasure } from 'react-use';
import { resolveTimeFromInterval } from 'components/TimelineV2/utils';
import { toFixed } from 'utils/toFixed';
import {
getIntervals,
getIntervalUnit,
getMinimumIntervalsBasedOnWidth,
Interval,
} from './utils';
import './TimelineV3.styles.scss';
interface ITimelineV3Props {
startTimestamp: number;
endTimestamp: number;
timelineHeight: number;
offsetTimestamp: number;
/** Cursor X as a fraction of the timeline width (01). null = no cursor. */
cursorXPercent?: number | null;
}
function TimelineV3(props: ITimelineV3Props): JSX.Element {
const {
startTimestamp,
endTimestamp,
timelineHeight,
offsetTimestamp,
cursorXPercent,
} = props;
const [intervals, setIntervals] = useState<Interval[]>([]);
const [ref, { width }] = useMeasure<HTMLDivElement>();
const spread = endTimestamp - startTimestamp;
useEffect(() => {
if (spread < 0) {
return;
}
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
const intervalisedSpread = (spread / minIntervals) * 1.0;
const newIntervals = getIntervals(
intervalisedSpread,
spread,
offsetTimestamp,
);
setIntervals(newIntervals);
}, [startTimestamp, endTimestamp, width, offsetTimestamp, spread]);
// Compute cursor time label using the same unit as timeline ticks
const cursorLabel = useMemo(() => {
if (cursorXPercent == null || spread <= 0) {
return null;
}
const timeAtCursor = offsetTimestamp + cursorXPercent * spread;
const unit = getIntervalUnit(spread, offsetTimestamp);
const formatted = toFixed(resolveTimeFromInterval(timeAtCursor, unit), 2);
return `${formatted}${unit.name}`;
}, [cursorXPercent, spread, offsetTimestamp]);
if (endTimestamp < startTimestamp) {
console.error(
'endTimestamp cannot be less than startTimestamp',
startTimestamp,
endTimestamp,
);
return <></>;
}
const strokeColor = 'var(--l3-foreground)';
const svgHeight = timelineHeight * 2.5;
const cursorX = cursorXPercent != null ? cursorXPercent * width : null;
return (
<div ref={ref as never} className="timeline-v3-container">
<svg
width={width}
height={svgHeight}
xmlns="http://www.w3.org/2000/svg"
overflow="visible"
>
{intervals &&
intervals.length > 0 &&
intervals.map((interval, index) => (
<g
transform={`translate(${(interval.percentage * width) / 100},0)`}
key={`${interval.percentage + interval.label + index}`}
textAnchor="middle"
fontSize="0.6rem"
>
<text
x={index === intervals.length - 1 ? -10 : 0}
y={timelineHeight * 2}
fill={strokeColor}
>
{interval.label}
</text>
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
</g>
))}
</svg>
{/* Cursor time badge — DOM element for easy CSS styling */}
{cursorX !== null && cursorLabel && (
<div className="timeline-v3-cursor-badge" style={{ left: cursorX }}>
{cursorLabel}
</div>
)}
</div>
);
}
export default TimelineV3;

View File

@@ -0,0 +1,109 @@
import {
IIntervalUnit,
Interval,
INTERVAL_UNITS,
resolveTimeFromInterval,
} from 'components/TimelineV2/utils';
import { toFixed } from 'utils/toFixed';
export type { Interval };
/**
* Select the interval unit matching the timeline's logic.
* Exported so crosshair labels use the same unit as timeline ticks.
*/
export function getIntervalUnit(
spread: number,
offsetTimestamp: number,
): IIntervalUnit {
const minIntervals = 6;
const intervalSpread = spread / minIntervals;
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
let unit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
if (valueForUnitSelection * INTERVAL_UNITS[idx].multiplier >= 1) {
unit = INTERVAL_UNITS[idx];
break;
}
}
return unit;
}
/** Fewer intervals than TimelineV2 for a cleaner flamegraph ruler. */
export function getMinimumIntervalsBasedOnWidth(width: number): number {
if (width < 640) {
return 3;
}
if (width < 768) {
return 4;
}
if (width < 1024) {
return 5;
}
return 6;
}
/**
* Computes timeline intervals with offset-aware labels.
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
*/
export function getIntervals(
intervalSpread: number,
baseSpread: number,
offsetTimestamp: number,
): Interval[] {
const integerPartString = intervalSpread.toString().split('.')[0];
const integerPartLength = integerPartString.length;
const intervalSpreadNormalized =
intervalSpread < 1.0
? intervalSpread
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
10 ** (integerPartLength - 1);
// Unit must suit both: (1) tick granularity (intervalSpread) and (2) label magnitude
// (offsetTimestamp). When zoomed deep into a trace, labels show offsetTimestamp + elapsed,
// so we must pick a unit where that value is readable (e.g. "500.00s" not "500000.00ms").
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
const standardInterval = INTERVAL_UNITS[idx];
if (valueForUnitSelection * standardInterval.multiplier >= 1) {
intervalUnit = INTERVAL_UNITS[idx];
break;
}
}
const intervals: Interval[] = [
{
label: `${toFixed(
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
2,
)}${intervalUnit.name}`,
percentage: 0,
},
];
// Only show even-interval ticks — skip the trailing partial tick at the edge.
// The last even tick sits before the full width, so it doesn't conflict with
// span duration labels that may have sub-millisecond precision.
let elapsedIntervals = 0;
while (
elapsedIntervals + intervalSpreadNormalized <= baseSpread &&
intervals.length < 20
) {
elapsedIntervals += intervalSpreadNormalized;
const labelTime = offsetTimestamp + elapsedIntervals;
intervals.push({
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
intervalUnit.name
}`,
percentage: (elapsedIntervals / baseSpread) * 100,
});
}
return intervals;
}

View File

@@ -41,11 +41,7 @@ describe('createGuardedRoute', () => {
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:*',
);
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
const mockMatch = {
params: {},
@@ -79,7 +75,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:{id}',
'role:{id}',
);
const mockMatch = {
@@ -113,7 +109,7 @@ describe('createGuardedRoute', () => {
relation: txn.relation,
object: {
resource: {
name: txn.object.resource.name,
kind: txn.object.resource.kind,
type: txn.object.resource.type,
},
selector: '123:456',
@@ -131,7 +127,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'update',
'dashboard:{id}:{version}',
'role:{id}:{version}',
);
const mockMatch = {
@@ -166,7 +162,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:{id}',
'role:{id}',
);
const mockMatch = {
@@ -201,11 +197,7 @@ describe('createGuardedRoute', () => {
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:*',
);
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
const mockMatch = {
params: {},
@@ -236,11 +228,7 @@ describe('createGuardedRoute', () => {
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:*',
);
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
const mockMatch = {
params: {},
@@ -278,7 +266,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'update',
'dashboard:{id}',
'role:{id}',
);
const mockMatch = {
@@ -304,7 +292,7 @@ describe('createGuardedRoute', () => {
});
expect(screen.getByText('update')).toBeInTheDocument();
expect(screen.getByText('dashboard:123')).toBeInTheDocument();
expect(screen.getByText('role:123')).toBeInTheDocument();
expect(
screen.queryByText('Test Component: test-value'),
).not.toBeInTheDocument();
@@ -335,7 +323,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
ComponentWithMultipleProps,
'read',
'dashboard:*',
'role:*',
);
const mockMatch = {
@@ -370,10 +358,10 @@ describe('createGuardedRoute', () => {
requestCount++;
const payload = (await req.json()) as AuthtypesTransactionDTO[];
const obj = payload[0]?.object;
const name = obj?.resource?.name;
const kind = obj?.resource?.kind;
const selector = obj?.selector ?? '*';
const objectStr =
obj?.resource?.type === 'metaresources' ? name : `${name}:${selector}`;
obj?.resource?.type === 'metaresources' ? kind : `${kind}:${selector}`;
requestedObjects.push(objectStr ?? '');
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
@@ -383,7 +371,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:{id}',
'role:{id}',
);
const mockMatch1 = {
@@ -407,7 +395,7 @@ describe('createGuardedRoute', () => {
});
expect(requestCount).toBe(1);
expect(requestedObjects).toContain('dashboard:123');
expect(requestedObjects).toContain('role:123');
unmount();
@@ -432,7 +420,7 @@ describe('createGuardedRoute', () => {
});
expect(requestCount).toBe(2);
expect(requestedObjects).toContain('dashboard:456');
expect(requestedObjects).toContain('role:456');
});
it('should handle different relation types', async () => {
@@ -446,7 +434,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'delete',
'dashboard:{id}',
'role:{id}',
);
const mockMatch = {

View File

@@ -37,6 +37,7 @@ export enum LOCALSTORAGE {
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
}

View File

@@ -33,6 +33,7 @@ export const REACT_QUERY_KEY = {
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL',
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
GET_POD_LIST: 'GET_POD_LIST',
GET_NODE_LIST: 'GET_NODE_LIST',

View File

@@ -8,6 +8,7 @@ const ROUTES = {
SERVICE_MAP: '/service-map',
TRACE: '/trace',
TRACE_DETAIL: '/trace/:id',
TRACE_DETAIL_OLD: '/trace-old/:id',
TRACES_EXPLORER: '/traces-explorer',
ONBOARDING: '/onboarding',
GET_STARTED: '/get-started',

View File

@@ -33,6 +33,102 @@ const themeColors = {
purple: '#800080',
cyan: '#00FFFF',
},
traceDetailColorsV3: {
// Blues
blue1: '#2F80ED',
blue2: '#3366E6',
blue3: '#4682B4',
blue4: '#1F63E0',
blue5: '#3A7AED',
blue6: '#5A9DF5',
blue7: '#2874A6',
blue8: '#2E86C1',
blue9: '#3498DB',
blue10: '#1E90FF',
blue11: '#4169E1',
// Cyans / Teals
cyan1: '#00CEC9',
cyan2: '#22A6F2',
cyan3: '#00B0AA',
cyan4: '#33D6C2',
cyan5: '#66E9DA',
cyan6: '#48DBFB',
cyan7: '#00BFFF',
cyan8: '#63B8FF',
teal1: '#009688',
teal2: '#1ABC9C',
teal3: '#48C9B0',
teal4: '#76D7C4',
teal5: '#20B2AA',
// Greens
green1: '#27AE60',
green2: '#3CB371',
green3: '#1E8449',
green4: '#2ECC71',
green5: '#58D68D',
green6: '#229954',
green7: '#52BE80',
green8: '#82E0AA',
green9: '#73C6B6',
// Limes
lime1: '#A3E635',
lime2: '#B9F18D',
lime3: '#84CC16',
lime4: '#65A30D',
// Yellows
yellow1: '#F1C40F',
yellow2: '#F7DC6F',
yellow3: '#F9E79F',
yellow4: '#F4D03F',
yellow5: '#D4AC0D',
// Golds / Ambers
gold1: '#F2C94C',
gold2: '#FFD93D',
gold3: '#FFCA28',
gold4: '#B7950B',
gold5: '#D4A017',
// Oranges (non-red)
orange1: '#F39C12',
orange2: '#E67E22',
orange3: '#F5B041',
orange4: '#D35400',
orange5: '#EB984E',
orange6: '#FAD7A0',
// Purples / Violets
purple1: '#BB6BD9',
purple2: '#9B51E0',
purple3: '#DA77F2',
purple4: '#C77DFF',
purple5: '#6C5CE7',
purple6: '#8E44AD',
purple7: '#9B59B6',
purple8: '#BB8FCE',
purple9: '#7D3C98',
purple10: '#A569BD',
// Lavenders
lavender1: '#AF7AC5',
lavender2: '#C39BD3',
lavender3: '#D2B4DE',
// Pinks / Magentas
pink1: '#E91E8C',
pink2: '#FF6FD8',
pink3: '#F06292',
pink4: '#CE93D8',
// Salmons / Corals (distinct from error red)
salmon1: '#FF8A65',
salmon2: '#FFAB91',
salmon3: '#E0876A',
},
chartcolors: {
// Blues (3)
dodgerBlue: '#2F80ED',

View File

@@ -24,6 +24,43 @@
line-height: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
align-self: flex-start;
}
.crossPanelSyncInfoIcon {
cursor: help;
color: var(--l3-foreground);
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
line-height: 1.5;
}
.crossPanelSyncTooltipDocLink {
display: flex;
align-items: center;
gap: 4px;
color: var(--primary-background);
font-size: 12px;
margin-top: 4px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
import { Col, Input, Radio, Select, Space, Tooltip, Typography } from 'antd';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
@@ -10,7 +10,7 @@ import {
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, X } from 'lucide-react';
import { Check, ExternalLink, Info, X } from '@signozhq/icons';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import styles from './GeneralSettings.module.scss';
@@ -173,9 +173,36 @@ function GeneralDashboardSettings(): JSX.Element {
</Space>
</Col>
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<div className={styles.crossPanelSyncSectionHeader}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<Tooltip
title={
<div className={styles.crossPanelSyncTooltipContent}>
<strong className={styles.crossPanelSyncTooltipTitle}>
Cross-Panel Sync
</strong>
<span className={styles.crossPanelSyncTooltipDescription}>
Sync crosshair and tooltip across all the dashboard panels
</span>
<a
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
target="_blank"
rel="noopener noreferrer"
className={styles.crossPanelSyncTooltipDocLink}
>
Learn more
<ExternalLink size={12} />
</a>
</div>
}
placement="top"
mouseEnterDelay={0.5}
>
<Info size={14} className={styles.crossPanelSyncInfoIcon} />
</Tooltip>
</div>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>

View File

@@ -123,6 +123,7 @@
&__row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-end;
max-width: 825px;
gap: 25px;

View File

@@ -11,6 +11,12 @@
}
}
.infra-metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.infra-metrics-card {
margin: 1rem 0;
height: 300px;

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useQueries, UseQueryResult } from 'react-query';
import { Card, Col, Row, Skeleton, Typography } from 'antd';
import { Card, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
@@ -163,16 +163,16 @@ function NodeMetrics({
);
};
return (
<Row gutter={24}>
<div className="infra-metrics-grid">
{queries.map((query, idx) => (
<Col span={12} key={widgetInfo[idx].title}>
<div key={widgetInfo[idx].title}>
<Typography.Text>{widgetInfo[idx].title}</Typography.Text>
<Card bordered className="infra-metrics-card" ref={graphRef}>
{renderCardContent(query, idx)}
</Card>
</Col>
</div>
))}
</Row>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useQueries, UseQueryResult } from 'react-query';
import { Card, Col, Row, Skeleton, Typography } from 'antd';
import { Card, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
@@ -146,16 +146,16 @@ function PodMetrics({
};
return (
<Row gutter={24}>
<div className="infra-metrics-grid">
{queries.map((query, idx) => (
<Col span={12} key={podWidgetInfo[idx].title}>
<div key={podWidgetInfo[idx].title}>
<Typography.Text>{podWidgetInfo[idx].title}</Typography.Text>
<Card bordered className="infra-metrics-card" ref={graphRef}>
{renderCardContent(query, idx)}
</Card>
</Col>
</div>
))}
</Row>
</div>
);
}

View File

@@ -4,7 +4,6 @@ import { useHistory, useLocation } from 'react-router-dom';
import { Table2, Trash2, Users } from '@signozhq/icons';
import { Button, toast, ToggleGroup, ToggleGroupItem } from '@signozhq/ui';
import { Skeleton } from 'antd';
import { useAuthzResources } from 'api/generated/services/authz';
import {
getGetObjectsQueryKey,
useDeleteRole,
@@ -12,6 +11,9 @@ import {
useGetRole,
usePatchObjects,
} from 'api/generated/services/role';
import permissionsType from 'hooks/useAuthZ/permissions.type';
import type { AuthzResources } from '../utils';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ROUTES from 'constants/routes';
import { capitalize } from 'lodash-es';
@@ -52,10 +54,7 @@ function RoleDetailsPage(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
const { data: authzResourcesResponse } = useAuthzResources({
query: { enabled: true },
});
const authzResources = authzResourcesResponse?.data ?? null;
const authzResources = permissionsType.data as unknown as AuthzResources;
// Extract channelId from URL pathname since useParams doesn't work in nested routing
const roleIdMatch = pathname.match(ROLE_ID_REGEX);
@@ -94,7 +93,7 @@ function RoleDetailsPage(): JSX.Element {
const initialConfig = useMemo(() => {
if (!objectsData?.data || !activePermission) {
return undefined;
return;
}
return objectsToPermissionConfig(
objectsData.data,

View File

@@ -15,15 +15,6 @@ const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
const MANAGED_ROLE_ID = '019c24aa-2248-756f-9833-984f1ab63819';
const rolesApiBase = 'http://localhost/api/v1/roles';
const authzResourcesUrl = 'http://localhost/api/v1/authz/resources';
const authzResourcesResponse = {
status: 'success',
data: {
relations: { create: ['dashboard'], read: ['dashboard'] },
resources: [{ name: 'dashboard', type: 'dashboard' }],
},
};
const emptyObjectsResponse = { status: 'success', data: [] };
@@ -45,9 +36,6 @@ function setupDefaultHandlers(roleId = CUSTOM_ROLE_ID): void {
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(roleResponse)),
),
rest.get(authzResourcesUrl, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(authzResourcesResponse)),
),
);
}

View File

@@ -1,12 +1,18 @@
import type {
AuthtypesGettableObjectsDTO,
AuthtypesGettableResourcesDTO,
CoretypesResourceRefDTO,
CoretypesObjectGroupDTO,
CoretypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
PermissionConfig,
ResourceDefinition,
} from '../PermissionSidePanel/PermissionSidePanel.types';
type AuthzResources = {
resources: CoretypesResourceRefDTO[];
relations: Record<string, string[]>;
};
import { PermissionScope } from '../PermissionSidePanel/PermissionSidePanel.types';
import {
buildConfig,
@@ -33,17 +39,17 @@ jest.mock('../RoleDetails/constants', () => {
};
});
const dashboardResource: AuthtypesGettableResourcesDTO['resources'][number] = {
name: 'dashboard',
type: 'metaresource',
const dashboardResource: AuthzResources['resources'][number] = {
kind: 'dashboard',
type: 'metaresource' as CoretypesTypeDTO,
};
const alertResource: AuthtypesGettableResourcesDTO['resources'][number] = {
name: 'alert',
type: 'metaresource',
const alertResource: AuthzResources['resources'][number] = {
kind: 'alert',
type: 'metaresource' as CoretypesTypeDTO,
};
const baseAuthzResources: AuthtypesGettableResourcesDTO = {
const baseAuthzResources: AuthzResources = {
resources: [dashboardResource, alertResource],
relations: {
create: ['metaresource'],
@@ -220,7 +226,7 @@ describe('buildPatchPayload', () => {
describe('objectsToPermissionConfig', () => {
it('maps a wildcard selector to ALL scope', () => {
const objects: AuthtypesGettableObjectsDTO[] = [
const objects: CoretypesObjectGroupDTO[] = [
{ resource: dashboardResource, selectors: ['*'] },
];
@@ -233,7 +239,7 @@ describe('objectsToPermissionConfig', () => {
});
it('maps specific selectors to ONLY_SELECTED scope with the IDs', () => {
const objects: AuthtypesGettableObjectsDTO[] = [
const objects: CoretypesObjectGroupDTO[] = [
{ resource: dashboardResource, selectors: [ID_A, ID_B] },
];
@@ -338,7 +344,7 @@ describe('buildConfig', () => {
describe('derivePermissionTypes', () => {
it('derives one PermissionType per relation key with correct key and capitalised label', () => {
const relations: AuthtypesGettableResourcesDTO['relations'] = {
const relations: AuthzResources['relations'] = {
create: ['metaresource'],
read: ['metaresource'],
delete: ['metaresource'],

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { Badge } from '@signozhq/ui';
import type {
AuthtypesGettableObjectsDTO,
AuthtypesGettableResourcesDTO,
CoretypesResourceRefDTO,
CoretypesObjectGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { capitalize } from 'lodash-es';
@@ -19,6 +19,11 @@ import {
PERMISSION_ICON_MAP,
} from './RoleDetails/constants';
export type AuthzResources = {
resources: ReadonlyArray<CoretypesResourceRefDTO>;
relations: Readonly<Record<string, ReadonlyArray<string>>>;
};
export interface PermissionType {
key: string;
label: string;
@@ -29,11 +34,11 @@ export interface PatchPayloadOptions {
newConfig: PermissionConfig;
initialConfig: PermissionConfig;
resources: ResourceDefinition[];
authzRes: AuthtypesGettableResourcesDTO;
authzRes: AuthzResources;
}
export function derivePermissionTypes(
relations: AuthtypesGettableResourcesDTO['relations'] | null,
relations: AuthzResources['relations'] | null,
): PermissionType[] {
const iconSize = { size: 14 };
@@ -55,7 +60,7 @@ export function derivePermissionTypes(
}
export function deriveResourcesForRelation(
authzResources: AuthtypesGettableResourcesDTO | null,
authzResources: AuthzResources | null,
relation: string,
): ResourceDefinition[] {
if (!authzResources?.relations) {
@@ -65,19 +70,19 @@ export function deriveResourcesForRelation(
return authzResources.resources
.filter((r) => supportedTypes.includes(r.type))
.map((r) => ({
id: r.name,
label: capitalize(r.name).replace(/_/g, ' '),
id: r.kind,
label: capitalize(r.kind).replaceAll('_', ' '),
options: [],
}));
}
export function objectsToPermissionConfig(
objects: AuthtypesGettableObjectsDTO[],
objects: CoretypesObjectGroupDTO[],
resources: ResourceDefinition[],
): PermissionConfig {
const config: PermissionConfig = {};
for (const res of resources) {
const obj = objects.find((o) => o.resource.name === res.id);
const obj = objects.find((o) => o.resource.kind === res.id);
if (!obj) {
config[res.id] = {
scope: PermissionScope.ONLY_SELECTED,
@@ -101,19 +106,19 @@ export function buildPatchPayload({
resources,
authzRes,
}: PatchPayloadOptions): {
additions: AuthtypesGettableObjectsDTO[] | null;
deletions: AuthtypesGettableObjectsDTO[] | null;
additions: CoretypesObjectGroupDTO[] | null;
deletions: CoretypesObjectGroupDTO[] | null;
} {
if (!authzRes) {
return { additions: null, deletions: null };
}
const additions: AuthtypesGettableObjectsDTO[] = [];
const deletions: AuthtypesGettableObjectsDTO[] = [];
const additions: CoretypesObjectGroupDTO[] = [];
const deletions: CoretypesObjectGroupDTO[] = [];
for (const res of resources) {
const initial = initialConfig[res.id];
const current = newConfig[res.id];
const resourceDef = authzRes.resources.find((r) => r.name === res.id);
const resourceDef = authzRes.resources.find((r) => r.kind === res.id);
if (!resourceDef) {
continue;
}

View File

@@ -46,6 +46,7 @@ export const routeConfig: Record<string, QueryParams[]> = {
[ROUTES.TRACES_EXPLORER]: [QueryParams.resourceAttributes],
[ROUTES.TRACE]: [QueryParams.resourceAttributes],
[ROUTES.TRACE_DETAIL]: [QueryParams.resourceAttributes],
[ROUTES.TRACE_DETAIL_OLD]: [QueryParams.resourceAttributes],
[ROUTES.UN_AUTHORIZED]: [QueryParams.resourceAttributes],
[ROUTES.USAGE_EXPLORER]: [QueryParams.resourceAttributes],
[ROUTES.VERSION]: [QueryParams.resourceAttributes],

View File

@@ -23,7 +23,6 @@
&-empty-content {
height: 100%;
border: 1px solid var(--l1-border);
border-top: none;
display: flex;
flex-direction: column;

View File

@@ -143,6 +143,7 @@ export const routesToSkip = [
ROUTES.SETTINGS,
ROUTES.LIST_ALL_ALERT,
ROUTES.TRACE_DETAIL,
ROUTES.TRACE_DETAIL_OLD,
ROUTES.ALL_CHANNELS,
ROUTES.USAGE_EXPLORER,
ROUTES.GET_STARTED,

View File

@@ -0,0 +1,158 @@
import { act, render } from '@testing-library/react';
import { Modal } from 'antd';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import useTabVisibility from 'hooks/useTabFocus';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useDashboardQuery } from './useDashboardQuery';
const mockDispatch = jest.fn();
const mockSetDashboardData = jest.fn();
const mockSetLayouts = jest.fn();
const mockSetPanelMap = jest.fn();
const mockResetDashboardStore = jest.fn();
const mockGetUrlVariables = jest.fn();
const mockUpdateUrlVariable = jest.fn();
const mockRefetch = jest.fn();
let mockGlobalTime = {
selectedTime: 'custom',
minTime: 1710000000000000000,
maxTime: 1710000300000000000,
isAutoRefreshDisabled: true,
};
let currentQueryData: unknown;
jest.mock('react-i18next', () => ({
useTranslation: (): { t: (key: string) => string } => ({
t: (key: string): string => key,
}),
}));
jest.mock('react-redux', () => ({
useDispatch: jest.fn(() => mockDispatch),
useSelector: jest.fn(
(
selectorFn: (state: { globalTime: typeof mockGlobalTime }) => unknown,
): unknown => selectorFn({ globalTime: mockGlobalTime }),
),
}));
jest.mock('hooks/useTabFocus', () => jest.fn(() => true));
jest.mock('hooks/dashboard/useDashboardVariablesSync', () => ({
useDashboardVariablesSync: jest.fn(),
}));
jest.mock('./useDashboardQuery', () => ({
useDashboardQuery: jest.fn(),
}));
jest.mock('hooks/dashboard/useTransformDashboardVariables', () => ({
useTransformDashboardVariables: jest.fn(),
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: jest.fn(),
}));
jest.mock('providers/Dashboard/initializeDefaultVariables', () => ({
initializeDefaultVariables: jest.fn(),
}));
jest.mock('lib/dashboard/getUpdatedLayout', () => ({
getUpdatedLayout: jest.fn(() => []),
}));
jest.mock('providers/Dashboard/util', () => ({
sortLayout: jest.fn((layout) => layout),
}));
jest.mock('lib/getMinMax', () => ({
getMinMaxForSelectedTime: jest.fn(),
}));
function TestComponent({ confirm }: { confirm: typeof Modal.confirm }): null {
useDashboardBootstrap('dashboard-1', { confirm });
return null;
}
describe('useDashboardBootstrap', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1710000000000000000,
maxTime: 1710000300000000000,
isAutoRefreshDisabled: true,
};
jest.mocked(useDashboardStore as unknown as jest.Mock).mockReturnValue({
setDashboardData: mockSetDashboardData,
setLayouts: mockSetLayouts,
setPanelMap: mockSetPanelMap,
resetDashboardStore: mockResetDashboardStore,
});
jest
.mocked(useTransformDashboardVariables as unknown as jest.Mock)
.mockReturnValue({
getUrlVariables: mockGetUrlVariables,
updateUrlVariable: mockUpdateUrlVariable,
transformDashboardVariables: <T,>(data: T): T => data,
});
jest.mocked(useTabVisibility as unknown as jest.Mock).mockReturnValue(true);
jest
.mocked(useDashboardQuery as unknown as jest.Mock)
.mockImplementation(() => ({
data: currentQueryData,
isLoading: false,
isError: false,
isFetching: false,
error: null,
refetch: mockRefetch,
}));
});
it('keeps minTime and maxTime unchanged for custom range on refresh confirm', () => {
const initialDashboard = {
id: 'dashboard-1',
updatedAt: '2024-01-01T00:00:00.000Z',
data: { layout: [], panelMap: {}, variables: {} },
};
const updatedDashboard = {
id: 'dashboard-1',
updatedAt: '2024-01-01T01:00:00.000Z',
data: { layout: [], panelMap: {}, variables: {} },
};
const mockConfirm = jest.fn<
ReturnType<typeof Modal.confirm>,
Parameters<typeof Modal.confirm>
>(() => ({ destroy: jest.fn(), update: jest.fn() }));
currentQueryData = { data: initialDashboard };
const { rerender } = render(<TestComponent confirm={mockConfirm} />);
expect(mockConfirm).not.toHaveBeenCalled();
currentQueryData = { data: updatedDashboard };
rerender(<TestComponent confirm={mockConfirm} />);
expect(mockConfirm).toHaveBeenCalledTimes(1);
const firstCall = mockConfirm.mock.calls[0];
expect(firstCall).toBeDefined();
const [confirmProps] = firstCall as Parameters<typeof Modal.confirm>;
act(() => {
confirmProps.onOk?.();
});
expect(getMinMaxForSelectedTime).not.toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledWith({
type: 'UPDATE_TIME_INTERVAL',
payload: {
selectedTime: 'custom',
minTime: mockGlobalTime.minTime,
maxTime: mockGlobalTime.maxTime,
},
});
});
});

View File

@@ -102,11 +102,19 @@ export function useDashboardBootstrap(
onOk() {
setDashboardData(updatedDashboardData);
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
const { maxTime, minTime } =
globalTime.selectedTime === 'custom'
? {
// For custom ranges, min/max are already stored in nanoseconds.
// Recomputing via getMinMaxForSelectedTime would multiply them again.
maxTime: globalTime.maxTime,
minTime: globalTime.minTime,
}
: getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
dispatch({
type: UPDATE_TIME_INTERVAL,
payload: { maxTime, minTime, selectedTime: globalTime.selectedTime },

View File

@@ -1,18 +1,23 @@
import { MouseEventHandler, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { useNotifications } from 'hooks/useNotifications';
import { toast } from '@signozhq/ui';
import useUrlQuery from 'hooks/useUrlQuery';
import { Span } from 'types/api/trace/getTraceV2';
import { getAbsoluteUrl } from 'utils/basePath';
// Accepts both V2 (spanId) and V3 (span_id) span shapes
// TODO: Remove V2 (spanId) support when phasing out V2
interface SpanLike {
spanId?: string;
span_id?: string;
}
export const useCopySpanLink = (
span?: Span,
span?: SpanLike,
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
const urlQuery = useUrlQuery();
const { pathname } = useLocation();
const [, setCopy] = useCopyToClipboard();
const { notifications } = useNotifications();
const onSpanCopy: MouseEventHandler<HTMLElement> = useCallback(
(event) => {
@@ -25,18 +30,19 @@ export const useCopySpanLink = (
urlQuery.delete('spanId');
if (span.spanId) {
urlQuery.set('spanId', span?.spanId);
const id = span.span_id || span.spanId;
if (id) {
urlQuery.set('spanId', id);
}
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);
notifications.success({
message: 'Copied to clipboard',
toast.success('Copied to clipboard', {
position: 'top-right',
});
},
[span, urlQuery, pathname, setCopy, notifications],
[span, urlQuery, pathname, setCopy],
);
return {

View File

@@ -13,11 +13,7 @@ const useGetTraceFlamegraph = (
useQuery({
queryFn: () => getTraceFlamegraph(props),
// if any of the props changes then we need to trigger an API call as the older data will be obsolete
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
props.traceId,
props.selectedSpanId,
],
queryKey: [REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH, props.traceId],
enabled: !!props.traceId,
keepPreviousData: true,
refetchOnWindowFocus: false,

View File

@@ -0,0 +1,29 @@
import { useQuery, UseQueryResult } from 'react-query';
import getTraceV3 from 'api/trace/getTraceV3';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV3PayloadProps,
GetTraceV3SuccessResponse,
} from 'types/api/trace/getTraceV3';
const useGetTraceV3 = (props: GetTraceV3PayloadProps): UseTraceV3 =>
useQuery({
queryFn: () => getTraceV3(props),
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V3_WATERFALL,
props.traceId,
props.selectedSpanId,
props.isSelectedSpanIDUnCollapsed,
],
enabled: !!props.traceId,
keepPreviousData: true,
refetchOnWindowFocus: false,
});
type UseTraceV3 = UseQueryResult<
SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse,
unknown
>;
export default useGetTraceV3;

View File

@@ -1,32 +1,29 @@
// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY scripts/generate-permissions-type
// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY cmd/enterprise/*.go generate authz
export default {
status: 'success',
data: {
resources: [
{
name: 'dashboard',
type: 'metaresource',
},
{
name: 'dashboards',
kind: 'role',
type: 'metaresources',
},
{
name: 'role',
kind: 'role',
type: 'role',
},
{
name: 'roles',
type: 'metaresources',
kind: 'serviceaccount',
type: 'serviceaccount',
},
],
relations: {
assignee: ['role'],
attach: ['role', 'serviceaccount'],
create: ['metaresources'],
delete: ['user', 'serviceaccount', 'role', 'organization', 'metaresource'],
delete: ['role', 'serviceaccount'],
list: ['metaresources'],
read: ['user', 'serviceaccount', 'role', 'organization', 'metaresource'],
update: ['user', 'serviceaccount', 'role', 'organization', 'metaresource'],
read: ['role', 'serviceaccount'],
update: ['role', 'serviceaccount'],
},
},
} as const;

View File

@@ -1,15 +1,16 @@
import permissionsType from './permissions.type';
import { ObjectSeparator } from './utils';
const ObjectSeparator = ':';
type PermissionsData = typeof permissionsType.data;
export type Resource = PermissionsData['resources'][number];
export type ResourceName = Resource['name'];
export type ResourceName = Resource['kind'];
export type ResourceType = Resource['type'];
type RelationsByType = PermissionsData['relations'];
type ResourceTypeMap = {
[K in ResourceName]: Extract<Resource, { name: K }>['type'];
[K in ResourceName]: Extract<Resource, { kind: K }>['type'];
};
type RelationName = keyof RelationsByType;
@@ -17,7 +18,7 @@ type RelationName = keyof RelationsByType;
export type ResourcesForRelation<R extends RelationName> = Extract<
Resource,
{ type: RelationsByType[R][number] }
>['name'];
>['kind'];
type IsPluralResource<R extends ResourceName> =
ResourceTypeMap[R] extends 'metaresources' ? true : false;

View File

@@ -36,8 +36,8 @@ const wrapper = ({ children }: { children: ReactElement }): ReactElement => (
describe('useAuthZ', () => {
it('should fetch and return permissions successfully', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
const expectedResponse = {
[permission1]: {
@@ -74,7 +74,7 @@ describe('useAuthZ', () => {
});
it('should handle API errors', async () => {
const permission = buildPermission('read', 'dashboard:*');
const permission = buildPermission('read', 'role:*');
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
@@ -95,9 +95,9 @@ describe('useAuthZ', () => {
});
it('should refetch when permissions array changes', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
const permission3 = buildPermission('delete', 'role:456');
let requestCount = 0;
@@ -161,8 +161,8 @@ describe('useAuthZ', () => {
});
it('should not refetch when permissions array order changes but content is the same', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
let requestCount = 0;
@@ -217,8 +217,8 @@ describe('useAuthZ', () => {
});
it('should send correct payload format to API', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
let receivedPayload: any = null;
@@ -244,23 +244,23 @@ describe('useAuthZ', () => {
expect(receivedPayload[0]).toMatchObject({
relation: 'read',
object: {
resource: { name: 'dashboard', type: 'metaresource' },
resource: { kind: 'role', type: 'role' },
selector: '*',
},
});
expect(receivedPayload[1]).toMatchObject({
relation: 'update',
object: {
resource: { name: 'dashboard', type: 'metaresource' },
resource: { kind: 'role', type: 'role' },
selector: '123',
},
});
});
it('should batch multiple hooks into single flight request', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
const permission3 = buildPermission('delete', 'role:456');
let requestCount = 0;
const receivedPayloads: any[] = [];
@@ -304,17 +304,17 @@ describe('useAuthZ', () => {
expect(receivedPayloads[0][0]).toMatchObject({
relation: 'read',
object: {
resource: { name: 'dashboard', type: 'metaresource' },
resource: { kind: 'role', type: 'role' },
selector: '*',
},
});
expect(receivedPayloads[0][1]).toMatchObject({
relation: 'update',
object: { resource: { name: 'dashboard' }, selector: '123' },
object: { resource: { kind: 'role' }, selector: '123' },
});
expect(receivedPayloads[0][2]).toMatchObject({
relation: 'delete',
object: { resource: { name: 'dashboard' }, selector: '456' },
object: { resource: { kind: 'role' }, selector: '456' },
});
expect(result1.current.permissions).toStrictEqual({
@@ -329,9 +329,9 @@ describe('useAuthZ', () => {
});
it('should create separate batches for calls after single flight window', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
const permission3 = buildPermission('delete', 'role:456');
let requestCount = 0;
const receivedPayloads: any[] = [];
@@ -386,18 +386,18 @@ describe('useAuthZ', () => {
expect(receivedPayloads[1]).toHaveLength(2);
expect(receivedPayloads[1][0]).toMatchObject({
relation: 'update',
object: { resource: { name: 'dashboard' }, selector: '123' },
object: { resource: { kind: 'role' }, selector: '123' },
});
expect(receivedPayloads[1][1]).toMatchObject({
relation: 'delete',
object: { resource: { name: 'dashboard' }, selector: '456' },
object: { resource: { kind: 'role' }, selector: '456' },
});
});
it('should map permissions correctly when API returns response out of order', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
const permission3 = buildPermission('delete', 'role:456');
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
@@ -435,8 +435,8 @@ describe('useAuthZ', () => {
});
it('should not leak state between separate batches', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
let requestCount = 0;

View File

@@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react';
import { useQueries } from 'react-query';
import { authzCheck } from 'api/generated/services/authz';
import type {
AuthtypesObjectDTO,
CoretypesObjectDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -34,7 +34,7 @@ function dispatchPermission(
});
setTimeout(() => {
const copiedPermissions = pendingPermissions.slice();
const copiedPermissions = [...pendingPermissions];
pendingPermissions = [];
ctx = null;
@@ -50,9 +50,9 @@ async function fetchManyPermissions(
): Promise<AuthZCheckResponse> {
const payload: AuthtypesTransactionDTO[] = permissions.map((permission) => {
const dto = permissionToTransactionDto(permission);
const object: AuthtypesObjectDTO = {
const object: CoretypesObjectDTO = {
resource: {
name: dto.object.resource.name,
kind: dto.object.resource.kind,
type: dto.object.resource.type,
},
selector: dto.object.selector,

View File

@@ -1,4 +1,8 @@
import { AuthtypesTransactionDTO } from '../../api/generated/services/sigNoz.schemas';
import {
AuthtypesTransactionDTO,
CoretypesTypeDTO,
AuthtypesRelationDTO,
} from '../../api/generated/services/sigNoz.schemas';
import permissionsType from './permissions.type';
import {
AuthZObject,
@@ -33,36 +37,72 @@ export function parsePermission(permission: BrandedPermission): {
return { relation: relation as AuthZRelation, object };
}
const resourceNameToType = permissionsType.data.resources.reduce(
const kindsByType = permissionsType.data.resources.reduce(
(acc, r) => {
acc[r.name] = r.type;
if (!acc[r.type]) {
acc[r.type] = new Set();
}
acc[r.type].add(r.kind);
return acc;
},
{} as Record<ResourceName, ResourceType>,
{} as Record<string, Set<string>>,
);
function resolveType(
relation: AuthZRelation,
kind: string,
): ResourceType | undefined {
const candidates: readonly string[] =
permissionsType.data.relations[relation] ?? [];
for (const t of candidates) {
if (kindsByType[t]?.has(kind)) {
return t as ResourceType;
}
}
return undefined;
}
function splitObjectString(objectStr: string): {
resourceName: string;
selector: string;
} {
const idx = objectStr.indexOf(ObjectSeparator);
if (idx === -1) {
return { resourceName: objectStr, selector: '' };
}
return {
resourceName: objectStr.slice(0, idx),
selector: objectStr.slice(idx + 1),
};
}
export function permissionToTransactionDto(
permission: BrandedPermission,
): AuthtypesTransactionDTO {
const { relation, object: objectStr } = parsePermission(permission);
const directType = resourceNameToType[objectStr as ResourceName];
const directType = resolveType(relation, objectStr);
if (directType === 'metaresources') {
return {
relation,
relation: relation as AuthtypesRelationDTO,
object: {
resource: { name: objectStr, type: directType },
resource: {
kind: objectStr as ResourceName,
type: directType as CoretypesTypeDTO,
},
selector: '*',
},
};
}
const [resourceName, selector] = objectStr.split(ObjectSeparator);
const type =
resourceNameToType[resourceName as ResourceName] ?? 'metaresource';
const { resourceName, selector } = splitObjectString(objectStr);
const type = resolveType(relation, resourceName) ?? 'metaresource';
return {
relation,
relation: relation as AuthtypesRelationDTO,
object: {
resource: { name: resourceName, type },
resource: {
kind: resourceName as ResourceName,
type: type as CoretypesTypeDTO,
},
selector: selector || '*',
},
};
@@ -75,7 +115,7 @@ export function gettableTransactionToPermission(
relation,
object: { resource, selector },
} = item;
const resourceName = String(resource.name);
const resourceName = String(resource.kind);
const selectorStr = typeof selector === 'string' ? selector : '*';
const objectStr =
resource.type === 'metaresources'

View File

@@ -7,6 +7,23 @@ export function hashFn(str: string): number {
return hash >>> 0;
}
export function colorToRgb(color: string): string {
// Handle hex colors
const hexMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
if (hexMatch) {
return `${parseInt(hexMatch[1], 16)}, ${parseInt(
hexMatch[2],
16,
)}, ${parseInt(hexMatch[3], 16)}`;
}
// Handle rgb() colors
const rgbMatch = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/.exec(color);
if (rgbMatch) {
return `${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}`;
}
return '136, 136, 136';
}
export function generateColor(
key: string,
colorMap: Record<string, string>,

View File

@@ -24,10 +24,15 @@ export default function Tooltip({
);
const showHeader = showTooltipHeader || activeItem != null;
// With a single series the active item is fully represented in the header —
// hide the divider and list to avoid showing a duplicate row.
const showList = tooltipContent.length > 1;
const showDivider = showList && showHeader;
// A single row collapses into the header when it's the active item, but
// must stay in the list when there's no active item (e.g. sync-driven
// tooltips with no focused series) — otherwise the row would vanish.
const showList =
tooltipContent.length > 1 ||
(tooltipContent.length === 1 && activeItem == null);
// The divider separates the active row in the header from the list; with
// no active item it has nothing to separate.
const showDivider = showList && showHeader && activeItem != null;
return (
<div

View File

@@ -137,7 +137,7 @@ function applyReceiverSync({
if (commonKeys.length === 0) {
uPlotInstance.setSeries(null, { focus: false });
return [];
return noMatchResult;
}
if ((uPlotInstance.cursor.left ?? -1) < 0) {

View File

@@ -867,8 +867,12 @@ describe('TooltipPlugin', () => {
}),
);
const resizeCall = addSpy.mock.calls.find(([type]) => type === 'resize');
const scrollCall = addSpy.mock.calls.find(([type]) => type === 'scroll');
const resizeCall = addSpy.mock.calls.find(
([type]) => type === ('resize' as keyof WindowEventMap),
);
const scrollCall = addSpy.mock.calls.find(
([type]) => type === ('scroll' as keyof WindowEventMap),
);
expect(resizeCall).toBeDefined();
expect(scrollCall).toBeDefined();

View File

@@ -0,0 +1,5 @@
import TraceDetailsV3 from '../TraceDetailsV3';
export default function TraceDetailV3Page(): JSX.Element {
return <TraceDetailsV3 />;
}

View File

@@ -0,0 +1,96 @@
.analytics-panel {
&__body {
padding: 12px 6px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
background: var(--l1-background);
// TabsRoot — last direct child div
> div:last-child {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
[role='tablist'] {
flex-shrink: 0;
}
}
&__tabs-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
}
&__list {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 4px 8px;
padding: 8px 0;
align-items: center;
}
&__dot {
width: 8px;
height: 8px;
border-radius: 2px;
}
&__service-name {
font-size: 13px;
color: var(--l1-foreground);
word-break: break-word;
}
&__bar-cell {
display: flex;
align-items: center;
gap: 8px;
}
&__bar {
flex: 1;
height: 6px;
background: var(--l3-background);
border-radius: 3px;
min-width: 40px;
&--small {
max-width: 80px;
flex: 0 0 80px;
}
}
&__bar-fill {
height: 100%;
border-radius: 3px;
}
&__value {
flex-shrink: 0;
text-align: right;
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
&--wide {
min-width: 55px;
}
&--narrow {
min-width: 25px;
}
}
// Tabs root
[class*='tabs__list-wrapper'] {
padding-left: 0 !important;
}
}

View File

@@ -0,0 +1,185 @@
import { useMemo } from 'react';
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from '@signozhq/ui';
import { DetailsHeader } from 'components/DetailsPanel';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import './AnalyticsPanel.styles.scss';
interface AnalyticsPanelProps {
isOpen: boolean;
onClose: () => void;
serviceExecTime?: Record<string, number>;
traceStartTime?: number;
traceEndTime?: number;
// TODO: Re-enable when backend provides per-service span counts
// spans?: Span[];
}
const PANEL_WIDTH = 350;
const PANEL_MARGIN_RIGHT = 100;
const PANEL_MARGIN_TOP = 50;
const PANEL_MARGIN_BOTTOM = 50;
function AnalyticsPanel({
isOpen,
onClose,
serviceExecTime = {},
traceStartTime = 0,
traceEndTime = 0,
}: AnalyticsPanelProps): JSX.Element | null {
const spread = traceEndTime - traceStartTime;
const execTimeRows = useMemo(() => {
if (spread <= 0) {
return [];
}
return Object.entries(serviceExecTime)
.map(([service, duration]) => ({
service,
percentage: (duration * 100) / spread,
color: generateColor(service, themeColors.traceDetailColorsV3),
}))
.sort((a, b) => b.percentage - a.percentage);
}, [serviceExecTime, spread]);
// const spanCountRows = useMemo(() => {
// const counts: Record<string, number> = {};
// for (const span of spans) {
// const name = span.serviceName || 'unknown';
// counts[name] = (counts[name] || 0) + 1;
// }
// return Object.entries(counts)
// .map(([service, count]) => ({
// service,
// count,
// color: generateColor(service, themeColors.traceDetailColorsV3),
// }))
// .sort((a, b) => b.count - a.count);
// }, [spans]);
// const maxSpanCount = spanCountRows[0]?.count || 1;
if (!isOpen) {
return null;
}
return (
<FloatingPanel
isOpen
className="analytics-panel"
width={PANEL_WIDTH}
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
defaultPosition={{
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
y: PANEL_MARGIN_TOP,
}}
enableResizing={{
top: true,
bottom: true,
left: false,
right: false,
topLeft: false,
topRight: false,
bottomLeft: false,
bottomRight: false,
}}
>
<DetailsHeader
title="Analytics"
onClose={onClose}
className="floating-panel__drag-handle"
/>
<div className="analytics-panel__body">
<TabsRoot defaultValue="exec-time">
<TabsList variant="secondary">
<TabsTrigger value="exec-time" variant="secondary">
% exec time
</TabsTrigger>
{/* TODO: Enable when backend provides per-service span counts
<TabsTrigger value="spans" variant="secondary">
Spans
</TabsTrigger>
*/}
</TabsList>
<div className="analytics-panel__tabs-scroll">
<TabsContent value="exec-time">
<div className="analytics-panel__list">
{execTimeRows.map((row) => (
<>
<div
key={`${row.service}-dot`}
className="analytics-panel__dot"
style={{ backgroundColor: row.color }}
/>
<span
key={`${row.service}-name`}
className="analytics-panel__service-name"
>
{row.service}
</span>
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
<div className="analytics-panel__bar">
<div
className="analytics-panel__bar-fill"
style={{
width: `${Math.min(row.percentage, 100)}%`,
backgroundColor: row.color,
}}
/>
</div>
<span className="analytics-panel__value analytics-panel__value--wide">
{row.percentage.toFixed(2)}%
</span>
</div>
</>
))}
</div>
</TabsContent>
{/* TODO: Enable when backend provides per-service span counts
<TabsContent value="spans">
<div className="analytics-panel__list">
{spanCountRows.map((row) => (
<>
<div
key={`${row.service}-dot`}
className="analytics-panel__dot"
style={{ backgroundColor: row.color }}
/>
<span
key={`${row.service}-name`}
className="analytics-panel__service-name"
>
{row.service}
</span>
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
<div className="analytics-panel__bar">
<div
className="analytics-panel__bar-fill"
style={{
width: `${(row.count / maxSpanCount) * 100}%`,
backgroundColor: row.color,
}}
/>
</div>
<span className="analytics-panel__value analytics-panel__value--narrow">
{row.count}
</span>
</div>
</>
))}
</div>
</TabsContent>
*/}
</div>
</TabsRoot>
</div>
</FloatingPanel>
);
}
export default AnalyticsPanel;

View File

@@ -0,0 +1,34 @@
.linked-spans {
position: relative;
&__toggle {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: inherit;
font: inherit;
}
&__label {
white-space: nowrap;
}
&__chevron {
transition: transform 0.15s ease;
&--open {
transform: rotate(90deg);
}
}
&__list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0;
}
}

View File

@@ -0,0 +1,118 @@
import { useCallback, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { Badge } from '@signozhq/ui';
import ROUTES from 'constants/routes';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import './LinkedSpans.styles.scss';
interface SpanReference {
traceId: string;
spanId: string;
refType: string;
}
interface LinkedSpansProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
references: any;
}
interface LinkedSpansState {
linkedSpans: SpanReference[];
count: number;
isOpen: boolean;
toggleOpen: () => void;
}
export function useLinkedSpans(references: any): LinkedSpansState {
const [isOpen, setIsOpen] = useState(false);
const linkedSpans: SpanReference[] = useMemo(
() =>
(references || []).filter(
(ref: SpanReference) => ref.refType !== 'CHILD_OF',
),
[references],
);
const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []);
return {
linkedSpans,
count: linkedSpans.length,
isOpen,
toggleOpen,
};
}
export function LinkedSpansToggle({
count,
isOpen,
toggleOpen,
}: {
count: number;
isOpen: boolean;
toggleOpen: () => void;
}): JSX.Element {
if (count === 0) {
return <span className="linked-spans__label">0 linked spans</span>;
}
return (
<button type="button" className="linked-spans__toggle" onClick={toggleOpen}>
<span className="linked-spans__label">
{count} linked span{count !== 1 ? 's' : ''}
</span>
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
);
}
export function LinkedSpansPanel({
linkedSpans,
isOpen,
}: {
linkedSpans: SpanReference[];
isOpen: boolean;
}): JSX.Element | null {
const getLink = useCallback(
(item: SpanReference): string =>
`${ROUTES.TRACE}/${item.traceId}?spanId=${item.spanId}`,
[],
);
if (!isOpen || linkedSpans.length === 0) {
return null;
}
return (
<div className="linked-spans__list">
{linkedSpans.map((item) => (
<KeyValueLabel
key={item.spanId}
badgeKey="Linked Span ID"
badgeValue={
<Link to={getLink(item)}>
<Badge color="vanilla">{item.spanId}</Badge>
</Link>
}
direction="column"
/>
))}
</div>
);
}
function LinkedSpans({ references }: LinkedSpansProps): JSX.Element {
const { linkedSpans, count, isOpen, toggleOpen } = useLinkedSpans(references);
return (
<div className="linked-spans">
<LinkedSpansToggle count={count} isOpen={isOpen} toggleOpen={toggleOpen} />
<LinkedSpansPanel linkedSpans={linkedSpans} isOpen={isOpen} />
</div>
);
}
export default LinkedSpans;

View File

@@ -0,0 +1,154 @@
.span-details-panel {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
&__header-nav {
display: flex;
align-items: center;
gap: 2px;
}
&__body {
padding: 12px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
background: var(--l1-background);
font-size: 14px;
gap: 16px;
.data-viewer {
min-height: 500px;
}
}
&__details-section {
flex-shrink: 0;
min-width: 0;
}
&__tabs-section {
flex: 1;
min-width: 0;
max-height: 100%;
min-height: 400px;
display: flex;
flex-direction: column;
overflow: hidden;
// TabsRoot — direct child of tabs-section
> div {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
[role='tablist'] {
flex-shrink: 0;
}
[class*='tabs__list-wrapper'] {
padding-left: 0 !important;
}
}
&__tabs-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
[role='tabpanel'] {
padding: 0;
}
}
&__span-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
justify-content: space-between;
}
&__span-info {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
padding: 8px 0;
}
&__span-info-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--l2-foreground);
white-space: nowrap;
}
&__highlighted-options {
padding: 8px 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
.key-value-label {
width: auto;
min-width: 0;
overflow: hidden;
}
}
&__service-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success-500);
}
&__trace-id {
color: var(--accent-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
&__key-attributes {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
&-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--l2-foreground);
text-transform: uppercase;
letter-spacing: 0.48px;
line-height: var(--line-height-20);
}
&-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
}
}
// Tooltip is rendered in a portal but the SpanDetailsPanel can be docked as a
// FloatingPanel (z-index 999), which would otherwise sit on top of the default
// tooltip (z-index 50). Bump the tooltip above the panel.
.dock-toggle-tooltip {
--tooltip-z-index: 1000;
}

View File

@@ -0,0 +1,618 @@
import { useCallback, useMemo, useState } from 'react';
import {
Button,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
TabsContent,
TabsList,
TabsRoot,
TabsTrigger,
} from '@signozhq/ui';
import {
Bookmark,
CalendarClock,
ChartBar,
ChartColumnBig,
Dock,
Link2,
Logs,
PanelBottom,
ScrollText,
Timer,
} from '@signozhq/icons';
import { Skeleton } from 'antd';
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
import { HeaderAction } from 'components/DetailsPanel/DetailsHeader/DetailsHeader';
import { DetailsPanelState } from 'components/DetailsPanel/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import Events from 'container/SpanDetailsDrawer/Events/Events';
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
import dayjs from 'dayjs';
import { getSpanAttribute, hasInfraMetadata } from 'pages/TraceDetailsV3/utils';
import { DataViewer } from 'periscope/components/DataViewer';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { getLeafKeyFromPath } from 'periscope/components/PrettyView/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import AnalyticsPanel from './AnalyticsPanel/AnalyticsPanel';
import { HIGHLIGHTED_OPTIONS } from './config';
import {
// KEY_ATTRIBUTE_KEYS, // uncomment when key attributes section is re-enabled
SpanDetailVariant,
VISIBLE_ACTIONS,
} from './constants';
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
import {
LinkedSpansPanel,
LinkedSpansToggle,
useLinkedSpans,
} from './LinkedSpans/LinkedSpans';
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
import './SpanDetailsPanel.styles.scss';
interface SpanDetailsPanelProps {
panelState: DetailsPanelState;
selectedSpan: SpanV3 | undefined;
variant?: SpanDetailVariant;
onVariantChange?: (variant: SpanDetailVariant) => void;
traceStartTime?: number;
traceEndTime?: number;
serviceExecTime?: Record<string, number>;
}
function SpanDetailsContent({
selectedSpan,
traceStartTime,
traceEndTime,
}: {
selectedSpan: SpanV3;
traceStartTime?: number;
traceEndTime?: number;
}): JSX.Element {
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
const spanAttributeActions = useSpanAttributeActions();
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
// Map span attribute actions to PrettyView actions format.
// Use the last key in fieldKeyPath (the actual attribute key), not the full display path.
const prettyViewCustomActions = useMemo(
() =>
spanAttributeActions.map((action) => ({
key: action.value,
label: action.label,
icon: action.icon,
shouldHide: action.shouldHide,
onClick: (context: {
fieldKey: string;
fieldKeyPath: (string | number)[];
fieldValue: unknown;
}): void => {
const leafKey = getLeafKeyFromPath(context.fieldKeyPath, context.fieldKey);
action.callback({
key: leafKey,
value: String(context.fieldValue),
});
},
})),
[spanAttributeActions],
);
// const [, setCopy] = useCopyToClipboard();
// Key attributes section — commented out as pinning in PrettyView covers this use case.
// Uncomment when key attributes need to be shown separately.
// const buildKeyAttrMenu = useCallback(
// (key: string, value: string): ActionMenuItem[] => {
// const items: ActionMenuItem[] = [
// {
// key: 'copy-value',
// label: 'Copy Value',
// icon: <Copy size={12} />,
// onClick: (): void => {
// setCopy(value);
// toast.success('Copied to clipboard', {
// richColors: true,
// position: 'top-right',
// });
// },
// },
// ];
// spanAttributeActions.forEach((action) => {
// if (action.shouldHide && action.shouldHide(key)) {
// return;
// }
// items.push({
// key: action.value,
// label: action.label,
// icon: action.icon,
// onClick: (): void => {
// action.callback({ key, value });
// },
// });
// });
// return items;
// },
// [spanAttributeActions, setCopy],
// );
const {
logs,
isLoading: isLogsLoading,
isError: isLogsError,
isFetching: isLogsFetching,
isLogSpanRelated,
hasTraceIdLogs,
} = useSpanContextLogs({
traceId: selectedSpan.trace_id,
spanId: selectedSpan.span_id,
timeRange: {
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
},
isDrawerOpen: true,
});
const infraMetadata = useMemo(() => {
if (!hasInfraMetadata(selectedSpan)) {
return null;
}
return {
clusterName: getSpanAttribute(selectedSpan, 'k8s.cluster.name') || '',
podName: getSpanAttribute(selectedSpan, 'k8s.pod.name') || '',
nodeName: getSpanAttribute(selectedSpan, 'k8s.node.name') || '',
hostName: getSpanAttribute(selectedSpan, 'host.name') || '',
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
};
}, [selectedSpan]);
const handleExplorerPageRedirect = useCallback((): void => {
const startTimeMs = (traceStartTime || 0) - FIVE_MINUTES_IN_MS;
const endTimeMs = (traceEndTime || 0) + FIVE_MINUTES_IN_MS;
const traceIdFilter = {
op: 'AND',
items: [
{
id: 'trace-id-filter',
key: {
key: 'trace_id',
id: 'trace-id-key',
dataType: 'string' as const,
isColumn: true,
type: '',
isJSON: false,
} as BaseAutocompleteData,
op: '=',
value: selectedSpan.trace_id,
},
],
};
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
filters: traceIdFilter,
},
],
},
};
const searchParams = new URLSearchParams();
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`);
}, [selectedSpan.trace_id, traceStartTime, traceEndTime]);
const emptyLogsStateConfig = useMemo(
() => ({
...getEmptyLogsListConfig(() => {}),
showClearFiltersButton: false,
}),
[],
);
// const keyAttributes = useMemo(() => {
// const keys = KEY_ATTRIBUTE_KEYS.traces || [];
// const allAttrs: Record<string, string> = {};
// Object.entries(selectedSpan.resource || {}).forEach(([k, v]) => {
// allAttrs[k] = String(v);
// });
// Object.entries(selectedSpan.attributes || {}).forEach(([k, v]) => {
// allAttrs[k] = String(v);
// });
// const span = (selectedSpan as unknown) as Record<string, unknown>;
// keys.forEach((key) => {
// if (!(key in allAttrs) && span[key] != null && span[key] !== '') {
// allAttrs[key] = String(span[key]);
// }
// });
// return keys
// .filter((key) => allAttrs[key])
// .map((key) => ({ key, value: allAttrs[key] }));
// }, [selectedSpan]);
return (
<div className="span-details-panel__body">
<div className="span-details-panel__details-section">
<div className="span-details-panel__span-row">
<KeyValueLabel
badgeKey="Span name"
badgeValue={selectedSpan.name}
maxCharacters={50}
/>
<SpanPercentileBadge
loading={percentile.loading}
percentileValue={percentile.percentileValue}
duration={percentile.duration}
spanPercentileData={percentile.spanPercentileData}
isOpen={percentile.isOpen}
toggleOpen={percentile.toggleOpen}
/>
</div>
<SpanPercentilePanel selectedSpan={selectedSpan} percentile={percentile} />
{/* Span info: exec time + start time */}
<div className="span-details-panel__span-info">
<div className="span-details-panel__span-info-item">
<Timer size={14} />
<span>
{getYAxisFormattedValue(`${selectedSpan.duration_nano / 1000000}`, 'ms')}
{traceStartTime && traceEndTime && traceEndTime > traceStartTime && (
<>
{' — '}
<strong>
{(
(selectedSpan.duration_nano * 100) /
((traceEndTime - traceStartTime) * 1e6)
).toFixed(2)}
%
</strong>
{' of total exec time'}
</>
)}
</span>
</div>
<div className="span-details-panel__span-info-item">
<CalendarClock size={14} />
<span>
{dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
</span>
</div>
<div className="span-details-panel__span-info-item">
<Link2 size={14} />
<LinkedSpansToggle
count={linkedSpans.count}
isOpen={linkedSpans.isOpen}
toggleOpen={linkedSpans.toggleOpen}
/>
</div>
</div>
<LinkedSpansPanel
linkedSpans={linkedSpans.linkedSpans}
isOpen={linkedSpans.isOpen}
/>
{/* Step 6: HighlightedOptions */}
<div className="span-details-panel__highlighted-options">
{HIGHLIGHTED_OPTIONS.map((option) => {
const rendered = option.render(selectedSpan);
if (!rendered) {
return null;
}
return (
<KeyValueLabel
key={option.key}
badgeKey={option.label}
badgeValue={rendered}
direction="column"
/>
);
})}
</div>
{/* Step 7: KeyAttributes — commented out, pinning in PrettyView covers this.
{keyAttributes.length > 0 && (
<div className="span-details-panel__key-attributes">
<div className="span-details-panel__key-attributes-label">
KEY ATTRIBUTES
</div>
<div className="span-details-panel__key-attributes-chips">
{keyAttributes.map(({ key, value }) => (
<ActionMenu
key={key}
items={buildKeyAttrMenu(key, value)}
trigger={['click']}
placement="bottomRight"
>
<div>
<KeyValueLabel badgeKey={key} badgeValue={value} />
</div>
</ActionMenu>
))}
</div>
</div>
)}
*/}
{/* Step 8: MiniTraceContext */}
</div>
<div className="span-details-panel__tabs-section">
{/* Step 9: ContentTabs */}
<TabsRoot defaultValue="overview">
<TabsList variant="secondary">
<TabsTrigger value="overview" variant="secondary">
<Bookmark size={14} /> Overview
</TabsTrigger>
<TabsTrigger value="events" variant="secondary">
<ScrollText size={14} /> Events ({selectedSpan.events?.length || 0})
</TabsTrigger>
<TabsTrigger value="logs" variant="secondary">
<Logs size={14} /> Logs
</TabsTrigger>
{infraMetadata && (
<TabsTrigger value="metrics" variant="secondary">
<ChartColumnBig size={14} /> Metrics
</TabsTrigger>
)}
</TabsList>
<div className="span-details-panel__tabs-scroll">
<TabsContent value="overview">
<DataViewer
data={selectedSpan}
drawerKey="trace-details"
prettyViewProps={{
showPinned: true,
actions: prettyViewCustomActions,
visibleActions: VISIBLE_ACTIONS,
}}
/>
</TabsContent>
<TabsContent value="events">
{/* V2 Events component expects span.event (singular), V3 has span.events (plural) */}
<Events
span={{ ...selectedSpan, event: selectedSpan.events } as any}
startTime={traceStartTime || 0}
isSearchVisible
/>
</TabsContent>
<TabsContent value="logs">
<SpanLogs
traceId={selectedSpan.trace_id}
spanId={selectedSpan.span_id}
timeRange={{
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
}}
logs={logs}
isLoading={isLogsLoading}
isError={isLogsError}
isFetching={isLogsFetching}
isLogSpanRelated={isLogSpanRelated}
handleExplorerPageRedirect={handleExplorerPageRedirect}
emptyStateConfig={!hasTraceIdLogs ? emptyLogsStateConfig : undefined}
/>
</TabsContent>
{infraMetadata && (
<TabsContent value="metrics">
<InfraMetrics
clusterName={infraMetadata.clusterName}
podName={infraMetadata.podName}
nodeName={infraMetadata.nodeName}
hostName={infraMetadata.hostName}
timestamp={infraMetadata.spanTimestamp}
dataSource={DataSource.TRACES}
/>
</TabsContent>
)}
</div>
</TabsRoot>
</div>
</div>
);
}
function SpanDetailsPanel({
panelState,
selectedSpan,
variant = SpanDetailVariant.DIALOG,
onVariantChange,
traceStartTime,
traceEndTime,
serviceExecTime,
}: SpanDetailsPanelProps): JSX.Element {
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const headerActions = useMemo((): HeaderAction[] => {
const actions: HeaderAction[] = [
{
key: 'analytics',
component: (
<Button
variant="ghost"
size="sm"
color="secondary"
prefix={<ChartBar size={14} />}
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
>
Analytics
</Button>
),
},
// TODO: Add back when driven through separate config for different pages
// {
// key: 'view-full-trace',
// component: (
// <Button variant="ghost" size="sm" color="secondary" prefixIcon={<ExternalLink size={14} />} onClick={noop}>
// View full trace
// </Button>
// ),
// },
// TODO: Add back when used in trace explorer page
// {
// key: 'nav',
// component: (
// <div className="span-details-panel__header-nav">
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronUp size={14} /></Button>
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronDown size={14} /></Button>
// </div>
// ),
// },
];
if (onVariantChange) {
const isDocked = variant === SpanDetailVariant.DOCKED;
actions.push({
key: 'dock-toggle',
component: (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void =>
onVariantChange(
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
)
}
>
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent className="dock-toggle-tooltip">
{isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
),
});
}
return actions;
}, [variant, onVariantChange]);
const PANEL_WIDTH = 500;
const PANEL_MARGIN_RIGHT = 20;
const PANEL_MARGIN_TOP = 25;
const PANEL_MARGIN_BOTTOM = 25;
const content = (
<>
<DetailsHeader
title="Span details"
onClose={panelState.close}
actions={headerActions}
className={
variant === SpanDetailVariant.DIALOG ? 'floating-panel__drag-handle' : ''
}
/>
{selectedSpan ? (
<SpanDetailsContent
selectedSpan={selectedSpan}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
/>
) : (
<div className="span-details-panel__body">
<Skeleton active paragraph={{ rows: 6 }} title={{ width: '60%' }} />
</div>
)}
</>
);
const analyticsPanel = (
<AnalyticsPanel
isOpen={isAnalyticsOpen}
onClose={(): void => setIsAnalyticsOpen(false)}
serviceExecTime={serviceExecTime}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
/>
);
if (variant === SpanDetailVariant.DOCKED) {
return (
<>
<div className="span-details-panel">{content}</div>
{analyticsPanel}
</>
);
}
if (variant === SpanDetailVariant.DRAWER) {
return (
<>
<DetailsPanelDrawer
isOpen={panelState.isOpen}
onClose={panelState.close}
className="span-details-panel"
>
{content}
</DetailsPanelDrawer>
{analyticsPanel}
</>
);
}
return (
<>
<FloatingPanel
isOpen={panelState.isOpen}
className="span-details-panel"
width={PANEL_WIDTH}
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
defaultPosition={{
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
y: PANEL_MARGIN_TOP,
}}
enableResizing={{
top: true,
right: true,
bottom: true,
left: true,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
}}
>
{content}
</FloatingPanel>
{analyticsPanel}
</>
);
}
export default SpanDetailsPanel;

View File

@@ -0,0 +1,258 @@
// Badge — wraps a KeyValueLabel, clickable to toggle panel
.span-percentile-badge {
cursor: pointer;
// Override key color for the percentile value (p99)
.key-value-label__key {
color: var(--destructive);
}
&__loader {
display: inline-flex;
align-items: center;
padding: 2px 4px;
color: var(--foreground);
}
&__value {
display: inline-flex;
align-items: center;
gap: 4px;
}
&__icon {
flex-shrink: 0;
color: var(--l2-foreground);
}
}
// Panel — collapsible, renders below the row
.span-percentile-panel {
display: flex;
flex-direction: column;
position: relative;
border: 1px solid var(--l1-border);
border-radius: 4px;
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
backdrop-filter: blur(20px);
margin: 8px 16px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
&-text {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
&-icon {
color: var(--l2-foreground);
}
}
&__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
&-title {
font-size: var(--font-size-sm);
line-height: var(--line-height-20);
}
&-highlight {
color: var(--destructive);
}
&-loader {
display: inline-flex;
align-items: flex-end;
margin: 0 4px;
line-height: 18px;
}
}
&__timerange {
width: 100%;
&-select {
width: 100%;
margin-top: 8px;
margin-bottom: 16px;
.ant-select-selector {
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
color: var(--l1-foreground);
font-size: 12px;
height: 32px;
}
}
}
&__table {
&-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
&-text {
color: var(--l1-foreground);
font-size: 11px;
font-weight: 500;
line-height: 20px;
text-transform: uppercase;
}
}
&-rows {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
&-skeleton {
.ant-skeleton-title {
width: 100% !important;
margin-top: 0 !important;
}
.ant-skeleton-paragraph {
margin-top: 8px;
& > li + li {
margin-top: 10px;
width: 100% !important;
}
}
}
&-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 4px;
&-key {
flex: 0 0 auto;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
&-value {
color: var(--l2-foreground);
font-size: 12px;
line-height: 20px;
}
&-dash {
flex: 1;
height: 0;
margin: 0 8px;
border-top: 1px solid transparent;
border-image: repeating-linear-gradient(
to right,
var(--l1-border) 0,
var(--l1-border) 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
&--current {
border-radius: 2px;
background: rgba(78, 116, 248, 0.2);
.span-percentile-panel__table-row-key {
color: var(--text-robin-300);
}
.span-percentile-panel__table-row-dash {
border-image: repeating-linear-gradient(
to right,
#abbdff 0,
#abbdff 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
.span-percentile-panel__table-row-value {
color: var(--text-robin-400);
}
}
}
}
&__resource-selector {
overflow: hidden;
width: calc(100% + 16px);
position: absolute;
top: 32px;
left: -8px;
z-index: 1000;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
&-header {
border-bottom: 1px solid var(--l1-border);
}
&-input {
border-radius: 0;
border: none !important;
box-shadow: none !important;
height: 36px;
}
&-items {
height: 200px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
}
}
&-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
&-value {
color: var(--l1-foreground);
font-size: 13px;
line-height: 20px;
}
}
}
}

View File

@@ -0,0 +1,67 @@
import { ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { UseSpanPercentileReturn } from './useSpanPercentile';
import './SpanPercentile.styles.scss';
type SpanPercentileBadgeProps = Pick<
UseSpanPercentileReturn,
| 'loading'
| 'percentileValue'
| 'duration'
| 'spanPercentileData'
| 'isOpen'
| 'toggleOpen'
>;
function SpanPercentileBadge({
loading,
percentileValue,
duration,
spanPercentileData,
isOpen,
toggleOpen,
}: SpanPercentileBadgeProps): JSX.Element | null {
if (loading) {
return (
<div className="span-percentile-badge__loader">
<Loader2 size={14} className="animate-spin" />
</div>
);
}
if (!spanPercentileData) {
return null;
}
return (
<div
className="span-percentile-badge"
onClick={toggleOpen}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
toggleOpen();
}
}}
>
<KeyValueLabel
badgeKey={`p${percentileValue}`}
badgeValue={
<span className="span-percentile-badge__value">
{duration}
{isOpen ? (
<ChevronUp size={14} className="span-percentile-badge__icon" />
) : (
<ChevronDown size={14} className="span-percentile-badge__icon" />
)}
</span>
}
/>
</div>
);
}
export default SpanPercentileBadge;

View File

@@ -0,0 +1,224 @@
import { Checkbox, Input, Select, Skeleton, Typography } from 'antd';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { Check, ChevronDown, Loader2, PlusIcon } from 'lucide-react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { UseSpanPercentileReturn } from './useSpanPercentile';
import './SpanPercentile.styles.scss';
const DEFAULT_RESOURCE_ATTRIBUTES = {
serviceName: 'service.name',
name: 'name',
};
const timerangeOptions = [1, 2, 4, 6, 12, 24].map((hours) => ({
label: `${hours}h`,
value: hours,
}));
interface SpanPercentilePanelProps {
selectedSpan: SpanV3;
percentile: UseSpanPercentileReturn;
}
function SpanPercentilePanel({
selectedSpan,
percentile,
}: SpanPercentilePanelProps): JSX.Element | null {
const {
isOpen,
toggleOpen,
isError,
loading,
spanPercentileData,
selectedTimeRange,
setSelectedTimeRange,
showResourceAttributesSelector,
setShowResourceAttributesSelector,
resourceAttributesSearchQuery,
setResourceAttributesSearchQuery,
spanResourceAttributes,
handleResourceAttributeChange,
resourceAttributesSelectorRef,
isLoadingData,
isFetchingData,
} = percentile;
if (!isOpen || isError) {
return null;
}
return (
<div className="span-percentile-panel">
<div className="span-percentile-panel__header">
<Typography.Text
className="span-percentile-panel__header-text"
onClick={toggleOpen}
>
<ChevronDown size={16} /> Span Percentile
</Typography.Text>
{showResourceAttributesSelector ? (
<Check
size={16}
className="cursor-pointer span-percentile-panel__header-icon"
onClick={(): void => setShowResourceAttributesSelector(false)}
/>
) : (
<PlusIcon
size={16}
className="cursor-pointer span-percentile-panel__header-icon"
onClick={(): void => setShowResourceAttributesSelector(true)}
/>
)}
</div>
{showResourceAttributesSelector && (
<div
className="span-percentile-panel__resource-selector"
ref={resourceAttributesSelectorRef}
>
<div className="span-percentile-panel__resource-selector-header">
<Input
placeholder="Search resource attributes"
className="span-percentile-panel__resource-selector-input"
value={resourceAttributesSearchQuery}
onChange={(e): void =>
setResourceAttributesSearchQuery(e.target.value as string)
}
/>
</div>
<div className="span-percentile-panel__resource-selector-items">
{spanResourceAttributes
.filter((attr) =>
attr.key
.toLowerCase()
.includes(resourceAttributesSearchQuery.toLowerCase()),
)
.map((attr) => (
<div
className="span-percentile-panel__resource-selector-item"
key={attr.key}
>
<Checkbox
checked={attr.isSelected}
onChange={(e): void => {
handleResourceAttributeChange(
attr.key,
attr.value,
e.target.checked,
);
}}
disabled={
attr.key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
attr.key === DEFAULT_RESOURCE_ATTRIBUTES.name
}
>
<div className="span-percentile-panel__resource-selector-item-value">
{attr.key}
</div>
</Checkbox>
</div>
))}
</div>
</div>
)}
<div className="span-percentile-panel__content">
<Typography.Text className="span-percentile-panel__content-title">
This span duration is{' '}
{!loading && spanPercentileData ? (
<span className="span-percentile-panel__content-highlight">
p{Math.floor(spanPercentileData.percentile || 0)}
</span>
) : (
<span className="span-percentile-panel__content-loader">
<Loader2 size={12} className="animate-spin" />
</span>
)}{' '}
out of the distribution for this resource evaluated for {selectedTimeRange}{' '}
hour(s) since the span start time.
</Typography.Text>
<div className="span-percentile-panel__timerange">
<Select
labelInValue
placeholder="Select timerange"
className="span-percentile-panel__timerange-select"
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement || document.body
}
value={{
label: `${selectedTimeRange}h : ${dayjs(selectedSpan.timestamp)
.subtract(selectedTimeRange, 'hour')
.format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)} - ${dayjs(
selectedSpan.timestamp,
).format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)}`,
value: selectedTimeRange,
}}
onChange={(value): void => {
setSelectedTimeRange(Number(value.value));
}}
options={timerangeOptions}
/>
</div>
<div className="span-percentile-panel__table">
<div className="span-percentile-panel__table-header">
<Typography.Text className="span-percentile-panel__table-header-text">
Percentile
</Typography.Text>
<Typography.Text className="span-percentile-panel__table-header-text">
Duration
</Typography.Text>
</div>
<div className="span-percentile-panel__table-rows">
{isLoadingData || isFetchingData ? (
<Skeleton
active
paragraph={{ rows: 3 }}
className="span-percentile-panel__table-skeleton"
/>
) : (
<>
{Object.entries(spanPercentileData?.percentiles || {}).map(
([pKey, pDuration]) => (
<div className="span-percentile-panel__table-row" key={pKey}>
<Typography.Text className="span-percentile-panel__table-row-key">
{pKey}
</Typography.Text>
<div className="span-percentile-panel__table-row-dash" />
<Typography.Text className="span-percentile-panel__table-row-value">
{getYAxisFormattedValue(`${pDuration / 1000000}`, 'ms')}
</Typography.Text>
</div>
),
)}
<div className="span-percentile-panel__table-row span-percentile-panel__table-row--current">
<Typography.Text className="span-percentile-panel__table-row-key">
p{Math.floor(spanPercentileData?.percentile || 0)}
</Typography.Text>
<div className="span-percentile-panel__table-row-dash" />
<Typography.Text className="span-percentile-panel__table-row-value">
(this span){' '}
{getYAxisFormattedValue(
`${selectedSpan.duration_nano / 1000000}`,
'ms',
)}
</Typography.Text>
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}
export default SpanPercentilePanel;

View File

@@ -0,0 +1,326 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
import getUserPreference from 'api/v1/user/preferences/name/get';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { USER_PREFERENCES } from 'constants/userPreferences';
import dayjs from 'dayjs';
import useClickOutside from 'hooks/useClickOutside';
import { SpanV3 } from 'types/api/trace/getTraceV3';
export interface IResourceAttribute {
key: string;
value: string;
isSelected: boolean;
}
const DEFAULT_RESOURCE_ATTRIBUTES = {
serviceName: 'service.name',
name: 'name',
};
export interface UseSpanPercentileReturn {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
toggleOpen: () => void;
loading: boolean;
percentileValue: number;
duration: string;
spanPercentileData: {
percentile: number;
description: string;
percentiles: Record<string, number>;
} | null;
isError: boolean;
selectedTimeRange: number;
setSelectedTimeRange: (range: number) => void;
showResourceAttributesSelector: boolean;
setShowResourceAttributesSelector: (show: boolean) => void;
resourceAttributesSearchQuery: string;
setResourceAttributesSearchQuery: (query: string) => void;
spanResourceAttributes: IResourceAttribute[];
handleResourceAttributeChange: (
key: string,
value: string,
isSelected: boolean,
) => void;
resourceAttributesSelectorRef: React.MutableRefObject<HTMLDivElement | null>;
isLoadingData: boolean;
isFetchingData: boolean;
}
function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
const [isOpen, setIsOpen] = useState(false);
const [selectedTimeRange, setSelectedTimeRange] = useState(1);
const [resourceAttributesSearchQuery, setResourceAttributesSearchQuery] =
useState('');
const [spanPercentileData, setSpanPercentileData] = useState<{
percentile: number;
description: string;
percentiles: Record<string, number>;
} | null>(null);
const [showResourceAttributesSelector, setShowResourceAttributesSelector] =
useState(false);
const [selectedResourceAttributes, setSelectedResourceAttributes] = useState<
Record<string, string>
>({});
const [spanResourceAttributes, updateSpanResourceAttributes] = useState<
IResourceAttribute[]
>([]);
const [initialWaitCompleted, setInitialWaitCompleted] = useState(false);
const [shouldFetchData, setShouldFetchData] = useState(false);
const [shouldUpdateUserPreference, setShouldUpdateUserPreference] =
useState(false);
const resourceAttributesSelectorRef = useRef<HTMLDivElement | null>(null);
useClickOutside({
ref: resourceAttributesSelectorRef,
onClickOutside: () => {
if (resourceAttributesSelectorRef.current) {
setShowResourceAttributesSelector(false);
}
},
eventType: 'mousedown',
});
const endTime = useMemo(
() => Math.floor(Number(selectedSpan.timestamp) / 1000) * 1000,
[selectedSpan.timestamp],
);
const startTime = useMemo(
() =>
dayjs(selectedSpan.timestamp)
.subtract(Number(selectedTimeRange), 'hour')
.unix() * 1000,
[selectedSpan.timestamp, selectedTimeRange],
);
const { mutate: updateUserPreferenceMutation } =
useMutation(updateUserPreference);
const {
data: userSelectedResourceAttributes,
isError: isErrorUserSelectedResourceAttributes,
} = useQuery({
queryFn: () =>
getUserPreference({
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
}),
queryKey: [
'getUserPreferenceByPreferenceName',
USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
selectedSpan.span_id,
],
enabled: selectedSpan.attributes !== undefined,
});
const {
isLoading: isLoadingData,
isFetching: isFetchingData,
data,
refetch: refetchData,
isError: isErrorData,
} = useQuery({
queryFn: () =>
getSpanPercentiles({
start: startTime || 0,
end: endTime || 0,
spanDuration: selectedSpan.duration_nano || 0,
serviceName: selectedSpan['service.name'] || '',
name: selectedSpan.name || '',
resourceAttributes: selectedResourceAttributes,
}),
queryKey: [
REACT_QUERY_KEY.GET_SPAN_PERCENTILES,
selectedSpan.span_id,
startTime,
endTime,
],
enabled:
shouldFetchData && !showResourceAttributesSelector && initialWaitCompleted,
onSuccess: (response) => {
if (response.httpStatusCode !== 200) {
return;
}
if (shouldUpdateUserPreference) {
updateUserPreferenceMutation({
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
value: [...Object.keys(selectedResourceAttributes)],
});
setShouldUpdateUserPreference(false);
}
},
keepPreviousData: false,
cacheTime: 0,
});
// 2-second delay before initial fetch
useEffect(() => {
setSpanPercentileData(null);
setIsOpen(false);
setInitialWaitCompleted(false);
const timer = setTimeout(() => {
setInitialWaitCompleted(true);
}, 2000);
return (): void => {
clearTimeout(timer);
};
}, [selectedSpan.span_id]);
useEffect(() => {
if (data?.httpStatusCode !== 200) {
setSpanPercentileData(null);
return;
}
if (data) {
setSpanPercentileData({
percentile: data.data?.position?.percentile || 0,
description: data.data?.position?.description || '',
percentiles: data.data?.percentiles || {},
});
}
}, [data]);
// Merge resource + attributes to get all span attributes (equivalent to V2 tagMap).
// Stringify all values since the backend expects map[string]string.
const allSpanAttributes = useMemo(() => {
const merged: Record<string, string> = {};
for (const [k, v] of Object.entries(selectedSpan.resource || {})) {
merged[k] = String(v);
}
for (const [k, v] of Object.entries(selectedSpan.attributes || {})) {
merged[k] = String(v);
}
return merged;
}, [selectedSpan.resource, selectedSpan.attributes]);
useEffect(() => {
if (userSelectedResourceAttributes) {
const userList = (
userSelectedResourceAttributes?.data?.value as string[]
).map((attr: string) => attr);
let selectedMap: Record<string, string> = {};
userList.forEach((attr: string) => {
selectedMap[attr] = allSpanAttributes[attr] || '';
});
selectedMap = Object.fromEntries(
Object.entries(selectedMap).filter(
([key]) => allSpanAttributes[key] !== undefined,
),
);
const resourceAttrs = Object.entries(allSpanAttributes).map(
([key, value]) => ({
key,
value,
isSelected:
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
key === DEFAULT_RESOURCE_ATTRIBUTES.name ||
(key in selectedMap &&
selectedMap[key] !== '' &&
selectedMap[key] !== undefined),
}),
);
const selected = resourceAttrs.filter((a) => a.isSelected);
const unselected = resourceAttrs.filter((a) => !a.isSelected);
updateSpanResourceAttributes([...selected, ...unselected]);
setSelectedResourceAttributes(selectedMap);
setShouldFetchData(true);
}
if (isErrorUserSelectedResourceAttributes) {
const resourceAttrs = Object.entries(allSpanAttributes).map(
([key, value]) => ({
key,
value,
isSelected:
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
key === DEFAULT_RESOURCE_ATTRIBUTES.name,
}),
);
updateSpanResourceAttributes(resourceAttrs);
setShouldFetchData(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
userSelectedResourceAttributes,
isErrorUserSelectedResourceAttributes,
allSpanAttributes,
]);
const handleResourceAttributeChange = useCallback(
(key: string, value: string, isSelected: boolean): void => {
updateSpanResourceAttributes((prev) =>
prev.map((attr) => (attr.key === key ? { ...attr, isSelected } : attr)),
);
const newSelected = { ...selectedResourceAttributes };
if (isSelected) {
newSelected[key] = value;
} else {
delete newSelected[key];
}
setSelectedResourceAttributes(newSelected);
setShouldFetchData(true);
setShouldUpdateUserPreference(true);
},
[selectedResourceAttributes],
);
useEffect(() => {
if (
shouldFetchData &&
!showResourceAttributesSelector &&
initialWaitCompleted
) {
refetchData();
setShouldFetchData(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldFetchData, showResourceAttributesSelector, initialWaitCompleted]);
const loading = isLoadingData || isFetchingData;
const percentileValue = Math.floor(spanPercentileData?.percentile || 0);
const duration = getYAxisFormattedValue(
`${selectedSpan.duration_nano / 1000000}`,
'ms',
);
const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []);
const handleTimeRangeChange = useCallback((range: number): void => {
setShouldFetchData(true);
setSelectedTimeRange(range);
}, []);
return {
isOpen,
setIsOpen,
toggleOpen,
loading,
percentileValue,
duration,
spanPercentileData,
isError: isErrorData,
selectedTimeRange,
setSelectedTimeRange: handleTimeRangeChange,
showResourceAttributesSelector,
setShowResourceAttributesSelector,
resourceAttributesSearchQuery,
setResourceAttributesSearchQuery,
spanResourceAttributes,
handleResourceAttributeChange,
resourceAttributesSelectorRef,
isLoadingData,
isFetchingData,
};
}
export default useSpanPercentile;

View File

@@ -0,0 +1,54 @@
import { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Badge } from '@signozhq/ui';
import { SpanV3 } from 'types/api/trace/getTraceV3';
interface HighlightedOption {
key: string;
label: string;
render: (span: SpanV3) => ReactNode | null;
}
export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
{
key: 'service',
label: 'SERVICE',
render: (span): ReactNode | null =>
span['service.name'] ? (
<Badge color="vanilla">
<span className="span-details-panel__service-dot" />
{span['service.name']}
</Badge>
) : null,
},
{
key: 'statusCodeString',
label: 'STATUS CODE STRING',
render: (span): ReactNode | null =>
span.status_code_string ? (
<Badge color="vanilla">{span.status_code_string}</Badge>
) : null,
},
{
key: 'traceId',
label: 'TRACE ID',
render: (span): ReactNode | null =>
span.trace_id ? (
<Link
to={{
pathname: `/trace/${span.trace_id}`,
search: window.location.search,
}}
className="span-details-panel__trace-id"
>
{span.trace_id}
</Link>
) : null,
},
{
key: 'spanKind',
label: 'SPAN KIND',
render: (span): ReactNode | null =>
span.kind_string ? <Badge color="vanilla">{span.kind_string}</Badge> : null,
},
];

View File

@@ -0,0 +1,45 @@
import { SPAN_ACTION } from './hooks/useSpanAttributeActions';
// Action identifiers for built-in PrettyView actions (copy, pin)
export const PRETTY_VIEW_ACTION = {
COPY: 'copy',
PIN: 'pin',
} as const;
// Which actions are visible per node type — drives the entire menu
export const VISIBLE_ACTIONS = {
leaf: [
PRETTY_VIEW_ACTION.COPY,
PRETTY_VIEW_ACTION.PIN,
SPAN_ACTION.FILTER_IN,
SPAN_ACTION.FILTER_OUT,
SPAN_ACTION.GROUP_BY,
],
nested: [PRETTY_VIEW_ACTION.COPY],
} as const;
export enum SpanDetailVariant {
DRAWER = 'drawer',
DIALOG = 'dialog',
DOCKED = 'docked',
}
export const KEY_ATTRIBUTE_KEYS: Record<string, string[]> = {
traces: [
'service.name',
'service.namespace',
'deployment.environment',
'timestamp',
'duration_nano',
'kind_string',
'status_code_string',
'http_method',
'http_url',
'http_host',
'db_name',
'db_operation',
'external_http_method',
'external_http_url',
'response_status_code',
],
};

View File

@@ -0,0 +1,208 @@
import React, { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui';
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { PANEL_TYPES, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { ArrowDownToDot, ArrowUpFromDot } from 'lucide-react';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
export interface SpanAttributeAction {
label: string;
value: string;
icon?: React.ReactNode;
disabled?: boolean;
hidden?: boolean;
callback: (args: { key: string; value: string; dataType?: string }) => void;
/** Returns true if this action should be hidden for the given field key */
shouldHide: (key: string) => boolean;
}
// Keys that should NOT support filter/group-by actions.
// These are system/internal/computed fields, not actual queryable attributes.
export const NON_FILTERABLE_KEYS = new Set([
'datetime',
'duration',
'parent_span_id',
'has_children',
'has_sibling',
'sub_tree_node_count',
'flags',
'trace_state',
'timestamp',
]);
const shouldHideForKey = (key: string): boolean => NON_FILTERABLE_KEYS.has(key);
// Action identifiers
export const SPAN_ACTION = {
FILTER_IN: 'filter-in',
FILTER_OUT: 'filter-out',
GROUP_BY: 'group-by',
} as const;
export function useSpanAttributeActions(): SpanAttributeAction[] {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const queryClient = useQueryClient();
const getAutocompleteKey = useCallback(
async (fieldKey: string): Promise<BaseAutocompleteData> => {
const response = await queryClient.fetchQuery(
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
async () =>
getAggregateKeys({
searchText: fieldKey,
aggregateOperator:
currentQuery.builder.queryData[0].aggregateOperator || '',
dataSource: DataSource.TRACES,
aggregateAttribute:
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
}),
);
return chooseAutocompleteFromCustomValue(
response.payload?.attributeKeys || [],
fieldKey,
DataTypes.String,
);
},
[queryClient, currentQuery.builder.queryData],
);
const handleFilter = useCallback(
async (
{ key, value }: { key: string; value: string },
operator: string,
): Promise<void> => {
try {
const autocompleteKey = await getAutocompleteKey(key);
const resolvedOperator = getOperatorValue(operator);
const nextQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) => {
const cleanedFilters = (item.filters?.items || []).filter(
(f) => f.key?.key !== autocompleteKey.key,
);
const newFilters = [
...cleanedFilters,
{
id: uuid(),
key: autocompleteKey,
op: resolvedOperator,
value,
},
];
const converted = convertFiltersToExpressionWithExistingQuery(
{ items: newFilters, op: item.filters?.op || 'AND' },
item.filter?.expression || '',
);
return {
...item,
dataSource: DataSource.TRACES,
filters: converted.filters,
filter: converted.filter,
};
}),
},
};
redirectWithQueryBuilderData(
nextQuery,
{ panelTypes: PANEL_TYPES.LIST },
ROUTES.TRACES_EXPLORER,
);
} catch {
toast.error(SOMETHING_WENT_WRONG, { position: 'top-right' });
}
},
[currentQuery, getAutocompleteKey, redirectWithQueryBuilderData],
);
const handleFilterIn = useCallback(
(args: { key: string; value: string }): void => {
handleFilter(args, '=');
},
[handleFilter],
);
const handleFilterOut = useCallback(
(args: { key: string; value: string }): void => {
handleFilter(args, '!=');
},
[handleFilter],
);
const handleGroupBy = useCallback(
async ({ key }: { key: string }): Promise<void> => {
try {
const autocompleteKey = await getAutocompleteKey(key);
const nextQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) => ({
...item,
dataSource: DataSource.TRACES,
groupBy: [...item.groupBy, autocompleteKey],
})),
},
};
redirectWithQueryBuilderData(
nextQuery,
{ panelTypes: PANEL_TYPES.TIME_SERIES },
ROUTES.TRACES_EXPLORER,
);
} catch {
toast.error(SOMETHING_WENT_WRONG, { position: 'top-right' });
}
},
[currentQuery, getAutocompleteKey, redirectWithQueryBuilderData],
);
return [
{
label: 'Filter for value',
value: SPAN_ACTION.FILTER_IN,
icon: React.createElement(ArrowDownToDot, {
size: 14,
style: { transform: 'rotate(90deg)' },
}),
callback: handleFilterIn,
shouldHide: shouldHideForKey,
},
{
label: 'Filter out value',
value: SPAN_ACTION.FILTER_OUT,
icon: React.createElement(ArrowUpFromDot, {
size: 14,
style: { transform: 'rotate(90deg)' },
}),
callback: handleFilterOut,
shouldHide: shouldHideForKey,
},
{
label: 'Group by attribute',
value: SPAN_ACTION.GROUP_BY,
icon: React.createElement(GroupByIcon),
callback: handleGroupBy,
shouldHide: shouldHideForKey,
},
];
}

View File

@@ -0,0 +1,60 @@
.event-tooltip-content {
font-size: 12px;
color: var(--l1-foreground);
max-width: 300px;
&__header {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l2-foreground);
margin-bottom: 6px;
}
&__name {
font-weight: 600;
margin-bottom: 2px;
color: var(--text-robin-400);
&.error {
color: var(--destructive);
}
}
&__time {
font-size: 11px;
color: var(--l2-foreground);
margin-bottom: 4px;
}
&__divider {
border-top: 1px solid var(--l2-border);
margin: 6px 0;
}
&__attributes {
font-size: 11px;
}
&__kv {
margin-bottom: 2px;
line-height: 1.4;
word-break: break-all;
}
&__key {
color: var(--l2-foreground);
}
&__value {
color: var(--l1-foreground);
}
}

View File

@@ -0,0 +1,49 @@
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { Diamond } from 'lucide-react';
import { toFixed } from 'utils/toFixed';
import './EventTooltipContent.styles.scss';
export interface EventTooltipContentProps {
eventName: string;
timeOffsetMs: number;
isError: boolean;
attributeMap: Record<string, string>;
}
export function EventTooltipContent({
eventName,
timeOffsetMs,
isError,
attributeMap,
}: EventTooltipContentProps): JSX.Element {
const { time, timeUnitName } = convertTimeToRelevantUnit(timeOffsetMs);
return (
<div className="event-tooltip-content">
<div className="event-tooltip-content__header">
<Diamond size={10} />
<span>EVENT DETAILS</span>
</div>
<div className={`event-tooltip-content__name ${isError ? 'error' : ''}`}>
{eventName}
</div>
<div className="event-tooltip-content__time">
{toFixed(time, 2)} {timeUnitName} from start
</div>
{Object.keys(attributeMap).length > 0 && (
<>
<div className="event-tooltip-content__divider" />
<div className="event-tooltip-content__attributes">
{Object.entries(attributeMap).map(([key, value]) => (
<div key={key} className="event-tooltip-content__kv">
<span className="event-tooltip-content__key">{key}:</span>{' '}
<span className="event-tooltip-content__value">{value}</span>
</div>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,53 @@
.span-hover-card-wrapper {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
}
.span-hover-card-popover {
// Event-dot tooltip is rendered while the SpanDetailsPanel may be docked as
// a FloatingPanel (z-index 999); bump above the default tooltip z-index 50.
--tooltip-z-index: 1000;
.ant-popover-inner {
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
}
.ant-popover-inner-content {
padding: 0;
}
}
// Flamegraph tooltip — rendered as a portal, uses same semantic tokens.
// Position is set inline on the element (left/top track the cursor); the
// static layout/decoration lives here.
.flamegraph-tooltip {
position: fixed;
z-index: 1000;
pointer-events: none;
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
}
.span-hover-card-content {
font-size: 12px;
color: var(--l1-foreground);
&__name {
font-weight: 600;
margin-bottom: 4px;
}
&__row {
line-height: 1.5;
color: var(--l2-foreground);
}
}

View File

@@ -0,0 +1,140 @@
import { memo, ReactNode, useCallback, useRef, useState } from 'react';
import { Popover } from 'antd';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import './SpanHoverCard.styles.scss';
interface ITraceMetadata {
startTime: number;
endTime: number;
}
export interface SpanTooltipContentProps {
spanName: string;
color: string;
hasError: boolean;
relativeStartMs: number;
durationMs: number;
}
export function SpanTooltipContent({
spanName,
color,
hasError,
relativeStartMs,
durationMs,
}: SpanTooltipContentProps): JSX.Element {
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(durationMs);
return (
<div className="span-hover-card-content">
<div className="span-hover-card-content__name" style={{ color }}>
{spanName}
</div>
<div className="span-hover-card-content__row">
Status: {hasError ? 'error' : 'ok'}
</div>
<div className="span-hover-card-content__row">
Start: {toFixed(relativeStartMs, 2)} ms
</div>
<div className="span-hover-card-content__row">
Duration: {toFixed(formattedDuration, 2)} {timeUnitName}
</div>
</div>
);
}
interface SpanHoverCardProps {
span: SpanV3;
traceMetadata: ITraceMetadata;
children: ReactNode;
}
/**
* Lazy hover card — only mounts the expensive antd Popover when the user
* actually hovers over the element (after a short delay). During fast scrolling,
* rows mount and unmount without ever creating a Popover instance, avoiding
* expensive DOM/effect overhead from antd Tooltip/Trigger internals.
*/
const SpanHoverCard = memo(function SpanHoverCard({
span,
traceMetadata,
children,
}: SpanHoverCardProps): JSX.Element {
const [showPopover, setShowPopover] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleMouseEnter = useCallback((): void => {
timerRef.current = setTimeout(() => {
setShowPopover(true);
}, 200);
}, []);
const handleMouseLeave = useCallback((): void => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setShowPopover(false);
}, []);
if (!showPopover) {
return (
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
<span
className="span-hover-card-wrapper"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
</span>
);
}
const durationMs = span.duration_nano / 1e6;
const relativeStartMs = span.timestamp - traceMetadata.startTime;
let color = generateColor(
span['service.name'],
themeColors.traceDetailColorsV3,
);
if (span.has_error) {
color = 'var(--bg-cherry-500)';
}
return (
<Popover
open
content={
<SpanTooltipContent
spanName={span.name}
color={color}
hasError={span.has_error}
relativeStartMs={relativeStartMs}
durationMs={durationMs}
/>
}
trigger="hover"
rootClassName="span-hover-card-popover"
autoAdjustOverflow
arrow={false}
onOpenChange={(open): void => {
if (!open) {
setShowPopover(false);
}
}}
>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<span className="span-hover-card-wrapper" onMouseLeave={handleMouseLeave}>
{children}
</span>
</Popover>
);
});
export default SpanHoverCard;

View File

@@ -0,0 +1,28 @@
.trace-details-header {
display: flex;
align-items: center;
padding: 8px 16px;
gap: 8px;
&__back-btn {
flex-shrink: 0;
}
.key-value-label {
flex-shrink: 0;
}
&__filter {
&.trace-v3-filter-row {
padding: 0;
}
max-width: 850px;
flex: 1;
min-width: 0;
}
&__old-view-btn {
margin-left: auto;
flex-shrink: 0;
}
}

View File

@@ -0,0 +1,93 @@
import { useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@signozhq/ui';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { ArrowLeft } from 'lucide-react';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import './TraceDetailsHeader.styles.scss';
interface FilterMetadata {
startTime: number;
endTime: number;
traceId: string;
}
interface TraceDetailsHeaderProps {
filterMetadata: FilterMetadata;
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
noData?: boolean;
}
function TraceDetailsHeader({
filterMetadata,
onFilteredSpansChange,
noData,
}: TraceDetailsHeaderProps): JSX.Element {
const { id: traceID } = useParams<TraceDetailV2URLProps>();
const handleSwitchToOldView = useCallback((): void => {
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
history.replace(oldUrl);
}, [traceID]);
const handlePreviousBtnClick = useCallback((): void => {
const isSpaNavigate =
document.referrer &&
// oxlint-disable-next-line signoz/no-raw-absolute-path
new URL(document.referrer).origin === window.location.origin;
const hasBackHistory = window.history.length > 1;
if (isSpaNavigate && hasBackHistory) {
history.goBack();
} else {
history.push(ROUTES.TRACES_EXPLORER);
}
}, []);
return (
<div className="trace-details-header">
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__back-btn"
onClick={handlePreviousBtnClick}
>
<ArrowLeft size={14} />
</Button>
<KeyValueLabel
badgeKey="Trace ID"
badgeValue={traceID || ''}
maxCharacters={100}
/>
{!noData && (
<>
<div className="trace-details-header__filter">
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
onFilteredSpansChange={onFilteredSpansChange}
/>
</div>
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__old-view-btn"
onClick={handleSwitchToOldView}
>
Old View
</Button>
</>
)}
</div>
);
}
export default TraceDetailsHeader;

View File

@@ -0,0 +1,107 @@
.trace-details-v3 {
height: calc(100vh);
display: flex;
flex-direction: column;
&__content {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
&__flame-collapse,
&__waterfall-collapse {
border: none;
border-radius: 0;
background: transparent;
.ant-collapse-item {
border: none;
}
.ant-collapse-header {
border: 1px solid var(--l2-border);
}
.ant-collapse-content {
background: transparent;
border-top: none;
// Disable collapse animation — virtualizer and canvas flicker during height transitions
transition: none !important;
}
}
&__collapse-label {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
&__collapse-count {
display: flex;
gap: 6px;
align-items: center;
font-size: 12px;
font-weight: 400;
color: var(--l2-foreground);
}
&__flame-collapse {
flex-shrink: 0;
.ant-collapse-content-box {
padding: 0 !important;
}
}
&__waterfall-collapse {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.ant-collapse-item {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.ant-collapse-content.ant-collapse-content-active {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.ant-collapse-content-box {
padding: 0 !important;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
&--docked {
flex: none;
.ant-collapse-item {
flex: none;
display: block;
}
.ant-collapse-content-box {
flex: none;
display: block;
}
}
}
&__docked-span-details {
flex: 1;
overflow: auto;
min-height: 0;
}
}

View File

@@ -0,0 +1,26 @@
.flamegraph-canvas {
display: flex;
flex-direction: column;
height: 100%;
padding: 0 15px;
}
.flamegraph-canvas__viewport {
flex: 1;
overflow: hidden;
position: relative;
}
.flamegraph-canvas__main {
display: block;
width: 100%;
cursor: grab;
}
.flamegraph-canvas__overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
pointer-events: none;
}

View File

@@ -0,0 +1,259 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useCrosshair } from '../hooks/useCrosshair';
import { EventTooltipContent } from '../SpanHoverCard/EventTooltipContent';
import { SpanTooltipContent } from '../SpanHoverCard/SpanHoverCard';
import { DEFAULT_ROW_HEIGHT } from './constants';
import { useCanvasSetup } from './hooks/useCanvasSetup';
import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
import { useFlamegraphHover } from './hooks/useFlamegraphHover';
import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
import { useScrollToSpan } from './hooks/useScrollToSpan';
import { EventRect, FlamegraphCanvasProps, SpanRect } from './types';
import './FlamegraphCanvas.styles.scss';
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
const {
layout,
traceMetadata,
firstSpanAtFetchLevel,
onSpanClick,
filteredSpanIds,
isFilterActive,
} = props;
const isDarkMode = useIsDarkMode();
const canvasRef = useRef<HTMLCanvasElement>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const spanRectsRef = useRef<SpanRect[]>([]);
const eventRectsRef = useRef<EventRect[]>([]);
const [viewStartTs, setViewStartTs] = useState<number>(
traceMetadata.startTime,
);
const [viewEndTs, setViewEndTs] = useState<number>(traceMetadata.endTime);
const [scrollTop, setScrollTop] = useState<number>(0);
const [rowHeight, setRowHeight] = useState<number>(DEFAULT_ROW_HEIGHT);
// Mutable refs for zoom and drag hooks to read during rAF / mouse callbacks
const viewStartRef = useRef(viewStartTs);
const viewEndRef = useRef(viewEndTs);
const rowHeightRef = useRef(rowHeight);
const scrollTopRef = useRef(scrollTop);
useEffect(() => {
viewStartRef.current = viewStartTs;
}, [viewStartTs]);
useEffect(() => {
viewEndRef.current = viewEndTs;
}, [viewEndTs]);
useEffect(() => {
rowHeightRef.current = rowHeight;
}, [rowHeight]);
useEffect(() => {
scrollTopRef.current = scrollTop;
}, [scrollTop]);
useEffect(() => {
//TODO: see if this can be removed as once loaded the view start and end ts will not change
setViewStartTs(traceMetadata.startTime);
setViewEndTs(traceMetadata.endTime);
viewStartRef.current = traceMetadata.startTime;
viewEndRef.current = traceMetadata.endTime;
}, [traceMetadata.startTime, traceMetadata.endTime]);
const totalHeight = layout.totalVisualRows * rowHeight;
const { isOverFlamegraphRef } = useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
});
const {
handleMouseDown,
handleMouseMove: handleDragMouseMove,
handleMouseUp,
handleDragMouseLeave,
isDraggingRef,
} = useFlamegraphDrag({
canvasRef,
containerRef,
traceMetadata,
viewStartRef,
viewEndRef,
setViewStartTs,
setViewEndTs,
scrollTopRef,
setScrollTop,
totalHeight,
});
const {
hoveredSpanId,
hoveredEventKey,
handleHoverMouseMove,
handleHoverMouseLeave,
handleMouseDownForClick,
handleClick,
tooltipContent,
} = useFlamegraphHover({
canvasRef,
spanRectsRef,
eventRectsRef,
traceMetadata,
viewStartTs,
viewEndTs,
isDraggingRef,
onSpanClick,
isDarkMode,
});
const { drawFlamegraph } = useFlamegraphDraw({
canvasRef,
containerRef,
spans: layout.visualRows,
connectors: layout.connectors,
viewStartTs,
viewEndTs,
scrollTop,
rowHeight,
selectedSpanId: firstSpanAtFetchLevel || undefined,
hoveredSpanId: hoveredSpanId ?? '',
isDarkMode,
spanRectsRef,
eventRectsRef,
hoveredEventKey,
filteredSpanIds,
isFilterActive,
});
useScrollToSpan({
firstSpanAtFetchLevel,
spans: layout.visualRows,
traceMetadata,
containerRef,
viewStartRef,
viewEndRef,
scrollTopRef,
rowHeight,
setViewStartTs,
setViewEndTs,
setScrollTop,
});
useCanvasSetup(canvasRef, containerRef, drawFlamegraph, overlayCanvasRef);
const {
cursorXPercent,
cursorX,
onMouseMove: onCrosshairMove,
onMouseLeave: onCrosshairLeave,
} = useCrosshair({ containerRef });
useFlamegraphCrosshair({ overlayCanvasRef, cursorX });
const handleMouseMove = useCallback(
(e: React.MouseEvent): void => {
handleDragMouseMove(e);
handleHoverMouseMove(e);
if (!isDraggingRef.current) {
onCrosshairMove(e);
}
},
[handleDragMouseMove, handleHoverMouseMove, onCrosshairMove, isDraggingRef],
);
const handleMouseLeave = useCallback((): void => {
isOverFlamegraphRef.current = false;
onCrosshairLeave();
handleDragMouseLeave();
handleHoverMouseLeave();
}, [
isOverFlamegraphRef,
onCrosshairLeave,
handleDragMouseLeave,
handleHoverMouseLeave,
]);
const tooltipElement = tooltipContent
? createPortal(
<div
className="span-hover-card-popover flamegraph-tooltip"
style={{
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
}}
>
{tooltipContent.event ? (
<EventTooltipContent
eventName={tooltipContent.event.name}
timeOffsetMs={tooltipContent.event.timeOffsetMs}
isError={tooltipContent.event.isError}
attributeMap={tooltipContent.event.attributeMap}
/>
) : (
<SpanTooltipContent
spanName={tooltipContent.spanName}
color={tooltipContent.spanColor}
hasError={tooltipContent.status === 'error'}
relativeStartMs={tooltipContent.startMs}
durationMs={tooltipContent.durationMs}
/>
)}
</div>,
document.body,
)
: null;
return (
<div className="flamegraph-canvas">
{tooltipElement}
<TimelineV3
startTimestamp={viewStartTs}
endTimestamp={viewEndTs}
offsetTimestamp={viewStartTs - traceMetadata.startTime}
timelineHeight={10}
cursorXPercent={cursorXPercent}
/>
<div
ref={containerRef}
className="flamegraph-canvas__viewport"
onMouseEnter={(): void => {
isOverFlamegraphRef.current = true;
}}
onMouseLeave={handleMouseLeave}
>
<canvas
ref={canvasRef}
className="flamegraph-canvas__main"
onMouseDown={(e): void => {
handleMouseDown(e);
handleMouseDownForClick(e);
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onClick={handleClick}
/>
<canvas ref={overlayCanvasRef} className="flamegraph-canvas__overlay" />
</div>
</div>
);
}
export default FlamegraphCanvas;

View File

@@ -0,0 +1,140 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { Skeleton } from 'antd';
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
import useUrlQuery from 'hooks/useUrlQuery';
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
import Error from '../TraceWaterfall/TraceWaterfallStates/Error/Error';
import { FLAMEGRAPH_SPAN_LIMIT } from './constants';
import FlamegraphCanvas from './FlamegraphCanvas';
import { useVisualLayoutWorker } from './hooks/useVisualLayoutWorker';
interface TraceFlamegraphProps {
filteredSpanIds: string[];
isFilterActive: boolean;
}
function TraceFlamegraph({
filteredSpanIds,
isFilterActive,
}: TraceFlamegraphProps): JSX.Element {
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
const urlQuery = useUrlQuery();
const history = useHistory();
const { search } = useLocation();
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
urlQuery.get('spanId') || '',
);
useEffect(() => {
setFirstSpanAtFetchLevel(urlQuery.get('spanId') || '');
}, [urlQuery]);
const handleSpanClick = useCallback(
(spanId: string): void => {
setFirstSpanAtFetchLevel(spanId);
const searchParams = new URLSearchParams(search);
//tood: use from query params constants
if (searchParams.get('spanId') !== spanId) {
searchParams.set('spanId', spanId);
history.replace({ search: searchParams.toString() });
}
},
[history, search],
);
const {
data,
isFetching,
error: fetchError,
} = useGetTraceFlamegraph({
traceId,
// selectedSpanId: firstSpanAtFetchLevel,
limit: FLAMEGRAPH_SPAN_LIMIT,
});
const spans = useMemo(
() => data?.payload?.spans || [],
[data?.payload?.spans],
);
const {
layout,
isComputing,
error: workerError,
} = useVisualLayoutWorker(spans);
const content = useMemo(() => {
// Loading: fetching data or worker computing layout
if (isFetching || isComputing) {
if (layout.totalVisualRows > 0) {
return (
<FlamegraphCanvas
layout={layout}
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
onSpanClick={handleSpanClick}
traceMetadata={{
startTime: data?.payload?.startTimestampMillis || 0,
endTime: data?.payload?.endTimestampMillis || 0,
}}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
);
}
return (
<div style={{ width: '100%', height: '100%', padding: '8px 12px' }}>
<Skeleton
active
paragraph={{
rows: 8,
width: ['100%', '95%', '85%', '70%', '50%', '35%', '20%', '10%'],
}}
title={false}
/>
</div>
);
}
// Error: network or worker failure
if (fetchError || workerError) {
return <Error error={(fetchError || workerError) as any} />;
}
if (data?.payload?.spans && data.payload.spans.length === 0) {
return <div>No data found for trace {traceId}</div>;
}
return (
<FlamegraphCanvas
layout={layout}
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
onSpanClick={handleSpanClick}
traceMetadata={{
startTime: data?.payload?.startTimestampMillis || 0,
endTime: data?.payload?.endTimestampMillis || 0,
}}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
);
}, [
data?.payload?.endTimestampMillis,
data?.payload?.startTimestampMillis,
data?.payload?.spans,
fetchError,
filteredSpanIds,
firstSpanAtFetchLevel,
handleSpanClick,
isComputing,
isFilterActive,
isFetching,
layout,
traceId,
workerError,
]);
return <>{content}</>;
}
export default TraceFlamegraph;

View File

@@ -0,0 +1,475 @@
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { computeVisualLayout } from '../computeVisualLayout';
function makeSpan(
overrides: Partial<FlamegraphSpan> & {
spanId: string;
timestamp: number;
durationNano: number;
},
): FlamegraphSpan {
return {
parentSpanId: '',
traceId: 'trace-1',
hasError: false,
serviceName: 'svc',
name: 'op',
level: 0,
event: [],
...overrides,
};
}
describe('computeVisualLayout', () => {
it('should handle empty input', () => {
const layout = computeVisualLayout([]);
expect(layout.totalVisualRows).toBe(0);
expect(layout.visualRows).toEqual([]);
});
it('should handle single root, no children — 1 visual row', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 100e6,
});
const layout = computeVisualLayout([[root]]);
expect(layout.totalVisualRows).toBe(1);
expect(layout.visualRows[0]).toEqual([root]);
expect(layout.spanToVisualRow['root']).toBe(0);
});
it('should keep non-overlapping siblings on the same row (compact)', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 500e6,
});
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 0,
durationNano: 100e6,
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 200,
durationNano: 100e6,
});
const c = makeSpan({
spanId: 'c',
parentSpanId: 'root',
timestamp: 400,
durationNano: 100e6,
});
const layout = computeVisualLayout([[root], [a, b, c]]);
// root on row 0, all children on row 1
expect(layout.totalVisualRows).toBe(2);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['a']).toBe(1);
expect(layout.spanToVisualRow['b']).toBe(1);
expect(layout.spanToVisualRow['c']).toBe(1);
});
it('should pack non-overlapping siblings into shared lanes (greedy packing)', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 300e6,
});
// A and B overlap; C does not overlap with either
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 0,
durationNano: 100e6, // ends at 100ms
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 50,
durationNano: 100e6, // starts at 50ms < 100ms end of A → overlap → lane 1
});
const c = makeSpan({
spanId: 'c',
parentSpanId: 'root',
timestamp: 200,
durationNano: 100e6, // 200 >= 100, fits lane 0 with A
});
const layout = computeVisualLayout([[root], [a, b, c]]);
// root on row 0, C placed first (latest) → row 1, B doesn't overlap C → row 1, A overlaps B → row 2
expect(layout.totalVisualRows).toBe(3);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['c']).toBe(1);
expect(layout.spanToVisualRow['b']).toBe(1);
expect(layout.spanToVisualRow['a']).toBe(2);
});
it('should handle full overlap — all siblings get own row', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 200e6,
});
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 0,
durationNano: 200e6,
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 0,
durationNano: 200e6,
});
const layout = computeVisualLayout([[root], [a, b]]);
expect(layout.totalVisualRows).toBe(3);
expect(layout.spanToVisualRow['a']).toBe(1);
expect(layout.spanToVisualRow['b']).toBe(2);
});
it('should stack children correctly below overlapping parents', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 300e6,
});
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 0,
durationNano: 200e6,
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 50,
durationNano: 200e6,
});
// Child of A
const childA = makeSpan({
spanId: 'childA',
parentSpanId: 'a',
timestamp: 10,
durationNano: 50e6,
});
// Child of B
const childB = makeSpan({
spanId: 'childB',
parentSpanId: 'b',
timestamp: 60,
durationNano: 50e6,
});
const layout = computeVisualLayout([[root], [a, b], [childA, childB]]);
// DFS processes b's subtree first (latest):
// root → row 0
// b → row 1 (parentRow 0 + 1)
// childB → row 2 (parentRow 1 + 1)
// a → try row 1 (parentRow 0 + 1), overlaps b → try row 2, overlaps childB → row 3
// childA → row 4 (parentRow 3 + 1)
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['b']).toBe(1);
expect(layout.spanToVisualRow['childB']).toBe(2);
expect(layout.spanToVisualRow['a']).toBe(3);
expect(layout.spanToVisualRow['childA']).toBe(4);
expect(layout.totalVisualRows).toBe(5);
});
it('should handle multiple roots as a sibling group', () => {
// Two overlapping roots
const r1 = makeSpan({
spanId: 'r1',
timestamp: 0,
durationNano: 100e6,
});
const r2 = makeSpan({
spanId: 'r2',
timestamp: 50,
durationNano: 100e6,
});
const layout = computeVisualLayout([[r1, r2]]);
expect(layout.spanToVisualRow['r1']).toBe(0);
expect(layout.spanToVisualRow['r2']).toBe(1);
expect(layout.totalVisualRows).toBe(2);
});
it('should produce compact layout for deep nesting without overlap', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 1000e6,
});
const child = makeSpan({
spanId: 'child',
parentSpanId: 'root',
timestamp: 10,
durationNano: 500e6,
});
const grandchild = makeSpan({
spanId: 'grandchild',
parentSpanId: 'child',
timestamp: 20,
durationNano: 200e6,
});
const layout = computeVisualLayout([[root], [child], [grandchild]]);
// No overlap at any level → visual rows == tree depth
expect(layout.totalVisualRows).toBe(3);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['child']).toBe(1);
expect(layout.spanToVisualRow['grandchild']).toBe(2);
});
it('should pack many sequential siblings into 1 row (no diagonal staircase)', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 500e6,
});
// 6 sequential children — like checkoutservice/PlaceOrder scenario
const spans = [
makeSpan({
spanId: 's1',
parentSpanId: 'root',
timestamp: 3,
durationNano: 30e6,
}),
makeSpan({
spanId: 's2',
parentSpanId: 'root',
timestamp: 35,
durationNano: 4e6,
}),
makeSpan({
spanId: 's3',
parentSpanId: 'root',
timestamp: 39,
durationNano: 1e6,
}),
makeSpan({
spanId: 's4',
parentSpanId: 'root',
timestamp: 40,
durationNano: 4e6,
}),
makeSpan({
spanId: 's5',
parentSpanId: 'root',
timestamp: 44,
durationNano: 5e6,
}),
makeSpan({
spanId: 's6',
parentSpanId: 'root',
timestamp: 49,
durationNano: 1e6,
}),
];
const layout = computeVisualLayout([[root], spans]);
// All 6 sequential siblings should share 1 row
expect(layout.totalVisualRows).toBe(2);
expect(layout.spanToVisualRow['root']).toBe(0);
for (const span of spans) {
expect(layout.spanToVisualRow[span.spanId]).toBe(1);
}
});
it('should keep children below parents even with misparented spans', () => {
// Simulates the dd_sig2 bug: /route spans have parentSpanId pointing
// to the wrong ancestor, but they are at level 2 in the spans[][] input.
// Level-based packing must place them below level 1 regardless.
const httpGet = makeSpan({
spanId: 'http-get',
timestamp: 0,
durationNano: 500e6,
});
const route = makeSpan({
spanId: 'route',
parentSpanId: 'some-wrong-ancestor', // misparented!
timestamp: 10,
durationNano: 200e6,
});
const layout = computeVisualLayout([[httpGet], [route]]);
// httpGet at level 0 → row 0, route at level 1 → row 1
expect(layout.spanToVisualRow['http-get']).toBe(0);
expect(layout.spanToVisualRow['route']).toBe(1);
expect(layout.totalVisualRows).toBe(2);
});
it('should keep parent-child pairs adjacent when sibling subtrees overlap', () => {
// Multiple overlapping parents each with a child — the subtree-unit
// guarantee means every parent→child gap should be exactly 1.
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 500e6,
});
// Three overlapping HTTP GET children of root, each with its own /route child
const get1 = makeSpan({
spanId: 'get1',
parentSpanId: 'root',
timestamp: 0,
durationNano: 200e6,
});
const route1 = makeSpan({
spanId: 'route1',
parentSpanId: 'get1',
timestamp: 10,
durationNano: 180e6,
});
const get2 = makeSpan({
spanId: 'get2',
parentSpanId: 'root',
timestamp: 50,
durationNano: 200e6,
});
const route2 = makeSpan({
spanId: 'route2',
parentSpanId: 'get2',
timestamp: 60,
durationNano: 180e6,
});
const get3 = makeSpan({
spanId: 'get3',
parentSpanId: 'root',
timestamp: 100,
durationNano: 200e6,
});
const route3 = makeSpan({
spanId: 'route3',
parentSpanId: 'get3',
timestamp: 110,
durationNano: 180e6,
});
const layout = computeVisualLayout([
[root],
[get1, get2, get3],
[route1, route2, route3],
]);
// Each parent-child pair should have a gap of exactly 1
const get1Row = layout.spanToVisualRow['get1'];
const route1Row = layout.spanToVisualRow['route1'];
const get2Row = layout.spanToVisualRow['get2'];
const route2Row = layout.spanToVisualRow['route2'];
const get3Row = layout.spanToVisualRow['get3'];
const route3Row = layout.spanToVisualRow['route3'];
expect(route1Row - get1Row).toBe(1);
expect(route2Row - get2Row).toBe(1);
expect(route3Row - get3Row).toBe(1);
});
it('should handle mixed levels — overlap at level 2 but not level 1', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 1000e6,
});
// Non-overlapping children
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 0,
durationNano: 400e6,
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 500,
durationNano: 400e6,
});
// Overlapping grandchildren under A
const ga1 = makeSpan({
spanId: 'ga1',
parentSpanId: 'a',
timestamp: 0,
durationNano: 200e6,
});
const ga2 = makeSpan({
spanId: 'ga2',
parentSpanId: 'a',
timestamp: 100,
durationNano: 200e6,
});
const layout = computeVisualLayout([[root], [a, b], [ga1, ga2]]);
// root → row 0
// a, b → row 1 (no overlap, share row)
// ga1 → row 2, ga2 → row 3 (overlap, expanded)
// b has no children, so nothing after ga2
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['a']).toBe(1);
expect(layout.spanToVisualRow['b']).toBe(1);
expect(layout.spanToVisualRow['ga2']).toBe(2);
expect(layout.spanToVisualRow['ga1']).toBe(3);
expect(layout.totalVisualRows).toBe(4);
});
it('should not place a span where it covers an existing connector point (Check 2)', () => {
// Scenario: root has 3 leaf children. Sorted latest-first: C(200), B(100), A(80).
//
// C placed at row 1 [200, 400].
// B overlaps C → placed at row 2 [100, 300]. Connector from row 0→2 at x=100
// passes through row 1, recording connector point at (row 1, x=100).
// A [80, 110] does NOT overlap C's span [200, 400] at row 1 (110 < 200),
// so without connector reservation A would fit at row 1.
// But A's span [80, 110) contains the connector point x=100 at row 1.
// Check 2 prevents this placement, pushing A further down.
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 500e6,
});
const c = makeSpan({
spanId: 'c',
parentSpanId: 'root',
timestamp: 200,
durationNano: 200e6, // [200, 400]
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 100,
durationNano: 200e6, // [100, 300]
});
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 80,
durationNano: 30e6, // [80, 110]
});
const layout = computeVisualLayout([[root], [a, b, c]]);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['c']).toBe(1); // latest, placed first
expect(layout.spanToVisualRow['b']).toBe(2); // overlaps C → row 2
// A would fit at row 1 by span overlap alone, but connector point at
// (row 1, x=100) falls within A's span [80, 110). Check 2 pushes A down.
const aRow = layout.spanToVisualRow['a']!;
expect(aRow).toBeGreaterThan(1); // must NOT be at row 1
expect(aRow).toBe(3); // next free row after B at row 2 (A overlaps B)
});
});

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