Compare commits

...

318 Commits

Author SHA1 Message Date
Aditya Singh
6d244d9d9f Merge branch 'main' into feat/add-clear-filter 2026-05-25 20:03:11 +05:30
aks07
78fe454860 feat: update tests 2026-05-25 20:02:02 +05:30
Naman Verma
bb471848cc chore: feature flag for dashboard v2 (#11339)
* chore: feature flag for dashboard v2

* fix: fix alignment

* chore: add flag to v1 api as well
2026-05-25 14:08:04 +00:00
SagarRajput-7
bd55e70882 feat(boot-settings): runtime enable/disable control for PostHog and Appcues (#11416)
* feat(boot-settings): move SDK config from build-time env vars to runtime boot data injection

* feat(boot-settings): scope runtime injection to posthog/appcues enabled flags only

* feat(boot-settings): refactor code

* feat(boot-settings): refactor code

* feat(boot-settings): use generated WebSettings types for BE↔FE contract
2026-05-25 13:49:55 +00:00
aks07
8243109934 feat: update tests 2026-05-25 19:12:02 +05:30
Jatinderjit Singh
6cf22e98dd feat(planned-downtime): scope maintenance windows to label expressions (#11186)
* add maintenanceMuteStage to move planned maintenance to alertmanager

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

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

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

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

* refactor: move maintenance mute stage into custom pipelineBuilder

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

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

* chore: add license header to pipeline_builder.go

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

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

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

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

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

* refactor: pass MaintenanceMuter directly to pipelineBuilder

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

* refactor: remove dead orgID param from task constructors

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

* rename buildReceiverStage -> createReceiverStage

* refactor: replace maintenanceMuteStage with notify.NewMuteStage

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

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

* refactor: hoist MuteStage construction out of the receiver loop

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

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

* refactor: always initialize maintenanceStore; remove nil guards

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

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

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

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

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

* remove redundant MemMarker wrapper

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

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

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

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

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

* code cleanup

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

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

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

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

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

* test: add unit tests for MaintenanceMuter

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

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

* Update schema changes

* Re-add marker

* fix NewMaintenanceStore in tests

* Go lint fixes

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

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

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

* chore: regenerate mocks via make gen-mocks

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

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

* cleanup test

* test: add e2e muting tests for maintenance window behaviour

* Add label expression support to planned downtime

Alert instances can now be scoped by label expression
(e.g. env == "prod"), scoping suppression below the rule level.
A window with no rule IDs and a label expression silences any
alert whose labels match, regardless of which rule fired it;
when rule IDs are also present, the expression is evaluated only
within the matched rules.

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

* Remove redundant || undefined from labelExpression assignment

* Move label expression evaluation into ShouldSkip

ShouldSkip now owns all three suppression checks in sequence:
rule ID match → schedule active → label expression.
IsActive passes nil labels so the expression check is skipped
(no instance labels available for UI status).

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

* remove redundant LabelSet->map conversion

* implement Down migration to drop label_expression column

* fix lset type and update openapi spec

* fix(tests): resolve envprovider env isolation, factory name length, and ShouldSkip signature

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

* remove unused function `evaluateLabelExpression`

* chore: rename label expression to scope

* test(maintenance): add tests for scope label expression filtering

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

* test(e2e): verify scope-based maintenance muting in alertmanager flow

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

* Use `AND` instead of `&&`

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>

* fix(maintenance): consolidate label-set-to-env conversion to avoid expr panic

Move ConvertLabelSetToEnv to alertmanagertypes so both the maintenance
scope evaluator and the route-policy evaluator share one implementation.
Dotted label keys (e.g. kubernetes.node) are expanded into nested maps,
preventing the expr-lang panic that occurs when one key is a prefix of
another.

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

* chore(planned-downtime): use clickable learn more link in scope tooltip

* chore(sqlmigration): renumber add_scope_to_planned_maintenance to 086

078 collided with add_sa_managed_role_txn; bumped to the next free number
and reordered registration after add_source_to_dashboard (085).

* refactor(maintenance): extract scope expression eval and surface errors

- Move ConvertLabelSetToEnv and EvalScopeExpression into expression.go
  with companion tests in expression_test.go.
- EvalScopeExpression now returns (bool, error) instead of swallowing
  compile/run failures and non-bool outputs; ShouldSkip logs the error
  via slog.Default() and falls back to not suppressing (safety-first).
- Update test fixtures to the SQL-style operator form (`=`, AND, OR)
  matching the placeholder and reviewer suggestions.

* chore: use `=` instead of `==` in expressions

* fix(maintenance): satisfy forbidigo/sloglint in scope eval

- Replace fmt.Errorf with pkg/errors Wrapf/Newf using a new
  ErrCodeInvalidScopeExpression code.
- Use slog ErrorContext (with context.Background()) instead of Error to
  satisfy sloglint.

* perf(maintenance): fold prefix-conflict detection into ConvertLabelSetToEnv

ConvertLabelSetToEnv now returns (env, conflict). The rulebased provider
drops its O(n^2) pre-scan and logs based on the flag, restoring the
previous O(n*d) cost while keeping the shared helper.

* chore: add docs URL for invalid scope

* refactor: don't log in types package

* remove down migration

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-05-25 13:40:06 +00:00
Ashwin Bhatkal
39957d322f fix(planned-downtime): remove unused timezone dep from useMemo hooks (#11448)
The startTimeText and endTimeText useMemo hooks did not reference
timezone in their callback bodies, so including it in the dependency
arrays caused unnecessary recomputations whenever the timezone form
field changed.
2026-05-25 13:24:03 +00:00
aks07
fb6d24be88 feat: minor fix 2026-05-25 18:46:16 +05:30
aks07
37987ac63b Merge branch 'main' of github.com:SigNoz/signoz into feat/add-clear-filter 2026-05-25 18:38:08 +05:30
aks07
e071f5ef28 feat: restructure trace details header 2026-05-25 18:37:01 +05:30
Vikrant Gupta
d1f143f675 feat(web): add support for generating web settings types (#11445)
* feat(web): add support for generating settings type

* feat(web): add support for generating settings type

* feat(web): add support for generating settings type

* refactor: rename generate settings to generate config web-settings

- Rename cmd/settings.go to cmd/genconfig.go
- Restructure command as `generate config web-settings`
- Move schema output to docs/config/web-settings.json
- Update frontend script to generate:config:web-settings
- Update CI checks to match new command names
- Strip Web prefix from generated JSON Schema definitions
2026-05-25 12:41:24 +00:00
Abhi kumar
1355b13504 chore: added changes to migrate slider component from antd to signozhq/ui (#11411)
* chore: added changes to migrate slider component from antd to signozhq/ui

* chore: package update

* chore(jest): allow copy-text-to-clipboard through transformIgnorePatterns
2026-05-25 12:02:48 +00:00
Vishal Sharma
22d6d5248f feat(ai-assistant): collapse thinking + tool-call steps into one row (#11361)
* feat(ai-assistant): collapse thinking + tool-call steps into one row

Long sequences of thinking and tool-call rows in the chat were noisy and
pushed the actual answer below the fold. ActivityGroup folds any run of
consecutive thinking + tool events behind a single "Worked through N steps"
summary that expands on click. While streaming, the trailing group reads
"Working… · Xs · N steps" with a live elapsed-time tick that re-stamps on
approval/clarification resume.

ThinkingStep now reads "Thinking…" while live and "Thought for a few
seconds" once done. Liveness is derived purely from render position
(trailing item in a trailing live group); persisted history blocks default
to not-live so they render the same wording without depending on
server-stored timing.

* refactor(ai-assistant): tighten ActivityGroup after review

- Bare-render lone activity items: a single thinking or tool step no
  longer renders as "Worked through 1 step" — the underlying chevron is
  enough disclosure.
- Memoize the group partition in MessageBubble and StreamingMessage so
  store updates that don't touch the message's blocks/events don't churn
  the underlying step children.
- Bump the elapsed-timer tick from 500ms to 1000ms (display is
  integer-second precision) and suppress the elapsed token until ≥ 1s.
- Add aria-expanded + aria-controls on the disclosure button and rename
  the SCSS keyframe to activityGroupPulse to avoid a global collision.
- Document the same-instance invariant ActivityGroup's timer relies on
  in groupStreamingEvents.

* refactor(ai-assistant): apply PR review feedback on ActivityGroup

- Reuse formatTime() from utils/timeUtils for the elapsed-time label
  instead of a local formatter.
- Tighten the isLive JSDoc on ActivityGroup and ThinkingStep so the doc
  only captures the non-obvious "why" (timer re-stamp on resume; vague
  copy because the API doesn't persist precise timing).

* refactor(ai-assistant): unify activity rows under ActivityGroup

Drop the bare-render shortcut for single-item activity groups and route
every "what the agent did" row through ActivityGroup. The summary now
adapts to the item count and kind — single-item groups read
"Thinking… / Thought for a few seconds" or the tool's display text
instead of the awkward "Worked through 1 step", and single-item
expansion renders the underlying content body directly (no second
chevron disclosure).

Extracts ThinkingContent / ToolCallContent body sub-components and a
small thinkingLabel / getToolDisplayLabel helper so ActivityGroup can
reuse them without duplicating markup.

* refactor(ai-assistant): apply ActivityGroup review feedback

- Rename common CSS module class names to scoped variants
  (.group → .activityGroup, .header → .activityHeader, etc.) so
  matches against module classes carry intent and don't collide
  with future styles in adjacent files.
- Swap the disclosure <button> for a <div> with onClick — drops the
  signoz Button component option since its action-button defaults
  (focus ring, base padding, hover background) visually regressed
  the quiet full-width row. Matches the existing ThinkingStep /
  ToolCallStep disclosure pattern.
- Drop the useId + aria-controls plumbing; with the disclosure
  collapsed inline beneath the header, the id wasn't carrying
  semantic weight beyond what aria-expanded already provides.
- Thread stable id fields through ActivityItem and the RenderGroup
  types so list keys come from typed data rather than the loop
  index. Persisted tool blocks key off the server-assigned
  toolCallId; streaming items and thinking blocks key off their
  position in the append-only source array.
2026-05-25 11:35:45 +00:00
Abhi kumar
fdbdbf27a8 chore: added changes to migerate alert chart component to new charts (#11308)
* chore: added changes to migerate alert chart component to new charts

* chore: minor changes

* chore: minor changes

* chore: pr review changes

* chore: minor refactor
2026-05-25 11:18:53 +00:00
Nikhil Soni
f47f1ad92b Remove unused field from waterfall response (part 1 of memory opt) (#11337)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: remove unused field from waterfall v3

* chore: update openapi specs

* chore: remove debug statements
2026-05-25 09:36:05 +00:00
aks07
879f8a2e16 Merge branch 'feat/add-clear-filter' of github.com:SigNoz/signoz into feat/add-clear-filter 2026-05-19 13:13:31 +05:30
aks07
1802480f1f feat: realign trace details filters 2026-05-19 13:13:05 +05:30
Aditya Singh
80a78a5426 Merge branch 'main' into feat/add-clear-filter 2026-05-18 19:42:55 +05:30
aks07
e7911999d7 feat: add clear filter 2026-05-18 02:23:12 +05:30
aks07
9efe2aacab feat: minor change 2026-05-18 01:51:32 +05:30
aks07
f85da6d8d4 Merge branch 'main' of github.com:SigNoz/signoz into feat/enable-trace-details-v3 2026-05-18 01:42:19 +05:30
aks07
0e9d7bf537 feat: remove unnecessary waterfall api calls if span already in the list 2026-05-18 01:41:30 +05:30
aks07
9c50930f00 Merge branch 'feat/enable-trace-details-v3' of github.com:SigNoz/signoz into feat/enable-trace-details-v3 2026-05-15 01:09:53 +05:30
aks07
3eab3e1556 Merge branch 'main' of github.com:SigNoz/signoz into feat/enable-trace-details-v3 2026-05-15 01:09:05 +05:30
Aditya Singh
b1a81c09ce Merge branch 'main' into feat/enable-trace-details-v3 2026-05-14 17:07:53 +05:30
Aditya Singh
227b098067 Merge branch 'main' into feat/enable-trace-details-v3 2026-05-14 16:53:23 +05:30
aks07
3882c06054 feat: enable new trace details 2026-05-13 20:21:50 +05:30
aks07
c8548ea27d feat: fix test 2026-05-13 16:34:40 +05:30
aks07
ede67c877a Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-details-css-modules 2026-05-13 16:04:49 +05:30
aks07
074d3b8c85 feat: fix minor style 2026-05-13 16:04:18 +05:30
aks07
07b0f8e6cb feat: migrate to css modules 2026-05-13 15:47:03 +05:30
aks07
24660642cb Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-details-pending-2 2026-05-13 12:38:22 +05:30
aks07
f379a01095 feat: hide v3 2026-05-13 09:48:06 +05:30
aks07
d5a07d10bb Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-details-pending-2 2026-05-13 09:45:49 +05:30
aks07
537d183d34 feat: update tests 2026-05-13 09:45:35 +05:30
aks07
10ad886981 feat: use pinned attributes accross views 2026-05-12 23:17:23 +05:30
aks07
6cf8ccbfb8 Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-details-pending-2 2026-05-12 22:31:19 +05:30
aks07
0162d3c7e2 Merge branch 'feat/trace-details-pending' of github.com:SigNoz/signoz into feat/trace-details-pending-2 2026-05-12 20:41:11 +05:30
aks07
7124896d37 feat: minor fix 2026-05-12 20:33:49 +05:30
aks07
9edd1e88bd Merge branch 'feat/trace-details-pending' of github.com:SigNoz/signoz into feat/trace-details-pending-2 2026-05-12 19:16:23 +05:30
aks07
c2ba08fd31 Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-12 19:15:57 +05:30
aks07
2d26411705 feat: add links to non filterable keys 2026-05-12 19:12:42 +05:30
aks07
181c81ec71 feat: support filter on parent keys 2026-05-12 18:49:27 +05:30
aks07
192cbb15f8 feat: add icons 2026-05-12 17:43:28 +05:30
aks07
c0b6ef28cd feat: update border color 2026-05-12 16:33:41 +05:30
aks07
ad6813fbe7 feat: update test cases 2026-05-12 16:30:09 +05:30
aks07
8be39cb4c5 feat: move to zustand 2026-05-12 15:49:17 +05:30
aks07
c1905361d2 Merge branch 'feat/trace-details-pending' of github.com:SigNoz/signoz into feat/trace-details-pending-2 2026-05-12 15:08:33 +05:30
aks07
d27d8443fc feat: minor change 2026-05-12 15:07:15 +05:30
aks07
90d09a7a37 feat: minor change 2026-05-12 15:03:14 +05:30
aks07
6a784088f2 Merge branch 'feat/trace-details-pending' of github.com:SigNoz/signoz into feat/trace-details-pending-2 2026-05-12 14:59:30 +05:30
aks07
08b08b85ca Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-12 14:57:44 +05:30
aks07
d1298b7b91 feat: update icons 2026-05-12 14:51:46 +05:30
aks07
2ae7ff394c feat: dont use antd button and tooltip 2026-05-12 14:40:27 +05:30
aks07
9b79d86436 feat: dont use antd button and tooltip 2026-05-12 14:36:01 +05:30
aks07
8d4df49bb4 feat: minor fix 2026-05-12 13:59:20 +05:30
aks07
4ba3c32ca4 feat: minor fix 2026-05-12 13:40:19 +05:30
aks07
593997caa2 feat: additional check 2026-05-12 13:27:24 +05:30
aks07
0ce20e963c Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-12 13:16:54 +05:30
aks07
5c58e8c2a4 feat: move analytics panel 2026-05-12 13:16:12 +05:30
aks07
7336557e79 feat: auto expand on highlight errors 2026-05-12 10:41:07 +05:30
aks07
ec392e6e4a feat: only scroll when span is not in view 2026-05-12 01:01:54 +05:30
aks07
b5e3ac7179 feat: send selected span when flamegraph is sampled 2026-05-11 20:58:37 +05:30
aks07
77a4eaeebb Merge branch 'feat/trace-details-pending' of github.com:SigNoz/signoz into feat/trace-details-pending-2 2026-05-11 18:45:05 +05:30
aks07
8f3ed3b725 feat: allow removing all fields in preview 2026-05-11 17:57:16 +05:30
aks07
43ccc88440 feat: replace antd btns 2026-05-11 17:53:10 +05:30
aks07
f9b23dbe29 Merge branch 'feat/trace-details-pending' of github.com:SigNoz/signoz into feat/trace-details-pending-2 2026-05-11 17:38:18 +05:30
aks07
1e07156cd0 Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-11 17:37:56 +05:30
aks07
74e5e4d2ac feat: revert route change 2026-05-11 17:37:00 +05:30
aks07
0a35a09f1e feat: revert route change 2026-05-11 17:34:42 +05:30
aks07
b88e9e52be feat: update test cases 2026-05-11 17:23:46 +05:30
aks07
cf58d49de4 feat: show api error correctly 2026-05-11 16:58:15 +05:30
aks07
8e95128414 feat: add llm as fast filter 2026-05-11 15:19:44 +05:30
aks07
aaff6d8bdd feat: add more fields in color by 2026-05-11 15:05:29 +05:30
aks07
4b22ac05b2 feat: persist user choosing old view 2026-05-11 15:05:08 +05:30
aks07
8e0ecb2666 feat: add status message to highlight section 2026-05-11 13:43:39 +05:30
aks07
83ed560236 feat: make trace id copyable 2026-05-11 13:37:14 +05:30
aks07
e46510fa02 Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-11 12:25:55 +05:30
aks07
07f0bf3e8b feat: minor fixes 2026-05-11 12:25:31 +05:30
aks07
3b6fd6a5e8 Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-11 12:12:15 +05:30
aks07
c312a54e63 feat: fix logs in span details styles 2026-05-11 10:48:55 +05:30
aks07
35ecfc5e37 feat: change how we show span hover data in waterfall 2026-05-11 10:48:07 +05:30
aks07
7b9caf14b8 feat: fix waterfall child sibling indent 2026-05-10 21:33:40 +05:30
aks07
e3db42b7ce feat: show error count 2026-05-10 20:27:58 +05:30
aks07
401f253090 feat: fix failing test 2026-05-10 19:46:17 +05:30
aks07
2d930c0e4b fix: pagination calls in waterfall 2026-05-10 19:43:35 +05:30
aks07
ce8d1837ef feat: correct status code 2026-05-10 18:57:26 +05:30
aks07
c38bfa1027 feat: trace details always visible 2026-05-08 18:28:38 +05:30
aks07
473b91f41f Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-07 16:46:51 +05:30
aks07
4f32c23f63 Merge branch 'feat/dropdown-items' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-07 16:46:39 +05:30
aks07
8708fa0627 feat: fix hidden filters 2026-05-07 16:22:10 +05:30
Aditya Singh
cabd7b6641 Merge branch 'main' into feat/dropdown-items 2026-05-07 16:08:38 +05:30
aks07
bc91476bce feat: fixed the waterfall span hover card 2026-05-07 00:43:40 +05:30
aks07
b5789e1e36 feat: remove feature flag 2026-05-07 00:31:00 +05:30
aks07
52f2b40e18 feat: update signoz ui 2026-05-06 23:49:06 +05:30
aks07
9fe4ca02da feat: fix mouse wheel in json view 2026-05-06 23:42:56 +05:30
aks07
41fd155fd3 feat: leaf node indent fix 2026-05-06 23:33:05 +05:30
aks07
88575f3ea1 Merge branch 'feat/dropdown-items' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-06 21:01:47 +05:30
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
aks07
13c087d34d feat: remove common component 2026-05-06 19:54:09 +05:30
aks07
f5e772f8a0 Merge branch 'feat/dropdown-items' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-06 19:19:52 +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
9d83c9b43d Merge branch 'feat/trace-details-pending' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-06 17:31:53 +05:30
aks07
0144bb78df Merge branch 'feat/dropdown-items' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-06 17:30:51 +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
aks07
6e35cee1e7 feat: preview fields 2026-05-06 12:22:31 +05:30
aks07
1298074cb2 feat: migrate v2 pinned attr 2026-05-05 22:59:41 +05:30
aks07
16c8db5fd9 feat: save color by pref in user pref 2026-05-05 21:20:54 +05:30
aks07
6855be7859 feat: color by field 2026-05-05 18:24:11 +05:30
aks07
2c398396dd feat: analytics spans 2026-05-04 19:29:52 +05:30
Aditya Singh
a58539e25c Merge branch 'main' into feat/trace-details-pending 2026-05-04 18:04:23 +05:30
aks07
eeb7fa3aa5 feat: lint fix 2026-05-04 18:03:49 +05:30
aks07
d11234531d Merge branch 'feat/dropdown-items' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-04 17:56:01 +05:30
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
0bd591458d Merge branch 'feat/dropdown-items' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-04 16:59:26 +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
33520c41c8 fix: scroll to span in frontend mode 2026-05-04 11:32:13 +05:30
aks07
b994d6dd8e feat: fix color 2026-04-25 16:22:48 +05:30
aks07
5e231e799e feat: filter toggle added 2026-04-25 11:26:59 +05:30
aks07
5f4a79c201 feat: filters init 2026-04-23 18:58:51 +05:30
aks07
8edf375019 feat: floating fields set 2026-04-22 21:11:33 +05:30
aks07
0d1fd6d0bd feat: minor changes 2026-04-22 18:47:47 +05:30
aks07
fefd0effef feat: add trace details header styles 2026-04-22 17:59:48 +05:30
aks07
36a137be4d feat: add trace details header styles 2026-04-22 15:49:17 +05:30
aks07
68dc7e426a feat: add trace details header 2026-04-22 15:32:01 +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
74 changed files with 2480 additions and 859 deletions

View File

@@ -123,3 +123,20 @@ jobs:
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)
web-settings:
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-web-settings
run: |
go run cmd/enterprise/*.go generate config web-settings
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in web settings schema. Run go run cmd/enterprise/*.go generate config web-settings locally and commit."; exit 1)

View File

@@ -90,3 +90,26 @@ jobs:
run: |
cd frontend && pnpm generate:api
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run pnpm generate:api in frontend/ locally and commit."; exit 1)
web-settings:
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: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
version: 10
- name: install-frontend
run: cd frontend && pnpm install
- name: generate-web-settings
run: |
cd frontend && pnpm generate:config:web-settings
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated web settings types. Run pnpm generate:config:web-settings in frontend/ locally and commit."; exit 1)

61
cmd/genconfig.go Normal file
View File

@@ -0,0 +1,61 @@
package cmd
import (
"encoding/json"
"os"
"reflect"
"strings"
"github.com/SigNoz/signoz/pkg/web"
"github.com/spf13/cobra"
"github.com/swaggest/jsonschema-go"
)
const webSettingsSchemaPath = "docs/config/web-settings.json"
func registerGenerateConfig(parentCmd *cobra.Command) {
configCmd := &cobra.Command{
Use: "config",
Short: "Generate JSON Schema for config",
}
configCmd.AddCommand(&cobra.Command{
Use: "web-settings",
Short: "Generate JSON Schema for web settings",
RunE: func(currCmd *cobra.Command, args []string) error {
return generateWebSettings()
},
})
parentCmd.AddCommand(configCmd)
}
func generateWebSettings() error {
falseVal := false
noAdditional := jsonschema.SchemaOrBool{TypeBoolean: &falseVal}
reflector := jsonschema.Reflector{}
reflector.DefaultOptions = append(reflector.DefaultOptions,
jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (bool, error) {
if params.Value.Kind() == reflect.Struct {
params.Schema.AdditionalProperties = &noAdditional
}
return false, nil
}),
jsonschema.InterceptDefName(func(t reflect.Type, defaultDefName string) string {
return strings.TrimPrefix(defaultDefName, "Web")
}),
)
schema, err := reflector.Reflect(web.Settings{})
if err != nil {
return err
}
data, err := json.MarshalIndent(schema, "", " ")
if err != nil {
return err
}
return os.WriteFile(webSettingsSchemaPath, append(data, '\n'), 0o600)
}

View File

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

View File

@@ -129,6 +129,8 @@ components:
type: string
schedule:
$ref: '#/components/schemas/AlertmanagertypesSchedule'
scope:
type: string
status:
$ref: '#/components/schemas/AlertmanagertypesMaintenanceStatus'
updatedAt:
@@ -272,6 +274,8 @@ components:
type: string
schedule:
$ref: '#/components/schemas/AlertmanagertypesSchedule'
scope:
type: string
required:
- name
- schedule
@@ -5686,12 +5690,6 @@ components:
type: string
rootServiceName:
type: string
serviceNameToTotalDurationMap:
additionalProperties:
minimum: 0
type: integer
nullable: true
type: object
spans:
items:
$ref: '#/components/schemas/SpantypesWaterfallSpan'

View File

@@ -0,0 +1,42 @@
{
"required": [
"posthog",
"appcues"
],
"additionalProperties": false,
"definitions": {
"Appcues": {
"required": [
"enabled"
],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
},
"Posthog": {
"required": [
"enabled"
],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
}
},
"properties": {
"appcues": {
"$ref": "#/definitions/Appcues"
},
"posthog": {
"$ref": "#/definitions/Posthog"
}
},
"type": "object"
}

View File

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

View File

@@ -94,6 +94,19 @@
}
})();
</script>
<script type="application/json" id="signoz-boot-settings">
[[.Settings]]
</script>
<script>
try {
var _el = document.getElementById('signoz-boot-settings');
window.signozBootData = {
settings: _el ? JSON.parse(_el.textContent) : null,
};
} catch (e) {
window.signozBootData = { settings: null };
}
</script>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
@@ -135,7 +148,10 @@
</script>
<script>
var APPCUES_APP_ID = '<%- APPCUES_APP_ID %>';
if (APPCUES_APP_ID) {
var appcuesSettings =
((window.signozBootData || {}).settings || {}).appcues || {};
var appcuesEnabled = appcuesSettings.enabled !== false;
if (APPCUES_APP_ID && appcuesEnabled) {
(function (d, t) {
var a = d.createElement(t);
a.async = 1;

View File

@@ -47,10 +47,10 @@ const config: Config.InitialOptions = {
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid)/)',
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -24,7 +24,8 @@
"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:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
"generate:config:web-settings": "json2ts ../docs/config/web-settings.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM docs/config/web-settings.json */'"
},
"engines": {
"node": ">=22.0.0",
@@ -49,7 +50,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.4.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.21",
"@signozhq/ui": "0.0.22",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
@@ -160,8 +161,8 @@
"@testing-library/user-event": "14.4.3",
"@types/color": "^3.0.3",
"@types/crypto-js": "4.2.2",
"@types/event-source-polyfill": "^1.0.0",
"@types/d3-hierarchy": "1.1.11",
"@types/event-source-polyfill": "^1.0.0",
"@types/history": "4.7.11",
"@types/jest": "30.0.0",
"@types/lodash-es": "^4.17.4",
@@ -187,6 +188,7 @@
"is-ci": "^3.0.1",
"jest-environment-jsdom": "29.7.0",
"jest-styled-components": "^7.2.0",
"json-schema-to-typescript": "^15.0.4",
"lint-staged": "^17.0.4",
"msw": "1.3.2",
"orval": "8.9.1",
@@ -241,4 +243,4 @@
"tmp": "0.2.4",
"vite": "npm:rolldown-vite@7.3.1"
}
}
}

View File

@@ -77,8 +77,8 @@ importers:
specifier: 0.0.2
version: 0.0.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@signozhq/ui':
specifier: 0.0.21
version: 0.0.21(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
specifier: 0.0.22
version: 0.0.22(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -449,6 +449,9 @@ importers:
jest-styled-components:
specifier: ^7.2.0
version: 7.2.0(styled-components@5.3.11(react-dom@18.2.0(react@18.2.0))(react-is@19.2.6)(react@18.2.0))
json-schema-to-typescript:
specifier: ^15.0.4
version: 15.0.4
lint-staged:
specifier: ^17.0.4
version: 17.0.4
@@ -457,7 +460,7 @@ importers:
version: 1.3.2(typescript@5.9.3)
orval:
specifier: 8.9.1
version: 8.9.1(typescript@5.9.3)
version: 8.9.1(prettier@3.8.3)(typescript@5.9.3)
oxfmt:
specifier: 0.47.0
version: 0.47.0
@@ -545,6 +548,10 @@ packages:
peerDependencies:
react: '>=16.9.0'
'@apidevtools/json-schema-ref-parser@11.9.3':
resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==}
engines: {node: '>= 16'}
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -1991,6 +1998,9 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
'@keyv/bigmap@1.3.1':
resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==}
engines: {node: '>= 18'}
@@ -3269,8 +3279,8 @@ packages:
peerDependencies:
react: ^18.2.0
'@signozhq/ui@0.0.21':
resolution: {integrity: sha512-uLM3Vqwxlk2USXbwtb3qRLpjZR9b9QSHFQq/jtcfYNMDmIE/sNjSj0nRkEhX4RqqRgsLRt2PVA33aeWxDOLO3g==}
'@signozhq/ui@0.0.22':
resolution: {integrity: sha512-CJDyA4H+uXG/U2/d7/nRMNY6WIW0YWc843mfzUQALjm+xOhbO4T+qt67THjV4s1wTMs1cZLkmScbMddf+hXLIQ==}
peerDependencies:
'@signozhq/icons': 0.3.0
react: ^18.2.0
@@ -6066,6 +6076,11 @@ packages:
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
json-schema-to-typescript@15.0.4:
resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==}
engines: {node: '>=16.0.0'}
hasBin: true
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -7104,6 +7119,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier@3.8.3:
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
engines: {node: '>=14'}
hasBin: true
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -9044,6 +9064,12 @@ snapshots:
resize-observer-polyfill: 1.5.1
throttle-debounce: 5.0.0
'@apidevtools/json-schema-ref-parser@11.9.3':
dependencies:
'@jsdevtools/ono': 7.1.3
'@types/json-schema': 7.0.15
js-yaml: 4.1.1
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -10798,6 +10824,8 @@ snapshots:
'@jridgewell/sourcemap-codec': 1.5.5
optional: true
'@jsdevtools/ono@7.1.3': {}
'@keyv/bigmap@1.3.1(keyv@5.6.0)':
dependencies:
hashery: 1.5.1
@@ -12013,7 +12041,7 @@ snapshots:
- react-dom
- tailwindcss
'@signozhq/ui@0.0.21(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
'@signozhq/ui@0.0.22(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
dependencies:
'@chenglou/pretext': 0.0.5
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -15374,6 +15402,18 @@ snapshots:
json-parse-even-better-errors@2.3.1: {}
json-schema-to-typescript@15.0.4:
dependencies:
'@apidevtools/json-schema-ref-parser': 11.9.3
'@types/json-schema': 7.0.15
'@types/lodash': 4.17.24
is-glob: 4.0.3
js-yaml: 4.1.1
lodash: 4.18.1
minimist: 1.2.8
prettier: 3.8.3
tinyglobby: 0.2.15
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
@@ -16290,7 +16330,7 @@ snapshots:
strip-ansi: 6.0.1
wcwidth: 1.0.1
orval@8.9.1(typescript@5.9.3):
orval@8.9.1(prettier@3.8.3)(typescript@5.9.3):
dependencies:
'@commander-js/extra-typings': 14.0.0(commander@14.0.2)
'@orval/angular': 8.9.1(typescript@5.9.3)
@@ -16321,6 +16361,8 @@ snapshots:
typedoc: 0.28.19(typescript@5.9.3)
typedoc-plugin-coverage: 4.0.2(typedoc@0.28.19(typescript@5.9.3))
typedoc-plugin-markdown: 4.11.0(typedoc@0.28.19(typescript@5.9.3))
optionalDependencies:
prettier: 3.8.3
transitivePeerDependencies:
- '@faker-js/faker'
- supports-color
@@ -16581,6 +16623,8 @@ snapshots:
prelude-ls@1.2.1: {}
prettier@3.8.3: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1

View File

@@ -35,6 +35,7 @@ import { PreferenceContextProvider } from 'providers/preferences/context/Prefere
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
import { extractDomain } from 'utils/app';
import { bootSettings } from 'utils/bootData';
import { Home } from './pageComponents';
import PrivateRoute from './Private';
@@ -332,7 +333,7 @@ function App(): JSX.Element {
useEffect(() => {
if (isCloudUser || isEnterpriseSelfHostedUser) {
if (process.env.POSTHOG_KEY) {
if (bootSettings.posthog.enabled && process.env.POSTHOG_KEY) {
posthog.init(process.env.POSTHOG_KEY, {
api_host: 'https://us.i.posthog.com',
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well

View File

@@ -225,6 +225,10 @@ export interface AlertmanagertypesPlannedMaintenanceDTO {
*/
name: string;
schedule: AlertmanagertypesScheduleDTO;
/**
* @type string
*/
scope?: string;
status: AlertmanagertypesMaintenanceStatusDTO;
/**
* @type string
@@ -1714,6 +1718,10 @@ export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
*/
name: string;
schedule: AlertmanagertypesScheduleDTO;
/**
* @type string
*/
scope?: string;
}
export interface AlertmanagertypesPostableRoutePolicyDTO {
@@ -6743,15 +6751,6 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
items: SpantypesSpanMapperGroupDTO[];
}
export type SpantypesGettableWaterfallTraceDTOServiceNameToTotalDurationMapAnyOf =
{ [key: string]: number };
/**
* @nullable
*/
export type SpantypesGettableWaterfallTraceDTOServiceNameToTotalDurationMap =
SpantypesGettableWaterfallTraceDTOServiceNameToTotalDurationMapAnyOf | null;
export enum SpantypesSpanAggregationTypeDTO {
span_count = 'span_count',
execution_time_percentage = 'execution_time_percentage',
@@ -6940,10 +6939,6 @@ export interface SpantypesGettableWaterfallTraceDTO {
* @type string
*/
rootServiceName?: string;
/**
* @type object,null
*/
serviceNameToTotalDurationMap?: SpantypesGettableWaterfallTraceDTOServiceNameToTotalDurationMap;
/**
* @type array,null
*/

View File

@@ -51,6 +51,10 @@
}
}
}
.duration-input-slider {
padding: 12px 0px;
margin-top: 12px;
}
}
.divider {

View File

@@ -11,4 +11,5 @@ export enum FeatureKeys {
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
USE_DASHBOARD_V2 = 'use_dashboard_v2',
}

View File

@@ -0,0 +1,64 @@
// Collapsed activity summary — one row that hides the underlying
// thinking + tool-call steps. Reuses the same quiet treatment as
// ThinkingStep / ToolCallStep so it sits flush in the assistant bubble.
.activityGroup {
width: 100%;
font-size: 12px;
}
.activityHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
background: transparent;
border: none;
cursor: pointer;
color: var(--l3-foreground);
user-select: none;
transition: color 0.12s ease;
width: 100%;
text-align: left;
&:hover {
color: var(--l1-foreground);
}
}
.sparkleIcon {
flex-shrink: 0;
color: var(--accent-primary);
&.iconPulsing {
animation: activityGroupPulse 1.4s ease-in-out infinite;
}
}
@keyframes activityGroupPulse {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
.activitySummary {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 400;
}
.toggleChevron {
flex-shrink: 0;
color: inherit;
}
.activityBody {
display: flex;
flex-direction: column;
padding: 2px 0 4px;
}

View File

@@ -0,0 +1,148 @@
import { useEffect, useRef, useState } from 'react';
import cx from 'classnames';
import { ChevronDown, ChevronRight, Sparkles } from '@signozhq/icons';
import { formatTime } from 'utils/timeUtils';
import { StreamingToolCall } from '../../types';
import ThinkingStep, { ThinkingContent, thinkingLabel } from '../ThinkingStep';
import ToolCallStep, {
getToolDisplayLabel,
ToolCallContent,
} from '../ToolCallStep';
import styles from './ActivityGroup.module.scss';
export type ActivityItem =
| { id: string; kind: 'thinking'; content: string }
| { id: string; kind: 'tool'; toolCall: StreamingToolCall };
interface ActivityGroupProps {
items: ActivityItem[];
/**
* True only for the trailing activity group of an active stream — drives
* the live "Working…" label and the elapsed-time tick (which re-stamps on
* approval/clarification resume so wait time isn't counted).
*/
isLive?: boolean;
}
/**
* Single-item groups get a step-specific summary so the user doesn't see a
* pointless "Worked through 1 step". Multi-item groups roll up into the
* generic "Working… / Worked through N steps" treatment.
*/
function buildSummary(
items: ActivityItem[],
isLive: boolean,
elapsed: number,
): string {
if (items.length === 1) {
const [only] = items;
if (only.kind === 'thinking') {
return thinkingLabel(isLive);
}
return getToolDisplayLabel(only.toolCall);
}
const stepLabel = `${items.length} steps`;
if (!isLive) {
return `Worked through ${stepLabel}`;
}
// Suppress the elapsed token until ≥ 1s — the first tick fires after
// 1s anyway, and showing "0s" or "<1s" briefly adds noise.
return elapsed >= 1000
? `Working… · ${formatTime(elapsed / 1000)} · ${stepLabel}`
: `Working… · ${stepLabel}`;
}
/**
* Single collapsed summary row that hides a run of thinking + tool-call steps.
* Expands to show each underlying step inline. Used for every activity row
* (including single-item ones) so all "what the agent did" rows share a
* consistent ✨-led visual contract.
*/
export default function ActivityGroup({
items,
isLive = false,
}: ActivityGroupProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
// Captures the moment this live phase started. Re-stamped on every
// false→true transition so a stream that pauses on
// approval/clarification and resumes doesn't roll the user's wait time
// into the elapsed counter.
const startedAtRef = useRef<number>(Date.now());
const wasLiveRef = useRef<boolean>(isLive);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (isLive && !wasLiveRef.current) {
startedAtRef.current = Date.now();
setElapsed(0);
}
wasLiveRef.current = isLive;
if (!isLive) {
return undefined;
}
// Tick once per second — the display is integer-second precision, so
// faster ticks would just re-render the bubble for no visible change.
const id = window.setInterval(() => {
setElapsed(Date.now() - startedAtRef.current);
}, 1000);
return (): void => window.clearInterval(id);
}, [isLive]);
const summary = buildSummary(items, isLive, elapsed);
const isSingle = items.length === 1;
const toggle = (): void => setExpanded((v) => !v);
return (
<div className={styles.activityGroup}>
<div className={styles.activityHeader} onClick={toggle}>
<Sparkles
size={12}
className={cx(styles.sparkleIcon, { [styles.iconPulsing]: isLive })}
/>
<span className={styles.activitySummary}>{summary}</span>
{expanded ? (
<ChevronDown size={12} className={styles.toggleChevron} />
) : (
<ChevronRight size={12} className={styles.toggleChevron} />
)}
</div>
{expanded && (
<div className={styles.activityBody}>
{isSingle ? (
// Single-item: the outer chevron already provides disclosure,
// so render the underlying content directly instead of wrapping
// it in a second collapsible step row.
items[0].kind === 'thinking' ? (
<ThinkingContent content={items[0].content} />
) : (
<ToolCallContent toolCall={items[0].toolCall} />
)
) : (
items.map((item, i) => {
// A thinking step is live only while it's the trailing item
// in a trailing live group — once any later event (text or
// tool) arrives, the pass is done.
const isLastItem = i === items.length - 1;
return item.kind === 'thinking' ? (
<ThinkingStep
key={item.id}
content={item.content}
isLive={isLive && isLastItem}
/>
) : (
<ToolCallStep key={item.id} toolCall={item.toolCall} />
);
})
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { default } from './ActivityGroup';
export type { ActivityItem } from './ActivityGroup';

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import cx from 'classnames';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -9,11 +9,10 @@ import '../blocks';
import { useVariant } from '../../VariantContext';
import { Message, MessageBlock } from '../../types';
import ActionsSection from '../ActionsSection';
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
import { RichCodeBlock } from '../blocks';
import { MessageContext } from '../MessageContext';
import MessageFeedback from '../MessageFeedback';
import ThinkingStep from '../ThinkingStep';
import ToolCallStep from '../ToolCallStep';
import UserMessageActions from '../UserMessageActions';
import styles from './MessageBubble.module.scss';
@@ -40,38 +39,61 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
/** Renders a single MessageBlock by type. */
function renderBlock(block: MessageBlock, index: number): JSX.Element {
switch (block.type) {
case 'thinking':
return <ThinkingStep key={index} content={block.content} />;
case 'tool_call':
// Blocks in a persisted message are always complete — done is always true.
return (
<ToolCallStep
key={index}
toolCall={{
toolName: block.toolName,
input: block.toolInput,
result: block.result,
done: true,
displayText: block.displayText,
}}
/>
);
case 'text':
default:
return (
<ReactMarkdown
key={index}
className={styles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{block.content}
</ReactMarkdown>
);
type RenderGroup =
| { kind: 'text'; id: string; content: string }
| { kind: 'activity'; id: string; items: ActivityItem[] };
/**
* Partition message blocks into render groups so consecutive thinking and
* tool_call blocks collapse into a single ActivityGroup row. Text blocks
* stand alone, mirroring the streaming view.
*/
function groupBlocks(blocks: MessageBlock[]): RenderGroup[] {
const groups: RenderGroup[] = [];
blocks.forEach((block, i) => {
if (block.type === 'text') {
groups.push({ kind: 'text', id: `text-${i}`, content: block.content });
return;
}
const item: ActivityItem =
block.type === 'thinking'
? { id: `t-${i}`, kind: 'thinking', content: block.content }
: {
id: `c-${block.toolCallId}`,
kind: 'tool',
// Persisted blocks are always complete.
toolCall: {
toolName: block.toolName,
input: block.toolInput,
result: block.result,
done: true,
displayText: block.displayText,
},
};
const last = groups[groups.length - 1];
if (last?.kind === 'activity') {
last.items.push(item);
} else {
groups.push({ kind: 'activity', id: `a-${i}`, items: [item] });
}
});
return groups;
}
function renderGroup(group: RenderGroup): JSX.Element {
if (group.kind === 'text') {
return (
<ReactMarkdown
key={group.id}
className={styles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{group.content}
</ReactMarkdown>
);
}
return <ActivityGroup key={group.id} items={group.items} />;
}
interface MessageBubbleProps {
@@ -90,6 +112,14 @@ export default function MessageBubble({
const isUser = message.role === 'user';
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
// Recompute groups only when the blocks array identity changes — store
// updates that don't touch this message's blocks should not re-render the
// underlying ThinkingStep/ToolCallStep children.
const groups = useMemo(
() => (hasBlocks ? groupBlocks(message.blocks!) : []),
[hasBlocks, message.blocks],
);
const messageClass = cx(
styles.message,
isUser ? styles.user : styles.assistant,
@@ -128,8 +158,7 @@ export default function MessageBubble({
<p className={styles.text}>{message.content}</p>
) : hasBlocks ? (
<MessageContext.Provider value={{ messageId: message.id }}>
{/* eslint-disable-next-line react/no-array-index-key */}
{message.blocks!.map((block, i) => renderBlock(block, i))}
{groups.map((g) => renderGroup(g))}
</MessageContext.Provider>
) : (
<MessageContext.Provider value={{ messageId: message.id }}>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import cx from 'classnames';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -10,11 +10,10 @@ import type {
import { useVariant } from '../../VariantContext';
import { StreamingEventItem } from '../../types';
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
import ApprovalCard from '../ApprovalCard';
import { RichCodeBlock } from '../blocks';
import ClarificationForm from '../ClarificationForm';
import ThinkingStep from '../ThinkingStep';
import ToolCallStep from '../ToolCallStep';
import messageStyles from '../MessageBubble/MessageBubble.module.scss';
import styles from './StreamingMessage.module.scss';
@@ -33,6 +32,59 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
type RenderGroup =
| { kind: 'text'; id: string; content: string }
| {
kind: 'activity';
id: string;
items: ActivityItem[];
isTrailing: boolean;
};
/**
* Partition the streaming event timeline into render groups: runs of
* consecutive thinking/tool events fold into a single activity group, text
* events stay standalone. The last group is flagged as trailing so the
* caller can drive a "live" indicator on it.
*
* Invariant relied on by the ActivityGroup elapsed-time timer: once a
* group exists at a given array index, later events only extend its
* `items` — they never shrink the array or re-key existing groups. That
* keeps each ActivityGroup React instance stable across re-renders so the
* timer's `wasLive` → `isLive` re-stamp captures the right transition.
* The id fields below piggyback on that invariant: each event's position in
* `events` is stable, so the derived id stays stable across re-renders.
*/
function groupStreamingEvents(events: StreamingEventItem[]): RenderGroup[] {
const groups: RenderGroup[] = [];
events.forEach((event, i) => {
if (event.kind === 'text') {
groups.push({ kind: 'text', id: `text-${i}`, content: event.content });
return;
}
const item: ActivityItem =
event.kind === 'thinking'
? { id: `t-${i}`, kind: 'thinking', content: event.content }
: { id: `c-${i}`, kind: 'tool', toolCall: event.toolCall };
const last = groups[groups.length - 1];
if (last?.kind === 'activity') {
last.items.push(item);
} else {
groups.push({
kind: 'activity',
id: `a-${i}`,
items: [item],
isTrailing: false,
});
}
});
const last = groups[groups.length - 1];
if (last?.kind === 'activity') {
last.isTrailing = true;
}
return groups;
}
/** Human-readable labels for execution status codes shown before any events arrive. */
const STATUS_LABEL: Record<string, string> = {
queued: 'Queued…',
@@ -79,6 +131,11 @@ export default function StreamingMessage({
[messageStyles.compact]: isCompact,
});
// Recompute groups only when the events array identity changes. The
// streaming reducer pushes new entries into the same array reference
// once per tick, so this naturally invalidates as events arrive.
const groups = useMemo(() => groupStreamingEvents(events), [events]);
return (
<div className={messageClass}>
<div className={messageStyles.bubble}>
@@ -88,27 +145,28 @@ export default function StreamingMessage({
)}
{isEmpty && !statusLabel && <TypingDots />}
{/* eslint-disable react/no-array-index-key */}
{/* Events rendered in arrival order: text, thinking, and tool calls interleaved */}
{events.map((event, i) => {
if (event.kind === 'tool') {
return <ToolCallStep key={i} toolCall={event.toolCall} />;
}
if (event.kind === 'thinking') {
return <ThinkingStep key={i} content={event.content} />;
{/* Runs of consecutive thinking + tool events collapse into a
single ActivityGroup; text events render inline between
them. The trailing group is "live" while streaming is
active and not blocked on the user. */}
{groups.map((group) => {
if (group.kind === 'text') {
return (
<ReactMarkdown
key={group.id}
className={messageStyles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{group.content}
</ReactMarkdown>
);
}
const groupIsLive = group.isTrailing && !isWaitingOnUser;
return (
<ReactMarkdown
key={i}
className={messageStyles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{event.content}
</ReactMarkdown>
<ActivityGroup key={group.id} items={group.items} isLive={groupIsLive} />
);
})}
{/* eslint-enable react/no-array-index-key */}
{/* While events are still streaming, append the typing dots so the
user has a clear "more is coming" signal. Hidden when the agent

View File

@@ -5,11 +5,31 @@ import styles from './ThinkingStep.module.scss';
interface ThinkingStepProps {
content: string;
/**
* When false, label reads "Thought for a few seconds" — intentionally
* vague because the API doesn't persist precise timing, so showing
* seconds would be inconsistent between fresh and reloaded threads.
*/
isLive?: boolean;
}
/** Body of a thinking step — extracted so ActivityGroup can render it directly. */
export function ThinkingContent({ content }: { content: string }): JSX.Element {
return (
<div className={styles.body}>
<p className={styles.content}>{content}</p>
</div>
);
}
export function thinkingLabel(isLive: boolean): string {
return isLive ? 'Thinking…' : 'Thought for a few seconds';
}
/** Collapsible thinking row — chevron + label, content in the expanded body. */
export default function ThinkingStep({
content,
isLive = false,
}: ThinkingStepProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
@@ -23,14 +43,10 @@ export default function ThinkingStep({
) : (
<ChevronRight size={12} className={styles.chevron} />
)}
<span className={styles.label}>Thinking</span>
<span className={styles.label}>{thinkingLabel(isLive)}</span>
</div>
{expanded && (
<div className={styles.body}>
<p className={styles.content}>{content}</p>
</div>
)}
{expanded && <ThinkingContent content={content} />}
</div>
);
}

View File

@@ -10,24 +10,58 @@ interface ToolCallStepProps {
toolCall: StreamingToolCall;
}
/**
* Server-supplied `displayText` is the human-friendly title the backend
* wants surfaced. Falls back to a derived label
* ("signoz_get_dashboard" → "Get Dashboard") when missing.
*/
export function getToolDisplayLabel(toolCall: StreamingToolCall): string {
const { toolName, displayText } = toolCall;
if (displayText && displayText.trim().length > 0) {
return displayText;
}
return toolName
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Body of a tool-call step — extracted so ActivityGroup can render it directly. */
export function ToolCallContent({
toolCall,
}: {
toolCall: StreamingToolCall;
}): JSX.Element {
const { toolName, input, result, done } = toolCall;
return (
<div className={styles.body}>
<div className={styles.section}>
<span className={styles.sectionLabel}>Tool</span>
<span className={styles.toolName}>{toolName}</span>
</div>
<div className={styles.section}>
<span className={styles.sectionLabel}>Input</span>
<pre className={styles.json}>{JSON.stringify(input, null, 2)}</pre>
</div>
{done && result !== undefined && (
<div className={styles.section}>
<span className={styles.sectionLabel}>Output</span>
<pre className={styles.json}>
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
);
}
/** Collapsible tool-call row — chevron + label, in/out detail in the body. */
export default function ToolCallStep({
toolCall,
}: ToolCallStepProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
const { toolName, input, result, done, displayText } = toolCall;
// Prefer the server-supplied `displayText` from `ToolCallEventDTO` —
// it's the human-friendly title the backend wants surfaced. Fall back
// to a derived label ("signoz_get_dashboard" → "Get Dashboard") when
// the field is empty / null / missing.
const label =
displayText && displayText.trim().length > 0
? displayText
: toolName
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
const { done } = toolCall;
const label = getToolDisplayLabel(toolCall);
const toggle = (): void => setExpanded((v) => !v);
@@ -44,26 +78,7 @@ export default function ToolCallStep({
<span className={styles.label}>{label}</span>
</div>
{expanded && (
<div className={styles.body}>
<div className={styles.section}>
<span className={styles.sectionLabel}>Tool</span>
<span className={styles.toolName}>{toolName}</span>
</div>
<div className={styles.section}>
<span className={styles.sectionLabel}>Input</span>
<pre className={styles.json}>{JSON.stringify(input, null, 2)}</pre>
</div>
{done && result !== undefined && (
<div className={styles.section}>
<span className={styles.sectionLabel}>Output</span>
<pre className={styles.json}>
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
)}
{expanded && <ToolCallContent toolCall={toolCall} />}
</div>
);
}

View File

@@ -91,7 +91,6 @@ function ChartPreview({
const renderQBChartPreview = (): JSX.Element => (
<ChartPreviewComponent
headline={headline}
name=""
query={stagedQuery}
selectedInterval={globalSelectedInterval}
alertDef={alertDef}
@@ -107,7 +106,6 @@ function ChartPreview({
const renderPromAndChQueryChartPreview = (): JSX.Element => (
<ChartPreviewComponent
headline={headline}
name="Chart Preview"
query={stagedQuery}
alertDef={alertDef}
selectedInterval={globalSelectedInterval}

View File

@@ -17,7 +17,6 @@ import { CreateAlertProvider } from '../../context';
import ChartPreview from '../ChartPreview/ChartPreview';
const REQUESTS_PER_SEC = 'requests/sec';
const CHART_PREVIEW_NAME = 'Chart Preview';
const QUERY_TYPE_TEST_ID = 'query-type';
const GRAPH_TYPE_TEST_ID = 'graph-type';
const CHART_PREVIEW_COMPONENT_TEST_ID = 'chart-preview-component';
@@ -34,7 +33,6 @@ jest.mock(
return (
<div data-testid={CHART_PREVIEW_COMPONENT_TEST_ID}>
<div data-testid="headline">{props.headline}</div>
<div data-testid="name">{props.name}</div>
<div data-testid={QUERY_TYPE_TEST_ID}>{props.query?.queryType}</div>
<div data-testid="selected-interval">
{props.selectedInterval?.startTime}
@@ -175,12 +173,6 @@ describe('ChartPreview', () => {
);
});
it('renders QueryBuilder chart preview with empty name when query type is QUERY_BUILDER', () => {
renderChartPreview();
expect(screen.getByTestId('name')).toHaveTextContent('');
});
it('renders QueryBuilder chart preview with correct props', () => {
renderChartPreview();
@@ -191,7 +183,6 @@ describe('ChartPreview', () => {
expect(screen.getByTestId(GRAPH_TYPE_TEST_ID)).toHaveTextContent(
PANEL_TYPES.TIME_SERIES,
);
expect(screen.getByTestId('name')).toHaveTextContent('');
expect(screen.getByTestId('headline')).toBeInTheDocument();
expect(screen.getByTestId('selected-interval')).toBeInTheDocument();
});
@@ -214,7 +205,6 @@ describe('ChartPreview', () => {
expect(
screen.getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID),
).toBeInTheDocument();
expect(screen.getByTestId('name')).toHaveTextContent(CHART_PREVIEW_NAME);
expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent(
EQueryType.PROM,
);
@@ -238,7 +228,6 @@ describe('ChartPreview', () => {
expect(
screen.getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID),
).toBeInTheDocument();
expect(screen.getByTestId('name')).toHaveTextContent(CHART_PREVIEW_NAME);
expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent(
EQueryType.CLICKHOUSE,
);

View File

@@ -17,10 +17,11 @@ import { getTimeRange } from 'utils/getTimeRange';
import BarChart from '../../charts/BarChart/BarChart';
import ChartManager from '../../components/ChartManager/ChartManager';
import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
import { prepareBarPanelConfig } from './utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -99,7 +100,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
if (!queryResponse?.data?.payload) {
return [];
}
return prepareBarPanelData(queryResponse?.data?.payload);
return prepareChartData(queryResponse?.data?.payload);
}, [queryResponse?.data?.payload]);
const layoutChildren = useMemo(() => {

View File

@@ -11,21 +11,10 @@ import { get } from 'lodash-es';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { AlignedData } from 'uplot';
import { PanelMode } from '../types';
import { fillMissingXAxisTimestamps, getXAxisTimestamps } from '../utils';
import { buildBaseConfig } from '../utils/baseConfigBuilder';
export function prepareBarPanelData(
apiResponse: MetricRangePayloadProps,
): AlignedData {
const seriesList = apiResponse?.data?.result || [];
const timestampArr = getXAxisTimestamps(seriesList);
const yAxisValuesArr = fillMissingXAxisTimestamps(timestampArr, seriesList);
return [timestampArr, ...yAxisValuesArr];
}
export function prepareBarPanelConfig({
widget,
isDarkMode,

View File

@@ -17,10 +17,11 @@ import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
import { prepareUPlotConfig } from '../TimeSeriesPanel/utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const {

View File

@@ -6,7 +6,8 @@ import {
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { PanelMode } from '../../types';
import { prepareChartData, prepareUPlotConfig } from '../utils';
import { prepareUPlotConfig } from '../utils';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',

View File

@@ -1,10 +1,6 @@
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
fillMissingXAxisTimestamps,
getXAxisTimestamps,
} from 'container/DashboardContainer/visualization/panels/utils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
@@ -15,42 +11,15 @@ import {
LineStyle,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { isInvalidPlotValue } from 'lib/uPlotV2/utils/dataUtils';
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
import get from 'lodash-es/get';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryData } from 'types/api/widgets/getQuery';
import { PanelMode } from '../types';
import { buildBaseConfig } from '../utils/baseConfigBuilder';
export const prepareChartData = (
apiResponse: MetricRangePayloadProps,
): uPlot.AlignedData => {
const seriesList = apiResponse?.data?.result || [];
const timestampArr = getXAxisTimestamps(seriesList);
const yAxisValuesArr = fillMissingXAxisTimestamps(timestampArr, seriesList);
return [timestampArr, ...yAxisValuesArr];
};
function hasSingleVisiblePointForSeries(series: QueryData): boolean {
const rawValues = series.values ?? [];
let validPointCount = 0;
for (const [, rawValue] of rawValues) {
if (!isInvalidPlotValue(rawValue)) {
validPointCount += 1;
if (validPointCount > 1) {
return false;
}
}
}
return true;
}
export const prepareUPlotConfig = ({
widget,
isDarkMode,
@@ -107,7 +76,7 @@ export const prepareUPlotConfig = ({
}
apiResponse.data.result.forEach((series) => {
const hasSingleValidPoint = hasSingleVisiblePointForSeries(series);
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
const baseLabelName = getLabelName(
series.metric,
series.queryName || '', // query

View File

@@ -0,0 +1,118 @@
import { useMemo } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import uPlot from 'uplot';
import {
AlertChartPanelType,
buildAlertChartConfig,
buildChartId,
} from './utils';
// Panel types that render through the UPlotConfigBuilder pipeline.
// To support a new modern-chart panel type, add an entry here and extend
// `AlertChartPanelType` / `buildAlertChartConfig` to handle its series setup.
const SUPPORTED_CHARTS: Record<
AlertChartPanelType,
typeof TimeSeries | typeof BarChart
> = {
[PANEL_TYPES.TIME_SERIES]: TimeSeries,
[PANEL_TYPES.BAR]: BarChart,
};
const isSupportedPanelType = (
panelType: PANEL_TYPES,
): panelType is AlertChartPanelType => panelType in SUPPORTED_CHARTS;
export interface ChartContentProps {
panelType: PANEL_TYPES;
alertId?: string;
query: Query;
apiResponse?: MetricRangePayloadProps;
data: uPlot.AlignedData;
thresholds: ThresholdProps[];
yAxisUnit: string;
legendPosition: LegendPosition;
isDarkMode: boolean;
timezone: Timezone;
width: number;
height: number;
minTimeScale?: number;
maxTimeScale?: number;
onDragSelect: (start: number, end: number) => void;
}
export default function ChartContent({
panelType,
alertId,
query,
thresholds,
apiResponse,
data,
yAxisUnit,
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
width,
height,
legendPosition,
}: ChartContentProps): JSX.Element | null {
const supported = isSupportedPanelType(panelType);
const config = useMemo(
() =>
buildAlertChartConfig({
id: buildChartId(alertId),
panelType: panelType as AlertChartPanelType,
query,
thresholds,
apiResponse,
yAxisUnit,
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
alertId,
panelType,
query,
thresholds,
apiResponse,
yAxisUnit,
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
],
);
if (!supported) {
return null;
}
const Component = SUPPORTED_CHARTS[panelType];
return (
<Component
config={config}
data={data}
width={width}
height={height}
legendConfig={{ position: legendPosition }}
canPinTooltip
yAxisUnit={yAxisUnit}
timezone={timezone}
/>
);
}

View File

@@ -15,8 +15,6 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import AnomalyAlertEvaluationView from 'container/AnomalyAlertEvaluationView';
import { INITIAL_CRITICAL_THRESHOLD } from 'container/CreateAlertV2/context/constants';
import { Threshold } from 'container/CreateAlertV2/context/types';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import GridPanelSwitch from 'container/GridPanelSwitch';
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
import { getFormatNameByOptionId } from 'container/NewWidget/RightContainer/alertFomatCategories';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
@@ -32,8 +30,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
@@ -41,24 +38,27 @@ import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { AlertDef } from 'types/api/alerts/def';
import { LegendPosition } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { getGraphType } from 'utils/getGraphType';
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import { getTimeRange } from 'utils/getTimeRange';
import { AlertDetectionTypes } from '..';
import ChartContent from './ChartContent';
import { ChartContainer } from './styles';
import { getThresholds } from './utils';
import './ChartPreview.styles.scss';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
// Height reserved for the `.chart-preview-header` strip rendered above the chart.
const CHART_PREVIEW_HEADER_HEIGHT = 48;
const CHART_PREVIEW_CONTAINER_PADDING = 16;
export interface ChartPreviewProps {
name: string;
query: Query | null;
graphType?: PANEL_TYPES;
selectedTime?: timePreferenceType;
@@ -77,7 +77,6 @@ export interface ChartPreviewProps {
// eslint-disable-next-line sonarjs/cognitive-complexity
function ChartPreview({
name,
query,
graphType = PANEL_TYPES.TIME_SERIES,
selectedTime = 'GLOBAL_TIME',
@@ -113,14 +112,6 @@ function ChartPreview({
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
const [graphVisibility, setGraphVisibility] = useState<boolean[]>([]);
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const { currentQuery } = useQueryBuilder();
const {
@@ -219,18 +210,6 @@ function ChartPreview({
setMaxTimeScale(endTime);
}, [maxTime, minTime, globalSelectedInterval, queryResponse, setQueryStatus]);
// Initialize graph visibility from localStorage
useEffect(() => {
if (queryResponse?.data?.payload?.data?.result) {
const { graphVisibilityStates: localStoredVisibilityState } =
getLocalStorageGraphVisibilityState({
apiResponse: queryResponse.data.payload.data.result,
name: 'alert-chart-preview',
});
setGraphVisibility(localStoredVisibilityState);
}
}, [queryResponse?.data?.payload?.data?.result]);
if (queryResponse.data && graphType === PANEL_TYPES.BAR) {
const sortedSeriesData = getSortedSeriesData(
queryResponse.data?.payload.data.result,
@@ -288,62 +267,17 @@ function ChartPreview({
return LegendPosition.RIGHT;
}, [queryResponse?.data?.payload?.data?.result?.length, showSideLegend]);
const options = useMemo(
() =>
getUPlotChartOptions({
id: 'alert_legend_widget',
yAxisUnit,
apiResponse: queryResponse?.data?.payload,
dimensions: {
height: containerDimensions?.height ? containerDimensions.height - 48 : 0,
width: containerDimensions?.width,
},
minTimeScale,
maxTimeScale,
isDarkMode,
onDragSelect,
thresholds: getThresholds(thresholds, t, optionName, yAxisUnit),
softMax: null,
softMin: null,
panelType: graphType,
tzDate: (timestamp: number) =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
timezone: timezone.value,
currentQuery,
query: query || currentQuery,
graphsVisibilityStates: graphVisibility,
setGraphsVisibilityStates: setGraphVisibility,
enhancedLegend: true,
legendPosition,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
[
yAxisUnit,
queryResponse?.data?.payload,
containerDimensions,
minTimeScale,
maxTimeScale,
isDarkMode,
onDragSelect,
thresholds,
t,
optionName,
graphType,
timezone.value,
currentQuery,
query,
graphVisibility,
legendPosition,
],
const resolvedThresholds = useMemo(
() => getThresholds(thresholds, t, optionName, yAxisUnit),
[thresholds, t, optionName, yAxisUnit],
);
const chartData = getUPlotChartData(queryResponse?.data?.payload);
const chartData = useMemo(() => {
if (!queryResponse?.data?.payload) {
return [];
}
return prepareChartData(queryResponse?.data?.payload);
}, [queryResponse?.data?.payload]);
const hasResultData = !!queryResponse?.data?.payload?.data?.result?.length;
@@ -361,6 +295,14 @@ function ChartPreview({
?.active || false;
const isWarning = !isEmpty(queryResponse.data?.warning);
const chartWidth = containerDimensions?.width
? containerDimensions.width - CHART_PREVIEW_CONTAINER_PADDING
: 0;
const chartHeight = containerDimensions?.height
? containerDimensions.height - CHART_PREVIEW_HEADER_HEIGHT
: 0;
return (
<div className="alert-chart-container" ref={graphRef}>
<ChartContainer>
@@ -384,16 +326,22 @@ function ChartPreview({
)}
{chartDataAvailable && !isAnomalyDetectionAlert && (
<GridPanelSwitch
options={options}
<ChartContent
panelType={graphType}
alertId={alertDef?.id}
query={query || currentQuery}
apiResponse={queryResponse.data?.payload}
data={chartData}
name={name || 'Chart Preview'}
panelData={
queryResponse.data?.payload?.data?.newResult?.data?.result || []
}
query={query || initialQueriesMap.metrics}
thresholds={resolvedThresholds}
yAxisUnit={yAxisUnit}
legendPosition={legendPosition}
isDarkMode={isDarkMode}
timezone={timezone}
width={chartWidth}
height={chartHeight}
minTimeScale={minTimeScale}
maxTimeScale={maxTimeScale}
onDragSelect={onDragSelect}
/>
)}

View File

@@ -1,6 +1,10 @@
import { Color } from '@signozhq/design-tokens';
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Threshold } from 'container/CreateAlertV2/context/types';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import {
BooleanFormats,
@@ -11,6 +15,20 @@ import {
TimeFormats,
} from 'container/NewWidget/RightContainer/types';
import { TFunction } from 'i18next';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DrawStyle,
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
import { get } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
dataFormatConfig,
@@ -20,6 +38,8 @@ import {
timeUnitsConfig,
} from './config';
const CHART_ID_PREFIX = 'alert_legend_widget';
export function covertIntoDataFormats({
value,
sourceUnit,
@@ -142,3 +162,110 @@ export const getThresholds = (
});
return thresholdsToReturn;
};
export type AlertChartPanelType = PANEL_TYPES.TIME_SERIES | PANEL_TYPES.BAR;
export interface BuildAlertChartConfigParams {
id: string;
panelType: AlertChartPanelType;
query: Query;
thresholds: ThresholdProps[];
apiResponse?: MetricRangePayloadProps;
yAxisUnit?: string;
isDarkMode: boolean;
timezone: Timezone;
minTimeScale?: number;
maxTimeScale?: number;
onDragSelect: (startTime: number, endTime: number) => void;
onClick?: OnClickPluginOpts['onClick'];
}
export const buildAlertChartConfig = ({
id,
panelType,
query,
thresholds,
apiResponse,
yAxisUnit,
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
}: BuildAlertChartConfigParams): UPlotConfigBuilder => {
const stepIntervals: ExecStats['stepIntervals'] = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
);
const stepIntervalValues = Object.values(stepIntervals);
const minStepInterval = stepIntervalValues.length
? Math.min(...stepIntervalValues)
: undefined;
const builder = buildBaseConfig({
id,
panelType,
panelMode: PanelMode.DASHBOARD_VIEW,
thresholds,
apiResponse,
yAxisUnit,
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
stepInterval: minStepInterval,
onDragSelect,
onClick,
});
const seriesList = apiResponse?.data?.result;
if (!seriesList?.length) {
return builder;
}
const isBar = panelType === PANEL_TYPES.BAR;
seriesList.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = query ? getLegend(series, query, baseLabelName) : baseLabelName;
if (isBar) {
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label,
colorMapping: {},
isDarkMode,
stepInterval: get(stepIntervals, series.queryName, undefined),
});
return;
}
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
builder.addSeries({
scaleKey: 'y',
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label,
colorMapping: {},
spanGaps: true,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
showPoints: hasSingleValidPoint,
pointSize: 5,
fillMode: FillMode.None,
isDarkMode,
metric: series.metric,
});
});
return builder;
};
export const buildChartId = (id?: string): string =>
id ? `${CHART_ID_PREFIX}_${id}` : CHART_ID_PREFIX;

View File

@@ -719,7 +719,6 @@ function FormAlertRules({
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
}
name=""
query={stagedQuery}
selectedInterval={globalSelectedInterval}
alertDef={alertDef}
@@ -739,7 +738,6 @@ function FormAlertRules({
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
}
name="Chart Preview"
query={stagedQuery}
alertDef={alertDef}
selectedInterval={globalSelectedInterval}

View File

@@ -295,37 +295,6 @@
.slider-container {
width: calc(100% - 16px);
.ant-slider .ant-slider-mark {
margin-top: 12px;
.ant-slider-mark-text {
color: var(--l3-foreground);
font-variant-numeric: lining-nums tabular-nums stacked-fractions
slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on,
'cpsp' on,
'case' on;
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 400;
line-height: 100%;
}
}
&.logs-slider-container {
.ant-slider .ant-slider-mark {
.ant-slider-mark-text {
&:last-child {
left: calc(100% - 8px) !important;
white-space: nowrap;
}
}
}
}
}
.do-later-container {

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Slider } from 'antd';
import { Slider } from '@signozhq/ui/slider';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { ArrowRight, LoaderCircle, Minus } from '@signozhq/icons';
@@ -204,23 +204,23 @@ function OptimiseSignozNeeds({
<label className="question-slider" htmlFor="organisationName">
Logs / Day
</label>
<div className="slider-container logs-slider-container">
<div className="slider-container">
<div>
<Slider
min={0}
max={100}
value={sliderValues.logsPerDay}
marks={marks}
onChange={(value: number): void =>
handleSliderChange('logsPerDay', value)
onChange={(value): void =>
handleSliderChange('logsPerDay', value as number)
}
styles={{
track: {
background: '#4E74F8',
range: {
backgroundColor: '#4E74F8',
},
}}
tooltip={{
formatter: (): string => `${logsPerDayValue.toLocaleString()} GB`, // Show whole number
formatter: (): string => `${logsPerDayValue.toLocaleString()} GB`,
}}
/>
</div>
@@ -238,16 +238,16 @@ function OptimiseSignozNeeds({
max={100}
value={sliderValues.hostsPerDay}
marks={hostMarks}
onChange={(value: number): void =>
handleSliderChange('hostsPerDay', value)
onChange={(value): void =>
handleSliderChange('hostsPerDay', value as number)
}
styles={{
track: {
background: '#4E74F8',
range: {
backgroundColor: '#4E74F8',
},
}}
tooltip={{
formatter: (): string => `${hostsPerDayValue.toLocaleString()}`, // Show whole number
formatter: (): string => `${hostsPerDayValue.toLocaleString()}`,
}}
/>
</div>
@@ -265,16 +265,16 @@ function OptimiseSignozNeeds({
max={100}
value={sliderValues.services}
marks={serviceMarks}
onChange={(value: number): void =>
handleSliderChange('services', value)
onChange={(value): void =>
handleSliderChange('services', value as number)
}
styles={{
track: {
background: '#4E74F8',
range: {
backgroundColor: '#4E74F8',
},
}}
tooltip={{
formatter: (): string => `${servicesValue.toLocaleString()}`, // Show whole number
formatter: (): string => `${servicesValue.toLocaleString()}`,
}}
/>
</div>

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Check } from '@signozhq/icons';
import { Check, Info } from '@signozhq/icons';
import {
Button,
DatePicker,
@@ -11,6 +11,7 @@ import {
Select,
SelectProps,
Spin,
Tooltip,
} from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
@@ -78,6 +79,7 @@ interface PlannedDowntimeFormData {
alertRules: DefaultOptionType[];
recurrenceSelect?: AlertmanagertypesRecurrenceDTO;
timezone?: string;
scope?: string;
}
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
@@ -144,6 +146,7 @@ export function PlannedDowntimeForm(
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[],
name: values.name,
scope: values.scope,
schedule: {
startTime: values.startTime?.format(),
endTime: values.endTime?.format(),
@@ -278,6 +281,7 @@ export function PlannedDowntimeForm(
duration: getDurationInfo(schedule?.recurrence?.duration)?.value ?? '',
} as AlertmanagertypesRecurrenceDTO,
timezone: schedule?.timezone as string,
scope: initialValues.scope || '',
};
}, [initialValues, alertOptions]);
@@ -311,7 +315,7 @@ export function PlannedDowntimeForm(
default:
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
}
}, [formData, recurrenceType, timezone]);
}, [formData, recurrenceType]);
const endTimeText = useMemo((): string => {
const endTime = formData.endTime;
@@ -322,7 +326,7 @@ export function PlannedDowntimeForm(
const formattedEndTime = endTime.format(TIME_FORMAT);
const formattedEndDate = endTime.format(DATE_FORMAT);
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
}, [formData, recurrenceType, timezone]);
}, [formData, recurrenceType]);
return (
<Modal
@@ -488,6 +492,36 @@ export function PlannedDowntimeForm(
</Select>
</Form.Item>
</div>
<Form.Item
label={
<span>
Scope&nbsp;
<Tooltip
mouseLeaveDelay={0.3}
title={
<span>
Scope the planned downtime by alert labels.{' '}
<a
href="https://signoz.io/docs/alerts-management/planned-maintenance/#scoping-with-label-expressions"
target="_blank"
rel="noopener noreferrer"
>
Learn more
</a>
</span>
}
>
<Info size={13} />
</Tooltip>
</span>
}
name="scope"
>
<Input.TextArea
placeholder='e.g. env = "prod" AND region = "us-east-1"'
autoSize={{ minRows: 2, maxRows: 4 }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<ModalButtonWrapper>
<Button

View File

@@ -8,8 +8,7 @@ import {
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { Slider } from 'antd';
import type { SliderRangeProps } from 'antd/lib/slider';
import { Slider } from '@signozhq/ui/slider';
import getFilters from 'api/trace/getFilters';
import useDebouncedFn from 'hooks/useDebouncedFunction';
// eslint-disable-next-line no-restricted-imports
@@ -169,16 +168,15 @@ function Duration(): JSX.Element {
debouncedFunction(min, max);
};
const onRangeHandler: SliderRangeProps['onChange'] = ([min, max]) => {
const onRangeHandler = (value: number | number[]): void => {
const [min, max] = value as number[];
updatedUrl(min, max);
};
const TipComponent = useCallback((value: undefined | number) => {
if (value === undefined) {
return <div />;
}
return <div>{`${value?.toString()}ms`}</div>;
}, []);
const TipComponent = useCallback(
(value: number) => <div>{`${value.toString()}ms`}</div>,
[],
);
return (
<div>
@@ -210,7 +208,8 @@ function Duration(): JSX.Element {
max={Number(getMs(String(preLocalMaxDuration.current || 0)))}
range
tooltip={{ formatter: TipComponent }}
onChange={([min, max]): void => {
onChange={(value): void => {
const [min, max] = value as number[];
onRangeSliderHandler([String(min), String(max)]);
}}
onAfterChange={onRangeHandler}

View File

@@ -1,3 +1,9 @@
import {
fillMissingXAxisTimestamps,
getXAxisTimestamps,
} from 'container/DashboardContainer/visualization/panels/utils';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
/**
* Checks if a value is invalid for plotting
*
@@ -52,6 +58,28 @@ export function normalizePlotValue(
return value as number;
}
/**
* Returns true if at most one entry in `values` is a valid plot value.
*
* Used to decide whether a series should render as a single point (drawStyle:
* Points) vs a line — a continuous line with only one visible sample is
* invisible to the user.
*/
export function hasSingleVisiblePoint(
values: ReadonlyArray<readonly [unknown, unknown]> | undefined,
): boolean {
let validPointCount = 0;
for (const [, rawValue] of values ?? []) {
if (!isInvalidPlotValue(rawValue)) {
validPointCount += 1;
if (validPointCount > 1) {
return false;
}
}
}
return true;
}
export interface SeriesSpanGapsOption {
spanGaps?: boolean | number;
}
@@ -226,3 +254,21 @@ export function applySpanGapsToAlignedData(
return [newX, ...transformedSeries] as uPlot.AlignedData;
}
/** * Transforms raw API response into aligned data format expected by uPlot.
*
* The API response contains multiple series of time-value pairs, each with its
* own set of timestamps. uPlot requires a single shared x-axis (timestamps)
* and separate y-value arrays for each series, aligned by index. This function
* extracts the unique sorted timestamps across all series and fills in missing
* values with null to maintain alignment.
*/
export const prepareChartData = (
apiResponse: MetricRangePayloadProps,
): uPlot.AlignedData => {
const seriesList = apiResponse?.data?.result || [];
const timestampArr = getXAxisTimestamps(seriesList);
const yAxisValuesArr = fillMissingXAxisTimestamps(timestampArr, seriesList);
return [timestampArr, ...yAxisValuesArr];
};

View File

@@ -8,6 +8,7 @@
align-items: center;
padding: 8px 16px;
gap: 8px;
min-height: 52px;
// KeyValueLabel renders with a global `.key-value-label` root; keep it from
// shrinking on the trace details header.
@@ -20,6 +21,28 @@
flex-shrink: 0;
}
.traceIdSection {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.filterSection {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
margin-left: auto;
}
.headerActions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.filter {
min-width: 0;
}
@@ -29,15 +52,6 @@
flex: 1;
}
.oldViewBtn {
flex-shrink: 0;
}
.analyticsBtn {
flex-shrink: 0;
margin-left: auto;
}
.subHeader {
display: flex;
align-items: center;

View File

@@ -21,6 +21,7 @@ import {
ArrowLeft,
CalendarClock,
ChartPie,
CornerUpLeft,
Server,
Timer,
} from '@signozhq/icons';
@@ -117,7 +118,7 @@ function TraceDetailsHeader({
<div className={styles.wrapper}>
<div className={styles.header}>
{!isFilterExpanded && (
<>
<div className={styles.traceIdSection}>
<Button
variant="solid"
color="secondary"
@@ -133,20 +134,39 @@ function TraceDetailsHeader({
badgeValue={traceID || ''}
maxCharacters={100}
/>
</>
</div>
)}
{isDataLoaded && (
<>
<div
className={cx(
styles.filterSection,
isFilterExpanded && styles.isExpanded,
)}
>
{!isFilterExpanded && (
<>
<TooltipProvider>
<TooltipProvider>
<div className={styles.headerActions}>
<TooltipRoot>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className={styles.analyticsBtn}
aria-label="Switch to legacy trace view"
onClick={handleSwitchToOldView}
>
<CornerUpLeft size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>Switch to legacy trace view</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
aria-label="Analytics"
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
>
<ChartPie size={14} />
@@ -154,15 +174,18 @@ function TraceDetailsHeader({
</TooltipTrigger>
<TooltipContent>Analytics</TooltipContent>
</TooltipRoot>
</TooltipProvider>
<TraceOptionsMenu
showTraceDetails={showTraceDetails}
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
/>
</>
<TraceOptionsMenu
showTraceDetails={showTraceDetails}
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
/>
</div>
</TooltipProvider>
)}
<div className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}>
<div
key="filter"
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
>
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
@@ -173,18 +196,7 @@ function TraceDetailsHeader({
onCollapse={(): void => setIsFilterExpanded(false)}
/>
</div>
{!isFilterExpanded && (
<Button
variant="solid"
color="secondary"
size="sm"
className={styles.oldViewBtn}
onClick={handleSwitchToOldView}
>
Legacy View
</Button>
)}
</>
</div>
)}
</div>

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
import { Ellipsis } from '@signozhq/icons';
import { Settings2 } from '@signozhq/icons';
import { useTraceStore } from '../stores/traceStore';
@@ -93,7 +93,8 @@ function TraceOptionsMenu({
variant="ghost"
size="icon"
color="secondary"
prefix={<Ellipsis size={14} />}
aria-label="Trace options"
prefix={<Settings2 size={14} />}
/>
</Dropdown>
);

View File

@@ -6,6 +6,7 @@ import TraceDetailsHeader from '../TraceDetailsHeader';
const mockGoBack = jest.fn();
const mockPush = jest.fn();
const mockReplace = jest.fn();
const mockHasInAppHistory = jest.fn();
jest.mock('lib/history', () => ({
@@ -13,13 +14,47 @@ jest.mock('lib/history', () => ({
default: {
goBack: (): void => mockGoBack(),
push: (path: string): void => mockPush(path),
replace: jest.fn(),
replace: (path: string): void => mockReplace(path),
location: { pathname: '/', search: '' },
listen: (): (() => void) => (): void => undefined,
},
hasInAppHistory: (): boolean => mockHasInAppHistory(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: (): { id: string } => ({ id: 'trace-123' }),
}));
const mockSetLocalStorageKey = jest.fn();
jest.mock('api/browser/localstorage/set', () => ({
__esModule: true,
default: (key: string, value: string): void =>
mockSetLocalStorageKey(key, value),
}));
jest.mock(
'../../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters',
() => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="filters-stub" />,
}),
);
jest.mock('../../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel', () => ({
__esModule: true,
default: ({ isOpen }: { isOpen: boolean }): JSX.Element => (
<div data-testid="analytics-panel" data-open={isOpen ? 'true' : 'false'} />
),
}));
jest.mock('components/FieldsSelector', () => ({
__esModule: true,
default: ({ isOpen }: { isOpen: boolean }): JSX.Element => (
<div data-testid="fields-selector" data-open={isOpen ? 'true' : 'false'} />
),
}));
const baseProps = {
filterMetadata: {
startTime: 0,
@@ -58,3 +93,70 @@ describe('TraceDetailsHeader back button', () => {
expect(mockGoBack).not.toHaveBeenCalled();
});
});
describe('TraceDetailsHeader action cluster', () => {
beforeEach(() => {
mockReplace.mockClear();
mockSetLocalStorageKey.mockClear();
});
it('does not render the action buttons while data is still loading', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
expect(
screen.queryByRole('button', { name: /switch to legacy trace view/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /trace options/i }),
).not.toBeInTheDocument();
});
it('renders Legacy View, Analytics, and Settings action buttons once data is loaded', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
expect(
screen.getByRole('button', { name: /switch to legacy trace view/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /^analytics$/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /trace options/i }),
).toBeInTheDocument();
});
it('routes to the legacy trace view and persists the preference on click', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
fireEvent.click(
screen.getByRole('button', { name: /switch to legacy trace view/i }),
);
expect(mockSetLocalStorageKey).toHaveBeenCalledWith(
'TRACE_DETAILS_PREFER_OLD_VIEW',
'true',
);
expect(mockReplace).toHaveBeenCalledTimes(1);
expect(mockReplace).toHaveBeenCalledWith(
expect.stringContaining('/trace-old/trace-123'),
);
});
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
const panel = screen.getByTestId('analytics-panel');
expect(panel).toHaveAttribute('data-open', 'false');
const analyticsBtn = screen.getByRole('button', { name: /^analytics$/i });
fireEvent.click(analyticsBtn);
expect(panel).toHaveAttribute('data-open', 'true');
fireEvent.click(analyticsBtn);
expect(panel).toHaveAttribute('data-open', 'false');
});
});

View File

@@ -3,12 +3,6 @@
align-items: center;
gap: 12px;
// QuerySearch child sets `query-builder-search-v2` globally; size it to the
// search container by reaching into the descendant.
:global(.query-builder-search-v2) {
width: 100%;
}
// ToggleGroup children use generated class names; nest the global selectors
// under the local row so they only apply inside this filter row.
:global([class*='toggle-group']) {
@@ -20,8 +14,43 @@
}
}
// Expanded-mode root: grows to fill .filter wrapper, and lets the search
// input flex within. In collapsed mode none of these grow — the whole
// Filters region is content-sized (just the pill + result + toggle).
.isExpanded {
flex: 1;
.searchInput {
flex: 1;
}
.searchAndNav {
flex: 1;
}
}
.categoryControls {
display: flex;
align-items: center;
flex-shrink: 0;
}
.searchInput {
display: flex;
align-items: center;
min-width: 0;
}
.searchPill {
display: flex;
align-items: center;
flex-shrink: 0;
}
.searchAndNav {
display: flex;
align-items: center;
min-width: 0;
}
.searchContainer {
@@ -29,6 +58,25 @@
min-width: 0;
}
.resultActions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.expandedActions {
display: flex;
align-items: center;
gap: 2px;
}
.highlightControl {
display: flex;
align-items: center;
flex-shrink: 0;
}
.pill {
display: flex;
align-items: center;
@@ -85,14 +133,6 @@
border-radius: 4px;
}
.collapseBtn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
}
.highlightErrorsToggle {
display: flex;
align-items: center;
@@ -100,37 +140,3 @@
flex-shrink: 0;
white-space: nowrap;
}
.preNextToggle {
display: flex;
flex-shrink: 0;
gap: 12px;
}
.preNextCount {
display: flex;
align-items: center;
margin: auto;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.filterStatus {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.hasError {
color: var(--destructive);
cursor: help;
}

View File

@@ -1,15 +1,7 @@
import { useCallback, useRef, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import {
ChevronDown,
ChevronUp,
Copy,
Info,
Loader,
Search,
X,
} from '@signozhq/icons';
import { ChevronsRight, Copy, Search, X } from '@signozhq/icons';
import { Switch } from '@signozhq/ui/switch';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { toast } from '@signozhq/ui/sonner';
@@ -21,7 +13,6 @@ import {
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import cx from 'classnames';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
@@ -42,6 +33,7 @@ import {
SpanCategory,
useSpanCategoryFilter,
} from './hooks/useSpanCategoryFilter';
import QueryResult from './QueryResult';
import styles from './Filters.module.scss';
@@ -152,6 +144,16 @@ function Filters({
runQuery(expressionRef.current);
}, [runQuery]);
const handleClear = useCallback((): void => {
setExpression('');
expressionRef.current = '';
setFilters({ items: [], op: 'AND' });
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}, [onFilteredSpansChange]);
// Expression-based filter hooks
const filterProps = {
expression,
@@ -266,164 +268,167 @@ function Filters({
</div>
);
const statusIndicators = (
<>
{isFetching && <Loader className="animate-spin" />}
{error && (
<TooltipRoot>
<TooltipTrigger asChild>
<span className={cx(styles.filterStatus, styles.hasError)}>
<Info />
API error
</span>
</TooltipTrigger>
<TooltipContent>
{(error as AxiosError)?.message || 'Something went wrong'}
</TooltipContent>
</TooltipRoot>
)}
{!error && noData && (
<Typography.Text className={styles.filterStatus}>
No results found
</Typography.Text>
)}
</>
const hasExpression = expression.trim().length > 0;
const hasResults = filteredSpanIds.length > 0;
const handlePrev = useCallback((): void => {
handlePrevNext(currentSearchedIndex - 1);
setCurrentSearchedIndex((prev) => prev - 1);
}, [currentSearchedIndex, handlePrevNext]);
const handleNext = useCallback((): void => {
handlePrevNext(currentSearchedIndex + 1);
setCurrentSearchedIndex((prev) => prev + 1);
}, [currentSearchedIndex, handlePrevNext]);
const pill = (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
<div className={styles.pill} onClick={onExpand}>
<Search size={12} />
<span className={styles.pillText}>{expression || 'Search...'}</span>
{expression && <span className={styles.pillIndicator} />}
</div>
);
// --- COLLAPSED VIEW ---
if (!isExpanded) {
const pill = (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
<div className={styles.pill} onClick={onExpand}>
<Search size={12} />
<span className={styles.pillText}>{expression || 'Search...'}</span>
{expression && <span className={styles.pillIndicator} />}
</div>
);
const pillWithPopover = expression ? (
<TooltipRoot>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<div className={styles.pillPopover}>
<div className={styles.pillPopoverHeader}>
<Typography.Text>Search query</Typography.Text>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void => {
setCopy(expression);
toast.success('Copied to clipboard', {
richColors: false,
position: 'top-right',
});
}}
>
<Copy size={12} />
</Button>
</div>
<div className={styles.pillPopoverExpression}>{expression}</div>
</div>
</TooltipContent>
</TooltipRoot>
) : (
pill
);
return (
<TooltipProvider>
<div className={styles.root}>
{expression ? (
<TooltipRoot>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<div className={styles.pillPopover}>
<div className={styles.pillPopoverHeader}>
<Typography.Text>Search query</Typography.Text>
// Mode-conditional render: only one of (pill | QuerySearch) is mounted
// at a time. Collapsing unmounts the editor — half-written queries are
// dropped, so collapse can't accidentally commit a malformed expression
// and fire an erroring /query_range request.
return (
<TooltipProvider>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={cx(styles.root, isExpanded && styles.isExpanded)}
ref={containerRef}
onBlur={(e): void => {
const relatedTarget = e.relatedTarget as Node | null;
const blurredIntoSelf = !!containerRef.current?.contains(relatedTarget);
if (!blurredIntoSelf) {
handleBlur();
}
}}
>
{isExpanded && (
<div className={styles.categoryControls}>
<ToggleGroup
type="single"
value={selectedCategory}
onChange={(value): void => {
if (value) {
handleCategoryChange(value as SpanCategory);
}
}}
size="sm"
>
{categories.map((category) => (
<ToggleGroupItem key={category} value={category}>
{category}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
)}
<div className={styles.searchInput}>
{isExpanded ? (
<div className={styles.searchAndNav}>
<div className={styles.searchContainer}>
<QuerySearch
queryData={{
...BASE_FILTER_QUERY,
filters,
filter: { expression },
}}
onChange={handleExpressionChange}
onRun={handleRunQuery}
dataSource={DataSource.TRACES}
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
/>
</div>
</div>
) : (
<div className={styles.searchPill}>{pillWithPopover}</div>
)}
</div>
<div className={styles.resultActions}>
<QueryResult
hasExpression={hasExpression}
hasResults={hasResults}
isFetching={isFetching}
error={error}
noData={noData}
currentIndex={currentSearchedIndex}
total={filteredSpanIds.length}
onPrev={handlePrev}
onNext={handleNext}
showNavigation={isExpanded}
/>
{isExpanded && (
<div className={styles.expandedActions}>
{hasExpression && (
<TooltipRoot>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void => {
setCopy(expression);
toast.success('Copied to clipboard', {
richColors: false,
position: 'top-right',
});
}}
onClick={handleClear}
>
<Copy size={12} />
<X size={14} />
</Button>
</div>
<div className={styles.pillPopoverExpression}>{expression}</div>
</div>
</TooltipContent>
</TooltipRoot>
) : (
pill
</TooltipTrigger>
<TooltipContent>Clear filter</TooltipContent>
</TooltipRoot>
)}
<TooltipRoot>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onCollapse}
>
<ChevronsRight size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>Collapse filters</TooltipContent>
</TooltipRoot>
</div>
)}
{highlightErrorsToggle}
{statusIndicators}
</div>
</TooltipProvider>
);
}
// --- EXPANDED VIEW ---
return (
<TooltipProvider>
<div className={cx(styles.root, styles.isExpanded)}>
<ToggleGroup
type="single"
value={selectedCategory}
onChange={(value): void => {
if (value) {
handleCategoryChange(value as SpanCategory);
}
}}
size="sm"
>
{categories.map((category) => (
<ToggleGroupItem key={category} value={category}>
{category}
</ToggleGroupItem>
))}
</ToggleGroup>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={styles.searchContainer}
ref={containerRef}
onBlur={(e): void => {
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
handleBlur();
}
}}
>
<QuerySearch
queryData={{
...BASE_FILTER_QUERY,
filters,
filter: { expression },
}}
onChange={handleExpressionChange}
onRun={handleRunQuery}
dataSource={DataSource.TRACES}
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
/>
</div>
{filteredSpanIds.length > 0 && (
<div className={styles.preNextToggle}>
<Typography.Text className={styles.preNextCount}>
{currentSearchedIndex + 1} / {filteredSpanIds.length}
</Typography.Text>
<Button
variant="ghost"
size="icon"
color="secondary"
disabled={currentSearchedIndex === 0}
onClick={(): void => {
handlePrevNext(currentSearchedIndex - 1);
setCurrentSearchedIndex((prev) => prev - 1);
}}
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
size="icon"
color="secondary"
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
onClick={(): void => {
handlePrevNext(currentSearchedIndex + 1);
setCurrentSearchedIndex((prev) => prev + 1);
}}
>
<ChevronDown size={14} />
</Button>
</div>
)}
<Button
variant="ghost"
size="icon"
color="secondary"
className={styles.collapseBtn}
onClick={onCollapse}
>
<X size={14} />
</Button>
{highlightErrorsToggle}
{statusIndicators}
<div className={styles.highlightControl}>{highlightErrorsToggle}</div>
</div>
</TooltipProvider>
);

View File

@@ -0,0 +1,32 @@
.resultNavCount {
padding: 0 6px;
white-space: nowrap;
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-size: 12px;
}
.resultNavDivider {
width: 1px;
height: 14px;
background: var(--l3-border);
margin: 0 4px;
flex-shrink: 0;
}
.filterStatus {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.hasError {
color: var(--destructive);
cursor: help;
}

View File

@@ -0,0 +1,111 @@
import { ChevronDown, ChevronUp, Info, Loader } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import cx from 'classnames';
import styles from './QueryResult.module.scss';
type QueryResultProps = {
hasExpression: boolean;
hasResults: boolean;
isFetching: boolean;
error: unknown;
noData: boolean;
currentIndex: number;
total: number;
onPrev: () => void;
onNext: () => void;
showNavigation?: boolean;
};
function QueryResult({
hasExpression,
hasResults,
isFetching,
error,
noData,
currentIndex,
total,
onPrev,
onNext,
showNavigation = true,
}: QueryResultProps): JSX.Element | null {
if (!hasExpression) {
return null;
}
let content: JSX.Element | null = null;
if (hasResults && showNavigation) {
// Prefer count over loader on refresh so stale results stay visible.
content = (
<>
<Typography.Text className={styles.resultNavCount}>
{currentIndex + 1} / {total}
</Typography.Text>
<Button
variant="ghost"
size="icon"
color="secondary"
disabled={currentIndex === 0}
onClick={onPrev}
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
size="icon"
color="secondary"
disabled={currentIndex === total - 1}
onClick={onNext}
>
<ChevronDown size={14} />
</Button>
</>
);
} else if (isFetching) {
content = <Loader className="animate-spin" />;
} else if (error) {
content = (
<TooltipRoot>
<TooltipTrigger asChild>
<span className={cx(styles.filterStatus, styles.hasError)}>
<Info />
API error
</span>
</TooltipTrigger>
<TooltipContent>
{(error as AxiosError)?.message || 'Something went wrong'}
</TooltipContent>
</TooltipRoot>
);
} else if (noData) {
content = (
<Typography.Text className={styles.filterStatus}>
No results found
</Typography.Text>
);
}
if (!content) {
return null;
}
return (
<>
{content}
{showNavigation && <span className={styles.resultNavDivider} />}
</>
);
}
QueryResult.defaultProps = {
showNavigation: true,
};
export default QueryResult;

View File

@@ -7,8 +7,8 @@ import {
useMemo,
useState,
} from 'react';
import { Input, Slider } from 'antd';
import type { SliderRangeProps } from 'antd/es/slider';
import { Input } from 'antd';
import { Slider } from '@signozhq/ui/slider';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import useDebouncedFn from 'hooks/useDebouncedFunction';
@@ -88,16 +88,15 @@ export function DurationSection(props: DurationProps): JSX.Element {
debouncedFunction(min, max);
};
const onRangeHandler: SliderRangeProps['onChange'] = ([min, max]) => {
const onRangeHandler = (value: number | number[]): void => {
const [min, max] = value as number[];
updateDurationFilter(min.toString(), max.toString());
};
const TipComponent = useCallback((value: undefined | number) => {
if (value === undefined) {
return <div />;
}
return <div>{`${value?.toString()}ms`}</div>;
}, []);
const TipComponent = useCallback(
(value: number) => <div>{`${value.toString()}ms`}</div>,
[],
);
return (
<div>
@@ -123,13 +122,14 @@ export function DurationSection(props: DurationProps): JSX.Element {
addonAfter="ms"
/>
</div>
<div>
<div className="duration-input-slider">
<Slider
min={0}
max={100000}
range
tooltip={{ formatter: TipComponent }}
onChange={([min, max]): void => {
onChange={(value): void => {
const [min, max] = value as number[];
onRangeSliderHandler([String(min), String(max)]);
}}
onAfterChange={onRangeHandler}

View File

@@ -0,0 +1,12 @@
/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM docs/config/web-settings.json */
export interface WebSettings {
appcues: Appcues;
posthog: Posthog;
}
export interface Appcues {
enabled: boolean;
}
export interface Posthog {
enabled: boolean;
}

View File

@@ -1,5 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import { compose, Store } from 'redux';
import type { WebSettings } from 'types/generated/webSettings';
declare global {
interface Window {
@@ -7,6 +8,7 @@ declare global {
pylon: any;
Appcues: Record<string, any>;
__REDUX_DEVTOOLS_EXTENSION_COMPOSE__: typeof compose;
signozBootData?: { settings: WebSettings | null };
}
}

View File

@@ -0,0 +1,74 @@
export {};
type BootData = typeof import('../bootData');
function loadModule(settings?: object | null): BootData {
(window as any).signozBootData =
settings !== undefined ? { settings } : undefined;
let mod!: BootData;
jest.isolateModules(() => {
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
mod = require('../bootData');
});
return mod;
}
afterEach(() => {
delete (window as any).signozBootData;
});
describe('when window.signozBootData is absent', () => {
it('defaults posthog and appcues to enabled', () => {
const { bootSettings } = loadModule();
expect(bootSettings.posthog.enabled).toBe(true);
expect(bootSettings.appcues.enabled).toBe(true);
});
});
describe('when window.signozBootData.settings is null (injection failed)', () => {
it('defaults posthog and appcues to enabled', () => {
const { bootSettings } = loadModule(null);
expect(bootSettings.posthog.enabled).toBe(true);
expect(bootSettings.appcues.enabled).toBe(true);
});
});
describe('when window.signozBootData.settings is populated', () => {
it('reads posthog enabled: true', () => {
const { bootSettings } = loadModule({ posthog: { enabled: true } });
expect(bootSettings.posthog.enabled).toBe(true);
});
it('reads posthog enabled: false', () => {
const { bootSettings } = loadModule({ posthog: { enabled: false } });
expect(bootSettings.posthog.enabled).toBe(false);
});
it('reads appcues enabled: true', () => {
const { bootSettings } = loadModule({ appcues: { enabled: true } });
expect(bootSettings.appcues.enabled).toBe(true);
});
it('reads appcues enabled: false', () => {
const { bootSettings } = loadModule({ appcues: { enabled: false } });
expect(bootSettings.appcues.enabled).toBe(false);
});
it('missing sub-namespace defaults to enabled', () => {
const { bootSettings } = loadModule({ posthog: { enabled: false } });
expect(bootSettings.appcues.enabled).toBe(true);
});
});
describe('when window.signozBootData exists but settings is undefined', () => {
it('defaults posthog and appcues to enabled', () => {
(window as any).signozBootData = {};
let mod!: BootData;
jest.isolateModules(() => {
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
mod = require('../bootData');
});
expect(mod.bootSettings.posthog.enabled).toBe(true);
expect(mod.bootSettings.appcues.enabled).toBe(true);
});
});

View File

@@ -0,0 +1,11 @@
import type { WebSettings } from 'types/generated/webSettings';
const raw = window.signozBootData?.settings as
| Partial<WebSettings>
| null
| undefined;
export const bootSettings: Readonly<WebSettings> = {
posthog: { enabled: raw?.posthog?.enabled ?? true },
appcues: { enabled: raw?.appcues?.enabled ?? true },
};

View File

@@ -23,6 +23,20 @@ function devBasePathPlugin(basePath: string): Plugin {
};
}
function devBootDataPlugin(env: Record<string, string>): Plugin {
return {
name: 'dev-boot-data',
apply: 'serve',
transformIndexHtml(html): string {
const settings = {
posthog: { enabled: env.VITE_POSTHOG_ENABLED !== 'false' },
appcues: { enabled: env.VITE_APPCUES_ENABLED !== 'false' },
};
return html.replaceAll('[[.Settings]]', JSON.stringify(settings));
},
};
}
function rawMarkdownPlugin(): Plugin {
return {
name: 'raw-markdown',
@@ -47,6 +61,7 @@ export default defineConfig(({ mode }): UserConfig => {
tsconfigPaths(),
rawMarkdownPlugin(),
devBasePathPlugin(basePath),
devBootDataPlugin(env),
react(),
createHtmlPlugin({
inject: {

View File

@@ -42,7 +42,14 @@ func (m *MaintenanceMuter) Mutes(ctx context.Context, lset model.LabelSet) bool
}
now := time.Now()
for _, mw := range m.getMaintenances(ctx) {
if mw.ShouldSkip(ruleID, now) {
skip, err := mw.ShouldSkip(ruleID, now, lset)
if err != nil {
m.logger.ErrorContext(ctx, "failed to test maintenance window skip condition",
slog.String("maintenance_id", mw.ID.StringValue()),
slog.String("scope", mw.Scope),
slog.Any("error", err),
)
} else if skip {
return true
}
}
@@ -61,7 +68,14 @@ func (m *MaintenanceMuter) MutedBy(ctx context.Context, lset model.LabelSet) []s
var ids []string
now := time.Now()
for _, mw := range m.getMaintenances(ctx) {
if mw.ShouldSkip(ruleID, now) {
skip, err := mw.ShouldSkip(ruleID, now, lset)
if err != nil {
m.logger.ErrorContext(ctx, "failed to test maintenance window skip condition",
slog.String("maintenance_id", mw.ID.StringValue()),
slog.String("scope", mw.Scope),
slog.Any("error", err),
)
} else if skip {
ids = append(ids, mw.ID.String())
}
}

View File

@@ -41,7 +41,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
Expression: `ruleId == "high-cpu-usage" && severity == "critical"`,
Expression: `ruleId = "high-cpu-usage" AND severity = "critical"`,
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Name: "high-cpu-usage",
Description: "High CPU critical alerts to webhook",
@@ -53,7 +53,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
Expression: `ruleId == "high-cpu-usage" && severity == "warning"`,
Expression: `ruleId = "high-cpu-usage" AND severity = "warning"`,
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Name: "high-cpu-usage",
Description: "High CPU warning alerts to webhook",
@@ -87,18 +87,25 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
err = notificationManager.SetNotificationConfig(orgID, "high-cpu-usage", &notifConfig)
require.NoError(t, err)
mwID := valuer.GenerateUUID()
activeSchedule := &alertmanagertypes.Schedule{
Timezone: "UTC",
StartTime: time.Now().Add(-time.Hour),
EndTime: time.Now().Add(time.Hour),
}
// mwRuleIDAndScope: only critical high-cpu-usage alerts.
mwRuleIDAndScope := valuer.GenerateUUID()
// mwRuleIDOnly: all high-cpu-usage alerts regardless of severity.
mwRuleIDOnly := valuer.GenerateUUID()
// mwScopeOnly: all critical alerts regardless of rule ID.
mwScopeOnly := valuer.GenerateUUID()
maintenanceStore := alertmanagertypestest.NewMockMaintenanceStore(t)
maintenanceStore.On("ListPlannedMaintenance", mock.Anything, orgID).Return(
[]*alertmanagertypes.PlannedMaintenance{{
ID: mwID,
Schedule: &alertmanagertypes.Schedule{
Timezone: "UTC",
StartTime: time.Now().Add(-time.Hour),
EndTime: time.Now().Add(time.Hour),
},
RuleIDs: []string{"high-cpu-usage"},
}}, nil,
[]*alertmanagertypes.PlannedMaintenance{
{ID: mwRuleIDAndScope, Schedule: activeSchedule, RuleIDs: []string{"high-cpu-usage"}, Scope: `severity = "critical"`},
{ID: mwRuleIDOnly, Schedule: activeSchedule, RuleIDs: []string{"high-cpu-usage"}},
{ID: mwScopeOnly, Schedule: activeSchedule, Scope: `severity = "critical"`},
}, nil,
)
srvCfg := NewConfig()
@@ -249,18 +256,42 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
require.Equal(t, "{__receiver__=\"webhook\"}:{cluster=\"prod-cluster\", instance=\"server-03\", ruleId=\"high-cpu-usage\"}", alertGroups[2].GroupKey)
})
t.Run("verify_muting", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
require.NoError(t, err)
params, err := alertmanagertypes.NewGettableAlertsParams(req)
require.NoError(t, err)
alerts, err := server.GetAlerts(ctx, params)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
require.NoError(t, err)
params, err := alertmanagertypes.NewGettableAlertsParams(req)
require.NoError(t, err)
alerts, err := server.GetAlerts(ctx, params)
require.NoError(t, err)
t.Run("verify_muting_ruleid_and_scope", func(t *testing.T) {
// Window with ruleID + scope mutes only alerts matching both.
for _, alert := range alerts {
if alert.Labels["ruleId"] == "high-cpu-usage" && alert.Labels["severity"] == "critical" {
require.Contains(t, alert.Status.MutedBy, mwRuleIDAndScope.String())
} else {
require.NotContains(t, alert.Status.MutedBy, mwRuleIDAndScope.String())
}
}
})
t.Run("verify_muting_ruleid_only", func(t *testing.T) {
// Window with ruleID but no scope mutes all severities for that rule.
for _, alert := range alerts {
if alert.Labels["ruleId"] == "high-cpu-usage" {
require.Equal(t, []string{mwID.String()}, alert.Status.MutedBy)
require.Contains(t, alert.Status.MutedBy, mwRuleIDOnly.String())
} else {
require.Empty(t, alert.Status.MutedBy)
require.NotContains(t, alert.Status.MutedBy, mwRuleIDOnly.String())
}
}
})
t.Run("verify_muting_scope_only", func(t *testing.T) {
// Window with scope but no ruleIDs mutes all critical alerts regardless of rule.
for _, alert := range alerts {
if alert.Labels["severity"] == "critical" {
require.Contains(t, alert.Status.MutedBy, mwScopeOnly.String())
} else {
require.NotContains(t, alert.Status.MutedBy, mwScopeOnly.String())
}
}
})

View File

@@ -89,6 +89,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
Description: maintenance.Description,
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
Scope: maintenance.Scope,
}
maintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
@@ -123,7 +124,6 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
NewInsert().
Model(&maintenanceRules).
Exec(ctx)
if err != nil {
return err
}
@@ -141,6 +141,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
Description: storablePlannedMaintenance.Description,
Schedule: storablePlannedMaintenance.Schedule,
RuleIDs: maintenance.AlertIds,
Scope: maintenance.Scope,
CreatedAt: storablePlannedMaintenance.CreatedAt,
CreatedBy: storablePlannedMaintenance.CreatedBy,
UpdatedAt: storablePlannedMaintenance.UpdatedAt,
@@ -189,6 +190,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
Description: maintenance.Description,
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
Scope: maintenance.Scope,
}
storablePlannedMaintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
@@ -224,7 +226,6 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
Model(new(alertmanagertypes.StorablePlannedMaintenanceRule)).
Where("planned_maintenance_id = ?", storablePlannedMaintenance.ID.StringValue()).
Exec(ctx)
if err != nil {
return err
}
@@ -241,7 +242,6 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
}
return nil
})
if err != nil {
return err

View File

@@ -3,7 +3,6 @@ package rulebasednotification
import (
"context"
"log/slog"
"strings"
"sync"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
@@ -235,65 +234,13 @@ func (r *provider) Match(ctx context.Context, orgID string, ruleID string, set m
return matchedChannels, nil
}
// convertLabelSetToEnv converts a flat label set with dotted keys into a nested map structure for expr env.
// when both a leaf and a deeper nested path exist (e.g. "foo" and "foo.bar"),
// the nested structure takes precedence. That means we will replace an existing leaf at any
// intermediate path with a map so we can materialize the deeper structure.
// TODO(srikanthccv): we need a better solution to handle this, remove the following
// when we update the expr to support dotted keys.
// convertLabelSetToEnv delegates to alertmanagertypes.ConvertLabelSetToEnv and
// logs when a key is a prefix of another (e.g. "foo" alongside "foo.bar").
func (r *provider) convertLabelSetToEnv(ctx context.Context, labelSet model.LabelSet) map[string]interface{} {
env := make(map[string]interface{})
logForReview := false
for lk, lv := range labelSet {
key := strings.TrimSpace(string(lk))
value := string(lv)
if strings.Contains(key, ".") {
parts := strings.Split(key, ".")
current := env
for i, raw := range parts {
part := strings.TrimSpace(raw)
last := i == len(parts)-1
if last {
if _, isMap := current[part].(map[string]interface{}); isMap {
logForReview = true
// deeper structure already exists; do not overwrite.
break
}
current[part] = value
break
}
// ensure a map so we can keep descending.
if nextMap, ok := current[part].(map[string]interface{}); ok {
current = nextMap
continue
}
// if absent or a leaf, replace it with a map.
newMap := make(map[string]interface{})
current[part] = newMap
current = newMap
}
continue
}
// if a map already sits here (due to nested keys), keep the map (nested wins).
if _, isMap := env[key].(map[string]interface{}); isMap {
logForReview = true
continue
}
env[key] = value
}
if logForReview {
env, conflict := alertmanagertypes.ConvertLabelSetToEnv(labelSet)
if conflict {
r.settings.Logger().InfoContext(ctx, "found label set with conflicting prefix dotted keys", slog.Any("labels", labelSet))
}
return env
}

View File

@@ -925,72 +925,3 @@ func TestProvider_CreateRoutes(t *testing.T) {
})
}
}
func TestConvertLabelSetToEnv(t *testing.T) {
tests := []struct {
name string
labelSet model.LabelSet
expected map[string]interface{}
}{
{
name: "simple keys",
labelSet: model.LabelSet{
"key1": "value1",
"key2": "value2",
},
expected: map[string]interface{}{
"key1": "value1",
"key2": "value2",
},
},
{
name: "nested keys",
labelSet: model.LabelSet{
"foo.bar": "value1",
"foo.baz": "value2",
},
expected: map[string]interface{}{
"foo": map[string]interface{}{
"bar": "value1",
"baz": "value2",
},
},
},
{
name: "conflict - nested structure wins",
labelSet: model.LabelSet{
"foo.bar.baz": "deep",
"foo.bar": "shallow",
},
expected: map[string]interface{}{
"foo": map[string]interface{}{
"bar": map[string]interface{}{
"baz": "deep",
},
},
},
},
{
name: "conflict - leaf value vs nested",
labelSet: model.LabelSet{
"foo.bar": "value",
"foo": "should_be_ignored",
},
expected: map[string]interface{}{
"foo": map[string]interface{}{
"bar": "value",
},
},
},
}
provider := &provider{
settings: factory.NewScopedProviderSettings(createTestProviderSettings(), "provider_test"),
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.convertLabelSetToEnv(context.Background(), tt.labelSet)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -2,6 +2,8 @@ package envprovider
import (
"context"
"os"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/config"
@@ -9,7 +11,21 @@ import (
"github.com/stretchr/testify/require"
)
// clearSignozEnv unsets all existing SIGNOZ_* env vars for the duration of the test.
func clearSignozEnv(t *testing.T) {
t.Helper()
for _, kv := range os.Environ() {
if strings.HasPrefix(kv, prefix) {
key := strings.SplitN(kv, "=", 2)[0]
orig, _ := os.LookupEnv(key)
os.Unsetenv(key)
t.Cleanup(func() { os.Setenv(key, orig) })
}
}
}
func TestGetWithStrings(t *testing.T) {
clearSignozEnv(t)
t.Setenv("SIGNOZ_K1_K2", "string")
t.Setenv("SIGNOZ_K3__K4", "string")
t.Setenv("SIGNOZ_K5__K6_K7__K8", "string")
@@ -31,6 +47,7 @@ func TestGetWithStrings(t *testing.T) {
}
func TestGetWithNoPrefix(t *testing.T) {
clearSignozEnv(t)
t.Setenv("K1_K2", "string")
t.Setenv("K3_K4", "string")
expected := map[string]any{}
@@ -43,6 +60,7 @@ func TestGetWithNoPrefix(t *testing.T) {
}
func TestGetWithGoTypes(t *testing.T) {
clearSignozEnv(t)
t.Setenv("SIGNOZ_BOOL", "true")
t.Setenv("SIGNOZ_STRING", "string")
t.Setenv("SIGNOZ_INT", "1")

View File

@@ -3,14 +3,15 @@ package flagger
import "github.com/SigNoz/signoz/pkg/types/featuretypes"
var (
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
FeatureUseDashboardV2 = featuretypes.MustNewName("use_dashboard_v2")
)
func MustNewRegistry() featuretypes.Registry {
@@ -79,6 +80,14 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureUseDashboardV2,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether dashboard v2 is enabled",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
)
if err != nil {
panic(err)

View File

@@ -97,7 +97,7 @@ func makeChain(n int) (*spantypes.WaterfallSpan, map[string]*spantypes.Waterfall
}
func getWaterfallTrace(roots []*spantypes.WaterfallSpan, spanMap map[string]*spantypes.WaterfallSpan) *spantypes.WaterfallTrace {
return spantypes.NewWaterfallTrace(0, 0, uint64(len(spanMap)), 0, spanMap, nil, roots, false)
return spantypes.NewWaterfallTrace(0, 0, uint64(len(spanMap)), 0, spanMap, roots, false)
}
// ─────────────────────────────────────────────────────────────────────────────

View File

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

View File

@@ -206,6 +206,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddIntegrationDashboardFactory(sqlstore, sqlschema),
sqlmigration.NewAddSourceToDashboardFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateCloudIntegrationDashboardsFactory(sqlstore),
sqlmigration.NewAddScopeToPlannedMaintenanceFactory(sqlstore, sqlschema),
)
}

View File

@@ -0,0 +1,70 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addScopeToPlannedMaintenance struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddScopeToPlannedMaintenanceFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("add_scope_to_planned"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addScopeToPlannedMaintenance{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
},
)
}
func (migration *addScopeToPlannedMaintenance) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *addScopeToPlannedMaintenance) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
table, _, err := migration.sqlschema.GetTable(ctx, "planned_maintenance")
if err != nil {
return err
}
column := &sqlschema.Column{
Name: sqlschema.ColumnName("scope"),
DataType: sqlschema.DataTypeText,
Nullable: true,
}
sqls := migration.sqlschema.Operator().AddColumn(table, nil, column, nil)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addScopeToPlannedMaintenance) Down(ctx context.Context, db *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,88 @@
package alertmanagertypes
import (
"strings"
"github.com/expr-lang/expr"
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/errors"
)
var ErrCodeInvalidScopeExpression = errors.MustNewCode("invalid_scope_expression")
// ConvertLabelSetToEnv converts a label set into a map suitable for use as an
// expr environment. Dotted keys (e.g. "kubernetes.node") are expanded into
// nested maps so that expr can resolve them without panicking. When a dotted
// path conflicts with a plain key, the nested structure takes precedence.
//
// The second return value reports whether such a prefix conflict was detected
// (a plain key collided with a nested map, or a nested path overwrote a plain
// leaf).
func ConvertLabelSetToEnv(lset model.LabelSet) (map[string]any, bool) {
env := map[string]any{}
conflict := false
for lk, lv := range lset {
key := strings.TrimSpace(string(lk))
value := string(lv)
if strings.Contains(key, ".") {
parts := strings.Split(key, ".")
current := env
for i, raw := range parts {
part := strings.TrimSpace(raw)
if i == len(parts)-1 {
// Last segment: if a nested map already exists here, a
// deeper path has been processed first — keep it and flag.
if _, isMap := current[part].(map[string]any); isMap {
conflict = true
break
}
current[part] = value
break
}
if nextMap, ok := current[part].(map[string]any); ok {
current = nextMap
} else {
// Intermediate segment hit a plain leaf — overwrite with a
// map so the deeper path can be materialised, and flag.
if _, exists := current[part]; exists {
conflict = true
}
newMap := map[string]any{}
current[part] = newMap
current = newMap
}
}
continue
}
// Plain key collides with an already-built nested map — keep the map
// (nested wins) and flag.
if _, isMap := env[key].(map[string]any); isMap {
conflict = true
continue
}
env[key] = value
}
return env, conflict
}
// EvalScopeExpression compiles and runs the expression against the provided
// labels. It returns (result, error). Callers should log the error and
// decide how to handle a failed evaluation (the maintenance muter treats a
// failure as "don't skip" so alerts pass through).
func EvalScopeExpression(expression string, lset model.LabelSet) (bool, error) {
env, _ := ConvertLabelSetToEnv(lset)
program, err := expr.Compile(expression, expr.Env(env), expr.AllowUndefinedVariables())
if err != nil {
return false, errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeInvalidScopeExpression, "compile scope expression %q", expression)
}
output, err := expr.Run(program, env)
if err != nil {
return false, errors.Wrapf(err, errors.TypeInternal, ErrCodeInvalidScopeExpression, "run scope expression %q", expression)
}
result, ok := output.(bool)
if !ok {
return false, errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidScopeExpression, "scope expression %q returned non-bool value %T (%v)", expression, output, output)
}
return result, nil
}

View File

@@ -0,0 +1,163 @@
package alertmanagertypes
import (
"reflect"
"testing"
"github.com/prometheus/common/model"
)
func TestEvalScopeExpression(t *testing.T) {
cases := []struct {
name string
expression string
lset model.LabelSet
want bool
wantErr bool
}{
{
name: "equality match",
expression: `env = "production"`,
lset: model.LabelSet{"env": "production"},
want: true,
},
{
name: "equality no match",
expression: `env = "production"`,
lset: model.LabelSet{"env": "staging"},
want: false,
},
{
name: "inequality match",
expression: `env != "production"`,
lset: model.LabelSet{"env": "staging"},
want: true,
},
{
name: "AND - both match",
expression: `env = "production" AND service = "api"`,
lset: model.LabelSet{"env": "production", "service": "api"},
want: true,
},
{
name: "AND - partial match",
expression: `env = "production" AND service = "api"`,
lset: model.LabelSet{"env": "production", "service": "worker"},
want: false,
},
{
name: "OR - first matches",
expression: `env = "production" OR env = "staging"`,
lset: model.LabelSet{"env": "production"},
want: true,
},
{
name: "OR - second matches",
expression: `env = "production" OR env = "staging"`,
lset: model.LabelSet{"env": "staging"},
want: true,
},
{
name: "OR - none match",
expression: `env = "production" OR env = "staging"`,
lset: model.LabelSet{"env": "development"},
want: false,
},
{
name: "undefined label returns false",
expression: `env = "production"`,
lset: model.LabelSet{"service": "api"},
want: false,
},
{
name: "in list - present",
expression: `env in ["production", "staging"]`,
lset: model.LabelSet{"env": "production"},
want: true,
},
{
name: "in list - absent",
expression: `env in ["production", "staging"]`,
lset: model.LabelSet{"env": "development"},
want: false,
},
{
name: "invalid expression returns error",
expression: `env =`,
lset: model.LabelSet{"env": "production"},
want: false,
wantErr: true,
},
{
name: "non-bool expression returns error",
expression: `env`,
lset: model.LabelSet{"env": "production"},
want: false,
wantErr: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := EvalScopeExpression(c.expression, c.lset)
if (err != nil) != c.wantErr {
t.Errorf("EvalScopeExpression(%q, %v) error = %v, wantErr %v", c.expression, c.lset, err, c.wantErr)
}
if got != c.want {
t.Errorf("EvalScopeExpression(%q, %v) = %v, want %v", c.expression, c.lset, got, c.want)
}
})
}
}
func TestConvertLabelSetToEnv(t *testing.T) {
cases := []struct {
name string
lset model.LabelSet
expected map[string]interface{}
wantConflict bool
}{
{
name: "simple keys",
lset: model.LabelSet{"key1": "value1", "key2": "value2"},
expected: map[string]interface{}{"key1": "value1", "key2": "value2"},
},
{
name: "dotted keys become nested maps",
lset: model.LabelSet{"foo.bar": "value1", "foo.baz": "value2"},
expected: map[string]interface{}{
"foo": map[string]interface{}{"bar": "value1", "baz": "value2"},
},
},
{
name: "deeper dotted key wins over shallow dotted key",
lset: model.LabelSet{"foo.bar.baz": "deep", "foo.bar": "shallow"},
expected: map[string]interface{}{
"foo": map[string]interface{}{
"bar": map[string]interface{}{"baz": "deep"},
},
},
wantConflict: true,
},
{
name: "nested structure wins over plain key",
lset: model.LabelSet{"foo.bar": "value", "foo": "ignored"},
expected: map[string]interface{}{
"foo": map[string]interface{}{"bar": "value"},
},
wantConflict: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, gotConflict := ConvertLabelSetToEnv(c.lset)
if !reflect.DeepEqual(got, c.expected) {
t.Errorf("ConvertLabelSetToEnv() map = %v, want %v", got, c.expected)
}
if gotConflict != c.wantConflict {
t.Errorf("ConvertLabelSetToEnv() conflict = %v, want %v", gotConflict, c.wantConflict)
}
})
}
}

View File

@@ -5,14 +5,19 @@ import (
"encoding/json"
"time"
"github.com/expr-lang/expr"
"github.com/prometheus/common/model"
"github.com/uptrace/bun"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
var ErrCodeInvalidPlannedMaintenancePayload = errors.MustNewCode("invalid_planned_maintenance_payload")
const scopeDocUrl = "https://signoz.io/docs/alerts-management/planned-maintenance/#scoping-with-label-expressions"
type MaintenanceStatus struct {
valuer.String
}
@@ -58,6 +63,7 @@ type StorablePlannedMaintenance struct {
Description string `bun:"description,type:text"`
Schedule *Schedule `bun:"schedule,type:text,notnull"`
OrgID string `bun:"org_id,type:text"`
Scope string `bun:"scope,type:text"`
}
type PlannedMaintenance struct {
@@ -66,6 +72,7 @@ type PlannedMaintenance struct {
Description string `json:"description"`
Schedule *Schedule `json:"schedule" required:"true"`
RuleIDs []string `json:"alertIds"`
Scope string `json:"scope,omitempty"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy string `json:"createdBy"`
UpdatedAt time.Time `json:"updatedAt"`
@@ -82,6 +89,7 @@ type PostablePlannedMaintenance struct {
Description string `json:"description"`
Schedule *Schedule `json:"schedule" required:"true"`
AlertIds []string `json:"alertIds"`
Scope string `json:"scope"`
}
func (p *PostablePlannedMaintenance) Validate() error {
@@ -116,6 +124,15 @@ func (p *PostablePlannedMaintenance) Validate() error {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "end time cannot be before start time")
}
}
if p.Scope != "" {
if _, err := expr.Compile(p.Scope, expr.AllowUndefinedVariables(), expr.AsBool()); err != nil {
err := errors.Newf(
errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload,
"invalid scope: %s", err.Error(),
)
return err.WithUrl(scopeDocUrl)
}
}
return nil
}
@@ -151,7 +168,7 @@ func (m *PlannedMaintenance) HasScheduleRecurrenceBoundsMismatch() bool {
(recurrence.EndTime != nil && !recurrence.EndTime.Equal(m.Schedule.EndTime))
}
func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bool {
func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time, lset model.LabelSet) (bool, error) {
// Check if the alert ID is in the maintenance window
found := false
if len(m.RuleIDs) > 0 {
@@ -168,9 +185,27 @@ func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bool {
}
if !found {
return false
return false, nil
}
if !m.IsActive(now) {
return false, nil
}
if m.Scope != "" {
result, err := EvalScopeExpression(m.Scope, lset)
if err != nil {
return false, err
}
if !result {
return false, nil
}
}
return true, nil
}
// IsActive reports whether [now] falls inside the maintenance window's schedule.
func (m *PlannedMaintenance) IsActive(now time.Time) bool {
// If alert is found, we check if it should be skipped based on the schedule
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
@@ -301,14 +336,6 @@ func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence
return currentTime.Sub(candidate) <= rec.Duration.Duration()
}
func (m *PlannedMaintenance) IsActive(now time.Time) bool {
ruleID := "maintenance"
if len(m.RuleIDs) > 0 {
ruleID = (m.RuleIDs)[0]
}
return m.ShouldSkip(ruleID, now)
}
func (m *PlannedMaintenance) IsUpcoming() bool {
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
@@ -389,6 +416,7 @@ func (m PlannedMaintenance) MarshalJSON() ([]byte, error) {
Description string `json:"description" db:"description"`
Schedule *Schedule `json:"schedule" db:"schedule"`
AlertIds []string `json:"alertIds" db:"alert_ids"`
Scope string `json:"scope,omitempty" db:"scope"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
CreatedBy string `json:"createdBy" db:"created_by"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
@@ -401,6 +429,7 @@ func (m PlannedMaintenance) MarshalJSON() ([]byte, error) {
Description: m.Description,
Schedule: m.Schedule,
AlertIds: m.RuleIDs,
Scope: m.Scope,
CreatedAt: m.CreatedAt,
CreatedBy: m.CreatedBy,
UpdatedAt: m.UpdatedAt,
@@ -424,6 +453,7 @@ func (m *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
Description: m.Description,
Schedule: m.Schedule,
RuleIDs: ruleIDs,
Scope: m.Scope,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
CreatedBy: m.CreatedBy,

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/common/model"
)
// Helper function to create a time pointer.
@@ -668,9 +669,193 @@ func TestShouldSkipMaintenance(t *testing.T) {
}
for idx, c := range cases {
result := c.maintenance.ShouldSkip(c.name, c.ts)
result, err := c.maintenance.ShouldSkip(c.name, c.ts, model.LabelSet{})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if result != c.skip {
t.Errorf("skip %v, got %v, case:%d - %s", c.skip, result, idx, c.name)
}
}
}
func TestShouldSkip_Scope(t *testing.T) {
activeSchedule := func() *Schedule {
return &Schedule{
Timezone: "UTC",
StartTime: time.Now().UTC().Add(-time.Hour),
EndTime: time.Now().UTC().Add(time.Hour),
}
}
now := time.Now().UTC()
cases := []struct {
name string
maintenance *PlannedMaintenance
ruleID string
ts time.Time
lset model.LabelSet
skip bool
}{
{
name: "empty scope - no label filtering applied",
maintenance: &PlannedMaintenance{Schedule: activeSchedule()},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
skip: true,
},
{
name: "scope matches labels",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
skip: true,
},
{
name: "scope does not match labels",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},
skip: false,
},
{
name: "AND expression - both conditions match",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" AND service = "api"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production", "service": "api"},
skip: true,
},
{
name: "AND expression - one condition does not match",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" AND service = "api"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production", "service": "worker"},
skip: false,
},
{
name: "OR expression - first alternative matches",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" OR env = "staging"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
skip: true,
},
{
name: "OR expression - second alternative matches",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" OR env = "staging"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},
skip: true,
},
{
name: "OR expression - neither alternative matches",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" OR env = "staging"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "development"},
skip: false,
},
{
name: "scope references label absent from lset",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"service": "api"},
skip: false,
},
{
name: "in expression - value is in list",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env in ["production", "staging"]`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},
skip: true,
},
{
name: "in expression - value not in list",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env in ["production", "staging"]`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "development"},
skip: false,
},
{
name: "ruleID in list and scope matches - should skip",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), RuleIDs: []string{"rule-1", "rule-2"}, Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
skip: true,
},
{
name: "ruleID not in list and scope matches - ruleID gate prevents skip",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), RuleIDs: []string{"rule-2"}, Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
skip: false,
},
{
name: "ruleID in list but scope does not match - should not skip",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), RuleIDs: []string{"rule-1"}, Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},
skip: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := c.maintenance.ShouldSkip(c.ruleID, c.ts, c.lset)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if got != c.skip {
t.Errorf("ShouldSkip() = %v, want %v", got, c.skip)
}
})
}
}
func TestPostablePlannedMaintenance_ValidateScope(t *testing.T) {
validSchedule := &Schedule{
Timezone: "UTC",
StartTime: time.Now().UTC(),
EndTime: time.Now().UTC().Add(time.Hour),
}
cases := []struct {
name string
scope string
wantErr bool
}{
{name: "empty scope", scope: "", wantErr: false},
{name: "simple equality", scope: `env = "production"`, wantErr: false},
{name: "AND expression", scope: `env = "production" AND service = "api"`, wantErr: false},
{name: "OR expression", scope: `env = "production" OR env = "staging"`, wantErr: false},
{name: "in expression", scope: `env in ["production", "staging"]`, wantErr: false},
{name: "incomplete expression", scope: `env =`, wantErr: true},
{name: "non-bool expression", scope: `"just a string"`, wantErr: true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p := &PostablePlannedMaintenance{
Name: "test",
Schedule: validSchedule,
Scope: c.scope,
}
err := p.Validate()
if (err != nil) != c.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, c.wantErr)
}
})
}
}

View File

@@ -108,6 +108,7 @@ func (s *Schedule) UnmarshalJSON(data []byte) error {
if err != nil {
return err
}
// TODO(jatinderjit): if endTime.IsZero() then we should not set the endTime
s.EndTime = time.Date(endTime.Year(), endTime.Month(), endTime.Day(), endTime.Hour(), endTime.Minute(), endTime.Second(), endTime.Nanosecond(), loc)
}

View File

@@ -33,7 +33,7 @@ func buildTraceFromSpans(spans ...*WaterfallSpan) *WaterfallTrace {
endTime = end
}
}
return NewWaterfallTrace(startTime, endTime, uint64(len(spanMap)), 0, spanMap, nil, nil, false)
return NewWaterfallTrace(startTime, endTime, uint64(len(spanMap)), 0, spanMap, nil, false)
}
var (

View File

@@ -20,50 +20,45 @@ type TraceSummary struct {
// WaterfallTrace holds processed trace data with childern populated in spans.
type WaterfallTrace struct {
StartTime uint64 `json:"startTime"`
EndTime uint64 `json:"endTime"`
TotalSpans uint64 `json:"totalSpans"`
TotalErrorSpans uint64 `json:"totalErrorSpans"`
ServiceNameToTotalDurationMap map[string]uint64 `json:"serviceNameToTotalDurationMap"`
SpanIDToSpanNodeMap map[string]*WaterfallSpan `json:"spanIdToSpanNodeMap"`
TraceRoots []*WaterfallSpan `json:"traceRoots"`
HasMissingSpans bool `json:"hasMissingSpans"`
StartTime uint64 `json:"startTime"`
EndTime uint64 `json:"endTime"`
TotalSpans uint64 `json:"totalSpans"`
TotalErrorSpans uint64 `json:"totalErrorSpans"`
SpanIDToSpanNodeMap map[string]*WaterfallSpan `json:"spanIdToSpanNodeMap"`
TraceRoots []*WaterfallSpan `json:"traceRoots"`
HasMissingSpans bool `json:"hasMissingSpans"`
}
// GettableWaterfallTrace is the response for the v3 waterfall API.
type GettableWaterfallTrace struct {
StartTimestampMillis uint64 `json:"startTimestampMillis"`
EndTimestampMillis uint64 `json:"endTimestampMillis"`
RootServiceName string `json:"rootServiceName"`
RootServiceEntryPoint string `json:"rootServiceEntryPoint"`
TotalSpansCount uint64 `json:"totalSpansCount"`
TotalErrorSpansCount uint64 `json:"totalErrorSpansCount"`
// Deprecated: use Aggregations with SpanAggregationExecutionTimePercentage on the service.name field instead.
ServiceNameToTotalDurationMap map[string]uint64 `json:"serviceNameToTotalDurationMap"`
Spans []*WaterfallSpan `json:"spans"`
HasMissingSpans bool `json:"hasMissingSpans"`
UncollapsedSpans []string `json:"uncollapsedSpans"`
HasMore bool `json:"hasMore"`
Aggregations []SpanAggregationResult `json:"aggregations"`
StartTimestampMillis uint64 `json:"startTimestampMillis"`
EndTimestampMillis uint64 `json:"endTimestampMillis"`
RootServiceName string `json:"rootServiceName"`
RootServiceEntryPoint string `json:"rootServiceEntryPoint"`
TotalSpansCount uint64 `json:"totalSpansCount"`
TotalErrorSpansCount uint64 `json:"totalErrorSpansCount"`
Spans []*WaterfallSpan `json:"spans"`
HasMissingSpans bool `json:"hasMissingSpans"`
UncollapsedSpans []string `json:"uncollapsedSpans"`
HasMore bool `json:"hasMore"`
Aggregations []SpanAggregationResult `json:"aggregations"`
}
// NewWaterfallTrace constructs a WaterfallTrace from processed span data.
func NewWaterfallTrace(
startTime, endTime, totalSpans, totalErrorSpans uint64,
spanIDToSpanNodeMap map[string]*WaterfallSpan,
serviceNameToTotalDurationMap map[string]uint64,
traceRoots []*WaterfallSpan,
hasMissingSpans bool,
) *WaterfallTrace {
return &WaterfallTrace{
StartTime: startTime,
EndTime: endTime,
TotalSpans: totalSpans,
TotalErrorSpans: totalErrorSpans,
SpanIDToSpanNodeMap: spanIDToSpanNodeMap,
ServiceNameToTotalDurationMap: serviceNameToTotalDurationMap,
TraceRoots: traceRoots,
HasMissingSpans: hasMissingSpans,
StartTime: startTime,
EndTime: endTime,
TotalSpans: totalSpans,
TotalErrorSpans: totalErrorSpans,
SpanIDToSpanNodeMap: spanIDToSpanNodeMap,
TraceRoots: traceRoots,
HasMissingSpans: hasMissingSpans,
}
}
@@ -124,7 +119,6 @@ func NewWaterfallTraceFromSpans(spans []StorableSpan) *WaterfallTrace {
uint64(len(spans)),
totalErrorSpans,
spanIDToSpanNodeMap,
calculateServiceTime(spanIDToSpanNodeMap),
traceRoots,
hasMissingSpans,
)
@@ -206,23 +200,19 @@ func (wt *WaterfallTrace) CalculateUncollapsedSpanIDs(uncollapsedSpanIDs []strin
}
func (wt *WaterfallTrace) Clone() cachetypes.Cacheable {
copyOfServiceNameToTotalDurationMap := make(map[string]uint64)
maps.Copy(copyOfServiceNameToTotalDurationMap, wt.ServiceNameToTotalDurationMap)
copyOfSpanIDToSpanNodeMap := make(map[string]*WaterfallSpan)
maps.Copy(copyOfSpanIDToSpanNodeMap, wt.SpanIDToSpanNodeMap)
copyOfTraceRoots := make([]*WaterfallSpan, len(wt.TraceRoots))
copy(copyOfTraceRoots, wt.TraceRoots)
return &WaterfallTrace{
StartTime: wt.StartTime,
EndTime: wt.EndTime,
TotalSpans: wt.TotalSpans,
TotalErrorSpans: wt.TotalErrorSpans,
ServiceNameToTotalDurationMap: copyOfServiceNameToTotalDurationMap,
SpanIDToSpanNodeMap: copyOfSpanIDToSpanNodeMap,
TraceRoots: copyOfTraceRoots,
HasMissingSpans: wt.HasMissingSpans,
StartTime: wt.StartTime,
EndTime: wt.EndTime,
TotalSpans: wt.TotalSpans,
TotalErrorSpans: wt.TotalErrorSpans,
SpanIDToSpanNodeMap: copyOfSpanIDToSpanNodeMap,
TraceRoots: copyOfTraceRoots,
HasMissingSpans: wt.HasMissingSpans,
}
}
@@ -257,11 +247,6 @@ func NewGettableWaterfallTrace(
rootServiceEntryPoint = traceData.TraceRoots[0].Name
}
serviceDurationsMillis := make(map[string]uint64, len(traceData.ServiceNameToTotalDurationMap))
for svc, dur := range traceData.ServiceNameToTotalDurationMap {
serviceDurationsMillis[svc] = dur / 1_000_000
}
// convert start timestamp to millis because client is expecting it in millis
for _, span := range selectedSpans {
span.TimeUnix = span.TimeUnix / 1_000_000
@@ -277,18 +262,17 @@ func NewGettableWaterfallTrace(
}
return &GettableWaterfallTrace{
Spans: selectedSpans,
UncollapsedSpans: uncollapsedSpans,
StartTimestampMillis: traceData.StartTime / 1_000_000,
EndTimestampMillis: traceData.EndTime / 1_000_000,
TotalSpansCount: traceData.TotalSpans,
TotalErrorSpansCount: traceData.TotalErrorSpans,
RootServiceName: rootServiceName,
RootServiceEntryPoint: rootServiceEntryPoint,
ServiceNameToTotalDurationMap: serviceDurationsMillis,
HasMissingSpans: traceData.HasMissingSpans,
HasMore: !selectAllSpans,
Aggregations: aggregations,
Spans: selectedSpans,
UncollapsedSpans: uncollapsedSpans,
StartTimestampMillis: traceData.StartTime / 1_000_000,
EndTimestampMillis: traceData.EndTime / 1_000_000,
TotalSpansCount: traceData.TotalSpans,
TotalErrorSpansCount: traceData.TotalErrorSpans,
RootServiceName: rootServiceName,
RootServiceEntryPoint: rootServiceEntryPoint,
HasMissingSpans: traceData.HasMissingSpans,
HasMore: !selectAllSpans,
Aggregations: aggregations,
}
}
@@ -311,21 +295,6 @@ func windowAroundIndex(selectedIndex, total int, spanLimitPerRequest float64) (s
return
}
func calculateServiceTime(spanIDToSpanNodeMap map[string]*WaterfallSpan) map[string]uint64 {
serviceSpans := make(map[string][]*WaterfallSpan)
for _, span := range spanIDToSpanNodeMap {
if span.ServiceName != "" {
serviceSpans[span.ServiceName] = append(serviceSpans[span.ServiceName], span)
}
}
totalTimes := make(map[string]uint64)
for service, spans := range serviceSpans {
totalTimes[service] = mergeSpanIntervals(spans)
}
return totalTimes
}
// mergeSpanIntervals computes non-overlapping execution time for a set of spans.
func mergeSpanIntervals(spans []*WaterfallSpan) uint64 {
if len(spans) == 0 {

View File

@@ -15,22 +15,21 @@ type Config struct {
// The directory from which to serve the web files.
Directory string `mapstructure:"directory"`
// Settings that are exposed to the web.
Settings Settings `mapstructure:"settings"`
// Web settings configuration.
Settings SettingsConfig `mapstructure:"settings"`
}
// Settings that are exposed to the web.
type Settings struct {
Posthog Posthog `mapstructure:"posthog"`
Appcues Appcues `mapstructure:"appcues"`
// SettingsConfig holds the configuration for web settings.
type SettingsConfig struct {
Posthog PosthogConfig `mapstructure:"posthog"`
Appcues AppcuesConfig `mapstructure:"appcues"`
}
type Posthog struct {
type PosthogConfig struct {
Enabled bool `mapstructure:"enabled"`
}
type Appcues struct {
type AppcuesConfig struct {
Enabled bool `mapstructure:"enabled"`
}
@@ -43,11 +42,11 @@ func newConfig() factory.Config {
Enabled: true,
Index: "index.html",
Directory: "/etc/signoz/web",
Settings: Settings{
Posthog: Posthog{
Settings: SettingsConfig{
Posthog: PosthogConfig{
Enabled: true,
},
Appcues: Appcues{
Appcues: AppcuesConfig{
Enabled: true,
},
},

View File

@@ -44,7 +44,8 @@ func New(ctx context.Context, settings factory.ProviderSettings, config web.Conf
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot read %q in web directory", config.Index)
}
settingsJSON, err := json.Marshal(config.Settings)
webSettings := web.NewSettings(config)
settingsJSON, err := json.Marshal(webSettings)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "cannot marshal web settings to JSON")
}

View File

@@ -118,9 +118,9 @@ func TestServeTemplatedIndex(t *testing.T) {
webConfig: web.Config{
Index: "valid_template.html",
Directory: "testdata",
Settings: web.Settings{
Posthog: web.Posthog{Enabled: true},
Appcues: web.Appcues{Enabled: true},
Settings: web.SettingsConfig{
Posthog: web.PosthogConfig{Enabled: true},
Appcues: web.AppcuesConfig{Enabled: true},
},
},
expected: expectedHTML("/", web.Settings{

25
pkg/web/settings.go Normal file
View File

@@ -0,0 +1,25 @@
package web
type Settings struct {
Posthog Posthog `json:"posthog" required:"true"`
Appcues Appcues `json:"appcues" required:"true"`
}
type Posthog struct {
Enabled bool `json:"enabled" required:"true"`
}
type Appcues struct {
Enabled bool `json:"enabled" required:"true"`
}
func NewSettings(config Config) Settings {
return Settings{
Posthog: Posthog{
Enabled: config.Settings.Posthog.Enabled,
},
Appcues: Appcues{
Enabled: config.Settings.Appcues.Enabled,
},
}
}