Compare commits

...

82 Commits

Author SHA1 Message Date
Yunus M
13f31ff52d feat: use default domain url for X-SigNoz-URL 2026-05-08 15:43:57 +05:30
Yunus M
74b531b32f Merge branch 'main' into feat/ai-assistant 2026-05-08 13:30:50 +05:30
SagarRajput-7
757c4a84c0 feat(ingestion): make set alert on ingestion limit prominent and added default alert name (#11215)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(ingestion): added default alert name when coming from ingestion limit

* feat(ingestion): added helper text and set alert badge for ingestion

* feat(ingestion): removed antd button, used signozhq library

* feat(ingestion): added test cases

* feat(ingestion): code refactor

* feat(ingestion): updated test cases

* feat(ingestion): base path fixes

* feat(ingestion): pass on relevant unit also from ingestion to alert

* feat(ingestion): updated test cases

* feat(ingestion): updated alert name format
2026-05-07 23:01:33 +00:00
Vinicius Lourenço
f198d20da7 feat(typography): migrate to @signozhq/ui (#11199)
* feat(typography): migrate to @signozhq/ui

* test(ingestion-settings): add more timeout for test

* fix(typography): missing few places introduced by merge main

* fix(traces): blue links instead of l1 foreground

* fix(span-duration): fix issue with bad mocking
2026-05-07 18:37:40 +00:00
Yunus M
3916565d6f chore: fmt 2026-05-07 23:22:54 +05:30
Yunus M
4151ce4946 Merge branch 'main' into feat/ai-assistant 2026-05-07 23:14:21 +05:30
Yunus M
0f275089cd chore: increase margin for title in Block component for improved spacing 2026-05-07 22:59:29 +05:30
Ashwin Bhatkal
ae2127afe8 test: dashboards list spec with new e2e framework (#11190)
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
* test: dashboards list spec with new e2e framework

* chore: update docker ignore

* test: dashboards list spec with new e2e framework

* test: fix skipped ones

* test: fix scroll

* test: fix flaky clicks

* test: fix formatting

* chore: doc update + ignore file changes

* chore: update fixtures vs helpers

* chore: resolve comments

* chore: resolve comments
2026-05-07 14:55:38 +00:00
Vikrant Gupta
0e97204c77 feat(sqlstore): enable transaction_mode immediate (#10825)
* feat(sqlstore): enable transaction_mode immediate

* feat(sqlstore): fix dashboard delete

* feat(sqlstore): fix rebase issue

* feat(sqlstore): fix golang lint

* feat(sqlstore): do not start with default immediate

* feat(sqlstore): revert the integrationci changes
2026-05-07 14:41:35 +00:00
Aditya Singh
f065edf53f Feat: Trace Details Revamp (#10973)
* feat: flamegraph canvas init

* feat: add text to spans

* feat: added timeline v3

* feat: zoom and drag added

* feat: update span colors

* feat: handle click and hover with tooltip

* feat: temp change

* feat: fix timerange unit selection when zoomed

* feat: scroll to selected span

* feat: fix style

* feat: fix style

* feat: reduce timeline intervals

* fix: style fix

* fix: update color

* feat: bg color for selected and hover spans

* feat: remove unnecessary props

* feat: minor comment added

* feat: add test cases for flamegraph

* feat: add test utils

* feat: waterfall init

* 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>

* 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>

* feat: match span style

* feat: fix hover option overflow

* feat: span hover popover sync

* feat: row based flamegraph

* feat: subtree segregated tree

* feat: subtree segregated tree

* feat: subtree segregated tree

* feat: move to service worker

* feat: connector line ux

* feat: event dots in trace details

* feat: waterfall resizable

* feat: span details init

* feat: span details header

* feat: details field component

* feat: added span percentile

* feat: key attr section added

* feat: added pretty view

* feat: update yarn lock

* feat: minor change

* feat: search in pretty view

* feat: refactor

* feat: style fix

* feat: json viewer with select dropdown added

* feat: span details floating drawer added

* feat: span details folder rename

* feat: replace draggable package

* feat: fix pinning. fix drag on top

* feat: add bound to drags while floating

* feat: add collapsible sections in trace details

* feat: use resizable for waterfall table as well

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

* feat: fix span details headr

* feat: key value label style fixes

* feat: linked spans

* feat: style fixes

* feat: setup types and interface for waterfall v3

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

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

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

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

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

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* feat: api integration

* feat: add limit

* feat: minor change

* feat: supress click

* chore: generate openapi spec for v3 waterfall

* feat: fix test

* feat: fix test

* feat: lint fix

* feat: span details ux

* feat: analytics

* feat: add icons

* feat: added loading to flamegraph and timeout to webworker

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

* feat: auto scroll horizontally to span

* feat: show total span count

* feat: disable anaytics span tab for now

* feat: add span details loader

* feat: prevent api call on closing span detail

* fix: remove timeout since waterfall take longer

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

* fix: update openapi specs

* feat: make filter and search work with flamegraph

* feat: filter ui fix

* feat: remove trace header

* feat: new filter ui

* feat: setup types and interface for waterfall v3

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

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

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

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

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

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* chore: generate openapi spec for v3 waterfall

* fix: remove timeout since waterfall take longer

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

* fix: update openapi specs

* feat: api integration

* feat: automatically scroll left on vertical scroll

* feat: reduce time

* feat: set limit to 100k for flamegraph

* feat: show child count in waterfall

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

* feat: fix flamegraph and waterfall bg color

* feat: show caution on sampled flamegraph

* feat: api integration v3

* feat: disable scroll to view for collapse and uncollapse

* feat: setup types and interface for waterfall v3

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

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

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

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

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

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* chore: generate openapi spec for v3 waterfall

* fix: remove timeout since waterfall take longer

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

* fix: update openapi specs

* refactor: break down GetWaterfall method for readability

* chore: avoid returning nil, nil

* refactor: move type creation and constants to types package

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

* refactor: extract ClickHouse queries into a store abstraction

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

* refactor: move error to types as well

* refactor: separate out store calls and computations

* refactor: breakdown GetSelectedSpans for readability

* refactor: return 404 on missing trace and other cleanup

* refactor: use same method for cache key creation

* chore: remove unused duration nano field

* chore: use sqlbuilder in clickhouse store where possible

* feat: dropdown added to span details

* feat: fix color duplications

* feat: no data screen

* feat: old trace btn added

* feat: minor fix

* feat: rename copy to copy value

* feat: delete unused file

* feat: use semantic tokens

* feat: use semantic tokens

* feat: add crosshair

* feat: fix test

* feat: disable crosshair in waterfall

* feat: fix colors

* feat: minor fix

* feat: add status codes

* feat: load all spans in waterfall under limit

* feat: uncollapse spans on select from flamegraph

* feat: style fix

* feat: add service name

* feat: open in new tab

* feat: delete waterfall go

* feat: minor change

* feat: minor change

* feat: minor refactors

* feat: minor refactors

* feat: v3 behind feature flag

* feat: minor refactors

* feat: packages remove

* feat: packages remove

* feat: remove common component

* feat: remove antd component usage

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Nikhil Soni <nikhil.soni@signoz.io>
2026-05-07 13:10:49 +00:00
Vinicius Lourenço
382cd57a6a feat(infra-monitoring): show total items count on list page (#11212) 2026-05-07 12:06:04 +00:00
Yunus M
54d5504e32 refactor: update color variables in AI Assistant styles for consistency and improved theming 2026-05-07 17:33:34 +05:30
Vishal Sharma
9915857a36 Merge branch 'main' into feat/ai-assistant 2026-05-07 16:57:57 +05:30
Vinicius Lourenço
fefef70d84 fix(lint-staged): run format for css files & parallel tsgo check (#11213) 2026-05-07 11:02:21 +00:00
Nikhil Mantri
9e94ee30b9 feat(infra-monitoring): v2 nodes list api (#11128)
* chore: baseline setup

* chore: endpoint detail update

* chore: added logic for hosts v3 api

* fix: bug fix

* chore: disk usage

* chore: added validate function

* chore: added some unit tests

* chore: return status as a string

* chore: yarn generate api

* chore: removed isSendingK8sAgentsMetricsCode

* chore: moved funcs

* chore: added validation on order by

* chore: added pods list logic

* chore: updated openapi yml

* chore: updated spec

* chore: pods api meta start time

* chore: nil pointer check

* chore: nil pointer dereference fix in req.Filter

* chore: added temporalities of metrics

* chore: added pods metrics temporality

* chore: unified composite key function

* chore: code improvements

* chore: added pods list api updates

* chore: hostStatusNone added for clarity that this field can be left empty as well in payload

* chore: yarn generate api

* chore: return errors from getMetadata and lint fix

* chore: return errors from getMetadata and lint fix

* chore: added hostName logic

* chore: modified getMetadata query

* chore: add type for response and files rearrange

* chore: warnings added passing from queryResponse warning to host lists response struct

* chore: added better metrics existence check

* chore: added a TODO remark

* chore: added required metrics check

* chore: distributed samples table to local table change for get metadata

* chore: frontend fix

* chore: endpoint correction

* chore: endpoint modification openapi

* chore: escape backtick to prevent sql injection

* chore: rearrage

* chore: improvements

* chore: validate order by to validate function

* chore: improved description

* chore: added TODOs and made filterByStatus a part of filter struct

* chore: ignore empty string hosts in get active hosts

* feat(infra-monitoring): v2 hosts list - return counts of active & inactive hosts for custom group by attributes (#10956)

* chore: add functionality for showing active and inactive counts in custom group by

* chore: bug fix

* chore: added subquery for active and total count

* chore: ignore empty string hosts in get active hosts

* fix: sinceUnixMilli for determining active hosts compute once per request

* chore: refactor code

* chore: rename HostsList -> ListHosts

* chore: rearrangement

* chore: inframonitoring types renaming

* chore: added types package

* chore: file structure further breakdown for clarity

* chore: comments correction

* chore: removed temporalities

* chore: pods code restructuring

* chore: comments resolve

* chore: added json tag required: true

* chore: removed pod metric temporalities

* chore: removed internal server error

* chore: added status unauthorized

* chore: remove a defensive nil map check, the function ensure non-nil map when err nil

* chore: cleanup and rename

* chore: make sort stable in case of tiebreaker by comparing composite group by keys

* chore: regen api client for inframonitoring

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: added phase counts feature

* chore: added queries for pod phase counts in custom group by

* chore: added required tags

* chore: added support for pod phase unknown

* chore: removed pods - order by phase

* chore: improved api description to document -1 as no data in numeric fields

* fix: rebase fixes

* chore: added unknown phase count

* fix: isPodUIDInGroupBy in buildPodRecords

* chore: 3 cte --> 2 cte

* chore: pod phase with local table of time series as counts

* chore: comment correction

* chore: corrected comment

* chore: value column for samples table added

* chore: removed query G for phase counts

* chore: rename variable

* chore: added PodPhaseNum constants to types

* feat(infra-monitoring): v2 pods list apis - phase counts when custom grouping (#11088)

* chore: added phase counts feature

* chore: added queries for pod phase counts in custom group by

* chore: added unknown phase count

* fix: isPodUIDInGroupBy in buildPodRecords

* chore: 3 cte --> 2 cte

* chore: pod phase with local table of time series as counts

* chore: comment correction

* chore: corrected comment

* chore: value column for samples table added

* chore: removed query G for phase counts

* chore: rename variable

* chore: added PodPhaseNum constants to types

* chore: nodes list v2 full blown

* chore: metadata fix

* chore: updated comment

* chore: v2 nodes api

* chore: added pod phase counts

* chore: for pods and nodes, replace none with no_data

* chore: node and pod counts structs added

* chore: strongly type meta

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-05-07 10:46:02 +00:00
Yunus M
ab7654f6ea feat: implement SSE backoff strategy and error handling in AI Assistant 2026-05-06 16:22:04 +05:30
Yunus M
e39d112809 feat: enhance AI Assistant conversation handling with hydration state 2026-05-06 15:43:56 +05:30
Yunus M
86dd140f7a feat: implement AI API instance with request/response interceptors 2026-05-06 14:30:51 +05:30
Yunus M
24618a7104 fix: skip custom AI block markers in language validation script 2026-05-06 13:30:06 +05:30
Yunus M
66370ec10f Merge branch 'main' into feat/ai-assistant 2026-05-06 12:39:31 +05:30
Yunus M
f036e98947 Merge branch 'main' into feat/ai-assistant 2026-05-05 19:53:59 +05:30
Yunus M
1b516a4d64 chore: remove hardcoded url 2026-05-05 19:52:30 +05:30
Yunus M
681e9772fb feat: add header actions to TraceDetailV2 for sharing and feedback 2026-05-05 19:04:11 +05:30
Yunus M
6db9b2455d feat: add SigNoz URL header to AI Assistant requests for multi-tenant support 2026-05-05 17:52:47 +05:30
makeavish
e5b54cd8f9 fix(frontend): position chat support around AI assistant 2026-05-05 16:57:04 +05:30
Yunus M
b3e3ace233 feat: pass comment for negative feedback 2026-05-05 11:17:31 +05:30
Yunus M
439d0174af feat: implement message regeneration functionality in AI Assistant 2026-05-05 00:12:01 +05:30
Yunus M
8700bc2f99 feat: add displayText property to ToolCallBlock 2026-05-04 23:59:19 +05:30
Yunus M
29e6b016d0 feat: update AI Assistant UI components with new icons 2026-05-04 23:49:55 +05:30
Yunus M
3313fa43ba feat: enhance conversation item and history sidebar with search functionality and dropdown actions 2026-05-04 21:40:34 +05:30
Yunus M
fd17be12d1 refactor: update styles and structure for MessageBubble, ThinkingStep, and ToolCallStep components 2026-05-04 21:10:27 +05:30
Yunus M
c427bfaf10 refactor: remove AI backend URL from environment variables 2026-05-04 20:00:23 +05:30
Yunus M
02a5c50c74 Merge branch 'main' into feat/ai-assistant 2026-05-04 19:26:59 +05:30
Yunus M
613ebc325e refactor: remove edit and resend functionality, streamline message handling 2026-05-04 18:55:16 +05:30
Yunus M
33c5239c6c feat: enhance AI Assistant components with new enums, improved clarification handling, code block 2026-05-03 15:31:15 +05:30
Yunus M
6bc04e98aa chore: update openapi.yaml with improved formatting and structure for API documentation 2026-05-02 19:59:20 +05:30
Yunus M
7e4eda39dc feat: add accessibility attributes and feedback buttons to HeaderRightSection 2026-05-02 19:52:03 +05:30
Yunus M
3d944fe064 feat: improve tool call instance rendering 2026-05-02 18:49:55 +05:30
Yunus M
999857cd01 feat: implement AI Assistant UI components for enhanced user interaction 2026-05-02 16:50:28 +05:30
Yunus M
9175356c46 Merge branch 'main' into feat/ai-assistant 2026-05-01 19:52:13 +05:30
Yunus M
808add5401 feat: add edit and resend functionality to user messages in ConversationView and ChatInput 2026-05-01 19:50:54 +05:30
Yunus M
3ac0c2f08a feat: implement push-to-talk functionality in ChatInput for improved voice interaction 2026-05-01 19:27:14 +05:30
Yunus M
df6aefa243 refactor: streamline Spinner component and update ChatInput styles for improved UI consistency 2026-05-01 19:09:44 +05:30
Yunus M
fd4f5f085b refactor: enhance AIAssistantModal and ConversationItem components with improved key handling and UI 2026-05-01 18:15:24 +05:30
Yunus M
292f99b922 Merge branch 'main' into feat/ai-assistant 2026-05-01 17:33:28 +05:30
Yunus M
46d630a38e refactor: enhance ChatInput component with improved overflow handling and context fetching logic 2026-05-01 17:31:10 +05:30
Yunus M
2df265abbf refactor: support auto-derived contexts and enhance diff display functionality 2026-05-01 15:39:01 +05:30
Yunus M
02a743f8ab refactor: implement AIAssistant axios instance and enhance SSE authentication handling 2026-04-30 21:52:33 +05:30
Yunus M
b8073ab33b refactor: enhance ConversationView loading state and improve action key stability 2026-04-30 19:32:06 +05:30
Yunus M
4ddebbaa07 refactor: improve loading indicators in HistorySidebar component 2026-04-30 18:36:53 +05:30
Yunus M
b55d1a2683 refactor: simplify action handling and enhance streaming message indicators 2026-04-30 18:31:36 +05:30
Yunus M
f11fb74584 refactor: update AIAssistant components to use Button from Signoz UI and enhance styling 2026-04-30 17:45:10 +05:30
Yunus M
4d91273d58 feat: enhance AI Assistant with new API integration and UI components 2026-04-30 17:18:19 +05:30
Yunus M
d6ec1295d7 feat: move to css modules 2026-04-30 14:41:40 +05:30
Yunus M
0dc1dfc1cb Merge branch 'main' into feat/ai-assistant 2026-04-29 18:20:33 +05:30
Yunus M
49e1b43317 feat: update AI Assistant styles and components to use new icon library and improve layout 2026-04-28 21:45:47 +05:30
Yunus M
a240aed898 feat: add MessageContext interface and enhance message sending functionality in AI Assistant 2026-04-28 21:19:00 +05:30
Yunus M
adc79062ed feat: streamline AI Assistant UI components and improve styles 2026-04-28 19:41:34 +05:30
Yunus M
4b1bb1aef7 feat: implement conversation archiving and restoration functionality in AI Assistant 2026-04-28 11:16:58 +05:30
Yunus M
a0f3b27d15 feat: enhance AI Assistant button with pending user input badge and styles 2026-04-28 00:39:59 +05:30
Yunus M
b3413230c9 feat: add character limit warning to ChatInput component 2026-04-28 00:02:17 +05:30
Yunus M
38f742a470 feat: update openapi.yml and improve ui 2026-04-27 23:52:44 +05:30
Yunus M
1c1fba9ea1 Merge branch 'main' into feat/ai-assistant 2026-04-27 17:41:07 +05:30
Yunus M
2dadb2b39b feat: integrate AI assistant feature with conditional routing and environment configuration 2026-04-22 12:57:14 +05:30
Yunus M
a595feb980 refactor: improve code readability and consistency 2026-04-22 10:36:11 +05:30
Yunus M
40f6994042 feat: enhance home header layout with new HeaderRightSection component and updated styles 2026-04-22 10:00:28 +05:30
Yunus M
542d984d91 refactor: migrate button components to @signozhq/ui and update styles for consistency 2026-04-22 09:54:32 +05:30
Yunus M
c6a885bc31 Merge branch 'main' into feat/ai-assistant 2026-04-22 09:35:32 +05:30
Yunus M
83a9d8fbfe refactor: update Tooltip imports to use @signozhq/ui across components 2026-04-21 14:43:38 +05:30
Yunus M
17a015d244 Merge branch 'main' into feat/ai-assistant 2026-04-21 14:40:53 +05:30
Yunus M
7f9f383a95 Merge branch 'main' into feat/ai-assistant 2026-04-17 20:21:50 +05:30
Yunus M
17a7227831 feat: introduce thinking step and message block structure in AI Assistant 2026-04-17 20:00:26 +05:30
Yunus M
9c8846ae63 chore: remove unused icons and page 2026-04-07 17:26:31 +05:30
Yunus M
18bb87f778 feat: refactor AI Assistant state management to support per-conversation streaming 2026-04-07 16:00:58 +05:30
Yunus M
71a5e4500c feat: implement thread management and feedback submission in AI Assistant 2026-04-06 14:50:27 +05:30
Yunus M
7305470e62 feat: enhance AI Assistant SSE event handling and markdown rendering
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:18:08 +05:30
Yunus M
1e140285ae feat: implement message feedback component and enhance message bubble styling 2026-04-03 14:36:19 +05:30
Yunus M
4f039da2a6 feat: enhance voice input functionality and integrate streaming chat 2026-04-02 01:35:09 +05:30
Yunus M
d4afc49882 feat: add AI Assistant action block and speech recognition capabilities 2026-04-01 19:25:16 +05:30
Yunus M
2bce8c9ea0 chore: format taglines for better readability in AIAssistantIconPreview 2026-03-31 16:18:03 +05:30
Yunus M
cae757041a feat: add AI Assistant with interactive blocks for data visualization 2026-03-31 16:16:32 +05:30
Yunus M
adabd1d8db feat: add AI Assistant with interactive blocks for data visualization 2026-03-30 22:08:27 +05:30
651 changed files with 83391 additions and 3962 deletions

View File

@@ -0,0 +1,163 @@
---
name: playwright-test-generator
description: Use this agent to convert a SigNoz E2E test plan into Playwright spec files under `tests/e2e/tests/<feature>/`. Examples — <example>Context: A test plan exists and needs to be turned into runnable specs. user: 'Generate the dashboards list specs from the plan in tests/e2e/specs/dashboards-list-test-plan.md' assistant: 'Using the generator agent to drive each scenario in a real browser and write the corresponding Playwright tests.'</example>
tools: Glob, Grep, Read, Bash, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test
model: sonnet
color: blue
---
You are the Playwright Test Generator for the SigNoz frontend. You take a plan written by `playwright-test-planner` and produce runnable Playwright specs that match the conventions documented in [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md). **Read that doc first.** Adhere to it.
# Repo conventions you must follow
- **Spec location:** `tests/e2e/tests/<feature>/<spec-name>.spec.ts`. One file per resource; cross-resource concerns get their own file. Don't repeat the feature name in the filename — the directory already provides it. `dashboards/list.spec.ts`, not `dashboards/dashboards-list.spec.ts`.
- **Auth fixture:** import `test` and `expect` from `'../../fixtures/auth'`, not `@playwright/test`. Specs receive an admin-authenticated page via the `authedPage` fixture (the only user the bootstrap seeds).
```ts
import { test, expect } from '../../fixtures/auth';
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
await page.goto('/alerts');
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
});
```
- **Test titles:** `TC-NN <short description>` — matches the planner's IDs.
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two cleanup shapes are valid; pick based on the spec size:
- **Per-test `try / finally`** — small specs (~ <10 scenarios) where each test owns its data.
- **Suite-level `beforeAll` + `afterAll` with a `seedIds: Set<string>` registry** — preferred for larger specs. Reduces per-test boilerplate, and one cleanup loop handles every dashboard the suite touched. See [tests/e2e/tests/dashboards/list.spec.ts](../../tests/e2e/tests/dashboards/list.spec.ts) for the canonical shape.
- **Reuse helpers from `tests/e2e/helpers/`.** Don't reinvent. The current set:
- [`helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) — `newAdminContext(browser)` for `beforeAll` / `afterAll` (the `authedPage` fixture is test-scoped and not visible to suite hooks).
- [`helpers/dashboards.ts`](../../tests/e2e/helpers/dashboards.ts) — `authToken`, `gotoDashboardsList`, `createDashboardViaApi`, `importApmMetricsDashboardViaUI`, `deleteDashboardViaApi`, `findDashboardIdByTitle`, `openDashboardActionMenu`, plus the constants used by both helpers and specs (`SEARCH_PLACEHOLDER`, `LIST_HEADING`, `APM_METRICS_TITLE`, `DEFAULT_DASHBOARD_TITLE`).
- **Seed via API when the UI flow is multi-step or brittle.** Implementation lives in `createDashboardViaApi` — use it. `page.request.*` does **not** auto-attach `Authorization`; the helpers handle that for you. The "Enter dashboard name…" inline input on the dashboards list page is a `RequestDashboardBtn` template-feedback form, **not** a create flow — never use it to seed.
- **Reusable JSON fixtures live in [tests/e2e/fixtures/](../../tests/e2e/fixtures/).** `apm-metrics.json` is a real, tag-rich dashboard payload — `importApmMetricsDashboardViaUI(page)` seeds it through the actual Import JSON UI flow.
- **Resource names:** short, descriptive, no timestamps — `dashboards-list-sort-click`, not `Test Dashboard ${Date.now()}`. Each test owns its names; uniqueness comes from cleanup, not disambiguation.
- **Serial mode** when tests in a file mutate the same list page:
```ts
test.describe.configure({ mode: 'serial' });
```
- **Locator priority** (matches Playwright best practice):
1. `data-testid` (preferred — these are stable, app-author-provided handles)
2. `getByRole('button', { name: 'Submit' })`
3. `getByLabel('Email')`, `getByPlaceholder(...)`, `getByText(...)`
4. CSS / `locator('.ant-…')` — last resort
- **Never commit `test.only`.** CI runs with `forbidOnly: true`.
- **No `page.waitForTimeout(ms)`** — always prefer `await expect(locator).toBe…()`.
# Your workflow
For each scenario in the plan:
1. **Read the plan.** Use `Read` to load `tests/e2e/specs/<feature>-test-plan.md` (or the path the user gave). The `specs/` directory is gitignored — plans are scratch input, not committed docs; the generated `.spec.ts` is the source of truth. Lock onto the TC-NN you're generating.
2. **Set up the page.** Call `generator_setup_page` once per scenario before any browser tool. The setup logs in as the admin user (the bootstrap-seeded `admin@integration.test`).
3. **Drive the scenario manually.** For each step in the plan:
- Use the description as the intent (it becomes the comment above the generated step).
- Use the appropriate `mcp__playwright-test__*` browser tool to execute it (click / type / verify / wait).
- For verifications, use the dedicated `browser_verify_*` tools — they capture the assertion as Playwright code in the log.
4. **Read the log.** Call `generator_read_log` immediately after the last step. Don't intersperse other tool calls.
5. **Write the spec.** Call `generator_write_test` with:
- **File path:** `tests/e2e/tests/<feature>/<scenario-slug>.spec.ts` — fs-friendly slug from the scenario title. Drop the feature prefix when it duplicates the directory (`dashboards/list.spec.ts`, not `dashboards/dashboards-list.spec.ts`).
- **Single test per file** if the planner specified one-test-per-file; otherwise group related tests into one file with a shared `test.describe('<Feature>', () => { … })`.
- **`describe` block** matches the top-level plan section.
- **Title** matches `TC-NN <description>` exactly.
- **Comments only where the WHY is non-obvious** — section dividers between TC groups, hidden constraints, gotchas the reader can't infer from the code (e.g. "Monaco swallows Escape — click the title to blur first"). **Do not narrate steps** by pasting the plan's bullets back as `// 1. Navigate…` `// 2. Verify…` comments — the helper / locator names already say what each line does, and the duplication is bloat. If a step's intent isn't clear from the code, rename the helper or extract a variable rather than reaching for a comment.
- **Imports** from `../../fixtures/auth`. **Do not** import from `@playwright/test` directly.
- **Try / finally** cleanup using the API (delete the resources you seeded).
# Quality bar — what to write, what to skip
The point of an E2E test is to catch a real regression. A TC that asserts something the code can't realistically break — a hard-coded string still being on the page, a button still being a button — adds nothing: it inflates the suite, slows CI, and trains future readers to skim past the directory. Push back on the plan when you see it:
- **Skip TCs that don't exercise behaviour.** "Verify the page heading is visible" alone is not a test — fold it into the first real scenario as a smoke-check, don't give it its own TC.
- **Collapse near-duplicates.** Two TCs that differ only in input value (search by title vs search by description, when the underlying code path is the same) should usually merge into one parameterised test, or one of them should be cut.
- **Prefer one assertion-rich test over three thin ones.** A "page chrome" test that checks heading + search + sort + thumbnail in one go is cheaper and more useful than three single-assertion tests.
- **If you're tempted to copy-paste a TC with a tiny tweak**, ask whether the tweak actually exercises a different branch in the source. If not, drop it.
When you cut, merge, or renumber TCs vs the plan, note it in your final summary. The plan and the QA checklist (`tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`) both live downstream of the spec — flag that the user should re-run the planner so plan + checklist re-derive from the current `.spec.ts`. Don't silently skip.
# Example output
For a plan section:
```markdown
### 1. Page Load
#### TC-01 page chrome and core controls render
**Steps:**
1. Navigate to `/dashboard`
2. Verify the page title is "SigNoz | All Dashboards"
3. Verify the heading "Dashboards" is visible
**Cleanup:** delete the seeded dashboard via API.
```
You produce (suite-level shape, preferred for files with multiple scenarios):
```ts
// tests/e2e/tests/dashboards/list.spec.ts
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import {
authToken,
createDashboardViaApi,
deleteDashboardViaApi,
gotoDashboardsList,
} from '../../helpers/dashboards';
test.describe.configure({ mode: 'serial' });
const seedIds = new Set<string>();
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
seedIds.add(id);
return id;
}
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('Dashboards List Page', () => {
test('TC-01 page chrome and core controls render', async ({
authedPage: page,
}) => {
await seed(page, 'list-chrome');
await gotoDashboardsList(page);
await expect(page).toHaveTitle('SigNoz | All Dashboards');
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
});
});
```
Note how the example carries no `// 1. …` `// 2. …` step narration — the helper and locator names already say what each line does. The only comments worth adding are ones a reader couldn't recover from the code itself.
# Known UI gotchas (apply when relevant)
- **Ant Popover positioning vs viewport.** Items inside a Popover — for example the "Delete dashboard" entry inside the row action menu — can render outside the viewport in headless CI even when scrolled. `click({ force: true })` skips actionability checks but Playwright still requires the click coordinates to land inside the viewport. Use `dispatchEvent('click')` instead — it fires the click directly on the DOM node, React's onClick still runs, and there are no coordinate checks. Reach for it whenever a CI failure complains about "Element is outside of the viewport" on a popover/tooltip option.
- **Sticky-header rows below the fold.** When the table accumulates rows, the search-filtered row's `dashboard-action-icon` can land below a sticky header. Always `await actionIcon.scrollIntoViewIfNeeded()` before clicking. The `openDashboardActionMenu` helper already does this — use it instead of clicking the icon directly.
- **React Query mutations vs navigation.** UI delete clicks fire an async DELETE through React Query. Navigating away before the mutation completes cancels it. Pair the click with `page.waitForResponse((r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()))` and `await expect(dialog).not.toBeVisible()` before the next `page.goto(...)`.
- **Monaco editor swallows Escape.** Inside the Import JSON dialog the Monaco editor grabs focus and intercepts the Escape keystroke. Click the modal title (or any non-editor element inside the dialog) first to blur Monaco; Ant's `keyboard` handler then sees the Escape and dismisses.
- **Empty zero-state hides controls.** With no dashboards in the workspace, the search input, sort button, "All Dashboards" header, and `new-dashboard-cta` testid are absent — only the page heading and the inline "request a template" form render. Always seed at least one dashboard before driving any test that touches list-page controls.
# Quality bar
- Every test runs end-to-end against a fresh stack. If you can't run it green from a fresh `test_setup`, it's not done.
- Use `data-testid` whenever the source exposes one; grep `frontend/src/<feature-dir>/` for `data-testid=` to find them.
- If a step depends on UI behaviour you can't verify (e.g. clipboard, downloads), use the matching Playwright primitive (`page.waitForEvent('download')`, `page.context().grantPermissions(...)` — note `page.context()`, not the `context` fixture, since the auth fixture creates its own context).
- If the page renders differently when the workspace is empty vs non-empty, **always** seed before driving the test.
- Iterate on a single failing TC with `npx playwright test -g "TC-NN" --project=chromium`. Use `--last-failed` after a multi-failure run to replay only what failed.

View File

@@ -0,0 +1,63 @@
---
name: playwright-test-healer
description: Use this agent to debug and fix failing SigNoz E2E Playwright tests. Examples — <example>Context: A spec is red. user: 'tests/e2e/tests/dashboards/list.spec.ts is failing, fix it' assistant: 'Using the healer agent to debug each failing scenario and adjust the spec.'</example> <example>Context: After a frontend change a previously-green spec broke. user: 'TC-09 in alerts started failing' assistant: 'Launching the healer to investigate.'</example>
tools: Glob, Grep, Read, Write, Edit, Bash, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_generate_locator, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_snapshot, mcp__playwright-test__test_debug, mcp__playwright-test__test_list, mcp__playwright-test__test_run
model: sonnet
color: red
---
You are the Playwright Test Healer for the SigNoz E2E suite. You debug and fix red specs with a methodical approach. Read [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) before you start — it documents the harness and the conventions you must preserve.
# Preconditions
The E2E backend stack must be up. If `tests/e2e/.env.local` does not exist, ask the user to bring up the stack via:
```
cd tests
uv run pytest --basetemp=./tmp/ -vv --reuse --with-web e2e/bootstrap/setup.py::test_setup
```
Don't try to start the stack yourself — it can take ~4 minutes on a cold build and the user controls when to pay that cost.
# Workflow
1. **Inventory.** `mcp__playwright-test__test_list` (or `npx playwright test <file> --list` from `tests/e2e/`) to see all tests in the spec.
2. **Initial run.** `mcp__playwright-test__test_run` (or `npx playwright test <file> --project=chromium`) to identify failing tests. Don't run all browsers — chromium first.
3. **Per failing test, debug.** Use `mcp__playwright-test__test_debug` to attach. When the test pauses on the error:
- `browser_snapshot` to read the current accessibility tree.
- `browser_console_messages` for client-side errors.
- `browser_network_requests` for API failures (the SigNoz API requires `Authorization: Bearer <localStorage.AUTH_TOKEN>`; 401s usually mean the test bypassed the fixture).
- `browser_generate_locator` to suggest a stable locator if the failing one drifted.
4. **Root-cause.** Distinguish between:
- **Selector drift** — the app changed `data-testid` or text. Fix the locator. Prefer `data-testid` (grep `frontend/src/<feature-dir>/` for the new one).
- **Timing** — the test races a load. Replace `waitForTimeout` with `await expect(locator).toBe…()` or `page.waitForResponse(...)` on the triggering action.
- **State leak** — a previous test left data behind, or this test assumes data the bootstrap doesn't seed. Ensure the test seeds via API and cleans up in `try / finally`. The bootstrap creates a fresh stack with **zero** dashboards / alerts.
- **Genuine app bug** — the app is broken, not the test. Mark the test with `test.fixme(...)` and add a one-line `// known: <description>` comment. Don't silently change the assertion to make it pass.
5. **Fix.** Edit the spec. Preserve TC-NN titles, the `authedPage` fixture, `try / finally` cleanup, and serial mode if present. If you renumber, retitle, or `test.fixme(...)` any TC, flag it in your final summary so the user can re-run the planner — the plan and the QA checklist (`tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`) re-derive from the current `.spec.ts` and will otherwise drift.
6. **Re-run only the fixed test** before moving to the next failure. Three options:
- `npx playwright test -g 'TC-09' --project=chromium` — target a single TC by title
- `npx playwright test --last-failed --project=chromium` — replay everything that failed last run
- `mcp__playwright-test__test_run` with the test name
Don't re-run the whole file each iteration — it slows the loop.
7. **Iterate** until the file is green. If a test stays red after high-confidence fixes, mark it `test.fixme(...)` with a comment and move on rather than spinning indefinitely.
# Repo-specific signals
- **Reuse helpers before adding new code.** [`tests/e2e/helpers/dashboards.ts`](../../tests/e2e/helpers/dashboards.ts) and [`tests/e2e/helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) already export the API-seed, cleanup, navigation, and action-menu helpers most fixes need. Prefer importing from there over re-inlining auth/login/POST plumbing in the spec.
- **Ant Popover items can fail with "Element is outside of the viewport" — even with `force: true`.** `force` skips actionability checks but Playwright still requires click coordinates to land in the viewport when it dispatches the synthetic mouse event. The robust fix is `tooltip.getByText('…').dispatchEvent('click')` — fires the click directly on the DOM node, React's `onClick` runs, and no coordinate calculation happens. Apply this whenever the failure log mentions "outside of the viewport" on a popover/tooltip option, especially in CI where layout differs subtly from local.
- **Action-icon rows below the fold.** With multiple seeded dashboards, a search-filtered row can scroll behind a sticky table header. The `openDashboardActionMenu` helper does `scrollIntoViewIfNeeded` already — if a test still drives the icon directly, fix it to use the helper or add the scroll.
- **React Query mutations vs page.goto.** UI delete clicks call `mutate()` asynchronously; if the test navigates away before the response lands, the mutation is cancelled and the dashboard is *not* deleted. Wait for the DELETE response and the dialog dismissal explicitly: `page.waitForResponse((r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()))` plus `await expect(dialog).not.toBeVisible()`.
- **Monaco editor swallows Escape inside the Import JSON dialog.** If a test that presses Escape times out, click the modal title (or any non-editor element inside the dialog) first to blur Monaco, then press Escape.
- **The list pages render zero-state when the workspace is empty.** Many locators (search input, sort button, `new-dashboard-cta` testid, "All Dashboards" header) are absent in zero-state. A 30s timeout on those usually means the workspace was empty — seed first via `createDashboardViaApi`.
- **The "Enter dashboard name…" inline field is a `RequestDashboardBtn` (template-request feedback form), not a create flow.** Tests that try to use it to create a named dashboard will silently no-op. The only UI create paths are the "New dashboard" dropdown → "Create dashboard" (default name "Sample Title", see `DEFAULT_DASHBOARD_TITLE`) or "Import JSON".
- **Auth.** `tests/e2e/fixtures/auth.ts` logs in once per worker and caches `storageState` (cookies + localStorage with `AUTH_TOKEN`). For API-driven seeding/cleanup, use `authToken(page)` from `helpers/dashboards.ts` and pass `Authorization: Bearer <token>`. Never re-implement login.
- **Ant Design popovers** (sort menu, action menu) are click-toggle. The trigger element is often an inline `<svg>` with a `data-testid` — clicking it opens the popover; clicking it again closes. After selecting an option, the popover auto-closes. If a test interacts with the popover twice, wait for the menu items to be visible explicitly between toggles.
- **Artifacts.** Every failed test writes to `tests/e2e/artifacts/results/<test-slug>/` — the `error-context.md` accessibility snapshot is the fastest way to see what the page actually looked like when it failed.
- **Type-check.** After edits, run `npx tsc --noEmit -p tests/e2e/tsconfig.json` if it succeeds, or rely on `npx playwright test --list` to validate the spec parses.
# Hard rules
- **Never wait for `networkidle`.** It's flaky and discouraged.
- **Never use `page.waitForTimeout(ms)`.** Always express the wait as `await expect(locator).toBeVisible()` or similar.
- **Never weaken an assertion just to make a test pass.** If the underlying behavior is broken, mark `test.fixme(...)` with a comment.
- **Don't ask the user questions** — make the most reasonable repair you can with the information at hand.
- **Don't rewrite passing tests** while fixing a failing one. Surgical edits only.
- **Never commit `test.only`** — CI fails on `forbidOnly: true`.

View File

@@ -0,0 +1,104 @@
---
name: playwright-test-planner
description: Use this agent to create a comprehensive E2E test plan for a SigNoz frontend feature. Examples — <example>Context: A new feature has shipped and we need test coverage. user: 'Plan E2E tests for the alerts list page' assistant: 'I'll use the planner agent to read the relevant frontend source, navigate the page in a real browser, and produce a structured test plan.' <commentary>Test planning needs both source code understanding and live browser exploration — perfect for this agent.</commentary></example> <example>Context: User wants edge-case coverage on an existing feature. user: 'What scenarios are we missing for dashboard variables?' assistant: 'Launching the planner agent to map flows and identify gaps.'</example>
tools: Glob, Grep, Read, Write, Bash, mcp__playwright-test__browser_click, mcp__playwright-test__browser_close, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_navigate_back, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_take_screenshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_wait_for, mcp__playwright-test__planner_setup_page
model: sonnet
color: green
---
You are an expert E2E test planner for the SigNoz frontend, working inside the SigNoz monorepo. Your test plans drive Playwright specs that run against the local pytest-bootstrapped backend. Read [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) before planning — it documents the harness, the `authedPage` fixture, the TC-NN naming convention, and the self-contained-state principle that every plan you write must respect.
You will:
1. **Inspect the source component**
- Read the relevant React source under [frontend/src/](../../frontend/src/) directly with the `Read` / `Grep` / `Glob` tools — this is a monorepo, no need to fetch from GitHub.
- For a feature like "dashboards list", start at `frontend/src/pages/<Feature>Page/` and `frontend/src/container/<Feature>/`. Trace the component tree to identify:
- Interactive elements and their `data-testid` attributes (preferred locators)
- Conditional rendering (empty states, loading, error, role-gated UI)
- URL query-param state (search, sort, pagination)
- API endpoints the UI calls — these inform what cleanup endpoints exist for `try/finally` teardown
- The frontend stores its JWT in `localStorage` under `AUTH_TOKEN` and the API requires `Authorization: Bearer <token>` for protected endpoints. Plans that need API-driven seeding should note this so the generator can use `page.request.*`.
2. **Check what's already wired up.**
- [tests/e2e/helpers/dashboards.ts](../../tests/e2e/helpers/dashboards.ts) and [tests/e2e/helpers/auth.ts](../../tests/e2e/helpers/auth.ts) hold reusable helpers (`createDashboardViaApi`, `gotoDashboardsList`, `openDashboardActionMenu`, `newAdminContext`, `importApmMetricsDashboardViaUI`, etc.). When the plan touches dashboards, reference these by name in the steps so the generator can reuse them rather than reinvent.
- [tests/e2e/fixtures/apm-metrics.json](../../tests/e2e/fixtures/apm-metrics.json) is a real-world dashboard payload (rich tags, panels, description) suitable as a seed fixture — note in the plan if a scenario benefits from it.
3. **Navigate and explore**
- Invoke `planner_setup_page` once before any other browser tool.
- Use `browser_snapshot` to read the page's accessibility tree. **Do not take screenshots unless absolutely necessary** — snapshots are cheaper and more legible.
- Drive each flow end-to-end: happy path, error states, edge cases, URL deep-linking, browser-back behaviour.
4. **Design comprehensive scenarios**
- Happy path
- Edge cases and boundary conditions (empty state, single item, > pagination threshold)
- Error handling and validation
- URL state and deep-linking
- Cross-flow regressions (e.g. searching while paginated)
5. **Structure the test plan**
Each scenario must include:
- **TC-NN** title — `TC-NN <short description>` (matches the naming this repo uses for test titles).
- Preconditions (what state the test expects — note that the bootstrap creates a fresh stack with **zero dashboards / alerts / etc.**, so plans must seed their own data).
- Step-by-step user actions.
- Expected outcomes per step.
- Cleanup notes (what gets created and how to remove it — usually via API).
6. **Save the plan and the checklist**
- **Plan:** `tests/e2e/specs/<feature>/<feature>-test-plan.md`. **`tests/e2e/specs/` is gitignored** — plans are scratch artifacts: working input for the generator, regenerable, not committed. Don't treat them as durable documentation. The committed tests are the source of truth.
- **Checklist:** `tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`. A manual-verification runbook that mirrors the TC list one-to-one (one checkbox per TC) for QA hand-off. Same gitignore, same scratch status.
- **The checklist must stay in sync with the TCs.** When you regenerate the plan, regenerate the checklist alongside it — they share TC IDs and titles, and the checklist ordering must match. If the existing spec under `tests/e2e/tests/<feature>/` has more / fewer / different TCs than the prior plan, the spec is authoritative: re-derive plan and checklist from it.
- **On re-runs against an evolved feature:** read the existing `.spec.ts` files first. Treat the committed tests as ground truth; produce a plan and checklist that reflect *what is currently in the spec*, not what the prior plan said. This is how the planner handles TC additions, deletions, merges, and renumbering performed by the generator or healer.
- Use clear headings, numbered steps, and a top-level "Application Overview" section.
- At the top of the plan, list any pre-existing limitations (e.g. "ascending sort not yet implemented") so the generator emits them as `// known behaviour` comments rather than failing assertions.
<example-spec>
# Dashboards List Page — Test Plan
## Application Overview
The dashboards list page (`/dashboard`) lists all dashboards in the workspace. From here a user can:
- Search by title, description, or tags (real-time, URL-synced via `?search=`)
- Sort by last-updated (URL-synced via `?columnKey=&order=`)
- Open per-row actions: View, Open in New Tab, Copy Link, Export JSON, Delete dashboard
- Create a new dashboard via the "New dashboard" dropdown (Create dashboard / Import JSON / View templates)
**Bootstrap state:** the pytest harness creates a fresh stack with no pre-seeded dashboards. Every test must seed its own data. The "Enter dashboard name…" inline input is a "request a new template" feedback form — **not** a create flow. The only UI create path is the dropdown.
**Known limitations:**
- Ascending sort is not yet implemented — repeated clicks on the sort button keep `order=descend`.
- Cancelling the delete confirmation dialog navigates to the dashboard detail page rather than staying on the list.
## Test Scenarios
### 1. Page Load and Layout
#### TC-01 page chrome and core controls render
**Preconditions:** at least one dashboard exists (seed via API).
**Steps:**
1. Navigate to `/dashboard`.
2. Verify URL is `/dashboard` (no query params).
3. Verify the page heading "Dashboards" (level 1) is visible.
4. ...
**Expected:**
- All Dashboards section header rendered.
- Search input, sort button, and at least one dashboard thumbnail visible.
**Cleanup:** delete the seeded dashboard via `DELETE /api/v1/dashboards/<id>`.
#### TC-02 ...
</example-spec>
**Quality bar:**
- Steps must be specific enough that any tester (or the generator agent) can follow without ambiguity.
- Include negative scenarios — empty state, no-match search, validation errors.
- Each scenario must own its preconditions and cleanup. **Do not invent cross-file global fixtures** — they break parallel-by-file execution. Suite-level `beforeAll` / `afterAll` *within* a single spec file is fine and is the preferred shape for files with > ~10 scenarios; per-test `try / finally` is fine for smaller specs.
- Prefer stable `data-testid` attributes when noting locators; fall back to ARIA roles or accessible names; treat CSS selectors as last resort.
- **Don't pad coverage.** Every TC must catch a regression that would actually ship if the test were missing — a real branch in the source, a real user-visible failure mode. A TC that asserts a hard-coded string is still rendered, or that a button is still a button, adds nothing but maintenance cost: it inflates the suite, slows CI, and trains readers to skim past the directory. Before writing a scenario, ask "what code change would break this?" — if the only answer is "deleting the literal under test," cut it or fold it into a richer scenario as one assertion among many.
- **Collapse near-duplicates.** Two TCs that differ only in input value (search by title vs search by description, when the underlying code path is the same) should merge into one parameterised scenario unless each input genuinely exercises a distinct branch. Prefer one assertion-rich TC over three thin ones.
- **Smoke-checks aren't TCs.** "Heading is visible" belongs as the first assertion inside a real scenario, not as its own numbered case.
**Output format:** a single Markdown file under `tests/e2e/specs/<feature>/<feature>-test-plan.md` (gitignored scratch path) ready to hand to the generator agent. The file is regenerable; once the spec is written, the plan can be discarded.

View File

@@ -7,4 +7,8 @@ deploy
sample-apps
# frontend
node_modules
**/node_modules
# local env files (tracked example.env templates are unaffected)
**/.env
**/.env.*

View File

@@ -2547,7 +2547,8 @@ components:
format: double
type: number
meta:
additionalProperties: {}
additionalProperties:
type: string
nullable: true
type: object
status:
@@ -2597,6 +2598,103 @@ components:
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesNodeCondition:
enum:
- ready
- not_ready
- no_data
type: string
InframonitoringtypesNodeCountsByReadiness:
properties:
notReady:
type: integer
ready:
type: integer
required:
- ready
- notReady
type: object
InframonitoringtypesNodeRecord:
properties:
condition:
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
meta:
additionalProperties:
type: string
nullable: true
type: object
nodeCPU:
format: double
type: number
nodeCPUAllocatable:
format: double
type: number
nodeCountsByReadiness:
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
nodeMemory:
format: double
type: number
nodeMemoryAllocatable:
format: double
type: number
nodeName:
type: string
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
required:
- nodeName
- condition
- nodeCountsByReadiness
- podCountsByPhase
- nodeCPU
- nodeCPUAllocatable
- nodeMemory
- nodeMemoryAllocatable
- meta
type: object
InframonitoringtypesNodes:
properties:
endTimeBeforeRetention:
type: boolean
records:
items:
$ref: '#/components/schemas/InframonitoringtypesNodeRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
$ref: '#/components/schemas/InframonitoringtypesResponseType'
warning:
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
required:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesPodCountsByPhase:
properties:
failed:
type: integer
pending:
type: integer
running:
type: integer
succeeded:
type: integer
unknown:
type: integer
required:
- pending
- running
- succeeded
- failed
- unknown
type: object
InframonitoringtypesPodPhase:
enum:
- pending
@@ -2604,18 +2702,15 @@ components:
- succeeded
- failed
- unknown
- ""
- no_data
type: string
InframonitoringtypesPodRecord:
properties:
failedPodCount:
type: integer
meta:
additionalProperties: {}
additionalProperties:
type: string
nullable: true
type: object
pendingPodCount:
type: integer
podAge:
format: int64
type: integer
@@ -2628,6 +2723,8 @@ components:
podCPURequest:
format: double
type: number
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podMemory:
format: double
type: number
@@ -2641,12 +2738,6 @@ components:
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
podUID:
type: string
runningPodCount:
type: integer
succeededPodCount:
type: integer
unknownPodCount:
type: integer
required:
- podUID
- podCPU
@@ -2656,11 +2747,7 @@ components:
- podMemoryRequest
- podMemoryLimit
- podPhase
- pendingPodCount
- runningPodCount
- succeededPodCount
- failedPodCount
- unknownPodCount
- podCountsByPhase
- podAge
- meta
type: object
@@ -2714,6 +2801,32 @@ components:
- end
- limit
type: object
InframonitoringtypesPostableNodes:
properties:
end:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
limit:
type: integer
offset:
type: integer
orderBy:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
start:
format: int64
type: integer
required:
- start
- end
- limit
type: object
InframonitoringtypesPostablePods:
properties:
end:
@@ -11618,12 +11731,83 @@ paths:
summary: List Hosts for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/nodes:
post:
deprecated: false
description: 'Returns a paginated list of Kubernetes nodes with key metrics:
CPU usage, CPU allocatable, memory working set, memory allocatable, per-group
nodeCountsByReadiness ({ ready, notReady } from each node''s latest k8s.node.condition_ready
in the window) and per-group podCountsByPhase ({ pending, running, succeeded,
failed, unknown } for pods scheduled on the listed nodes). Each node includes
metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is
''list'' for the default k8s.node.name grouping (each row is one node with
its current condition string: ready / not_ready / no_data) or ''grouped_list''
for custom groupBy keys (each row aggregates nodes in the group; condition
stays no_data). Supports filtering via a filter expression, custom groupBy,
ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination
via offset/limit. Also reports missing required metrics and whether the requested
time range falls before the data retention boundary. Numeric metric fields
(nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1
as a sentinel when no data is available for that field.'
operationId: ListNodes
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/InframonitoringtypesPostableNodes'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/InframonitoringtypesNodes'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List Nodes for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/pods:
post:
deprecated: false
description: 'Returns a paginated list of Kubernetes pods with key metrics:
CPU usage, CPU request/limit utilization, memory working set, memory request/limit
utilization, current pod phase (pending/running/succeeded/failed/unknown),
utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data),
and pod age (ms since start time). Each pod includes metadata attributes (namespace,
node, workload owner such as deployment/statefulset/daemonset/job/cronjob,
cluster). Supports filtering via a filter expression, custom groupBy to aggregate
@@ -11631,13 +11815,13 @@ paths:
cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit.
The response type is ''list'' for the default k8s.pod.uid grouping (each row
is one pod with its current phase) or ''grouped_list'' for custom groupBy
keys (each row aggregates pods in the group with per-phase counts: pendingPodCount,
runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived
from each pod''s latest phase in the window). Also reports missing required
metrics and whether the requested time range falls before the data retention
boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory,
podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no
data is available for that field.'
keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase:
{ pending, running, succeeded, failed, unknown } derived from each pod''s
latest phase in the window). Also reports missing required metrics and whether
the requested time range falls before the data retention boundary. Numeric
metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest,
podMemoryLimit, podAge) return -1 as a sentinel when no data is available
for that field.'
operationId: ListPods
requestBody:
content:

View File

@@ -81,23 +81,52 @@ tests/
├── .env.local # generated by bootstrap/setup.py (gitignored)
├── bootstrap/
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
├── fixtures/
│ └── auth.ts # authedPage Playwright fixture + per-worker storageState cache
├── fixtures/ # Playwright test fixtures (test.extend) only
│ └── auth.ts
├── helpers/ # function helpers + the constants they share with tests
│ ├── auth.ts
│ └── dashboards.ts
├── testdata/ # static data files (JSON) used by helpers and tests
│ └── apm-metrics.json # (example)
├── tests/ # Playwright .spec.ts files, one dir per feature area
│ └── alerts/
│ └── alerts.spec.ts
│ └── alerts.spec.ts # (example)
└── artifacts/ # per-run output (gitignored)
├── html/ # HTML reporter output
├── json/ # JSON reporter output
└── results/ # per-test traces / screenshots / videos on failure
```
### `fixtures/` vs `helpers/` — what goes where
These two folders look similar but mean different things:
- **`fixtures/`** holds *Playwright test fixtures* (created via `test.extend({...})`). By the canonical definition, a fixture is "a consistent, predefined set of data, objects, or environmental conditions used to ensure tests run in a stable state" — i.e. setup/teardown that runs *automatically* around each test or worker. `auth.ts` matches: it extends Playwright's `test` with an `authedPage` that's logged-in before every test runs and torn down after. If the only thing in this folder ever is `auth.ts`, that's fine — fixtures are a deliberately small surface.
- **`helpers/`** holds plain function helpers that you call *explicitly* from a test or hook — they don't extend Playwright's `test`. This covers both behaviour helpers (e.g. `gotoDashboardsList(page)`) and the constants those helpers and the tests both refer to (e.g. `SEARCH_PLACEHOLDER`). Constants live next to the helpers that use them so a single import line in a test covers both.
- **`testdata/`** holds static data files (typically JSON / YAML) consumed by the helpers — for example, `apm-metrics.json`, a real dashboard payload uploaded through the UI by an importer helper.
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
Each spec follows these principles:
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
2. **Test titles use `TC-NN`**: `test('TC-01 alerts page — tabs render', ...)`. Preserves ordering at a glance and maps to external coverage tracking.
3. **UI-first**: drive flows through the UI. Playwright traces capture every BE request/response the UI triggers, so asserting on UI outcomes implicitly validates BE contracts. Reach for direct `page.request.*` only when the test's *purpose* is asserting a response contract (use `page.waitForResponse` on a UI click) or when a specific UI step is structurally flaky (e.g. Ant DatePicker calendar-cell indices) — and even then try UI first.
4. **Self-contained state**: each spec creates what it needs and cleans up in `try/finally`. No global pre-seeding fixtures.
4. **Self-contained state**: each spec seeds its own data and cleans up at suite teardown. The pytest harness creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two patterns work:
- **Per-test seed + cleanup in `try / finally`** — small specs where each test owns its data.
- **Suite-level seed + `afterAll` teardown** — preferred for larger specs. Each `createDashboard(...)` call adds the resulting ID to a module-level `Set<string>`, and one `test.afterAll(...)` deletes everything in the set. See `tests/e2e/tests/dashboards/list.spec.ts` for the full pattern. `test.beforeAll` / `test.afterAll` cannot use `authedPage` directly (it's test-scoped); use `newAdminContext(browser)` from `helpers/auth.ts` instead — it performs one fresh login per suite hook.
5. **Seed via API when the UI flow is multi-step or brittle.** The frontend stores its JWT in `localStorage` under `AUTH_TOKEN`; `page.request.*` inherits the auth fixture's storage state. A typical pattern:
```ts
const token = await page.evaluate(
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
await page.request.post('/api/v1/dashboards', {
data: { title: 'my-name', uploadedGrafana: false },
headers: { Authorization: `Bearer ${token}` },
});
```
This is faster and more reliable than a multi-step UI seed. Reach for the UI flow only when the test's *purpose* is asserting that flow.
6. **Reusable static data lives in `tests/e2e/testdata/`.** For example, `apm-metrics.json` is a real dashboard payload that `importApmMetricsDashboardViaUI` (in `helpers/dashboards.ts`) uploads through the actual Import JSON UI flow to seed a richly-tagged dashboard for search/list tests.
## How to write an E2E test?
@@ -155,13 +184,23 @@ test('TC-02 alerts list — create, toggle, delete', async ({ authedPage: page }
### Locator priority
1. `getByRole('button', { name: 'Submit' })`
2. `getByLabel('Email')`
3. `getByPlaceholder('...')`
4. `getByText('...')`
5. `getByTestId('...')`
1. `getByTestId('...')` — preferred when the source exposes one. Stable, app-author-provided handle that survives copy-edits.
2. `getByRole('button', { name: 'Submit' })`
3. `getByLabel('Email')`
4. `getByPlaceholder('...')`
5. `getByText('...')`
6. `locator('.ant-select')` — last resort (Ant Design dropdowns often have no semantic alternative)
## Agents
Three Claude agents in `.claude/agents/` accelerate writing and maintaining E2E specs:
- **`playwright-test-planner`** — explores a feature in a real browser plus the local frontend source and writes a test plan as a scratch markdown file (under `tests/e2e/specs/`, which is gitignored — plans are working artifacts for the generator, not committed docs).
- **`playwright-test-generator`** — converts a test plan into Playwright spec files under `tests/e2e/tests/<feature>/`. Drives each scenario through MCP browser tools and emits TC-NN-titled tests using the `authedPage` fixture and the API-seed pattern.
- **`playwright-test-healer`** — runs failing specs, debugs them with snapshots / console / network introspection, and edits the spec to fix selector drift, timing, or state-leak issues.
The agents rely on the Playwright-test MCP server (`mcp__playwright-test__*` tools). Configure it in your Claude MCP settings; the permission allowlist lives in [.claude/settings.local.json](../../../.claude/settings.local.json).
## How to run E2E tests?
### Running All Tests

View File

@@ -143,7 +143,7 @@ func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.U
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
err := module.deletePublic(ctx, orgID, id)
err := module.store.DeletePublic(ctx, id.String())
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
@@ -216,7 +216,3 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) deletePublic(ctx context.Context, _ valuer.UUID, dashboardID valuer.UUID) error {
return module.store.DeletePublic(ctx, dashboardID.StringValue())
}

View File

@@ -0,0 +1,20 @@
---
description: Prefer SigNoz UI and icons across frontend code
globs: **/*.{ts,tsx,js,jsx}
alwaysApply: true
---
# UI Components and Icons Source of Truth
For all frontend implementation work in this repository:
- Always use UI primitives/components from `@signozhq/ui`.
- Always use icons from `@signozhq/icons`.
- Do not introduce new usage of icon libraries directly (for example `lucide-react`) in app code.
- Do not mix multiple component systems for the same UI surface when an equivalent exists in `@signozhq/ui`.
## Migration guidance
- If touching a file that already uses non-`@signozhq/icons` icons, prefer migrating that file to `@signozhq/icons` as part of the same change when practical.
- If a required component or icon is missing from SigNoz packages, call this out explicitly in the PR/summary before introducing alternatives.

View File

@@ -289,6 +289,8 @@
// Prevents navigator.clipboard - use useCopyToClipboard hook instead (disabled in tests via override)
"signoz/no-raw-absolute-path": "error",
// Prevents window.open(path), window.location.origin + path, window.location.href = path
"signoz/no-antd-components": "error",
// Prevents the usage of specific antd components in favor of our lib
"no-restricted-globals": [
"error",
{

39763
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -50,7 +50,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.1.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.12",
"@signozhq/ui": "0.0.17",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
@@ -109,6 +109,7 @@
"react": "18.2.0",
"react-addons-update": "15.6.3",
"react-beautiful-dnd": "13.1.1",
"react-chartjs-2": "4",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "18.2.0",
@@ -120,10 +121,12 @@
"react-helmet-async": "1.3.0",
"react-hook-form": "7.71.2",
"react-i18next": "^11.16.1",
"react-json-tree": "^0.20.0",
"react-lottie": "1.2.10",
"react-markdown": "8.0.7",
"react-query": "3.39.3",
"react-redux": "^7.2.2",
"react-rnd": "^10.5.3",
"react-router-dom": "^5.2.0",
"react-router-dom-v5-compat": "6.27.0",
"react-syntax-highlighter": "15.5.0",
@@ -132,6 +135,7 @@
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"rehype-raw": "7.0.0",
"remark-gfm": "^3.0.1",
"rollup-plugin-visualizer": "7.0.0",
"rrule": "2.8.1",
"stream": "^0.0.2",
@@ -239,10 +243,12 @@
},
"lint-staged": {
"*.(js|jsx|ts|tsx)": [
"oxlint --fix",
"oxfmt --write",
"sh -c tsgo --noEmit"
],
"*.(js|jsx|ts|tsx|scss|css)": [
"oxlint --fix --quiet --no-error-on-unmatched-pattern",
"oxfmt --write"
],
"*.(scss|css)": [
"stylelint"
]

View File

@@ -0,0 +1,66 @@
/**
* Rule: no-antd-components
*
* Prevents importing specific components from antd.
*
* This rule catches patterns like:
* import { Typography } from 'antd'
* import { Typography, Button } from 'antd'
* import Typography from 'antd/es/typography'
* import { Text } from 'antd/es/typography'
*
* Add components to BANNED_COMPONENTS to ban them.
* Key should be PascalCase component name, will match lowercase path too.
*/
const BANNED_COMPONENTS = {
Typography: 'Use @signozhq/ui Typography instead of antd Typography.',
};
export default {
create(context) {
return {
ImportDeclaration(node) {
const source = node.source.value;
// Check direct antd import: import { Typography } from 'antd'
if (source === 'antd') {
for (const specifier of node.specifiers) {
if (specifier.type !== 'ImportSpecifier') {
continue;
}
const importedName = specifier.imported.name;
const message = BANNED_COMPONENTS[importedName];
if (message) {
context.report({
node: specifier,
message: `Do not import '${importedName}' from antd. ${message}`,
});
}
}
return;
}
// Check antd/es/<component> import: import Typography from 'antd/es/typography'
const match = source.match(/^antd\/es\/([^/]+)/);
if (!match) {
return;
}
const pathComponent = match[1].toLowerCase();
for (const [componentName, message] of Object.entries(BANNED_COMPONENTS)) {
if (pathComponent === componentName.toLowerCase()) {
context.report({
node,
message: `Do not import from '${source}'. ${message}`,
});
break;
}
}
},
};
},
};

View File

@@ -9,6 +9,7 @@ import noZustandGetStateInHooks from './rules/no-zustand-getstate-in-hooks.mjs';
import noNavigatorClipboard from './rules/no-navigator-clipboard.mjs';
import noUnsupportedAssetPattern from './rules/no-unsupported-asset-pattern.mjs';
import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
import noAntdComponents from './rules/no-antd-components.mjs';
export default {
meta: {
@@ -19,5 +20,6 @@ export default {
'no-navigator-clipboard': noNavigatorClipboard,
'no-unsupported-asset-pattern': noUnsupportedAssetPattern,
'no-raw-absolute-path': noRawAbsolutePath,
'no-antd-components': noAntdComponents,
},
};

9
frontend/req.md Normal file
View File

@@ -0,0 +1,9 @@
# SigNoz AI Assistant
1. Chat interface (Side Drawer View)
1. Should be able to expand the view to full screen (open in a new route - with converstation ID)
2. Conversation would be stream (for in process message), the older messages would be listed (Virtualized) - older - newest
2. Input Section
1. Users should be able to upload images / files to the chat

View File

@@ -17,6 +17,12 @@ registered_languages=$(grep -oP "registerLanguage\('\K[^']+" "$SYNTAX_HIGHLIGHTE
missing_languages=()
for lang in $md_languages; do
# Skip ai-* block markers — these are custom AI block types rendered by
# RichCodeBlock as React components (e.g. ActionBlock, LineChartBlock),
# not real syntax languages, so they don't need highlighter registration.
if [[ "$lang" == ai-* ]]; then
continue
fi
if ! echo "$registered_languages" | grep -qx "$lang"; then
missing_languages+=("$lang")
fi

View File

@@ -8,6 +8,7 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
@@ -40,6 +41,10 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
} = useAppContext();
const isAdmin = user.role === USER_ROLES.ADMIN;
const isAIAssistantEnabled = isLoggedInState
? useIsAIAssistantEnabled()
: false;
const mapRoutes = useMemo(
() =>
new Map(
@@ -99,6 +104,10 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
return <>{children}</>;
}
if (pathname.startsWith('/ai-assistant/') && !isAIAssistantEnabled) {
return <Redirect to={ROUTES.HOME} />;
}
// Check for workspace access restriction (cloud only)
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;

View File

@@ -164,14 +164,17 @@ function createMockAppContext(
featureFlags: [],
orgPreferences: createMockOrgPreferences(),
userPreferences: [],
hostsData: null,
isLoggedIn: true,
org: [{ createdAt: 0, id: 'org-id', displayName: 'Test Org' }],
isFetchingUser: false,
isFetchingActiveLicense: false,
isFetchingHosts: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
userFetchError: null,
activeLicenseFetchError: null,
hostsFetchError: null,
featureFlagsFetchError: null,
orgPreferencesFetchError: null,
changelog: null,

View File

@@ -18,6 +18,7 @@ import AppLayout from 'container/AppLayout';
import Hex from 'crypto-js/enc-hex';
import HmacSHA256 from 'crypto-js/hmac-sha256';
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useIsDarkMode, useThemeConfig } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { NotificationProvider } from 'hooks/useNotifications';
@@ -60,13 +61,23 @@ function App(): JSX.Element {
org,
} = useAppContext();
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
const isAIAssistantEnabled = isLoggedInState
? useIsAIAssistantEnabled()
: false;
const { hostname, pathname } = window.location;
const { hostname } = window.location;
const [pathname, setPathname] = useState(history.location.pathname);
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const [isSentryInitialized, setIsSentryInitialized] = useState(false);
useEffect(() => {
return history.listen((location) => {
setPathname(location.pathname);
});
}, []);
const enableAnalytics = useCallback(
(user: IUser): void => {
// wait for the required data to be loaded before doing init for anything!
@@ -212,6 +223,27 @@ function App(): JSX.Element {
activeLicenseFetchError,
]);
useEffect(() => {
if (!isLoggedInState) {
return;
}
setRoutes((prev) => {
const hasAi = prev.some((r) => r.path === ROUTES.AI_ASSISTANT);
if (isAIAssistantEnabled === hasAi) {
return prev;
}
if (isAIAssistantEnabled) {
const aiRoute = defaultRoutes.find((r) => r.path === ROUTES.AI_ASSISTANT);
if (!aiRoute) {
return prev;
}
return [...prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT), aiRoute];
}
return prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT);
});
}, [isLoggedInState, isAIAssistantEnabled]);
const isDarkMode = useIsDarkMode();
useEffect(() => {
@@ -221,7 +253,8 @@ function App(): JSX.Element {
useEffect(() => {
if (
pathname === ROUTES.ONBOARDING ||
pathname.startsWith('/public/dashboard/')
pathname.startsWith('/public/dashboard/') ||
pathname.startsWith('/ai-assistant/')
) {
window.Pylon?.('hideChatBubble');
} else {

View File

@@ -65,6 +65,13 @@ export const TraceDetail = Loadable(
),
);
export const TraceDetailV3 = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetailV3 Page" */ 'pages/TraceDetailV3Page/index'
),
);
export const UsageExplorerPage = Loadable(
() => import(/* webpackChunkName: "UsageExplorerPage" */ 'modules/Usage'),
);
@@ -317,3 +324,10 @@ export const MeterExplorerPage = Loadable(
() =>
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),
);
export const AIAssistantPage = Loadable(
() =>
import(
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
),
);

View File

@@ -2,6 +2,7 @@ import { RouteProps } from 'react-router-dom';
import ROUTES from 'constants/routes';
import {
AIAssistantPage,
AlertHistory,
AlertOverview,
AlertTypeSelectionPage,
@@ -48,6 +49,7 @@ import {
StatusPage,
SupportPage,
TraceDetail,
TraceDetailV3,
TraceFilter,
TracesExplorer,
TracesFunnelDetails,
@@ -138,6 +140,9 @@ const routes: AppRoutes[] = [
exact: true,
key: 'LOGS_SAVE_VIEWS',
},
// V3 trace details is gated until release: /trace serves V2 (public),
// /trace-old serves V3 (URL-only access). Flip the two `component`
// values back to release V3.
{
path: ROUTES.TRACE_DETAIL,
exact: true,
@@ -145,6 +150,13 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'TRACE_DETAIL',
},
{
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
component: TraceDetailV3,
isPrivate: true,
key: 'TRACE_DETAIL_OLD',
},
{
path: ROUTES.SETTINGS,
exact: false,
@@ -496,6 +508,13 @@ const routes: AppRoutes[] = [
key: 'API_MONITORING',
isPrivate: true,
},
{
path: ROUTES.AI_ASSISTANT,
exact: true,
component: AIAssistantPage,
key: 'AI_ASSISTANT',
isPrivate: true,
},
];
export const SUPPORT_ROUTE: AppRoutes = {

View File

@@ -0,0 +1,80 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import {
interceptorRejected,
interceptorsRequestBasePath,
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
import { getSigNozInstanceUrl } from 'utils/signozInstanceUrl';
/** Path-only base for the AI Assistant API. */
export const AI_API_PATH = '/api/v1/assistant';
/** Header that tells the AI backend which SigNoz instance to query against. */
export const SIGNOZ_URL_HEADER = 'X-SigNoz-URL';
/**
* Sets `X-SigNoz-URL` on every outgoing AI Assistant request. The backend
* needs the originating SigNoz instance URL for multi-tenant deployments;
* when omitted it falls back to its `SIGNOZ_API_URL` env var.
*/
export const interceptorsRequestSigNozUrl = (
value: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig => {
if (value.headers) {
value.headers[SIGNOZ_URL_HEADER] = getSigNozInstanceUrl();
}
return value;
};
/**
* AI backend URL — sourced from the global config's `ai_assistant_url` field
* at runtime. `useIsAIAssistantEnabled` keeps this in sync via `setAIBackendUrl`
* whenever the config response changes; consumers (the axios instance and the
* SSE fetch path) read it lazily so they always see the current value.
*/
let aiBackendUrl: string | null = null;
export function setAIBackendUrl(url: string | null): void {
if (aiBackendUrl === url) {
return;
}
aiBackendUrl = url;
AIAssistantInstance.defaults.baseURL = url ? `${url}${AI_API_PATH}` : '';
}
/**
* Full base URL for the AI Assistant API (host + path). Throws when the
* config hasn't yet provided a URL — should never happen in practice
* because `useIsAIAssistantEnabled` gates every consumer surface.
*/
export function getAIBaseUrl(): string {
if (!aiBackendUrl) {
throw new Error('AI assistant URL is not configured.');
}
return `${aiBackendUrl}${AI_API_PATH}`;
}
/**
* Dedicated axios instance for the AI Assistant.
*
* Mirrors the request/response interceptor stack of the main SigNoz axios
* instance — most importantly `interceptorRejected`, which transparently
* rotates the access token via `/sessions/rotate` on a 401 and replays the
* original request. That's why we don't need any AI-specific 401 handling
* for REST calls: this instance inherits the same flow as the rest of the
* app for free.
*
* Only the SSE stream (`streamEvents`) still needs raw fetch since axios
* doesn't expose `ReadableStream` — that path keeps its own auth wrapper.
*/
export const AIAssistantInstance = axios.create({});
AIAssistantInstance.interceptors.request.use(interceptorsRequestResponse);
AIAssistantInstance.interceptors.request.use(interceptorsRequestBasePath);
AIAssistantInstance.interceptors.request.use(interceptorsRequestSigNozUrl);
AIAssistantInstance.interceptors.response.use(
interceptorsResponse,
interceptorRejected,
);

543
frontend/src/api/ai/chat.ts Normal file
View File

@@ -0,0 +1,543 @@
/**
* AI Assistant API client.
*
* Flow:
* 1. POST /api/v1/assistant/threads → { threadId }
* 2. POST /api/v1/assistant/threads/{threadId}/messages → { executionId }
* 3. GET /api/v1/assistant/executions/{executionId}/events → SSE stream (closes on 'done')
*
* For subsequent messages in the same thread, repeat steps 23.
* Approval/clarification events pause the stream; use approveExecution/clarifyExecution
* to resume, which each return a new executionId to open a fresh SSE stream.
*
* Types in this file re-use the OpenAPI-generated DTOs in
* `src/api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas.ts`.
* Local types are defined only when the UI needs a different shape — for
* example, the SSE event union adds a literal `type` discriminator that the
* generated event DTOs leave loose.
*
* REST calls go through `AIAssistantInstance` (an axios instance configured
* with the same interceptor stack as the rest of the app) — that gives them
* automatic 401-then-rotate behaviour for free. Only the SSE call is still
* a raw `fetch` because axios doesn't expose `ReadableStream`; that one
* path gets its own small auth wrapper.
*/
import axios from 'axios';
import getLocalStorageApi from 'api/browser/localstorage/get';
import { Logout } from 'api/utils';
import rotateSession from 'api/v2/sessions/rotate/post';
import afterLogin from 'AppRoutes/utils';
import type {
ActionResultResponseDTO,
ApprovalEventDTO,
ApproveResponseDTO,
CancelResponseDTO,
ClarificationEventDTO,
ClarifyResponseDTO,
ConversationEventDTO,
CreateMessageResponseDTO,
CreateThreadResponseDTO,
DoneEventDTO,
ErrorEventDTO,
ExecutionStateDTO,
FeedbackRatingDTO,
ListThreadsApiV1AssistantThreadsGetArchived,
ListThreadsApiV1AssistantThreadsGetParams,
MessageContextDTO,
MessageContextDTOSource,
MessageContextDTOType,
MessageEventDTO,
MessageSummaryDTO,
RegenerateResponseDTO,
StatusEventDTO,
ThinkingEventDTO,
ThreadDetailResponseDTO,
ThreadListResponseDTO,
ThreadSummaryDTO,
ToolCallEventDTO,
ToolResultEventDTO,
} from 'api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
AIAssistantInstance,
getAIBaseUrl,
SIGNOZ_URL_HEADER,
} from '../AIAPIInstance';
import { getSigNozInstanceUrl } from 'utils/signozInstanceUrl';
// ---------------------------------------------------------------------------
// SSE-only auth wrapper.
//
// REST calls go through `AIAssistantInstance` (axios) and get refresh-token
// behaviour from the shared `interceptorRejected`. The SSE call has to use
// raw `fetch` (axios can't stream a `ReadableStream`), so it can't ride that
// interceptor — this small wrapper handles 401 at SSE open time by hitting
// the same rotate endpoint and replaying the request once.
//
// In typical use a REST call (e.g. sendMessage / loadThread) precedes every
// stream open, so axios will already have refreshed the token and `fetch`
// just reads the fresh one from localStorage. The wrapper exists for the
// edge case where SSE is the first call to encounter a 401.
// ---------------------------------------------------------------------------
let pendingRotate: Promise<string | null> | null = null;
async function rotateAccessToken(): Promise<string | null> {
if (pendingRotate) {
return pendingRotate;
}
const refreshToken = getLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN) || '';
if (!refreshToken) {
return null;
}
pendingRotate = (async (): Promise<string | null> => {
try {
const response = await rotateSession({ refreshToken });
afterLogin(response.data.accessToken, response.data.refreshToken, true);
return response.data.accessToken;
} catch {
Logout();
return null;
} finally {
pendingRotate = null;
}
})();
return pendingRotate;
}
// Backoff schedule for 429 retries on SSE open. Three attempts is enough to
// absorb the brief window between cancel→send→stream when the backend is
// rate-limiting the burst, without making real "you're saturated" errors
// take forever to surface.
const SSE_429_BACKOFF_MS = [400, 1200, 2500];
function parseRetryAfterMs(value: string | null): number | null {
if (!value) {
return null;
}
const seconds = Number(value);
if (Number.isFinite(seconds)) {
return Math.max(0, seconds * 1000);
}
const date = Date.parse(value);
if (Number.isFinite(date)) {
return Math.max(0, date - Date.now());
}
return null;
}
async function fetchSSEWithAuth(
url: string,
signal?: AbortSignal,
): Promise<Response> {
const send = async (token: string | null): Promise<Response> => {
const headers: Record<string, string> = {
[SIGNOZ_URL_HEADER]: getSigNozInstanceUrl(),
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return fetch(url, { headers, signal });
};
const sendWithAuth = async (): Promise<Response> => {
const initialToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN) || '';
const res = await send(initialToken);
if (res.status !== 401) {
return res;
}
const refreshed = await rotateAccessToken();
if (!refreshed) {
return res;
}
return send(refreshed);
};
let res = await sendWithAuth();
for (const baseDelay of SSE_429_BACKOFF_MS) {
if (res.status !== 429 || signal?.aborted) {
return res;
}
const retryAfter = parseRetryAfterMs(res.headers.get('Retry-After'));
const delay = retryAfter ?? baseDelay;
// eslint-disable-next-line no-await-in-loop
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, delay);
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer);
reject(new DOMException('SSE 429 backoff aborted', 'AbortError'));
},
{ once: true },
);
});
// eslint-disable-next-line no-await-in-loop
res = await sendWithAuth();
}
return res;
}
// ---------------------------------------------------------------------------
// SSE event types
//
// The generated event DTOs each declare `type?: string` (loose). The UI needs
// a discriminated union, so we intersect each variant with a string-literal
// `type` to enable narrowing via `event.type === 'status'`.
// ---------------------------------------------------------------------------
export type SSEEvent =
| (StatusEventDTO & { type: 'status' })
| (MessageEventDTO & { type: 'message' })
| (ThinkingEventDTO & { type: 'thinking' })
| (ToolCallEventDTO & { type: 'tool_call' })
| (ToolResultEventDTO & { type: 'tool_result' })
| (ApprovalEventDTO & { type: 'approval' })
| (ClarificationEventDTO & { type: 'clarification' })
| (ErrorEventDTO & { type: 'error' })
| (ConversationEventDTO & { type: 'conversation' })
| (DoneEventDTO & { type: 'done' });
/** String-literal view of `ExecutionStateDTO` for ergonomic comparisons. */
export type ExecutionState = `${ExecutionStateDTO}`;
// ---------------------------------------------------------------------------
// Re-exported DTOs — the wire shape, used directly without remapping.
// ---------------------------------------------------------------------------
export type ThreadSummary = ThreadSummaryDTO;
export type ThreadListResponse = ThreadListResponseDTO;
export type ThreadDetailResponse = ThreadDetailResponseDTO;
export type MessageSummary = MessageSummaryDTO;
export type CancelResponse = CancelResponseDTO;
/**
* Construction-friendly view of `MessageContextDTO`: enum fields are widened
* to their string-literal unions so call-sites can pass `'mention'` instead
* of `MessageContextDTOSource.mention`.
*/
export type MessageContext = Omit<MessageContextDTO, 'source' | 'type'> & {
source: `${MessageContextDTOSource}`;
type: `${MessageContextDTOType}`;
};
/** Construction-friendly view of `ListThreadsApiV1AssistantThreadsGetParams`. */
export type ListThreadsOptions = Omit<
ListThreadsApiV1AssistantThreadsGetParams,
'archived'
> & {
archived?: `${ListThreadsApiV1AssistantThreadsGetArchived}`;
};
/** String-literal view of `FeedbackRatingDTO` so call-sites can pass `'positive'`/`'negative'`. */
export type FeedbackRating = `${FeedbackRatingDTO}`;
// ---------------------------------------------------------------------------
// Thread listing & detail
// ---------------------------------------------------------------------------
export async function listThreads(
options: ListThreadsOptions = {},
): Promise<ThreadListResponse> {
const {
archived = 'false',
limit = 20,
cursor = null,
sort = 'updated_desc',
} = options;
const response = await AIAssistantInstance.get<ThreadListResponse>(
'/threads',
{
params: {
archived,
limit,
sort,
...(cursor ? { cursor } : {}),
},
},
);
return response.data;
}
export async function updateThread(
threadId: string,
update: { title?: string | null; archived?: boolean | null },
): Promise<ThreadSummary> {
const response = await AIAssistantInstance.patch<ThreadSummary>(
`/threads/${threadId}`,
update,
);
return response.data;
}
export async function getThreadDetail(
threadId: string,
): Promise<ThreadDetailResponse> {
const response = await AIAssistantInstance.get<ThreadDetailResponse>(
`/threads/${threadId}`,
);
return response.data;
}
// ---------------------------------------------------------------------------
// Step 1 — Create thread
// POST /api/v1/assistant/threads → { threadId }
// ---------------------------------------------------------------------------
export async function createThread(signal?: AbortSignal): Promise<string> {
const response = await AIAssistantInstance.post<CreateThreadResponseDTO>(
'/threads',
{},
{ signal },
);
return response.data.threadId;
}
// ---------------------------------------------------------------------------
// Step 2 — Send message
// POST /api/v1/assistant/threads/{threadId}/messages → { executionId }
// ---------------------------------------------------------------------------
/** Fetches the thread's active executionId for reconnect on thread_busy (409). */
async function getActiveExecutionId(threadId: string): Promise<string | null> {
try {
const response = await AIAssistantInstance.get<ThreadDetailResponseDTO>(
`/threads/${threadId}`,
);
return response.data.activeExecutionId ?? null;
} catch {
return null;
}
}
export async function sendMessage(
threadId: string,
content: string,
contexts?: MessageContext[],
signal?: AbortSignal,
): Promise<string> {
try {
const response = await AIAssistantInstance.post<CreateMessageResponseDTO>(
`/threads/${threadId}/messages`,
{
content,
...(contexts && contexts.length > 0 ? { contexts } : {}),
},
{ signal },
);
return response.data.executionId;
} catch (err) {
// Thread already has an active execution — reconnect to it instead of
// failing the user's send.
if (axios.isAxiosError(err) && err.response?.status === 409) {
const executionId = await getActiveExecutionId(threadId);
if (executionId) {
return executionId;
}
}
throw err;
}
}
// ---------------------------------------------------------------------------
// Step 3 — Stream execution events
// GET /api/v1/assistant/executions/{executionId}/events → SSE
// ---------------------------------------------------------------------------
function parseSSELine(line: string): SSEEvent | null {
if (!line.startsWith('data: ')) {
return null;
}
const json = line.slice('data: '.length).trim();
if (!json || json === '[DONE]') {
return null;
}
try {
return JSON.parse(json) as SSEEvent;
} catch {
return null;
}
}
function parseSSEChunk(chunk: string): SSEEvent[] {
return chunk
.split('\n\n')
.map((part) => part.split('\n').find((l) => l.startsWith('data: ')) ?? '')
.map(parseSSELine)
.filter((e): e is SSEEvent => e !== null);
}
async function* readSSEReader(
reader: ReadableStreamDefaultReader<Uint8Array>,
): AsyncGenerator<SSEEvent> {
const decoder = new TextDecoder();
let lineBuffer = '';
try {
// eslint-disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line no-await-in-loop
const { done, value } = await reader.read();
if (done) {
break;
}
lineBuffer += decoder.decode(value, { stream: true });
const parts = lineBuffer.split('\n\n');
lineBuffer = parts.pop() ?? '';
yield* parts.flatMap(parseSSEChunk);
}
yield* parseSSEChunk(lineBuffer);
} finally {
reader.releaseLock();
}
}
/**
* Thrown by `streamEvents` when the SSE open returns a non-2xx response.
* Carries the HTTP status so callers can branch on rate-limit vs. other
* failures (e.g. show a "please wait a moment" message on 429).
*/
export class SSEStreamError extends Error {
status: number;
constructor(status: number, statusText: string) {
super(`SSE stream failed: ${status} ${statusText}`);
this.name = 'SSEStreamError';
this.status = status;
}
}
export async function* streamEvents(
executionId: string,
signal?: AbortSignal,
): AsyncGenerator<SSEEvent> {
const res = await fetchSSEWithAuth(
`${getAIBaseUrl()}/executions/${executionId}/events`,
signal,
);
if (!res.ok || !res.body) {
throw new SSEStreamError(res.status, res.statusText);
}
yield* readSSEReader(res.body.getReader());
}
// ---------------------------------------------------------------------------
// Approval / Clarification / Cancel actions
// ---------------------------------------------------------------------------
/** Approve a pending action. Returns a new executionId — open a fresh SSE stream for it. */
export async function approveExecution(
approvalId: string,
signal?: AbortSignal,
): Promise<string> {
const response = await AIAssistantInstance.post<ApproveResponseDTO>(
'/approve',
{ approvalId },
{ signal },
);
return response.data.executionId;
}
/** Reject a pending action. */
export async function rejectExecution(
approvalId: string,
signal?: AbortSignal,
): Promise<void> {
await AIAssistantInstance.post('/reject', { approvalId }, { signal });
}
/** Submit clarification answers. Returns a new executionId — open a fresh SSE stream for it. */
export async function clarifyExecution(
clarificationId: string,
answers: Record<string, unknown>,
signal?: AbortSignal,
): Promise<string> {
const response = await AIAssistantInstance.post<ClarifyResponseDTO>(
'/clarify',
{ clarificationId, answers },
{ signal },
);
return response.data.executionId;
}
/**
* Clean-slate regeneration of an assistant response. The backend rewinds the
* conversation up to (excluding) the supplied messageId and starts a fresh
* execution. Returns the new executionId — open an SSE stream for it the
* same way `sendMessage` and `approve` do.
*/
export async function regenerateMessage(
messageId: string,
signal?: AbortSignal,
): Promise<string> {
const response = await AIAssistantInstance.post<RegenerateResponseDTO>(
`/messages/${messageId}/regenerate`,
undefined,
{ signal },
);
return response.data.executionId;
}
export async function cancelExecution(
threadId: string,
signal?: AbortSignal,
): Promise<CancelResponse> {
const response = await AIAssistantInstance.post<CancelResponse>(
'/cancel',
{ threadId },
{ signal },
);
return response.data;
}
// ---------------------------------------------------------------------------
// Rollback actions — undo / revert / restore
// All three POST `{ actionMetadataId }` and return `ActionResultResponseDTO`.
// ---------------------------------------------------------------------------
async function postRollback(
endpoint: 'undo' | 'revert' | 'restore',
actionMetadataId: string,
signal?: AbortSignal,
): Promise<ActionResultResponseDTO> {
const response = await AIAssistantInstance.post<ActionResultResponseDTO>(
`/${endpoint}`,
{ actionMetadataId },
{ signal },
);
return response.data;
}
export const undoExecution = (
actionMetadataId: string,
signal?: AbortSignal,
): Promise<ActionResultResponseDTO> =>
postRollback('undo', actionMetadataId, signal);
export const revertExecution = (
actionMetadataId: string,
signal?: AbortSignal,
): Promise<ActionResultResponseDTO> =>
postRollback('revert', actionMetadataId, signal);
export const restoreExecution = (
actionMetadataId: string,
signal?: AbortSignal,
): Promise<ActionResultResponseDTO> =>
postRollback('restore', actionMetadataId, signal);
// ---------------------------------------------------------------------------
// Feedback
// ---------------------------------------------------------------------------
export async function submitFeedback(
messageId: string,
rating: FeedbackRating,
comment?: string,
): Promise<void> {
await AIAssistantInstance.post(`/messages/${messageId}/feedback`, {
rating,
comment: comment ?? null,
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,10 @@ import type {
import type {
InframonitoringtypesPostableHostsDTO,
InframonitoringtypesPostableNodesDTO,
InframonitoringtypesPostablePodsDTO,
ListHosts200,
ListNodes200,
ListPods200,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -107,7 +109,91 @@ export const useListHosts = <
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts: pendingPodCount, runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready in the window) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } for pods scheduled on the listed nodes). Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / no_data) or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group; condition stays no_data). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* @summary List Nodes for Infra Monitoring
*/
export const listNodes = (
inframonitoringtypesPostableNodesDTO: BodyType<InframonitoringtypesPostableNodesDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListNodes200>({
url: `/api/v2/infra_monitoring/nodes`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: inframonitoringtypesPostableNodesDTO,
signal,
});
};
export const getListNodesMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
> => {
const mutationKey = ['listNodes'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof listNodes>>,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> }
> = (props) => {
const { data } = props ?? {};
return listNodes(data);
};
return { mutationFn, ...mutationOptions };
};
export type ListNodesMutationResult = NonNullable<
Awaited<ReturnType<typeof listNodes>>
>;
export type ListNodesMutationBody =
BodyType<InframonitoringtypesPostableNodesDTO>;
export type ListNodesMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List Nodes for Infra Monitoring
*/
export const useListNodes = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
> => {
const mutationOptions = getListNodesMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
* @summary List Pods for Infra Monitoring
*/
export const listPods = (

View File

@@ -4579,7 +4579,7 @@ export interface InframonitoringtypesHostFilterDTO {
* @nullable
*/
export type InframonitoringtypesHostRecordDTOMeta = {
[key: string]: unknown;
[key: string]: string;
} | null;
export interface InframonitoringtypesHostRecordDTO {
@@ -4652,35 +4652,127 @@ export interface InframonitoringtypesHostsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export enum InframonitoringtypesNodeConditionDTO {
ready = 'ready',
not_ready = 'not_ready',
no_data = 'no_data',
}
export interface InframonitoringtypesNodeCountsByReadinessDTO {
/**
* @type integer
*/
notReady: number;
/**
* @type integer
*/
ready: number;
}
/**
* @nullable
*/
export type InframonitoringtypesNodeRecordDTOMeta = {
[key: string]: string;
} | null;
export interface InframonitoringtypesNodeRecordDTO {
condition: InframonitoringtypesNodeConditionDTO;
/**
* @type object
* @nullable true
*/
meta: InframonitoringtypesNodeRecordDTOMeta;
/**
* @type number
* @format double
*/
nodeCPU: number;
/**
* @type number
* @format double
*/
nodeCPUAllocatable: number;
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
/**
* @type number
* @format double
*/
nodeMemory: number;
/**
* @type number
* @format double
*/
nodeMemoryAllocatable: number;
/**
* @type string
*/
nodeName: string;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
}
export interface InframonitoringtypesNodesDTO {
/**
* @type boolean
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @nullable true
*/
records: InframonitoringtypesNodeRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
total: number;
type: InframonitoringtypesResponseTypeDTO;
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesPodCountsByPhaseDTO {
/**
* @type integer
*/
failed: number;
/**
* @type integer
*/
pending: number;
/**
* @type integer
*/
running: number;
/**
* @type integer
*/
succeeded: number;
/**
* @type integer
*/
unknown: number;
}
export enum InframonitoringtypesPodPhaseDTO {
pending = 'pending',
running = 'running',
succeeded = 'succeeded',
failed = 'failed',
unknown = 'unknown',
'' = '',
no_data = 'no_data',
}
/**
* @nullable
*/
export type InframonitoringtypesPodRecordDTOMeta = {
[key: string]: unknown;
[key: string]: string;
} | null;
export interface InframonitoringtypesPodRecordDTO {
/**
* @type integer
*/
failedPodCount: number;
/**
* @type object
* @nullable true
*/
meta: InframonitoringtypesPodRecordDTOMeta;
/**
* @type integer
*/
pendingPodCount: number;
/**
* @type integer
* @format int64
@@ -4701,6 +4793,7 @@ export interface InframonitoringtypesPodRecordDTO {
* @format double
*/
podCPURequest: number;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
/**
* @type number
* @format double
@@ -4721,18 +4814,6 @@ export interface InframonitoringtypesPodRecordDTO {
* @type string
*/
podUID: string;
/**
* @type integer
*/
runningPodCount: number;
/**
* @type integer
*/
succeededPodCount: number;
/**
* @type integer
*/
unknownPodCount: number;
}
export interface InframonitoringtypesPodsDTO {
@@ -4782,6 +4863,34 @@ export interface InframonitoringtypesPostableHostsDTO {
start: number;
}
export interface InframonitoringtypesPostableNodesDTO {
/**
* @type integer
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array
* @nullable true
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
/**
* @type integer
*/
limit: number;
/**
* @type integer
*/
offset?: number;
orderBy?: Querybuildertypesv5OrderByDTO;
/**
* @type integer
* @format int64
*/
start: number;
}
export interface InframonitoringtypesPostablePodsDTO {
/**
* @type integer
@@ -9133,6 +9242,14 @@ export type ListHosts200 = {
status: string;
};
export type ListNodes200 = {
data: InframonitoringtypesNodesDTO;
/**
* @type string
*/
status: string;
};
export type ListPods200 = {
data: InframonitoringtypesPodsDTO;
/**

View File

@@ -4,14 +4,46 @@ import {
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import { ENVIRONMENT } from 'constants/env';
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
// generated API Instance
const generatedAPIAxiosInstance = axios.create({
baseURL: ENVIRONMENT.baseURL,
});
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;
export const setGeneratedAPIQueryKeyHeaderContext = <THeaders extends object>(
headers?: THeaders,
): void => {
generatedAPIQueryKeyHeaderContext = headers
? { ...(headers as Record<string, unknown>) }
: undefined;
};
const hashHeaderValue = (value: string): string => {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash.toString(16);
};
const mergeHeaderRecord = (
target: Record<string, unknown>,
source: unknown,
): Record<string, unknown> => {
if (!source || typeof source !== 'object') {
return target;
}
return Object.assign(target, source as Record<string, unknown>);
};
export const GeneratedAPIInstance = <T>(
config: AxiosRequestConfig,
): Promise<T> => {
@@ -26,5 +58,59 @@ generatedAPIAxiosInstance.interceptors.response.use(
interceptorRejected,
);
const getDefaultQueryKeyHeaders = (): Record<string, unknown> => {
const defaults = generatedAPIAxiosInstance.defaults
.headers as unknown as Record<string, unknown>;
const headers: Record<string, unknown> = {};
const methodKeys = new Set([
'common',
'delete',
'get',
'head',
'options',
'patch',
'post',
'put',
]);
mergeHeaderRecord(headers, defaults?.common);
mergeHeaderRecord(headers, defaults?.get);
for (const [key, value] of Object.entries(defaults ?? {})) {
if (!methodKeys.has(key)) {
headers[key] = value;
}
}
return headers;
};
export const getGeneratedAPIQueryKeyHeaders = <THeaders extends object>(
headers?: THeaders,
): [{ headers: Record<string, unknown> }] | [] => {
const mergedHeaders = {
...getDefaultQueryKeyHeaders(),
...generatedAPIQueryKeyHeaderContext,
...(headers as Record<string, unknown> | undefined),
};
const queryKeyHeaders = Object.fromEntries(
Object.entries(mergedHeaders)
.filter(([, value]) => value !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => {
if (key.toLowerCase() === 'authorization' && typeof value === 'string') {
return [key, hashHeaderValue(value)];
}
return [key, value];
}),
);
return Object.keys(queryKeyHeaders).length
? [{ headers: queryKeyHeaders }]
: [];
};
export type ErrorType<Error> = AxiosError<Error>;
export type BodyType<BodyData> = BodyData;

View File

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

View File

@@ -1,4 +1,4 @@
import { Typography } from 'antd';
import { Typography } from '@signozhq/ui';
import get from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { THEME_MODE } from 'hooks/useDarkMode/constant';

View File

@@ -14,8 +14,8 @@ import {
TableColumnsType,
TableColumnType,
Tooltip,
Typography,
} from 'antd';
import { Typography } from '@signozhq/ui';
import type { FilterDropdownProps } from 'antd/lib/table/interface';
import logEvent from 'api/common/logEvent';
import {

View File

@@ -1,5 +1,6 @@
import { useHistory, useLocation } from 'react-router-dom';
import { Select, Spin, Typography } from 'antd';
import { Select, Spin } from 'antd';
import { Typography } from '@signozhq/ui';
import { SelectMaxTagPlaceholder } from 'components/MessagingQueues/MQCommon/MQCommon';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Divider, Drawer, Typography } from 'antd';
import { Divider, Drawer } from 'antd';
import { Typography } from '@signozhq/ui';
import logEvent from 'api/common/logEvent';
import { PANEL_TYPES } from 'constants/queryBuilder';
import dayjs from 'dayjs';

View File

@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Card, Typography } from 'antd';
import { Card } from 'antd';
import { Typography } from '@signozhq/ui';
import logEvent from 'api/common/logEvent';
import { CardContainer } from 'container/GridCardLayout/styles';
import { useIsDarkMode } from 'hooks/useDarkMode';

View File

@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useMutation } from 'react-query';
import { useLocation } from 'react-router-dom';
import { Button, Modal, Typography } from 'antd';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import { useNotifications } from 'hooks/useNotifications';

View File

@@ -554,10 +554,9 @@ function ClientSideQBSearch(
>
<Tooltip title={chipValue}>
<TypographyText
ellipsis
$isInNin={isInNin}
disabled={isDisabled}
$isEnabled={!!searchValue}
$disabled={isDisabled}
onClick={(): void => {
if (!isDisabled) {
tagEditHandler(value);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from 'react';
import { Button, Popover, Radio, Tooltip, Typography } from 'antd';
import { Button, Popover, Radio, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui';
import { TelemetryFieldKey } from 'api/v5/v5';
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
import { Download, DownloadIcon, Loader2 } from 'lucide-react';

View File

@@ -15,8 +15,8 @@ import {
Row,
Select,
Space,
Typography,
} from 'antd';
import { Typography } from '@signozhq/ui';
import axios from 'axios';
import TextToolTip from 'components/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';

View File

@@ -1,6 +1,7 @@
import { MouseEvent, useCallback } from 'react';
import { DeleteOutlined } from '@ant-design/icons';
import { Col, Row, Tooltip, Typography } from 'antd';
import { Col, Row, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useDeleteView } from 'hooks/saveViews/useDeleteView';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
@@ -81,7 +82,7 @@ function MenuItemGenerator({
</Tooltip>
</Row>
<Row>
<Typography.Text type="secondary">Created by {createdBy}</Typography.Text>
<Typography.Text color="muted">Created by {createdBy}</Typography.Text>
</Row>
</Col>
<Col span={2}>

View File

@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Card, Form, Input, Typography } from 'antd';
import { Card, Form, Input } from 'antd';
import { Typography } from '@signozhq/ui';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSaveView } from 'hooks/saveViews/useSaveView';

View File

@@ -1,4 +1,4 @@
import { Typography } from 'antd';
import { Typography } from '@signozhq/ui';
function AnnouncementsModal(): JSX.Element {
return (

View File

@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/ui';
import { Button, Input, Radio, RadioChangeEvent, Typography } from 'antd';
import { Button, Input, Radio, RadioChangeEvent } from 'antd';
import { Typography } from '@signozhq/ui';
import logEvent from 'api/common/logEvent';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';

View File

@@ -4,6 +4,49 @@
gap: 8px;
}
.header-ai-assistant-btn-container {
display: flex;
align-items: center;
gap: 4px;
}
.header-ai-assistant-btn__prefix {
display: inline-flex;
align-items: center;
gap: 6px;
}
.header-ai-assistant-btn__badge {
flex-shrink: 0;
display: inline-flex;
line-height: 0;
color: var(--bg-robin-500);
}
.header-ai-assistant-btn__pulse-dot {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
animation: header-ai-assistant-dot-pulse 1.5s ease-in-out infinite;
transform: scale(0.8);
margin-right: -12px;
}
@keyframes header-ai-assistant-dot-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.82);
}
}
.share-modal-content,
.feedback-modal-container {
display: flex;

View File

@@ -1,8 +1,16 @@
import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Button, Popover } from 'antd';
import { Dot, Sparkles } from '@signozhq/icons';
import { Button, Tooltip } from '@signozhq/ui';
import { Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import {
openAIAssistant,
useAIAssistantStore,
} from 'container/AIAssistant/store/useAIAssistantStore';
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { Globe, Inbox, SquarePen } from 'lucide-react';
import AnnouncementsModal from './AnnouncementsModal';
@@ -10,6 +18,7 @@ import FeedbackModal from './FeedbackModal';
import ShareURLModal from './ShareURLModal';
import './HeaderRightSection.styles.scss';
import { useAppContext } from 'providers/App/App';
interface HeaderRightSectionProps {
enableAnnouncements: boolean;
@@ -29,6 +38,8 @@ function HeaderRightSection({
const [openAnnouncementsModal, setOpenAnnouncementsModal] = useState(false);
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const { isLoggedIn } = useAppContext();
const isAIAssistantEnabled = isLoggedIn ? useIsAIAssistantEnabled() : false;
const handleOpenFeedbackModal = useCallback((): void => {
logEvent('Feedback: Clicked', {
@@ -67,9 +78,46 @@ function HeaderRightSection({
};
const isLicenseEnabled = isEnterpriseSelfHostedUser || isCloudUser;
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
const pendingUserInputCount: number = useAIAssistantStore(
selectPendingUserInputStreamCount,
);
const showHeaderPendingBadge =
pendingUserInputCount > 0 && !isDrawerOpen && !isModalOpen;
return (
<div className="header-right-section-container">
{isAIAssistantEnabled && !isDrawerOpen && (
<div className="header-ai-assistant-btn-container">
{showHeaderPendingBadge ? (
<span className="header-ai-assistant-btn__badge" aria-hidden>
<span className="header-ai-assistant-btn__pulse-dot">
<Dot size={36} />
</span>
</span>
) : null}
<Tooltip title="AI Assistant">
<Button
variant="solid"
color="secondary"
onClick={openAIAssistant}
aria-label={
showHeaderPendingBadge
? pendingUserInputCount === 1
? 'Open AI Assistant, 1 action needs your response'
: `Open AI Assistant, ${pendingUserInputCount} actions need your response`
: 'Open AI Assistant'
}
prefix={<Sparkles size={14} color="var(--primary)" />}
>
AI Assistant
</Button>
</Tooltip>
</div>
)}
{enableFeedback && isLicenseEnabled && (
<Popover
rootClassName="header-section-popover-root"
@@ -83,12 +131,13 @@ function HeaderRightSection({
onOpenChange={handleOpenFeedbackModalChange}
>
<Button
className="share-feedback-btn periscope-btn ghost"
icon={<SquarePen size={14} />}
variant="ghost"
size="icon"
className="share-feedback-btn"
aria-label="Feedback"
prefix={<SquarePen size={14} />}
onClick={handleOpenFeedbackModal}
>
Feedback
</Button>
/>
</Popover>
)}
@@ -105,8 +154,10 @@ function HeaderRightSection({
onOpenChange={handleOpenAnnouncementsModalChange}
>
<Button
icon={<Inbox size={14} />}
className="periscope-btn ghost announcements-btn"
variant="ghost"
size="icon"
aria-label="Announcements"
prefix={<Inbox size={14} />}
onClick={(): void => {
logEvent('Announcements: Clicked', {
page: location.pathname,
@@ -129,12 +180,12 @@ function HeaderRightSection({
onOpenChange={handleOpenShareURLModalChange}
>
<Button
className="share-link-btn periscope-btn ghost"
icon={<Globe size={14} />}
variant="ghost"
size="icon"
aria-label="Share"
prefix={<Globe size={14} />}
onClick={handleOpenShareURLModal}
>
Share
</Button>
/>
</Popover>
)}
</div>

View File

@@ -4,7 +4,8 @@ import { useSelector } from 'react-redux';
import { matchPath, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { Color } from '@signozhq/design-tokens';
import { Button, Switch, Typography } from 'antd';
import { Button, Switch } from 'antd';
import { Typography } from '@signozhq/ui';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';

View File

@@ -46,6 +46,10 @@ jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: jest.fn(),
}));
jest.mock('hooks/useIsAIAssistantEnabled', () => ({
useIsAIAssistantEnabled: (): boolean => false,
}));
const mockLogEvent = logEvent as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;

View File

@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Color } from '@signozhq/design-tokens';
import { Button, Typography } from 'antd';
import { Button } from 'antd';
import { Typography } from '@signozhq/ui';
import logEvent from 'api/common/logEvent';
import { useNotifications } from 'hooks/useNotifications';
import { CheckCircle2, HandPlatter } from 'lucide-react';

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Button, Input, Typography } from 'antd';
import { Button, Input } from 'antd';
import { Typography } from '@signozhq/ui';
import cx from 'classnames';
import { X } from 'lucide-react';

View File

@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react';
import { useMutation } from 'react-query';
import { useLocation } from 'react-router-dom';
import { Button, Modal, Tooltip, Typography } from 'antd';
import { Button, Modal, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import cx from 'classnames';

View File

@@ -4,7 +4,8 @@ import { useSelector } from 'react-redux'; // old code, TODO: fix this correctly
import { useCopyToClipboard, useLocation } from 'react-use';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui';
import { Divider, Drawer, Radio, Tooltip, Typography } from 'antd';
import { Divider, Drawer, Radio, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui';
import type { RadioChangeEvent } from 'antd/lib';
import cx from 'classnames';
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
@@ -17,7 +18,6 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import JSONView from 'container/LogDetailedView/JsonView';
import Overview from 'container/LogDetailedView/Overview';
import {
aggregateAttributesResourcesToString,
@@ -47,6 +47,7 @@ import {
TextSelect,
X,
} from 'lucide-react';
import { JsonView } from 'periscope/components/JsonView';
import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -562,7 +563,9 @@ function LogDetailInner({
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
{selectedView === VIEW_TYPES.JSON && (
<JsonView data={LogJsonData} height="68vh" />
)}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView
@@ -587,7 +590,7 @@ function LogDetailInner({
<div className="log-detail-drawer__footer-hint">
<div className="log-detail-drawer__footer-hint-content">
<Typography.Text
type="secondary"
color="muted"
className="log-detail-drawer__footer-hint-text"
>
Use
@@ -596,7 +599,7 @@ function LogDetailInner({
<span>/</span>
<ArrowDown size={14} className="log-detail-drawer__footer-hint-icon" />
<Typography.Text
type="secondary"
color="muted"
className="log-detail-drawer__footer-hint-text"
>
to view previous/next log

View File

@@ -6,7 +6,7 @@ interface ICategoryHeadingProps {
children: ReactNode;
}
function CategoryHeading({ children }: ICategoryHeadingProps): JSX.Element {
return <CategoryHeadingText type="secondary">{children}</CategoryHeadingText>;
return <CategoryHeadingText color="muted">{children}</CategoryHeadingText>;
}
export default CategoryHeading;

View File

@@ -1,4 +1,4 @@
import { Typography } from 'antd';
import { Typography } from '@signozhq/ui';
import styled from 'styled-components';
export const CategoryHeadingText = styled(Typography.Text)`

View File

@@ -1,6 +1,6 @@
import { memo, useCallback, useMemo } from 'react';
import { blue } from '@ant-design/colors';
import { Typography } from 'antd';
import { Typography } from '@signozhq/ui';
import cx from 'classnames';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -89,7 +89,7 @@ function LogSelectedField({
</span>
</Typography.Text>
</AddToQueryHOC>
<Typography.Text ellipsis className={cx('selected-log-kv', fontSize)}>
<Typography.Text truncate={1} className={cx('selected-log-kv', fontSize)}>
<span className={cx('selected-log-field-key', fontSize)}>{': '}</span>
<span className={cx('selected-log-value', fontSize)}>
{fieldValue || "''"}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Input, InputNumber, Popover, Tooltip, Typography } from 'antd';
import { Button, Input, InputNumber, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui';
import type { DefaultOptionType } from 'antd/es/select';
import cx from 'classnames';
import { LogViewMode } from 'container/LogsTable';

View File

@@ -12,6 +12,7 @@ import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml';
import a11yDark from 'react-syntax-highlighter/dist/esm/styles/prism/a11y-dark';
import oneLight from 'react-syntax-highlighter/dist/esm/styles/prism/one-light';
SyntaxHighlighter.registerLanguage('bash', bash);
SyntaxHighlighter.registerLanguage('docker', docker);
@@ -31,4 +32,4 @@ SyntaxHighlighter.registerLanguage('yaml', yaml);
SyntaxHighlighter.registerLanguage('yml', yaml);
export default SyntaxHighlighter;
export { a11yDark };
export { a11yDark, oneLight };

View File

@@ -1,15 +1,8 @@
import { Typography } from '@signozhq/ui';
import { ReactNode, useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { CaretDownOutlined, LoadingOutlined } from '@ant-design/icons';
import {
Modal,
Select,
Spin,
Tooltip,
Tree,
TreeDataNode,
Typography,
} from 'antd';
import { Modal, Select, Spin, Tooltip, Tree, TreeDataNode } from 'antd';
import { OnboardingStatusResponse } from 'api/messagingQueues/onboarding/getOnboardingStatus';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
@@ -84,7 +77,7 @@ function ErrorTitleAndKey({
key: `${title}-key-${uuid()}`,
title: (
<div className="attribute-error-title">
<Typography.Text className="tree-text" ellipsis={{ tooltip: title }}>
<Typography.Text title={title} className="tree-text" truncate={1}>
{title}
</Typography.Text>
<Tooltip title={errorMsg}>
@@ -125,7 +118,7 @@ function treeTitleAndKey({
key: `${title}-key-${uuid()}`,
title: (
<div className="attribute-success-title">
<Typography.Text className="tree-text" ellipsis={{ tooltip: title }}>
<Typography.Text title={title} className="tree-text" truncate={1}>
{title}
</Typography.Text>
{isLeaf && (

View File

@@ -13,7 +13,8 @@ import {
ReloadOutlined,
} from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Button, Checkbox, Select, Typography } from 'antd';
import { Button, Checkbox, Select } from 'antd';
import { Typography } from '@signozhq/ui';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';
@@ -755,15 +756,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}}
>
<div className="option-content">
<Typography.Text
ellipsis={{
tooltip: {
placement: 'right',
autoAdjustOverflow: true,
},
}}
className="option-label-text"
>
<Typography.Text truncate={1} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</Typography.Text>
{(option.type === 'custom' || option.type === 'regex') && (

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { Button, Tooltip, Typography } from 'antd';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { Button, Tooltip, Typography } from 'antd';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui';
import cx from 'classnames';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';

View File

@@ -1,4 +1,4 @@
import { Typography } from 'antd';
import { Typography } from '@signozhq/ui';
import eyesEmojiUrl from 'assets/Images/eyesEmoji.svg';
import styles from './QueryCancelledPlaceholder.module.scss';

View File

@@ -1,6 +1,7 @@
/* eslint-disable sonarjs/no-identical-functions */
import { Fragment, useMemo, useState } from 'react';
import { Button, Checkbox, Input, Skeleton, Typography } from 'antd';
import { Button, Checkbox, Input, Skeleton } from 'antd';
import { Typography } from '@signozhq/ui';
import cx from 'classnames';
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import {
@@ -640,16 +641,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
{filter.customRendererForValue ? (
filter.customRendererForValue(value)
) : (
<Typography.Text
className="value-string"
ellipsis={{
tooltip: {
placement: 'top',
mouseEnterDelay: 0.2,
mouseLeaveDelay: 0,
},
}}
>
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
)}

View File

@@ -12,7 +12,8 @@ import {
ComboboxList,
ComboboxTrigger,
} from '@signozhq/ui';
import { Skeleton, Switch, Tooltip, Typography } from 'antd';
import { Skeleton, Switch, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';

View File

@@ -1,4 +1,4 @@
import { Typography } from 'antd';
import { Typography } from '@signozhq/ui';
import Time from './Time';

View File

@@ -1,4 +1,4 @@
import { Typography } from 'antd';
import { Typography } from '@signozhq/ui';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { useTimezone } from 'providers/Timezone';

View File

@@ -1,5 +1,6 @@
import { ReactNode } from 'react';
import { Popover, Typography } from 'antd';
import { Popover } from 'antd';
import { Typography } from '@signozhq/ui';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';

View File

@@ -11,10 +11,12 @@ import {
Space,
SpaceProps,
TabsProps,
Typography,
} from 'antd';
import type { TextProps } from 'antd/lib/typography/Text';
import type { TitleProps } from 'antd/lib/typography/Title';
import {
Typography,
TypographyTextProps,
TypographyTitleProps,
} from '@signozhq/ui';
import styled, { FlattenSimpleInterpolation } from 'styled-components';
import { IStyledClass } from './types';
@@ -53,13 +55,13 @@ const StyledButton = styled(Button)<TStyledButton>`
`;
const { Text } = Typography;
type TStyledTypographyText = TextProps & IStyledClass;
type TStyledTypographyText = TypographyTextProps & IStyledClass;
const StyledTypographyText = styled(Text)<TStyledTypographyText>`
${styledClass}
`;
const { Title } = Typography;
type TStyledTypographyTitle = TitleProps & IStyledClass;
type TStyledTypographyTitle = TypographyTitleProps & IStyledClass;
const StyledTypographyTitle = styled(Title)<TStyledTypographyTitle>`
${styledClass}
`;

View File

@@ -589,6 +589,16 @@ function TanStackTableInner<TData>(
{showPagination && pagination && (
<div className={cx(viewStyles.paginationContainer, paginationClassname)}>
{prefixPaginationContent}
{pagination.showTotalCount && effectiveTotalCount > 0 && (
<span
className={viewStyles.paginationTotalCount}
data-testid="pagination-total-count"
>
Showing {(page - 1) * limit + 1} -{' '}
{Math.min(page * limit, effectiveTotalCount)} of {effectiveTotalCount}
{pagination.totalCountLabel ? ` ${pagination.totalCountLabel}` : ''}
</span>
)}
<Pagination
current={page}
pageSize={limit}

View File

@@ -117,6 +117,10 @@
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
ul {
padding: 0;
}
}
.paginationPageSize {
@@ -124,6 +128,11 @@
--combobox-trigger-height: 2rem;
}
.paginationTotalCount {
font-size: var(--periscope-font-size-base);
color: var(--l1-foreground);
}
.tanstackLoadingOverlay {
position: absolute;
left: 50%;

View File

@@ -188,6 +188,87 @@ describe('TanStackTableView Integration', () => {
expect(screen.getByTestId('suffix-content')).toBeInTheDocument();
});
});
it('renders total count when showTotalCount is true', async () => {
renderTanStackTable({
props: {
pagination: {
total: 100,
defaultPage: 1,
defaultLimit: 10,
showTotalCount: true,
},
},
});
await waitFor(() => {
const totalCount = screen.getByTestId('pagination-total-count');
expect(totalCount).toBeInTheDocument();
expect(totalCount).toHaveTextContent('Showing 1 - 10 of 100');
});
});
it('renders total count with label when totalCountLabel is provided', async () => {
renderTanStackTable({
props: {
pagination: {
total: 50,
defaultPage: 1,
defaultLimit: 10,
showTotalCount: true,
totalCountLabel: 'Pods',
},
},
});
await waitFor(() => {
const totalCount = screen.getByTestId('pagination-total-count');
expect(totalCount).toBeInTheDocument();
expect(totalCount).toHaveTextContent('Showing 1 - 10 of 50 Pods');
});
});
it('does not render total count when showTotalCount is false', async () => {
renderTanStackTable({
props: {
pagination: {
total: 100,
defaultPage: 1,
defaultLimit: 10,
showTotalCount: false,
},
},
});
await waitFor(() => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
expect(
screen.queryByTestId('pagination-total-count'),
).not.toBeInTheDocument();
});
it('does not render total count when total is 0', async () => {
renderTanStackTable({
props: {
pagination: {
total: 0,
defaultPage: 1,
defaultLimit: 10,
showTotalCount: true,
},
},
});
await waitFor(() => {
expect(screen.getByRole('table')).toBeInTheDocument();
});
expect(
screen.queryByTestId('pagination-total-count'),
).not.toBeInTheDocument();
});
});
describe('sorting', () => {

View File

@@ -115,6 +115,8 @@ export type PaginationProps = {
total: number;
defaultPage?: number;
defaultLimit?: number;
showTotalCount?: boolean;
totalCountLabel?: string;
};
export type TanstackTableQueryParamsConfig = {

View File

@@ -1,4 +1,4 @@
import { Typography } from 'antd';
import { Typography } from '@signozhq/ui';
import { timeItems } from 'container/NewWidget/RightContainer/timeItems';
export const menuItems = timeItems.map((item) => ({

View File

@@ -1,6 +1,7 @@
import { Dispatch, SetStateAction, useCallback, useMemo } from 'react';
import { DownOutlined } from '@ant-design/icons';
import { Button, Dropdown, Typography } from 'antd';
import { Button, Dropdown } from 'antd';
import { Typography } from '@signozhq/ui';
import TimeItems, {
timePreferance,
timePreferenceType,

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import {
useRef,
} from 'react';
import * as Sentry from '@sentry/react';
import { Typography } from 'antd';
import { Typography } from '@signozhq/ui';
import { ToggleGraphProps } from 'components/Graph/types';
import { LineChart } from 'lucide-react';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';

View File

@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ExclamationCircleFilled } from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { getBackgroundColorAndThresholdCheck } from './utils';

View File

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

View File

@@ -56,4 +56,5 @@ export enum QueryParams {
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
isTestAlert = 'isTestAlert',
yAxisUnit = 'yAxisUnit',
ruleName = 'ruleName',
}

View File

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

View File

@@ -8,6 +8,7 @@ const ROUTES = {
SERVICE_MAP: '/service-map',
TRACE: '/trace',
TRACE_DETAIL: '/trace/:id',
TRACE_DETAIL_OLD: '/trace-old/:id',
TRACES_EXPLORER: '/traces-explorer',
ONBOARDING: '/onboarding',
GET_STARTED: '/get-started',
@@ -87,6 +88,8 @@ const ROUTES = {
HOME_PAGE: '/',
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
AI_ASSISTANT: '/ai-assistant/:conversationId',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
} as const;

View File

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

View File

@@ -0,0 +1,101 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { Button, Tooltip } from '@signozhq/ui';
import { Drawer } from 'antd';
import ROUTES from 'constants/routes';
import { Maximize2, MessageSquare, Plus, X } from '@signozhq/icons';
import ConversationView from '../ConversationView';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { VariantContext } from '../VariantContext';
export default function AIAssistantDrawer(): JSX.Element {
const history = useHistory();
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
const closeDrawer = useAIAssistantStore((s) => s.closeDrawer);
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const handleExpand = useCallback(() => {
if (!activeConversationId) {
return;
}
closeDrawer();
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
);
}, [activeConversationId, closeDrawer, history]);
const handleNewConversation = useCallback(() => {
startNewConversation();
}, [startNewConversation]);
return (
<Drawer
open={isDrawerOpen}
onClose={closeDrawer}
placement="right"
width={420}
// Suppress default close button — we render our own header
closeIcon={null}
title={
<div>
<div>
<MessageSquare size={16} />
<span>AI Assistant</span>
</div>
<div>
<Tooltip title="New conversation">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleNewConversation}
aria-label="New conversation"
>
<Plus size={16} />
</Button>
</Tooltip>
<Tooltip title="Open full screen">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleExpand}
disabled={!activeConversationId}
aria-label="Open full screen"
>
<Maximize2 size={16} />
</Button>
</Tooltip>
<Tooltip title="Close">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={closeDrawer}
aria-label="Close drawer"
>
<X size={16} />
</Button>
</Tooltip>
</div>
</div>
}
>
<VariantContext.Provider value="panel">
{activeConversationId ? (
<ConversationView conversationId={activeConversationId} />
) : null}
</VariantContext.Provider>
</Drawer>
);
}

View File

@@ -0,0 +1,2 @@
export * from './AIAssistantDrawer';
export { default } from './AIAssistantDrawer';

View File

@@ -0,0 +1,100 @@
$radius: 4px;
.backdrop {
position: fixed;
inset: 0;
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(2px);
animation: backdropIn 0.15s ease;
}
@keyframes backdropIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
display: flex;
flex-direction: column;
width: 70vw;
height: 80vh;
background: var(--l1-background);
border: 1px solid var(--l1-border);
border-radius: $radius;
overflow: hidden;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35);
animation: modalIn 0.18s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.96) translateY(-6px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--l1-border);
flex-shrink: 0;
background: var(--l1-background);
}
.title {
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 600;
color: var(--l1-foreground);
}
.shortcut {
font-size: 10px;
font-family: var(--font-mono, monospace);
font-weight: 500;
color: var(--l3-foreground);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: $radius;
padding: 1px 5px;
letter-spacing: 0;
line-height: 1.6;
display: flex;
align-items: center;
gap: 4px;
}
.actions {
display: flex;
align-items: center;
gap: 2px;
}
.body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.toggleBtnActive {
background: var(--l2-background) !important;
color: var(--accent-primary) !important;
}

View File

@@ -0,0 +1,212 @@
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useHistory } from 'react-router-dom';
import { Button, Tooltip } from '@signozhq/ui';
import ROUTES from 'constants/routes';
import { History, Maximize2, Minus, Plus, Sparkles, X } from '@signozhq/icons';
import HistorySidebar from '../components/HistorySidebar';
import ConversationView from '../ConversationView';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { VariantContext } from '../VariantContext';
import styles from './AIAssistantModal.module.scss';
/**
* Global floating modal for the AI Assistant.
*
* - Triggered by Cmd+J (Mac) / Ctrl+J (Windows/Linux)
* - Escape or the × button fully closes it
* - The (minimize) button collapses to the side panel
* - Mounted once in AppLayout; always in the DOM, conditionally visible
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function AIAssistantModal(): JSX.Element | null {
const history = useHistory();
const [showHistory, setShowHistory] = useState(false);
const isOpen = useAIAssistantStore((s) => s.isModalOpen);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
const openModal = useAIAssistantStore((s) => s.openModal);
const closeModal = useAIAssistantStore((s) => s.closeModal);
const minimizeModal = useAIAssistantStore((s) => s.minimizeModal);
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
// Cmd+J (Mac) / Ctrl+J (Win/Linux) — toggle modal. Opening
// always starts a brand-new conversation; resuming earlier
// threads is done via the in-modal history sidebar.
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') {
// Don't intercept Cmd+J inside input/textarea — those are for the user
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') {
return;
}
e.preventDefault();
if (isOpen) {
closeModal();
} else {
startNewConversation();
setShowHistory(false);
openModal();
}
return;
}
// Escape — close modal
if (e.key === 'Escape' && isOpen) {
closeModal();
}
};
window.addEventListener('keydown', handleKeyDown);
return (): void => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, openModal, closeModal, startNewConversation]);
// ── Handlers ────────────────────────────────────────────────────────────────
const handleExpand = useCallback(() => {
if (!activeConversationId) {
return;
}
closeModal();
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
);
}, [activeConversationId, closeModal, history]);
const handleNew = useCallback(() => {
startNewConversation();
setShowHistory(false);
}, [startNewConversation]);
const handleHistorySelect = useCallback(() => {
setShowHistory(false);
}, []);
const handleMinimize = useCallback(() => {
minimizeModal();
setShowHistory(false);
}, [minimizeModal]);
const handleBackdropClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only close when clicking the backdrop itself, not the modal card
if (e.target === e.currentTarget) {
closeModal();
}
},
[closeModal],
);
// ── Render ──────────────────────────────────────────────────────────────────
if (!isOpen) {
return null;
}
return createPortal(
<VariantContext.Provider value="modal">
<div
className={styles.backdrop}
role="dialog"
aria-modal="true"
aria-label="AI Assistant"
onClick={handleBackdropClick}
>
<div className={styles.modal}>
{/* Header */}
<div className={styles.header}>
<div className={styles.title}>
<Sparkles size={16} color="var(--primary)" />
<span>AI Assistant</span>
<kbd className={styles.shortcut}>
<span></span>
<span>J</span>
</kbd>
</div>
<div className={styles.actions}>
<Tooltip title={showHistory ? 'Back to chat' : 'Conversations'}>
<Button
variant="ghost"
size="icon"
onClick={(): void => setShowHistory((v) => !v)}
aria-label="Toggle conversations"
className={showHistory ? styles.toggleBtnActive : ''}
>
<History size={14} />
</Button>
</Tooltip>
<Tooltip title="New conversation">
<Button
variant="ghost"
size="icon"
onClick={handleNew}
aria-label="New conversation"
>
<Plus size={14} />
</Button>
</Tooltip>
<Tooltip title="Open full screen">
<Button
variant="ghost"
size="icon"
onClick={handleExpand}
disabled={!activeConversationId}
aria-label="Open full screen"
>
<Maximize2 size={14} />
</Button>
</Tooltip>
<Tooltip title="Minimize to side panel">
<Button
variant="ghost"
size="icon"
onClick={handleMinimize}
aria-label="Minimize to side panel"
>
<Minus size={14} />
</Button>
</Tooltip>
<Tooltip title="Close">
<Button
variant="ghost"
size="icon"
onClick={closeModal}
aria-label="Close"
>
<X size={14} />
</Button>
</Tooltip>
</div>
</div>
{/* Body */}
<div className={styles.body}>
{showHistory ? (
<HistorySidebar onSelect={handleHistorySelect} />
) : (
activeConversationId && (
<ConversationView conversationId={activeConversationId} />
)
)}
</div>
</div>
</div>
</VariantContext.Provider>,
document.body,
);
}

View File

@@ -0,0 +1,2 @@
export * from './AIAssistantModal';
export { default } from './AIAssistantModal';

View File

@@ -0,0 +1,62 @@
$radius: 4px;
.panel {
display: flex;
flex-direction: column;
flex-shrink: 0;
height: 100%;
border-left: 1px solid var(--l1-border);
background: var(--l1-background);
overflow: hidden;
position: relative;
}
.resizeHandle {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
cursor: col-resize;
z-index: 10;
&::after {
content: '';
position: absolute;
top: 0;
left: 1px;
width: 2px;
height: 100%;
background: transparent;
transition: background 0.15s ease;
}
&:hover::after {
background: var(--accent-primary);
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-bottom: 1px solid var(--l1-border);
flex-shrink: 0;
background: var(--l1-background);
}
.title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--l1-foreground);
}
.actions {
display: flex;
align-items: center;
gap: 2px;
}

View File

@@ -0,0 +1,188 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { matchPath, useHistory, useLocation } from 'react-router-dom';
import { Button, Tooltip } from '@signozhq/ui';
import ROUTES from 'constants/routes';
import { History, Maximize2, Plus, Sparkles, X } from '@signozhq/icons';
import HistorySidebar from '../components/HistorySidebar';
import ConversationView from '../ConversationView';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { VariantContext } from '../VariantContext';
import styles from './AIAssistantPanel.module.scss';
const AI_ASSISTANT_PANEL_OPEN_CLASS = 'ai-assistant-panel-open';
const AI_ASSISTANT_PANEL_WIDTH_VAR = '--ai-assistant-panel-width';
export default function AIAssistantPanel(): JSX.Element | null {
const history = useHistory();
const { pathname } = useLocation();
const [showHistory, setShowHistory] = useState(false);
const isOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const isFullScreenPage = !!matchPath(pathname, {
path: ROUTES.AI_ASSISTANT,
exact: true,
});
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
const closeDrawer = useAIAssistantStore((s) => s.closeDrawer);
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const handleExpand = useCallback(() => {
if (!activeConversationId) {
return;
}
closeDrawer();
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
);
}, [activeConversationId, closeDrawer, history]);
const handleNew = useCallback(() => {
startNewConversation();
setShowHistory(false);
}, [startNewConversation]);
// When user picks a conversation from the list, close the sidebar
const handleHistorySelect = useCallback(() => {
setShowHistory(false);
}, []);
// ── Resize logic ──────────────────────────────────────────────────────────
const [panelWidth, setPanelWidth] = useState(380);
const dragStartX = useRef(0);
const dragStartWidth = useRef(0);
useLayoutEffect(() => {
const shouldOffsetChatSupport = isOpen && !isFullScreenPage;
document.body.classList.toggle(
AI_ASSISTANT_PANEL_OPEN_CLASS,
shouldOffsetChatSupport,
);
if (shouldOffsetChatSupport) {
document.body.style.setProperty(
AI_ASSISTANT_PANEL_WIDTH_VAR,
`${panelWidth}px`,
);
} else {
document.body.style.removeProperty(AI_ASSISTANT_PANEL_WIDTH_VAR);
}
return (): void => {
document.body.classList.remove(AI_ASSISTANT_PANEL_OPEN_CLASS);
document.body.style.removeProperty(AI_ASSISTANT_PANEL_WIDTH_VAR);
};
}, [isFullScreenPage, isOpen, panelWidth]);
const handleResizeMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
dragStartX.current = e.clientX;
dragStartWidth.current = panelWidth;
const onMouseMove = (ev: MouseEvent): void => {
// Panel is on the right; dragging left (lower clientX) increases width
const delta = dragStartX.current - ev.clientX;
const next = Math.min(Math.max(dragStartWidth.current + delta, 380), 800);
setPanelWidth(next);
};
const onMouseUp = (): void => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
},
[panelWidth],
);
if (!isOpen || isFullScreenPage) {
return null;
}
return (
<VariantContext.Provider value="panel">
<div className={styles.panel} style={{ width: panelWidth }}>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className={styles.resizeHandle} onMouseDown={handleResizeMouseDown} />
<div className={styles.header}>
<div className={styles.title}>
<Sparkles size={18} color="var(--primary)" />
<span>AI Assistant</span>
</div>
<div className={styles.actions}>
<Tooltip title={showHistory ? 'Back to chat' : 'Conversations'}>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void => setShowHistory((v) => !v)}
aria-label="Toggle conversations"
>
<History size={14} />
</Button>
</Tooltip>
<Tooltip title="New conversation">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleNew}
aria-label="New conversation"
>
<Plus size={14} />
</Button>
</Tooltip>
<Tooltip title="Open full screen">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleExpand}
disabled={!activeConversationId}
aria-label="Open full screen"
>
<Maximize2 size={14} />
</Button>
</Tooltip>
<Tooltip title="Close">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={closeDrawer}
aria-label="Close panel"
>
<X size={14} />
</Button>
</Tooltip>
</div>
</div>
{showHistory ? (
<HistorySidebar onSelect={handleHistorySelect} />
) : (
activeConversationId && (
<ConversationView conversationId={activeConversationId} />
)
)}
</div>
</VariantContext.Provider>
);
}

View File

@@ -0,0 +1,2 @@
export * from './AIAssistantPanel';
export { default } from './AIAssistantPanel';

View File

@@ -0,0 +1,32 @@
.trigger {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 10;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-primary);
color: var(--accent-primary-foreground);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
transition:
transform 0.15s,
box-shadow 0.15s;
&:hover {
transform: scale(1.08);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.32);
}
&:active {
transform: scale(0.96);
}
}

View File

@@ -0,0 +1,44 @@
import { matchPath, useLocation } from 'react-router-dom';
import { Button, Tooltip } from '@signozhq/ui';
import ROUTES from 'constants/routes';
import { Bot } from '@signozhq/icons';
import {
openAIAssistant,
useAIAssistantStore,
} from '../store/useAIAssistantStore';
import styles from './AIAssistantTrigger.module.scss';
/**
* Floating action button anchored to the bottom-right of the content area.
* Hidden when the panel is already open or when on the full-screen AI Assistant page.
*/
export default function AIAssistantTrigger(): JSX.Element | null {
const { pathname } = useLocation();
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
const isFullScreenPage = !!matchPath(pathname, {
path: ROUTES.AI_ASSISTANT,
exact: true,
});
if (isDrawerOpen || isModalOpen || isFullScreenPage) {
return null;
}
return (
<Tooltip title="AI Assistant">
<Button
variant="solid"
color="primary"
className={styles.trigger}
onClick={openAIAssistant}
aria-label="Open AI Assistant"
>
<Bot size={20} />
</Button>
</Tooltip>
);
}

View File

@@ -0,0 +1,2 @@
export * from './AIAssistantTrigger';
export { default } from './AIAssistantTrigger';

View File

@@ -0,0 +1,53 @@
.conversation {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 13px;
color: var(--l3-foreground);
}
.spinner {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.inputWrapper {
flex-shrink: 0;
padding: 12px;
border-top: 1px solid var(--l1-border);
&.compact {
padding: 8px;
}
}
.disclaimer {
flex-shrink: 0;
padding: 8px 16px;
font-size: 10px;
line-height: 1.4;
margin-top: 4px;
color: var(--l3-foreground);
text-align: center;
&.compact {
padding: 8px 12px;
}
}

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