Compare commits

...

61 Commits

Author SHA1 Message Date
SagarRajput-7
34c116fc7e fix: fixed stepInterval not getting updated in the request payload for Bar (#8350)
* fix: fixed stepInterval not getting updated in the request payload for Bar

* fix: added test case
2025-06-25 11:47:59 +05:30
Vibhu Pandey
250646a354 feat(telemetry): remove telemetry (#8326) 2025-06-24 15:59:23 +00:00
Shaheer Kochai
00191d5774 fix: show status message in trace details v2 drawer (#8346) 2025-06-24 15:36:36 +00:00
Sahil Khan
525a0d7a1a fix: back button issue in trace details page (#8347) 2025-06-24 15:27:24 +00:00
Sahil Khan
564edc7430 fix: added network call on search in explorercolumnsrenderer with debounce (#8325) 2025-06-24 15:16:53 +00:00
Amlan Kumar Nandy
78f396b94a chore: add environment filter in infra monitoring (#8309) 2025-06-24 13:25:34 +00:00
Sahil Khan
9e53c150b8 fix: added missing context provider (#8342) 2025-06-24 18:00:47 +05:30
Shaheer Kochai
f80a6c3014 feat: add support for expandable popover for stack message and body in trace details page (#8330)
* feat: add support for expandable popover for stack message and body in trace details page

* refactor: overall improvements + refactor ExpandableAttribute
2025-06-24 11:52:33 +00:00
Vishal Sharma
1eff6d82c9 fix: color code expiry dates in ingestion key settings (#8323) 2025-06-24 13:00:30 +05:30
Shaheer Kochai
f138eff26c fix: fix the flickering in logs explorer table view (#8304) 2025-06-24 08:31:13 +04:30
Vishal Sharma
50f3fc0ff9 Chore/update request texts and integrations (#8305)
* chore: update request dashboard and integrations text

* chore: hide tab/table when data is not available

* chore: add new template text

* fix: test case
2025-06-23 14:24:47 +00:00
Yunus M
ebcb172614 feat(user-profile): update organisation onboarding questions (#8206)
* feat: update org onboarding questions

* feat: type updates

* chore(user-profile): update the onboarding questions

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2025-06-23 13:27:05 +00:00
Shaheer Kochai
133c0deaa8 fix: prevent sending order by id with traces query (#8250)
* fix: prevent sending order by id with traces query

* test: write tests for preventing sending order by id with traces query
2025-06-23 12:34:59 +00:00
Shaheer Kochai
35e8165463 fix: recalculate the query_range start and end timestamps for logs explorer chart and list queries (#8277)
* fix: recalculate start and end time only for relative time ranges

* fix: don't dsipatch UpdateTimeInterval on initial load

* fix: change list query to state instead of memo

* fix: fix the failing test

* chore: improvement to the test

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>
2025-06-23 09:06:46 +00:00
Srikanth Chekuri
6d009c6607 chore: recognize variable in expression (#8328) 2025-06-23 08:30:50 +00:00
Amlan Kumar Nandy
f0994e52c0 chore: alerts fixes and improvements (#8327) 2025-06-23 07:08:17 +00:00
Srikanth Chekuri
7f5b388722 chore: add time range optimization for trace id search (#8317) 2025-06-23 04:09:19 +00:00
Sahil Khan
b11a4c0c21 fix: log details filters use data types from log data response as primary data type (#8278)
* fix: log details filters use data types from log data response as primary data type

* chore: added test cases

* test: add comprehensive unit tests for chooseAutocompleteFromCustomValue function

* fix: added datatypes to util and test cases

* fix: added new tests
2025-06-22 10:58:43 +00:00
Srikanth Chekuri
bbb21f608f chore: more validation, zero values and enfore max step interval (#8319) 2025-06-21 17:49:33 +05:30
Nityananda Gohain
50a5b88708 fix: fetch only required log fields (#8299)
* fix: fetch only required log fields

* fix: update old endpoints

* fix: remove old code
2025-06-21 04:37:57 +00:00
Vibhu Pandey
5601c0886d chore(signoz): deprecate all flags (#8308)
Deprecate all flags

- Use querier.config.fluxInterval in lieu of passing `--flux-interval` and `--flux-interval-for-trace-detail`
- Remove `--gateway-url`
- Use telemetrystore.clickhouse.cluster in lieu of passing `--cluster` or `--cluster-name`
- Add an `unparam` check in the linter. Updated some functions across the querier codebase to be compatible with this linter.
- Remove prometheus config from docker builds.
2025-06-21 00:55:38 +05:30
Srikanth Chekuri
5b342b9b5d chore: handle nan/inf in response (#8318) 2025-06-20 22:26:25 +05:30
Vishal Sharma
7ec59c3c77 chore: move posthog and appcues ids to userId and orgId (#8316) 2025-06-20 13:21:59 +00:00
Nityananda Gohain
a12990f0bd fix: update trace panel query (#8315) 2025-06-20 11:18:17 +00:00
Yunus M
1ee1ca7951 fix: update app layout height based on banners visible (#8307)
* fix: update app layout height based on banners visible

* fix: show banners only in logged in state

---------

Co-authored-by: Vishal Sharma <makeavish786@gmail.com>
2025-06-20 11:08:30 +05:30
Abhi kumar
3b1bf34d3e feat(changelog): show changelogs for newer versions available (#8270)
* feat(changelog): add getChangelogByVersion API and related types

* feat(changelog): implement ChangelogModal and ChangelogRenderer components with styles

* test(dateUtils): add unit tests for formatDate utility

* chore(changelog): fixed pr review changes

* style(ChangelogRenderer): format SCSS for improved readability

* feat(SideNav): integrate ChangelogModal and manage its visibility state

* feat(changelog): refactor changelog handling and integrate into app state

* test(ChangelogModal): add unit tests for scroll functionality and data rendering

* test(ChangelogRenderer): add unit tests for rendering changelog details

* test(ChangelogModal, ChangelogRenderer): refactor tests

* fix(applayout): bot fetching changelog for cloud users

* fix(ChangelogModal): update footer to display feature count dynamically

* fix(ChangelogModal): update link for workspace migration to point to releases page

* feat(ChangelogModal): enhance footer layout and update link behavior

* test(ChangelogModal): update link for workspace migration to point to releases page

* refactor(AppContext): migrate changelog state management to context and update related components

* feat(test-utils): add changelog state and updateChangelog mock to app context

* test(changelogModal): fixed test by adding mock for useAppContext

* fix: added PR review fixes

* Fixed css variable name in ChangelogModal.styles.scss

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* fix(style): added light mode support for changelog modal

* Fixed heading color token

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* fix: remove debug log for isLatestVersion in AppLayout

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-06-20 10:55:52 +05:30
Vibhu Pandey
fbcff29fae chore(sqlstore): remove sqlx (#8306)
## 📄 Summary

remove sqlx
2025-06-20 00:34:54 +05:30
Yunus M
81fcca3bd3 fix: use pathname to get channel id while saving (#8303) 2025-06-19 14:57:32 +00:00
Yunus M
4f7d84aa37 fix: use pathname to get channel id (#8298) 2025-06-19 19:28:47 +05:30
Abhi kumar
8f8dedb8b3 fix(sidebar): added fix routes not highlighting, minor gitter fix (#8297)
* fix(sidebar): added fix routes not highlighting, minor gitter fix

* chore(routes): tsc fix

* fix(private): added check in private route for routes with no role

* fix(private): minor fix in condition

* chore: added roles in empty routes
2025-06-19 16:17:54 +05:30
Ankit Nayan
3f65229506 fix: tracefunnel analytics duration fixes + 2-step funnel fixes (#8294) 2025-06-19 06:19:31 +00:00
Srikanth Chekuri
f006260719 chore: find contradictory condition keys in expression (#8238) 2025-06-19 05:40:50 +00:00
Piyush Singariya
3fc8f6c353 fix: JSON Query parse string int value (#8292)
* fix: json query parse string int

* chore: minor

* Update pkg/query-service/app/logs/v3/enrich_query_test.go

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-06-18 16:14:23 +00:00
Yunus M
e02ae9a5c4 feat: show billing , settings to admin when workspace is blocked (#8291)
* feat: show billing , settings to admin when workspace is blocked

* feat: enable keyboard shortcuts page for all

* feat: remove duplicated option
2025-06-18 20:43:30 +05:30
Nityananda Gohain
1989d07e52 fix: delete existing agents in migration (#8289) 2025-06-18 18:06:36 +05:30
Shaheer Kochai
78194ae955 chore: remove dev env check (#7994)
Co-authored-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-06-18 07:45:54 +00:00
Shivanshu Raj Shrivastava
da1b6d1ed0 feat: adds a final part of trace funnel feature (analytics APIs, and analytics queries) implementation (#8129)
* feat: trace funnel queries

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* fix: update access

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* fix: fix queries

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* fix: minor fix in handler

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* fix: update clauses

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* fix: update step overview queries

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* fix: add new api endpoints for analytics (#8253)

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* fixing steps and funnel (#8283)

* add todo: remove identical function

---------

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
Co-authored-by: Ankit Nayan <ankit@signoz.io>
2025-06-18 07:40:20 +00:00
Amlan Kumar Nandy
d3c76ae8be chore: update alert details error state (#8246) 2025-06-18 07:20:07 +00:00
Shaheer Kochai
bed3dbc698 chore: funnel run and save flow changes (#8231)
* feat: while the funnel steps are invalid, handle auto save in local storage

* chore: handle lightmode style in 'add span to funnel' modal

* fix: don't save incomplete steps state in local storage if last saved configuration has valid steps

* chore: close the 'Add span to funnel' modal on clicking save or discard

* chore: deprecate the run funnel flow for unexecuted funnel

* feat: change the funnel configuration save logic, and deprecate auto save

* refactor: send all steps in the payload of analytics/overview

* refactor: send all steps in the payload of analytics/steps (graph API)

* chore: send all steps in the payload of analytics/steps/overview API

* chore: send funnel steps with slow and error traces + deprecate the refetch on latency type change

* chore: overall improvements

* chore: change the save funnel icon + increase the width of funnel steps

* fix: make the changes w.r.t. the updated funnel steps validation API + bugfixes

* fix: remove funnelId from funnel results APIs

* fix: handle edge case i.e. refetch funnel results on deleting a funnel step

* chore: remove funnel steps configuration cache on removing funnel

* chore: don't refetch the results on changing the latency type

* fix: fix the edge cases of save funnel button being enabled even after saving the funnel steps

* chore: remove the span count column from top traces tables

* fix: fix the failing CI check by removing unnecessary props / fixing the types
2025-06-18 06:08:41 +00:00
Amlan Kumar Nandy
66affb0ece chore: add unit tests for hosts list in infra monitoring (#8230) 2025-06-18 05:53:42 +00:00
Vibhu Pandey
75f62372ae feat(analytics): move frontend event to group_id (#8279)
* chore(api_key): add api key analytics

* feat(analytics): move frontend events

* feat(analytics): add collect config

* feat(analytics): add collect config

* feat(analytics): fix traits

* feat(analytics): fix traits

* feat(analytics): fix traits

* feat(analytics): fix traits

* feat(analytics): fix traits

* feat(analytics): fix factor api key

* fix(analytics): fix org stats

* fix(analytics): fix org stats
2025-06-18 01:54:55 +05:30
Sahil Khan
a3ac307b4e fix: sentry issues SIGNOZ-UI-Q9 SIGNOZ-UI-QA (#8281) 2025-06-17 23:44:21 +05:30
Vikrant Gupta
7672d2f636 chore(user): return user resource on register user request (#8271) 2025-06-17 17:26:06 +05:30
aniketio-ctrl
e3018d9529 fix(8232): added fix for error graph in services tab (#8263) 2025-06-17 08:08:38 +00:00
Nityananda Gohain
385ee268e3 fix: use first org in agent migration (#8269)
* fix: exit gracefull if there are more than one org

* fix: use first org
2025-06-17 06:25:12 +00:00
Piyush Singariya
01036a8a2f fix: top level keys EXIST and NOTEXIST filter simulation (#8255)
* fix: top level keys EXIST and NOTEXIST filter simulation

* test: fix tests

* test: temporarily change collector version

* test: updating go.mod

* fix: tests

* chore: revert changes

* chore: update collector's reference to stable version
2025-06-17 11:28:40 +05:30
Srikanth Chekuri
1542b9d6e9 chore: disallow unknown fields and address gaps (#8237) 2025-06-16 23:11:28 +05:30
Nityananda Gohain
8455349459 fix: support orgId and postgres in agents (#7327)
* fix: initial commit for agents

* fix: remove frontend package manger commit

* fix: use sqlstore

* fix: opamp server changes

* fix: tests

* fix: tests

* fix: minor changes

* fix: migrations

* fix: use uuid7

* fix: use default orgID for single tenant

* fix: pipelines tests fixed

* fix: use correct agentId

* fix: use orgID in coordinator

* fix: fix tests

* fix: remove redundant hash check

* fix: migration

* fix: migration

* fix: address comments

* fix: rename migration file

* fix: remove unwanted orgid code

* fix: use orggetter

* fix: comment

* fix: schema cleanup

* fix: minor changes

* chore: addresses changes

* fix: add back agentID as it used ulid

* fix: keep only 50 agents for an orgId

* chore: explicitly specify text type

* chore: use valuer.uuid for orgid

* fix: linting complain

* chore: final fixes

* chore: minor changes

* fix: add not null

* fix: fe tests

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2025-06-16 20:07:16 +05:30
aniketio-ctrl
c488a24d09 fix(prom-aggr): fix prom aggregation queries using utf-8 charset (#8262)
* fix(prom-aggr): added fix for prom aggregation

* fix(prom-aggr): added fix for prom aggregation
2025-06-16 19:42:17 +05:30
Vikrant Gupta
9091cf61fd chore(github): fix codeowners file (#8261) 2025-06-16 11:37:07 +00:00
Ekansh Gupta
eeb2ab3212 feat: added support for trace_operators in query range v5 (#8165)
* feat: added support for trace_operators in query range v5

* feat: added support for trace_operators in query range v5

* feat: added support for trace_operators in query range v5

* feat: added support for trace_operators in query range v5\n

* feat: added support for trace_operators in query range v5\n

* feat: added support for trace_operators in query range v5

* feat: added support for trace_operators in query range v5

* feat: added support for trace_operators in query range v5

* feat: added support for trace_operators in query range v5

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-06-16 16:43:51 +05:30
Nageshbansal
3f128f0f1d fix: configs in multi-node docker-swarm cluster (#8239) 2025-06-16 11:42:02 +05:30
Vibhu Pandey
59ff7ed1e1 feat(licensing): add analytics (#8252) 2025-06-16 01:09:41 +05:30
Vibhu Pandey
d236b6ce1e feat(statsreporter): add stats for telemetry.*.last_observed.time (#8251)
## 📄 Summary

- add stats for telemetry.*.last_observed.time
2025-06-16 00:02:17 +05:30
Dimitris Mavrommatis
44b118a212 fix: span links tab to span details drawer (#7888)
* fix: span links tab to span details drawer

* Update LinkedSpans.styles.scss

---------

Co-authored-by: Vishal Sharma <makeavish786@gmail.com>
2025-06-15 16:22:36 +04:30
Dimitris Mavrommatis
3fc6f7ee63 feat(trace): add visuals for events on span waterfall and flamegraph (#7889)
* fix: add visuals on waterfall and flamegraph for span events

* fix: correct offsets for events

* fix: addressed comments

* chore: update the name of the import

* fix: interface change

* chore: formatting

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2025-06-15 10:39:39 +00:00
Sahil Khan
f1016baf03 chore: updated http-proxy-middleware to 3.0.5 from 3.0.3 (#8245) 2025-06-13 21:35:48 +05:30
Amlan Kumar Nandy
e5c0d9e44a chore: allow url as label value in alerts (#8244) 2025-06-13 17:47:01 +07:00
Yunus M
e51056c804 fix: use preference.name rather than preference.key (#8234) 2025-06-13 11:44:42 +05:30
Nityananda Gohain
7d8dad4550 Revert "fix: remove whitespace from sso cert (#8141)" (#8233)
This reverts commit 44ea237039.
2025-06-12 22:39:24 +05:30
Yunus M
c477e0ef16 feat: sidebar revamp (#8087)
* feat: sidebar revamp - initial commit

* feat: move billinga and other isolated routes to settings

* feat: handle channel related routes

* feat: update account settings page

* feat: show dropdown for secondary items

* feat: handle reordering of pinned nav items

* feat: improve font load performance

* feat: update font reference

* feat: update page content styles

* feat: handle external links in sidebar

* feat: handle secondary nav item clicks

* feat: handle pinned nav items reordering

* feat: handle sidenav pinned state using preference, handle light mode

* feat: show sidenav items conditionally

* feat: show version diff indicator only to self hosted users

* feat: show billing to admins only and integrations to cloud and enterprise users

* feat: update fallback link

* feat: handle settings menu items

* fix: settings page reload on nav chnage

* feat: intercom to pylon

* feat: show invite user to admin only

* feat: handle review comments

* chore: remove react query dev tools

* feat: minor ui updates

* feat: update changes based on preference store changes

* feat: handle sidenav shortcut state

* feat: handle scroll for more

* feat: maintain shortcuts order

* feat: manage license ui updates

* feat: manage settings options based on license and roles

* feat: update types

* chore: add logEvents

* feat: update types

* chore: fix type errors

* chore: remove unused variable

* feat: update my settings page test cases

---------

Co-authored-by: makeavish <makeavish786@gmail.com>
2025-06-12 19:55:32 +05:30
368 changed files with 21017 additions and 7863 deletions

7
.github/CODEOWNERS vendored
View File

@@ -12,4 +12,9 @@
/pkg/factory/ @grandwizard28
/pkg/types/ @grandwizard28
.golangci.yml @grandwizard28
**/(zeus|licensing|sqlmigration)/ @vikrantgupta25
/pkg/zeus/ @vikrantgupta25
/pkg/licensing/ @vikrantgupta25
/pkg/sqlmigration/ @vikrantgupta25
/ee/zeus/ @vikrantgupta25
/ee/licensing/ @vikrantgupta25
/ee/sqlmigration/ @vikrantgupta25

View File

@@ -7,6 +7,7 @@ linters:
- sloglint
- depguard
- iface
- unparam
linters-settings:
sloglint:

View File

@@ -90,6 +90,15 @@ apiserver:
- /api/v1/version
- /
##################### Querier #####################
querier:
# The TTL for cached query results.
cache_ttl: 168h
# The interval for recent data that should not be cached.
flux_interval: 5m
# The maximum number of concurrent queries for missing ranges.
max_concurrent_queries: 4
##################### TelemetryStore #####################
telemetrystore:
# Maximum number of idle connections in the connection pool.
@@ -103,13 +112,15 @@ telemetrystore:
clickhouse:
# The DSN to use for clickhouse.
dsn: tcp://localhost:9000
# The cluster name to use for clickhouse.
cluster: cluster
# The query settings for clickhouse.
settings:
max_execution_time: 0
max_execution_time_leaf: 0
timeout_before_checking_execution_speed: 0
max_bytes_to_read: 0
max_result_rows_for_ch_query: 0
max_result_rows: 0
##################### Prometheus #####################
prometheus:
@@ -224,3 +235,12 @@ statsreporter:
enabled: true
# The interval at which the stats are collected.
interval: 6h
collect:
# Whether to collect identities and traits (emails).
identities: true
##################### Gateway (License only) #####################
gateway:
# The URL of the gateway's api.
url: http://localhost:8080

View File

@@ -100,12 +100,18 @@ services:
# - "9000:9000"
# - "8123:8123"
# - "9181:9181"
configs:
- source: clickhouse-config
target: /etc/clickhouse-server/config.xml
- source: clickhouse-users
target: /etc/clickhouse-server/users.xml
- source: clickhouse-custom-function
target: /etc/clickhouse-server/custom-function.xml
- source: clickhouse-cluster
target: /etc/clickhouse-server/config.d/cluster.xml
volumes:
- ../common/clickhouse/config.xml:/etc/clickhouse-server/config.xml
- ../common/clickhouse/users.xml:/etc/clickhouse-server/users.xml
- ../common/clickhouse/custom-function.xml:/etc/clickhouse-server/custom-function.xml
- ../common/clickhouse/user_scripts:/var/lib/clickhouse/user_scripts/
- ../common/clickhouse/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml
- clickhouse:/var/lib/clickhouse/
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
@@ -117,9 +123,10 @@ services:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port
volumes:
- ../common/signoz/prometheus.yml:/root/config/prometheus.yml
- ../common/dashboards:/root/config/dashboards
- sqlite:/var/lib/signoz/
configs:
- source: signoz-prometheus-config
target: /root/config/prometheus.yml
environment:
- SIGNOZ_ALERTMANAGER_PROVIDER=signoz
- SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://clickhouse:9000
@@ -147,9 +154,11 @@ services:
- --manager-config=/etc/manager-config.yaml
- --copy-path=/var/tmp/collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
configs:
- source: otel-collector-config
target: /etc/otel-collector-config.yaml
- source: otel-manager-config
target: /etc/manager-config.yaml
environment:
- OTEL_RESOURCE_ATTRIBUTES=host.name={{.Node.Hostname}},os.type={{.Node.Platform.OS}}
- LOW_CARDINAL_EXCEPTION_GROUPING=false
@@ -186,3 +195,26 @@ volumes:
name: signoz-sqlite
zookeeper-1:
name: signoz-zookeeper-1
configs:
clickhouse-config:
file: ../common/clickhouse/config.xml
clickhouse-users:
file: ../common/clickhouse/users.xml
clickhouse-custom-function:
file: ../common/clickhouse/custom-function.xml
clickhouse-cluster:
file: ../common/clickhouse/cluster.xml
signoz-prometheus-config:
file: ../common/signoz/prometheus.yml
# If you have multiple dashboard files, you can list them individually:
# dashboard-foo:
# file: ../common/dashboards/foo.json
# dashboard-bar:
# file: ../common/dashboards/bar.json
otel-collector-config:
file: ./otel-collector-config.yaml
otel-manager-config:
file: ../common/signoz/otel-collector-opamp-config.yaml

View File

@@ -6,11 +6,13 @@ import (
"time"
"github.com/SigNoz/signoz/ee/licensing/licensingstore/sqllicensingstore"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/analyticstypes"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/zeus"
@@ -23,16 +25,17 @@ type provider struct {
config licensing.Config
settings factory.ScopedProviderSettings
orgGetter organization.Getter
analytics analytics.Analytics
stopChan chan struct{}
}
func NewProviderFactory(store sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
func NewProviderFactory(store sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter, analytics analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
return factory.NewProviderFactory(factory.MustNewName("http"), func(ctx context.Context, providerSettings factory.ProviderSettings, config licensing.Config) (licensing.Licensing, error) {
return New(ctx, providerSettings, config, store, zeus, orgGetter)
return New(ctx, providerSettings, config, store, zeus, orgGetter, analytics)
})
}
func New(ctx context.Context, ps factory.ProviderSettings, config licensing.Config, sqlstore sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter) (licensing.Licensing, error) {
func New(ctx context.Context, ps factory.ProviderSettings, config licensing.Config, sqlstore sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter, analytics analytics.Analytics) (licensing.Licensing, error) {
settings := factory.NewScopedProviderSettings(ps, "github.com/SigNoz/signoz/ee/licensing/httplicensing")
licensestore := sqllicensingstore.New(sqlstore)
return &provider{
@@ -42,6 +45,7 @@ func New(ctx context.Context, ps factory.ProviderSettings, config licensing.Conf
settings: settings,
orgGetter: orgGetter,
stopChan: make(chan struct{}),
analytics: analytics,
}, nil
}
@@ -159,6 +163,25 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI
return err
}
stats := licensetypes.NewStatsFromLicense(activeLicense)
provider.analytics.Send(ctx,
analyticstypes.Track{
UserId: "stats_" + organizationID.String(),
Event: "License Updated",
Properties: analyticstypes.NewPropertiesFromMap(stats),
Context: &analyticstypes.Context{
Extra: map[string]interface{}{
analyticstypes.KeyGroupID: organizationID.String(),
},
},
},
analyticstypes.Group{
UserId: "stats_" + organizationID.String(),
GroupId: organizationID.String(),
Traits: analyticstypes.NewTraitsFromMap(stats),
},
)
return nil
}

View File

@@ -11,11 +11,9 @@ RUN apk update && \
COPY ./target/${OS}-${TARGETARCH}/signoz /root/signoz
COPY ./conf/prometheus.yml /root/config/prometheus.yml
COPY ./templates/email /root/templates
COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz
ENTRYPOINT ["./signoz"]
CMD ["-config", "/root/config/prometheus.yml"]
ENTRYPOINT ["./signoz"]

View File

@@ -12,11 +12,9 @@ RUN apk update && \
rm -rf /var/cache/apk/*
COPY ./target/${OS}-${ARCH}/signoz /root/signoz
COPY ./conf/prometheus.yml /root/config/prometheus.yml
COPY ./templates/email /root/templates
COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz
ENTRYPOINT ["./signoz"]
CMD ["-config", "/root/config/prometheus.yml"]

View File

@@ -7,7 +7,6 @@ import (
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/query-service/integrations/gateway"
"github.com/SigNoz/signoz/ee/query-service/interfaces"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/apis/fields"
@@ -17,6 +16,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/signoz"
@@ -26,8 +26,7 @@ import (
)
type APIHandlerOptions struct {
DataConnector interfaces.DataConnector
PreferSpanMetrics bool
DataConnector interfaces.Reader
RulesManager *rules.Manager
UsageManager *usage.Manager
IntegrationsController *integrations.Controller
@@ -51,7 +50,6 @@ type APIHandler struct {
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, error) {
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
Reader: opts.DataConnector,
PreferSpanMetrics: opts.PreferSpanMetrics,
RuleManager: opts.RulesManager,
IntegrationsController: opts.IntegrationsController,
CloudIntegrationsController: opts.CloudIntegrationsController,
@@ -61,7 +59,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore),
Signoz: signoz,
QuerierAPI: querierAPI.NewAPI(signoz.Querier),
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier),
})
if err != nil {

View File

@@ -96,7 +96,7 @@ func (ah *APIHandler) receiveSAML(w http.ResponseWriter, r *http.Request) {
return
}
nextPage, err := ah.Signoz.Modules.User.PrepareSsoRedirect(ctx, redirectUri, email, ah.opts.JWT)
nextPage, err := ah.Signoz.Modules.User.PrepareSsoRedirect(ctx, redirectUri, email)
if err != nil {
zap.L().Error("[receiveSAML] failed to generate redirect URI after successful login ", zap.String("domain", domain.String()), zap.Error(err))
handleSsoError(w, r, redirectUri)

View File

@@ -59,7 +59,7 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
}
}
if ah.opts.PreferSpanMetrics {
if constants.IsPreferSpanMetrics {
for idx, feature := range featureSet {
if feature.Name == licensetypes.UseSpanMetrics {
featureSet[idx].Active = true

View File

@@ -1,39 +0,0 @@
package db
import (
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/prometheus"
basechr "github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
)
type ClickhouseReader struct {
conn clickhouse.Conn
appdb sqlstore.SQLStore
*basechr.ClickHouseReader
}
func NewDataConnector(
sqlDB sqlstore.SQLStore,
telemetryStore telemetrystore.TelemetryStore,
prometheus prometheus.Prometheus,
cluster string,
fluxIntervalForTraceDetail time.Duration,
cache cache.Cache,
) *ClickhouseReader {
chReader := basechr.NewReader(sqlDB, telemetryStore, prometheus, cluster, fluxIntervalForTraceDetail, cache)
return &ClickhouseReader{
conn: telemetryStore.ClickhouseDB(),
appdb: sqlDB,
ClickHouseReader: chReader,
}
}
func (r *ClickhouseReader) GetSQLStore() sqlstore.SQLStore {
return r.appdb
}

View File

@@ -6,14 +6,10 @@ import (
"net"
"net/http"
_ "net/http/pprof" // http profiler
"time"
"github.com/gorilla/handlers"
"github.com/jmoiron/sqlx"
"github.com/SigNoz/signoz/ee/query-service/app/api"
"github.com/SigNoz/signoz/ee/query-service/app/db"
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/ee/query-service/integrations/gateway"
"github.com/SigNoz/signoz/ee/query-service/rules"
"github.com/SigNoz/signoz/ee/query-service/usage"
@@ -32,6 +28,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
@@ -41,7 +38,6 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/telemetry"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"go.uber.org/zap"
)
@@ -59,62 +55,55 @@ type ServerOptions struct {
Jwt *authtypes.JWT
}
// Server runs HTTP api service
// Server runs HTTP, Mux and a grpc server
type Server struct {
serverOptions *ServerOptions
ruleManager *baserules.Manager
config signoz.Config
signoz *signoz.SigNoz
jwt *authtypes.JWT
ruleManager *baserules.Manager
// public http router
httpConn net.Listener
httpServer *http.Server
httpConn net.Listener
httpServer *http.Server
httpHostPort string
// private http
privateConn net.Listener
privateHTTP *http.Server
privateConn net.Listener
privateHTTP *http.Server
privateHostPort string
opampServer *opamp.Server
// Usage manager
usageManager *usage.Manager
opampServer *opamp.Server
unavailableChannel chan healthcheck.Status
}
// HealthCheckStatus returns health check status channel a client can subscribe to
func (s Server) HealthCheckStatus() chan healthcheck.Status {
return s.unavailableChannel
}
// NewServer creates and initializes Server
func NewServer(serverOptions *ServerOptions) (*Server, error) {
gatewayProxy, err := gateway.NewProxy(serverOptions.GatewayUrl, gateway.RoutePrefix)
func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT) (*Server, error) {
gatewayProxy, err := gateway.NewProxy(config.Gateway.URL.String(), gateway.RoutePrefix)
if err != nil {
return nil, err
}
fluxIntervalForTraceDetail, err := time.ParseDuration(serverOptions.FluxIntervalForTraceDetail)
if err != nil {
return nil, err
}
reader := db.NewDataConnector(
serverOptions.SigNoz.SQLStore,
serverOptions.SigNoz.TelemetryStore,
serverOptions.SigNoz.Prometheus,
serverOptions.Cluster,
fluxIntervalForTraceDetail,
serverOptions.SigNoz.Cache,
reader := clickhouseReader.NewReader(
signoz.SQLStore,
signoz.TelemetryStore,
signoz.Prometheus,
signoz.TelemetryStore.Cluster(),
config.Querier.FluxInterval,
signoz.Cache,
)
rm, err := makeRulesManager(
serverOptions.SigNoz.SQLStore.SQLxDB(),
reader,
serverOptions.SigNoz.Cache,
serverOptions.SigNoz.Alertmanager,
serverOptions.SigNoz.SQLStore,
serverOptions.SigNoz.TelemetryStore,
serverOptions.SigNoz.Prometheus,
serverOptions.SigNoz.Modules.OrgGetter,
signoz.Cache,
signoz.Alertmanager,
signoz.SQLStore,
signoz.TelemetryStore,
signoz.Prometheus,
signoz.Modules.OrgGetter,
)
if err != nil {
@@ -122,19 +111,16 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
}
// initiate opamp
_, err = opAmpModel.InitDB(serverOptions.SigNoz.SQLStore.SQLxDB())
if err != nil {
return nil, err
}
opAmpModel.Init(signoz.SQLStore, signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter)
integrationsController, err := integrations.NewController(serverOptions.SigNoz.SQLStore)
integrationsController, err := integrations.NewController(signoz.SQLStore)
if err != nil {
return nil, fmt.Errorf(
"couldn't create integrations controller: %w", err,
)
}
cloudIntegrationsController, err := cloudintegrations.NewController(serverOptions.SigNoz.SQLStore)
cloudIntegrationsController, err := cloudintegrations.NewController(signoz.SQLStore)
if err != nil {
return nil, fmt.Errorf(
"couldn't create cloud provider integrations controller: %w", err,
@@ -143,7 +129,8 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
// ingestion pipelines manager
logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController(
serverOptions.SigNoz.SQLStore, integrationsController.GetPipelinesForInstalledIntegrations,
signoz.SQLStore,
integrationsController.GetPipelinesForInstalledIntegrations,
)
if err != nil {
return nil, err
@@ -151,7 +138,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
// initiate agent config handler
agentConfMgr, err := agentConf.Initiate(&agentConf.ManagerOptions{
DB: serverOptions.SigNoz.SQLStore.SQLxDB(),
Store: signoz.SQLStore,
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController},
})
if err != nil {
@@ -159,7 +146,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
}
// start the usagemanager
usageManager, err := usage.New(serverOptions.SigNoz.Licensing, serverOptions.SigNoz.TelemetryStore.ClickhouseDB(), serverOptions.SigNoz.Zeus, serverOptions.SigNoz.Modules.OrgGetter)
usageManager, err := usage.New(signoz.Licensing, signoz.TelemetryStore.ClickhouseDB(), signoz.Zeus, signoz.Modules.OrgGetter)
if err != nil {
return nil, err
}
@@ -168,47 +155,36 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
return nil, err
}
telemetry.GetInstance().SetReader(reader)
telemetry.GetInstance().SetSqlStore(serverOptions.SigNoz.SQLStore)
telemetry.GetInstance().SetSaasOperator(constants.SaasSegmentKey)
telemetry.GetInstance().SetSavedViewsInfoCallback(telemetry.GetSavedViewsInfo)
telemetry.GetInstance().SetAlertsInfoCallback(telemetry.GetAlertsInfo)
telemetry.GetInstance().SetGetUsersCallback(telemetry.GetUsers)
telemetry.GetInstance().SetUserCountCallback(telemetry.GetUserCount)
telemetry.GetInstance().SetDashboardsInfoCallback(telemetry.GetDashboardsInfo)
fluxInterval, err := time.ParseDuration(serverOptions.FluxInterval)
if err != nil {
return nil, err
}
apiOpts := api.APIHandlerOptions{
DataConnector: reader,
PreferSpanMetrics: serverOptions.PreferSpanMetrics,
RulesManager: rm,
UsageManager: usageManager,
IntegrationsController: integrationsController,
CloudIntegrationsController: cloudIntegrationsController,
LogsParsingPipelineController: logParsingPipelineController,
FluxInterval: fluxInterval,
FluxInterval: config.Querier.FluxInterval,
Gateway: gatewayProxy,
GatewayUrl: serverOptions.GatewayUrl,
JWT: serverOptions.Jwt,
GatewayUrl: config.Gateway.URL.String(),
JWT: jwt,
}
apiHandler, err := api.NewAPIHandler(apiOpts, serverOptions.SigNoz)
apiHandler, err := api.NewAPIHandler(apiOpts, signoz)
if err != nil {
return nil, err
}
s := &Server{
config: config,
signoz: signoz,
jwt: jwt,
ruleManager: rm,
serverOptions: serverOptions,
httpHostPort: baseconst.HTTPHostPort,
privateHostPort: baseconst.PrivateHostPort,
unavailableChannel: make(chan healthcheck.Status),
usageManager: usageManager,
}
httpServer, err := s.createPublicServer(apiHandler, serverOptions.SigNoz.Web)
httpServer, err := s.createPublicServer(apiHandler, signoz.Web)
if err != nil {
return nil, err
@@ -241,18 +217,22 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
return s, nil
}
// HealthCheckStatus returns health check status channel a client can subscribe to
func (s Server) HealthCheckStatus() chan healthcheck.Status {
return s.unavailableChannel
}
func (s *Server) createPrivateServer(apiHandler *api.APIHandler) (*http.Server, error) {
r := baseapp.NewRouter()
r.Use(middleware.NewAuth(s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}, s.serverOptions.SigNoz.Sharder, s.serverOptions.SigNoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAPIKey(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.serverOptions.SigNoz.Instrumentation.Logger(), s.serverOptions.SigNoz.Sharder).Wrap)
r.Use(middleware.NewTimeout(s.serverOptions.SigNoz.Instrumentation.Logger(),
s.serverOptions.Config.APIServer.Timeout.ExcludedRoutes,
s.serverOptions.Config.APIServer.Timeout.Default,
s.serverOptions.Config.APIServer.Timeout.Max,
r.Use(middleware.NewAuth(s.jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAPIKey(s.signoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.signoz.Instrumentation.Logger(), s.signoz.Sharder).Wrap)
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
s.config.APIServer.Timeout.ExcludedRoutes,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewAnalytics().Wrap)
r.Use(middleware.NewLogging(s.serverOptions.SigNoz.Instrumentation.Logger(), s.serverOptions.Config.APIServer.Logging.ExcludedRoutes).Wrap)
r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
apiHandler.RegisterPrivateRoutes(r)
@@ -274,17 +254,16 @@ func (s *Server) createPrivateServer(apiHandler *api.APIHandler) (*http.Server,
func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*http.Server, error) {
r := baseapp.NewRouter()
am := middleware.NewAuthZ(s.serverOptions.SigNoz.Instrumentation.Logger())
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger())
r.Use(middleware.NewAuth(s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}, s.serverOptions.SigNoz.Sharder, s.serverOptions.SigNoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAPIKey(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.serverOptions.SigNoz.Instrumentation.Logger(), s.serverOptions.SigNoz.Sharder).Wrap)
r.Use(middleware.NewTimeout(s.serverOptions.SigNoz.Instrumentation.Logger(),
s.serverOptions.Config.APIServer.Timeout.ExcludedRoutes,
s.serverOptions.Config.APIServer.Timeout.Default,
s.serverOptions.Config.APIServer.Timeout.Max,
r.Use(middleware.NewAuth(s.jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAPIKey(s.signoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.signoz.Instrumentation.Logger(), s.signoz.Sharder).Wrap)
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
s.config.APIServer.Timeout.ExcludedRoutes,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewAnalytics().Wrap)
r.Use(middleware.NewLogging(s.serverOptions.SigNoz.Instrumentation.Logger(), s.serverOptions.Config.APIServer.Logging.ExcludedRoutes).Wrap)
r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
apiHandler.RegisterRoutes(r, am)
apiHandler.RegisterLogsRoutes(r, am)
@@ -325,7 +304,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
func (s *Server) initListeners() error {
// listen on public port
var err error
publicHostPort := s.serverOptions.HTTPHostPort
publicHostPort := s.httpHostPort
if publicHostPort == "" {
return fmt.Errorf("baseconst.HTTPHostPort is required")
}
@@ -335,10 +314,10 @@ func (s *Server) initListeners() error {
return err
}
zap.L().Info(fmt.Sprintf("Query server started listening on %s...", s.serverOptions.HTTPHostPort))
zap.L().Info(fmt.Sprintf("Query server started listening on %s...", s.httpHostPort))
// listen on private port to support internal services
privateHostPort := s.serverOptions.PrivateHostPort
privateHostPort := s.privateHostPort
if privateHostPort == "" {
return fmt.Errorf("baseconst.PrivateHostPort is required")
@@ -348,7 +327,7 @@ func (s *Server) initListeners() error {
if err != nil {
return err
}
zap.L().Info(fmt.Sprintf("Query server started listening on private port %s...", s.serverOptions.PrivateHostPort))
zap.L().Info(fmt.Sprintf("Query server started listening on private port %s...", s.privateHostPort))
return nil
}
@@ -368,7 +347,7 @@ func (s *Server) Start(ctx context.Context) error {
}
go func() {
zap.L().Info("Starting HTTP server", zap.Int("port", httpPort), zap.String("addr", s.serverOptions.HTTPHostPort))
zap.L().Info("Starting HTTP server", zap.Int("port", httpPort), zap.String("addr", s.httpHostPort))
switch err := s.httpServer.Serve(s.httpConn); err {
case nil, http.ErrServerClosed, cmux.ErrListenerClosed:
@@ -394,7 +373,7 @@ func (s *Server) Start(ctx context.Context) error {
}
go func() {
zap.L().Info("Starting Private HTTP server", zap.Int("port", privatePort), zap.String("addr", s.serverOptions.PrivateHostPort))
zap.L().Info("Starting Private HTTP server", zap.Int("port", privatePort), zap.String("addr", s.privateHostPort))
switch err := s.privateHTTP.Serve(s.privateConn); err {
case nil, http.ErrServerClosed, cmux.ErrListenerClosed:
@@ -446,7 +425,6 @@ func (s *Server) Stop(ctx context.Context) error {
}
func makeRulesManager(
db *sqlx.DB,
ch baseint.Reader,
cache cache.Cache,
alertmanager alertmanager.Alertmanager,
@@ -459,7 +437,6 @@ func makeRulesManager(
managerOpts := &baserules.ManagerOptions{
TelemetryStore: telemetryStore,
Prometheus: prometheus,
DBConn: db,
Context: context.Background(),
Logger: zap.L(),
Reader: ch,

View File

@@ -37,9 +37,14 @@ func GetDefaultSiteURL() string {
const DotMetricsEnabled = "DOT_METRICS_ENABLED"
var IsDotMetricsEnabled = false
var IsPreferSpanMetrics = false
func init() {
if GetOrDefaultEnv(DotMetricsEnabled, "false") == "true" {
IsDotMetricsEnabled = true
}
if GetOrDefaultEnv("USE_SPAN_METRICS", "false") == "true" {
IsPreferSpanMetrics = true
}
}

View File

@@ -1,11 +0,0 @@
package interfaces
import (
baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces"
)
// Connector defines methods for interaction
// with o11y data. for example - clickhouse
type DataConnector interface {
baseint.Reader
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/ee/zeus"
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/config"
"github.com/SigNoz/signoz/pkg/config/envprovider"
"github.com/SigNoz/signoz/pkg/config/fileprovider"
@@ -101,10 +102,14 @@ func main() {
fileprovider.NewFactory(),
},
}, signoz.DeprecatedFlags{
MaxIdleConns: maxIdleConns,
MaxOpenConns: maxOpenConns,
DialTimeout: dialTimeout,
Config: promConfigPath,
MaxIdleConns: maxIdleConns,
MaxOpenConns: maxOpenConns,
DialTimeout: dialTimeout,
Config: promConfigPath,
FluxInterval: fluxInterval,
FluxIntervalForTraceDetail: fluxIntervalForTraceDetail,
Cluster: cluster,
GatewayUrl: gatewayUrl,
})
if err != nil {
zap.L().Fatal("Failed to create config", zap.Error(err))
@@ -134,8 +139,8 @@ func main() {
zeus.Config(),
httpzeus.NewProviderFactory(),
licensing.Config(24*time.Hour, 3),
func(sqlstore sqlstore.SQLStore, zeus pkgzeus.Zeus, orgGetter organization.Getter) factory.ProviderFactory[pkglicensing.Licensing, pkglicensing.Config] {
return httplicensing.NewProviderFactory(sqlstore, zeus, orgGetter)
func(sqlstore sqlstore.SQLStore, zeus pkgzeus.Zeus, orgGetter organization.Getter, analytics analytics.Analytics) factory.ProviderFactory[pkglicensing.Licensing, pkglicensing.Config] {
return httplicensing.NewProviderFactory(sqlstore, zeus, orgGetter, analytics)
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),
@@ -147,20 +152,7 @@ func main() {
zap.L().Fatal("Failed to create signoz", zap.Error(err))
}
serverOptions := &app.ServerOptions{
Config: config,
SigNoz: signoz,
HTTPHostPort: baseconst.HTTPHostPort,
PreferSpanMetrics: preferSpanMetrics,
PrivateHostPort: baseconst.PrivateHostPort,
FluxInterval: fluxInterval,
FluxIntervalForTraceDetail: fluxIntervalForTraceDetail,
Cluster: cluster,
GatewayUrl: gatewayUrl,
Jwt: jwt,
}
server, err := app.NewServer(serverOptions)
server, err := app.NewServer(config, signoz, jwt)
if err != nil {
zap.L().Fatal("Failed to create server", zap.Error(err))
}

View File

@@ -17,19 +17,21 @@ var (
)
var (
Org = "org"
User = "user"
UserNoCascade = "user_no_cascade"
FactorPassword = "factor_password"
CloudIntegration = "cloud_integration"
Org = "org"
User = "user"
UserNoCascade = "user_no_cascade"
FactorPassword = "factor_password"
CloudIntegration = "cloud_integration"
AgentConfigVersion = "agent_config_version"
)
var (
OrgReference = `("org_id") REFERENCES "organizations" ("id")`
UserReference = `("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE`
UserReferenceNoCascade = `("user_id") REFERENCES "users" ("id")`
FactorPasswordReference = `("password_id") REFERENCES "factor_password" ("id")`
CloudIntegrationReference = `("cloud_integration_id") REFERENCES "cloud_integration" ("id") ON DELETE CASCADE`
OrgReference = `("org_id") REFERENCES "organizations" ("id")`
UserReference = `("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE`
UserReferenceNoCascade = `("user_id") REFERENCES "users" ("id")`
FactorPasswordReference = `("password_id") REFERENCES "factor_password" ("id")`
CloudIntegrationReference = `("cloud_integration_id") REFERENCES "cloud_integration" ("id") ON DELETE CASCADE`
AgentConfigVersionReference = `("version_id") REFERENCES "agent_config_version" ("id")`
)
type dialect struct{}
@@ -274,6 +276,8 @@ func (dialect *dialect) RenameTableAndModifyModel(ctx context.Context, bun bun.I
fkReferences = append(fkReferences, FactorPasswordReference)
} else if reference == CloudIntegration && !slices.Contains(fkReferences, CloudIntegrationReference) {
fkReferences = append(fkReferences, CloudIntegrationReference)
} else if reference == AgentConfigVersion && !slices.Contains(fkReferences, AgentConfigVersionReference) {
fkReferences = append(fkReferences, AgentConfigVersionReference)
}
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
)
@@ -19,7 +18,6 @@ type provider struct {
settings factory.ScopedProviderSettings
sqldb *sql.DB
bundb *sqlstore.BunDB
sqlxdb *sqlx.DB
dialect *dialect
}
@@ -61,7 +59,6 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
settings: settings,
sqldb: sqldb,
bundb: sqlstore.NewBunDB(settings, sqldb, pgdialect.New(), hooks),
sqlxdb: sqlx.NewDb(sqldb, "postgres"),
dialect: new(dialect),
}, nil
}
@@ -74,10 +71,6 @@ func (provider *provider) SQLDB() *sql.DB {
return provider.sqldb
}
func (provider *provider) SQLxDB() *sqlx.DB {
return provider.sqlxdb
}
func (provider *provider) Dialect() sqlstore.SQLDialect {
return provider.dialect
}

View File

@@ -78,7 +78,7 @@
"fontfaceobserver": "2.3.0",
"history": "4.10.1",
"html-webpack-plugin": "5.5.0",
"http-proxy-middleware": "3.0.3",
"http-proxy-middleware": "3.0.5",
"http-status-codes": "2.3.0",
"i18next": "^21.6.12",
"i18next-browser-languagedetector": "^6.1.3",
@@ -250,7 +250,7 @@
"xml2js": "0.5.0",
"phin": "^3.7.1",
"body-parser": "1.20.3",
"http-proxy-middleware": "3.0.3",
"http-proxy-middleware": "3.0.5",
"cross-spawn": "7.0.5",
"cookie": "^0.7.1",
"serialize-javascript": "6.0.2",

View File

@@ -9,8 +9,8 @@
"tooltip_notification_channels": "More details on how to setting notification channels",
"sending_channels_note": "The alerts will be sent to all the configured channels.",
"loading_channels_message": "Loading Channels..",
"page_title_create": "New Notification Channels",
"page_title_edit": "Edit Notification Channels",
"page_title_create": "New Notification Channel",
"page_title_edit": "Edit Notification Channel",
"button_save_channel": "Save",
"button_test_channel": "Test",
"button_return": "Back",
@@ -62,5 +62,8 @@
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",
"webhook_url_required": "Webhook URL is mandatory",
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)"
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
"api_key_required": "API Key is mandatory",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory"
}

View File

@@ -129,5 +129,6 @@
"text_num_points": "data points in each result group",
"text_alert_frequency": "Run alert every",
"text_for": "minutes",
"selected_query_placeholder": "Select query"
"selected_query_placeholder": "Select query",
"alert_rule_not_found": "Alert Rule not found"
}

View File

@@ -9,8 +9,8 @@
"tooltip_notification_channels": "More details on how to setting notification channels",
"sending_channels_note": "The alerts will be sent to all the configured channels.",
"loading_channels_message": "Loading Channels..",
"page_title_create": "New Notification Channels",
"page_title_edit": "Edit Notification Channels",
"page_title_create": "New Notification Channel",
"page_title_edit": "Edit Notification Channel",
"button_save_channel": "Save",
"button_test_channel": "Test",
"button_return": "Back",
@@ -77,5 +77,8 @@
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",
"webhook_url_required": "Webhook URL is mandatory",
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)"
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
"api_key_required": "API Key is mandatory",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory"
}

View File

@@ -3,6 +3,7 @@ import setLocalStorageApi from 'api/browser/localstorage/set';
import getAll from 'api/v1/user/get';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
@@ -14,6 +15,7 @@ import { matchPath, useLocation } from 'react-router-dom';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
import { Organization } from 'types/api/user/getOrganization';
import { UserResponse } from 'types/api/user/getUser';
import { USER_ROLES } from 'types/roles';
@@ -95,7 +97,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
usersData.data
) {
const isOnboardingComplete = orgPreferences?.find(
(preference: Record<string, any>) => preference.name === 'org_onboarding',
(preference: OrgPreference) =>
preference.name === ORG_PREFERENCES.ORG_ONBOARDING,
)?.value;
const isFirstUser = checkFirstTimeUser();
@@ -123,7 +126,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const isRouteEnabledForWorkspaceBlockedState =
isAdmin &&
(path === ROUTES.ORG_SETTINGS ||
(path === ROUTES.SETTINGS ||
path === ROUTES.ORG_SETTINGS ||
path === ROUTES.BILLING ||
path === ROUTES.MY_SETTINGS);

View File

@@ -71,7 +71,7 @@ function App(): JSX.Element {
const orgName =
org && Array.isArray(org) && org.length > 0 ? org[0].displayName : '';
const { displayName, email, role } = user;
const { displayName, email, role, id, orgId } = user;
const domain = extractDomain(email);
const hostNameParts = hostname.split('.');
@@ -105,7 +105,7 @@ function App(): JSX.Element {
logEvent('Domain Identified', groupTraits, 'group');
}
if (window && window.Appcues) {
window.Appcues.identify(email, {
window.Appcues.identify(id, {
name: displayName,
tenant_id: hostNameParts[0],
@@ -131,7 +131,7 @@ function App(): JSX.Element {
isPaidUser: !!trialInfo?.trialConvertedToSubscription,
});
posthog?.identify(email, {
posthog?.identify(id, {
email,
name: displayName,
orgName,
@@ -143,7 +143,7 @@ function App(): JSX.Element {
isPaidUser: !!trialInfo?.trialConvertedToSubscription,
});
posthog?.group('company', domain, {
posthog?.group('company', orgId, {
name: orgName,
tenant_id: hostNameParts[0],
data_region: hostNameParts[1],
@@ -193,11 +193,12 @@ function App(): JSX.Element {
updatedRoutes = updatedRoutes.filter(
(route) => route?.path !== ROUTES.BILLING,
);
if (isEnterpriseSelfHostedUser) {
updatedRoutes.push(LIST_LICENSES);
}
}
if (isEnterpriseSelfHostedUser) {
updatedRoutes.push(LIST_LICENSES);
}
// always add support route for cloud users
updatedRoutes = [...updatedRoutes, SUPPORT_ROUTE];
} else {

View File

@@ -128,12 +128,7 @@ export const AlertOverview = Loadable(
);
export const CreateAlertChannelAlerts = Loadable(
() =>
import(/* webpackChunkName: "Create Channels" */ 'pages/AlertChannelCreate'),
);
export const EditAlertChannelsAlerts = Loadable(
() => import(/* webpackChunkName: "Edit Channels" */ 'pages/ChannelsEdit'),
() => import(/* webpackChunkName: "Create Channels" */ 'pages/Settings'),
);
export const AllAlertChannels = Loadable(
@@ -165,7 +160,7 @@ export const APIKeys = Loadable(
);
export const MySettings = Loadable(
() => import(/* webpackChunkName: "All MySettings" */ 'pages/MySettings'),
() => import(/* webpackChunkName: "All MySettings" */ 'pages/Settings'),
);
export const CustomDomainSettings = Loadable(
@@ -222,7 +217,7 @@ export const LogsIndexToFields = Loadable(
);
export const BillingPage = Loadable(
() => import(/* webpackChunkName: "BillingPage" */ 'pages/Billing'),
() => import(/* webpackChunkName: "BillingPage" */ 'pages/Settings'),
);
export const SupportPage = Loadable(
@@ -249,7 +244,7 @@ export const WorkspaceAccessRestricted = Loadable(
);
export const ShortcutsPage = Loadable(
() => import(/* webpackChunkName: "ShortcutsPage" */ 'pages/Shortcuts'),
() => import(/* webpackChunkName: "ShortcutsPage" */ 'pages/Settings'),
);
export const InstalledIntegrations = Loadable(

View File

@@ -7,20 +7,15 @@ import {
AlertOverview,
AllAlertChannels,
AllErrors,
APIKeys,
ApiMonitoring,
BillingPage,
CreateAlertChannelAlerts,
CreateNewAlerts,
CustomDomainSettings,
DashboardPage,
DashboardWidget,
EditAlertChannelsAlerts,
EditRulesPage,
ErrorDetails,
Home,
InfrastructureMonitoring,
IngestionSettings,
InstalledIntegrations,
LicensePage,
ListAllALertsPage,
@@ -31,12 +26,10 @@ import {
LogsIndexToFields,
LogsSaveViews,
MetricsExplorer,
MySettings,
NewDashboardPage,
OldLogsExplorer,
Onboarding,
OnboardingV2,
OrganizationSettings,
OrgOnboarding,
PasswordReset,
PipelinePage,
@@ -45,7 +38,6 @@ import {
ServicesTablePage,
ServiceTopLevelOperationsPage,
SettingsPage,
ShortcutsPage,
SignupPage,
SomethingWentWrong,
StatusPage,
@@ -150,7 +142,7 @@ const routes: AppRoutes[] = [
},
{
path: ROUTES.SETTINGS,
exact: true,
exact: false,
component: SettingsPage,
isPrivate: true,
key: 'SETTINGS',
@@ -260,13 +252,6 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'CHANNELS_NEW',
},
{
path: ROUTES.CHANNELS_EDIT,
exact: true,
component: EditAlertChannelsAlerts,
isPrivate: true,
key: 'CHANNELS_EDIT',
},
{
path: ROUTES.ALL_CHANNELS,
exact: true,
@@ -295,41 +280,6 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'VERSION',
},
{
path: ROUTES.ORG_SETTINGS,
exact: true,
component: OrganizationSettings,
isPrivate: true,
key: 'ORG_SETTINGS',
},
{
path: ROUTES.INGESTION_SETTINGS,
exact: true,
component: IngestionSettings,
isPrivate: true,
key: 'INGESTION_SETTINGS',
},
{
path: ROUTES.API_KEYS,
exact: true,
component: APIKeys,
isPrivate: true,
key: 'API_KEYS',
},
{
path: ROUTES.MY_SETTINGS,
exact: true,
component: MySettings,
isPrivate: true,
key: 'MY_SETTINGS',
},
{
path: ROUTES.CUSTOM_DOMAIN_SETTINGS,
exact: true,
component: CustomDomainSettings,
isPrivate: true,
key: 'CUSTOM_DOMAIN_SETTINGS',
},
{
path: ROUTES.LOGS,
exact: true,
@@ -393,13 +343,6 @@ const routes: AppRoutes[] = [
key: 'SOMETHING_WENT_WRONG',
isPrivate: false,
},
{
path: ROUTES.BILLING,
exact: true,
component: BillingPage,
key: 'BILLING',
isPrivate: true,
},
{
path: ROUTES.WORKSPACE_LOCKED,
exact: true,
@@ -421,13 +364,6 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'WORKSPACE_ACCESS_RESTRICTED',
},
{
path: ROUTES.SHORTCUTS,
exact: true,
component: ShortcutsPage,
isPrivate: true,
key: 'SHORTCUTS',
},
{
path: ROUTES.INTEGRATIONS,
exact: true,

View File

@@ -0,0 +1,29 @@
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import axios, { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { ChangelogSchema } from 'types/api/changelog/getChangelogByVersion';
const getChangelogByVersion = async (
versionId: string,
): Promise<SuccessResponse<ChangelogSchema> | ErrorResponse> => {
try {
const response = await axios.get(`
https://cms.signoz.cloud/api/release-changelogs?filters[version][$eq]=${versionId}&populate[features][sort]=sort_order:asc&populate[features][populate][media][fields]=id,ext,url,mime,alternativeText
`);
if (!Array.isArray(response.data.data) || response.data.data.length === 0) {
throw new Error('No changelog found!');
}
return {
statusCode: 200,
error: null,
message: response.statusText,
payload: response.data.data[0],
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getChangelogByVersion;

View File

@@ -119,6 +119,7 @@ export const updateFunnelSteps = async (
export interface ValidateFunnelPayload {
start_time: number;
end_time: number;
steps: FunnelStepData[];
}
export interface ValidateFunnelResponse {
@@ -132,12 +133,11 @@ export interface ValidateFunnelResponse {
}
export const validateFunnelSteps = async (
funnelId: string,
payload: ValidateFunnelPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<ValidateFunnelResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/validate`,
`${FUNNELS_BASE_PATH}/analytics/validate`,
payload,
{ signal },
);
@@ -185,6 +185,7 @@ export interface FunnelOverviewPayload {
end_time: number;
step_start?: number;
step_end?: number;
steps: FunnelStepData[];
}
export interface FunnelOverviewResponse {
@@ -202,12 +203,11 @@ export interface FunnelOverviewResponse {
}
export const getFunnelOverview = async (
funnelId: string,
payload: FunnelOverviewPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<FunnelOverviewResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/overview`,
`${FUNNELS_BASE_PATH}/analytics/overview`,
payload,
{
signal,
@@ -235,12 +235,11 @@ export interface SlowTraceData {
}
export const getFunnelSlowTraces = async (
funnelId: string,
payload: FunnelOverviewPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<SlowTraceData> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/slow-traces`,
`${FUNNELS_BASE_PATH}/analytics/slow-traces`,
payload,
{
signal,
@@ -273,7 +272,7 @@ export const getFunnelErrorTraces = async (
signal?: AbortSignal,
): Promise<SuccessResponse<ErrorTraceData> | ErrorResponse> => {
const response: AxiosResponse = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/error-traces`,
`${FUNNELS_BASE_PATH}/analytics/error-traces`,
payload,
{
signal,
@@ -291,6 +290,7 @@ export const getFunnelErrorTraces = async (
export interface FunnelStepsPayload {
start_time: number;
end_time: number;
steps: FunnelStepData[];
}
export interface FunnelStepGraphMetrics {
@@ -307,12 +307,11 @@ export interface FunnelStepsResponse {
}
export const getFunnelSteps = async (
funnelId: string,
payload: FunnelStepsPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<FunnelStepsResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/steps`,
`${FUNNELS_BASE_PATH}/analytics/steps`,
payload,
{ signal },
);
@@ -330,6 +329,7 @@ export interface FunnelStepsOverviewPayload {
end_time: number;
step_start?: number;
step_end?: number;
steps: FunnelStepData[];
}
export interface FunnelStepsOverviewResponse {
@@ -341,12 +341,11 @@ export interface FunnelStepsOverviewResponse {
}
export const getFunnelStepsOverview = async (
funnelId: string,
payload: FunnelStepsOverviewPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<FunnelStepsOverviewResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/steps/overview`,
`${FUNNELS_BASE_PATH}/analytics/steps/overview`,
payload,
{ signal },
);

View File

@@ -1,21 +0,0 @@
import axios from 'api';
import { Props } from 'types/api/userFeedback/sendResponse';
const sendFeedback = async (props: Props): Promise<number> => {
const response = await axios.post(
'/feedback',
{
email: props.email,
message: props.message,
},
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
return response.status;
};
export default sendFeedback;

View File

@@ -0,0 +1,161 @@
.changelog-modal {
.ant-modal-content {
padding: unset;
background-color: var(--bg-ink-400, #121317);
.ant-modal-header {
margin-bottom: unset;
}
.ant-modal-footer {
margin-top: unset;
}
}
&-title {
display: flex;
align-items: center;
gap: 8px;
background-color: var(--bg-ink-400, #121317);
padding: 16px;
font-size: 14px;
line-height: 20px;
color: var(--text-vanilla-100, #fff);
border-bottom: 1px solid var(--bg-slate-500, #161922);
}
&-footer.scroll-available {
.scroll-btn-container {
display: block;
}
}
&-footer {
position: relative;
border: 1px solid var(--bg-slate-500, #161922);
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
&-label {
color: var(--text-robin-400, #7190f9);
font-size: 14px;
line-height: 24px;
position: relative;
padding-left: 14px;
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
width: 6px;
height: 6px;
border-radius: 100%;
background-color: var(--bg-robin-500, #7190f9);
}
}
&-ctas {
display: flex;
& svg {
font-size: 14px;
}
}
.scroll-btn-container {
display: none;
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
.scroll-btn {
all: unset;
padding: 4px 12px 4px 10px;
background-color: var(--bg-slate-400, #1d212d);
border-radius: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
transition: background-color 0.1s;
&:hover {
background-color: var(--bg-slate-200, #2c3140);
}
&:active {
background-color: var(--bg-slate-600, #1c1f2a);
}
span {
font-size: 12px;
line-height: 18px;
color: var(--text-vanilla-400, #c0c1c3);
}
// add animation to the chevrons down icon
svg {
animation: pulse 1s infinite;
}
}
}
}
&-content {
max-height: calc(100vh - 300px);
overflow-y: auto;
padding: 16px;
border: 1px solid var(--bg-slate-500, #161922);
border-top-width: 0;
border-bottom-width: 0;
}
}
// pulse for the scroll for more icon
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.lightMode {
.changelog-modal {
.ant-modal-content {
background-color: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-300);
}
&-title {
background: var(--bg-vanilla-100);
color: var(--bg-ink-500);
border-color: var(--bg-vanilla-300);
}
&-content {
border-color: var(--bg-vanilla-300);
}
&-footer {
border-color: var(--bg-vanilla-300);
.scroll-btn-container {
.scroll-btn {
background-color: var(--bg-vanilla-300);
span {
color: var(--text-ink-500);
}
}
}
}
}
}

View File

@@ -0,0 +1,131 @@
import './ChangelogModal.styles.scss';
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { Button, Modal } from 'antd';
import cx from 'classnames';
import dayjs from 'dayjs';
import { ChevronsDown, ScrollText } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useCallback, useEffect, useRef, useState } from 'react';
import ChangelogRenderer from './components/ChangelogRenderer';
interface Props {
onClose: () => void;
}
function ChangelogModal({ onClose }: Props): JSX.Element {
const [hasScroll, setHasScroll] = useState(false);
const changelogContentSectionRef = useRef<HTMLDivElement>(null);
const { changelog } = useAppContext();
const formattedReleaseDate = dayjs(changelog?.release_date).format(
'MMMM D, YYYY',
);
const checkScroll = useCallback((): void => {
if (changelogContentSectionRef.current) {
const {
scrollHeight,
clientHeight,
scrollTop,
} = changelogContentSectionRef.current;
const isAtBottom = scrollHeight - clientHeight - scrollTop <= 8;
setHasScroll(scrollHeight > clientHeight + 24 && !isAtBottom); // 24px - buffer height to show show more
}
}, []);
useEffect(() => {
checkScroll();
const changelogContentSection = changelogContentSectionRef.current;
if (changelogContentSection) {
changelogContentSection.addEventListener('scroll', checkScroll);
}
return (): void => {
if (changelogContentSection) {
changelogContentSection.removeEventListener('scroll', checkScroll);
}
};
}, [checkScroll]);
const onClickUpdateWorkspace = (): void => {
window.open(
'https://github.com/SigNoz/signoz/releases',
'_blank',
'noopener,noreferrer',
);
};
const onClickScrollForMore = (): void => {
if (changelogContentSectionRef.current) {
changelogContentSectionRef.current.scrollTo({
top: changelogContentSectionRef.current.scrollTop + 600, // Scroll 600px from the current position
behavior: 'smooth',
});
}
};
return (
<Modal
className={cx('changelog-modal')}
title={
<div className="changelog-modal-title">
<ScrollText size={16} />
Whats New Changelog : {formattedReleaseDate}
</div>
}
width={820}
open
onCancel={onClose}
footer={
<div
className={cx('changelog-modal-footer', hasScroll && 'scroll-available')}
>
{changelog?.features && changelog.features.length > 0 && (
<span className="changelog-modal-footer-label">
{changelog.features.length} new&nbsp;
{changelog.features.length > 1 ? 'features' : 'feature'}
</span>
)}
<div className="changelog-modal-footer-ctas">
<Button type="default" icon={<CloseOutlined />} onClick={onClose}>
Skip for now
</Button>
<Button
type="primary"
icon={<CheckOutlined />}
onClick={onClickUpdateWorkspace}
>
Update my workspace
</Button>
</div>
{changelog && (
<div className="scroll-btn-container">
<button
data-testid="scroll-more-btn"
type="button"
className="scroll-btn"
onClick={onClickScrollForMore}
>
<ChevronsDown size={14} />
<span>Scroll for more</span>
</button>
</div>
)}
</div>
}
>
<div
className="changelog-modal-content"
data-testid="changelog-content"
ref={changelogContentSectionRef}
>
{changelog && <ChangelogRenderer changelog={changelog} />}
</div>
</Modal>
);
}
export default ChangelogModal;

View File

@@ -0,0 +1,79 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable sonarjs/no-identical-functions */
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { fireEvent, render, screen } from '@testing-library/react';
import ChangelogModal from '../ChangelogModal';
const mockChangelog = {
release_date: '2025-06-10',
features: [
{
id: 1,
title: 'Feature 1',
description: 'Description for feature 1',
media: null,
},
],
bug_fixes: 'Bug fix details',
maintenance: 'Maintenance details',
};
// Mock react-markdown to just render children as plain text
jest.mock(
'react-markdown',
() =>
function ReactMarkdown({ children }: any) {
return <div>{children}</div>;
},
);
// mock useAppContext
jest.mock('providers/App/App', () => ({
useAppContext: jest.fn(() => ({ changelog: mockChangelog })),
}));
describe('ChangelogModal', () => {
it('renders modal with changelog data', () => {
render(<ChangelogModal onClose={jest.fn()} />);
expect(
screen.getByText('Whats New ⎯ Changelog : June 10, 2025'),
).toBeInTheDocument();
expect(screen.getByText('Feature 1')).toBeInTheDocument();
expect(screen.getByText('Description for feature 1')).toBeInTheDocument();
expect(screen.getByText('Bug fix details')).toBeInTheDocument();
expect(screen.getByText('Maintenance details')).toBeInTheDocument();
});
it('calls onClose when Skip for now is clicked', () => {
const onClose = jest.fn();
render(<ChangelogModal onClose={onClose} />);
fireEvent.click(screen.getByText('Skip for now'));
expect(onClose).toHaveBeenCalled();
});
it('opens migration docs when Update my workspace is clicked', () => {
window.open = jest.fn();
render(<ChangelogModal onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Update my workspace'));
expect(window.open).toHaveBeenCalledWith(
'https://github.com/SigNoz/signoz/releases',
'_blank',
'noopener,noreferrer',
);
});
it('scrolls for more when Scroll for more is clicked', () => {
render(<ChangelogModal onClose={jest.fn()} />);
const scrollBtn = screen.getByTestId('scroll-more-btn');
const contentDiv = screen.getByTestId('changelog-content');
if (contentDiv) {
contentDiv.scrollTo = jest.fn();
}
fireEvent.click(scrollBtn);
if (contentDiv) {
expect(contentDiv.scrollTo).toHaveBeenCalled();
}
});
});

View File

@@ -0,0 +1,63 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable sonarjs/no-identical-functions */
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { render, screen } from '@testing-library/react';
import ChangelogRenderer from '../components/ChangelogRenderer';
// Mock react-markdown to just render children as plain text
jest.mock(
'react-markdown',
() =>
function ReactMarkdown({ children }: any) {
return <div>{children}</div>;
},
);
const mockChangelog = {
id: 1,
documentId: 'changelog-doc-1',
version: '1.0.0',
createdAt: '2025-06-09T12:00:00Z',
release_date: '2025-06-10',
features: [
{
id: 1,
documentId: '1',
title: 'Feature 1',
description: 'Description for feature 1',
sort_order: 1,
createdAt: '',
updatedAt: '',
publishedAt: '',
deployment_type: 'All',
media: {
id: 1,
documentId: 'doc1',
ext: '.webp',
url: '/uploads/feature1.webp',
mime: 'image/webp',
alternativeText: null,
},
},
],
bug_fixes: 'Bug fix details',
updatedAt: '2025-06-09T12:00:00Z',
publishedAt: '2025-06-09T12:00:00Z',
maintenance: 'Maintenance details',
};
describe('ChangelogRenderer', () => {
it('renders release date', () => {
render(<ChangelogRenderer changelog={mockChangelog} />);
expect(screen.getByText('June 10, 2025')).toBeInTheDocument();
});
it('renders features, media, and description', () => {
render(<ChangelogRenderer changelog={mockChangelog} />);
expect(screen.getByText('Feature 1')).toBeInTheDocument();
expect(screen.getByAltText('Media')).toBeInTheDocument();
expect(screen.getByText('Description for feature 1')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,136 @@
.changelog-renderer {
position: relative;
padding-left: 20px;
.changelog-release-date {
font-size: 14px;
line-height: 20px;
color: var(--text-vanilla-400, #c0c1c3);
}
&-list {
display: flex;
flex-direction: column;
gap: 28px;
}
&-line {
position: absolute;
left: 0;
top: 6px;
bottom: -30px;
width: 1px;
background-color: var(--bg-slate-400, #1d212d);
.inner-ball {
position: absolute;
left: 50%;
width: 6px;
height: 6px;
border-radius: 100%;
transform: translateX(-50%);
background-color: var(--bg-robin-500, #7190f9);
}
}
ul,
ol {
list-style: none;
display: flex;
flex-direction: column;
gap: 16px;
padding-left: 30px;
li {
position: relative;
&::before {
content: '';
position: absolute;
left: -10px;
top: 10px;
width: 20px;
height: 2px;
background-color: var(--bg-robin-500, #7190f9);
transform: translate(-100%, -50%);
}
}
}
li,
p {
font-size: 14px;
line-height: 20px;
color: var(--text-vanilla-400, #c0c1c3);
}
code {
padding: 2px 4px;
background-color: var(--bg-slate-500, #161922);
border-radius: 6px;
font-size: 95%;
vertical-align: middle;
border: 1px solid var(--bg-slate-600, #1c1f2a);
}
a {
color: var(--text-robin-500, #7190f9);
font-weight: 600;
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
color: var(--text-vanilla-100, #fff);
}
h1 {
font-size: 24px;
line-height: 32px;
}
h2 {
font-size: 20px;
line-height: 28px;
}
.changelog-media-image {
height: auto;
width: 100%;
overflow: hidden;
border-radius: 4px;
border: 1px solid var(--bg-slate-400, #1d212d);
}
}
.lightMode {
.changelog-renderer {
.changelog-release-date {
color: var(--text-ink-500);
}
&-line {
background-color: var(--bg-vanilla-300);
}
li,
p {
color: var(--text-ink-500);
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--text-ink-500);
}
}
}

View File

@@ -0,0 +1,89 @@
import './ChangelogRenderer.styles.scss';
import dayjs from 'dayjs';
import ReactMarkdown from 'react-markdown';
import {
ChangelogSchema,
Media,
SupportedImageTypes,
SupportedVideoTypes,
} from 'types/api/changelog/getChangelogByVersion';
interface Props {
changelog: ChangelogSchema;
}
function renderMedia(media: Media): JSX.Element | null {
if (SupportedImageTypes.includes(media.ext)) {
return (
<img
src={media.url}
alt={media.alternativeText || 'Media'}
width={800}
height={450}
className="changelog-media-image"
/>
);
}
if (SupportedVideoTypes.includes(media.ext)) {
return (
<video
autoPlay
controls
controlsList="nodownload noplaybackrate"
loop
className="my-3 h-auto w-full rounded"
>
<source src={media.url} type={media.mime} />
<track kind="captions" src="" label="No captions available" default />
Your browser does not support the video tag.
</video>
);
}
return null;
}
function ChangelogRenderer({ changelog }: Props): JSX.Element {
const formattedReleaseDate = dayjs(changelog.release_date).format(
'MMMM D, YYYY',
);
return (
<div className="changelog-renderer">
<div className="changelog-renderer-line">
<div className="inner-ball" />
</div>
<span className="changelog-release-date">{formattedReleaseDate}</span>
{changelog.features && changelog.features.length > 0 && (
<div className="changelog-renderer-list flex flex-col gap-7">
{changelog.features.map((feature) => (
<div key={feature.id}>
<h2>{feature.title}</h2>
{feature.media && renderMedia(feature.media)}
<ReactMarkdown>{feature.description}</ReactMarkdown>
</div>
))}
</div>
)}
{changelog.bug_fixes && changelog.bug_fixes.length > 0 && (
<div>
<h2>Bug Fixes</h2>
{changelog.bug_fixes && (
<ReactMarkdown>{changelog.bug_fixes}</ReactMarkdown>
)}
</div>
)}
{changelog.maintenance && changelog.maintenance.length > 0 && (
<div>
<h2>Maintenance</h2>
{changelog.maintenance && (
<ReactMarkdown>{changelog.maintenance}</ReactMarkdown>
)}
</div>
)}
</div>
);
}
export default ChangelogRenderer;

View File

@@ -5,17 +5,19 @@ import cx from 'classnames';
import { OPERATORS } from 'constants/queryBuilder';
import { FontSize } from 'container/OptionsMenu/types';
import { memo, MouseEvent, ReactNode, useMemo } from 'react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
function AddToQueryHOC({
fieldKey,
fieldValue,
onAddToQuery,
fontSize,
dataType = DataTypes.EMPTY,
children,
}: AddToQueryHOCProps): JSX.Element {
const handleQueryAdd = (event: MouseEvent<HTMLDivElement>): void => {
event.stopPropagation();
onAddToQuery(fieldKey, fieldValue, OPERATORS['=']);
onAddToQuery(fieldKey, fieldValue, OPERATORS['='], undefined, dataType);
};
const popOverContent = useMemo(() => <span>Add to query: {fieldKey}</span>, [
@@ -35,9 +37,20 @@ function AddToQueryHOC({
export interface AddToQueryHOCProps {
fieldKey: string;
fieldValue: string;
onAddToQuery: (fieldKey: string, fieldValue: string, operator: string) => void;
onAddToQuery: (
fieldKey: string,
fieldValue: string,
operator: string,
isJSON?: boolean,
dataType?: DataTypes,
) => void;
fontSize: FontSize;
dataType?: DataTypes;
children: ReactNode;
}
AddToQueryHOC.defaultProps = {
dataType: DataTypes.EMPTY,
};
export default memo(AddToQueryHOC);

View File

@@ -20,6 +20,7 @@ export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
export const defaultTableStyle: CSSProperties = {
minWidth: '40rem',
maxWidth: '60rem',
};
export const defaultListViewPanelStyle: CSSProperties = {

View File

@@ -1,7 +1,12 @@
import { Tabs, TabsProps } from 'antd';
import { useLocation, useParams } from 'react-router-dom';
import { RouteTabProps } from './types';
interface Params {
[key: string]: string;
}
function RouteTab({
routes,
activeKey,
@@ -9,19 +14,38 @@ function RouteTab({
history,
...rest
}: RouteTabProps & TabsProps): JSX.Element {
const params = useParams<Params>();
const location = useLocation();
// Replace dynamic parameters in routes
const routesWithParams = routes.map((route) => ({
...route,
route: route.route.replace(
/:(\w+)/g,
(match, param) => params[param] || match,
),
}));
// Find the matching route for the current pathname
const currentRoute = routesWithParams.find((route) => {
const routePattern = route.route.replace(/:(\w+)/g, '([^/]+)');
const regex = new RegExp(`^${routePattern}$`);
return regex.test(location.pathname);
});
const onChange = (activeRoute: string): void => {
if (onChangeHandler) {
onChangeHandler(activeRoute);
}
const selectedRoute = routes.find((e) => e.key === activeRoute);
const selectedRoute = routesWithParams.find((e) => e.key === activeRoute);
if (selectedRoute) {
history.push(selectedRoute.route);
}
};
const items = routes.map(({ Component, name, route, key }) => ({
const items = routesWithParams.map(({ Component, name, route, key }) => ({
label: name,
key,
tabKey: route,
@@ -32,8 +56,8 @@ function RouteTab({
<Tabs
onChange={onChange}
destroyInactiveTabPane
activeKey={activeKey}
defaultActiveKey={activeKey}
activeKey={currentRoute?.key || activeKey}
defaultActiveKey={currentRoute?.key || activeKey}
animated
items={items}
// eslint-disable-next-line react/jsx-props-no-spreading

View File

@@ -30,5 +30,5 @@ export enum LOCALSTORAGE {
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
BANNER_DISMISSED = 'BANNER_DISMISSED',
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
UNEXECUTED_FUNNELS = 'UNEXECUTED_FUNNELS',
FUNNEL_STEPS = 'FUNNEL_STEPS',
}

View File

@@ -0,0 +1,18 @@
export const ORG_PREFERENCES = {
ORG_ONBOARDING: 'org_onboarding',
WELCOME_CHECKLIST_DO_LATER: 'welcome_checklist_do_later',
WELCOME_CHECKLIST_SEND_LOGS_SKIPPED: 'welcome_checklist_send_logs_skipped',
WELCOME_CHECKLIST_SEND_TRACES_SKIPPED: 'welcome_checklist_send_traces_skipped',
WELCOME_CHECKLIST_SETUP_ALERTS_SKIPPED:
'welcome_checklist_setup_alerts_skipped',
WELCOME_CHECKLIST_SETUP_SAVED_VIEW_SKIPPED:
'welcome_checklist_setup_saved_view_skipped',
WELCOME_CHECKLIST_SEND_INFRA_METRICS_SKIPPED:
'welcome_checklist_send_infra_metrics_skipped',
WELCOME_CHECKLIST_SETUP_DASHBOARDS_SKIPPED:
'welcome_checklist_setup_dashboards_skipped',
WELCOME_CHECKLIST_SETUP_WORKSPACE_SKIPPED:
'welcome_checklist_setup_workspace_skipped',
WELCOME_CHECKLIST_ADD_DATA_SOURCE_SKIPPED:
'welcome_checklist_add_data_source_skipped',
};

View File

@@ -29,12 +29,12 @@ const ROUTES = {
ALERT_OVERVIEW: '/alerts/overview',
ALL_CHANNELS: '/settings/channels',
CHANNELS_NEW: '/settings/channels/new',
CHANNELS_EDIT: '/settings/channels/:id',
CHANNELS_EDIT: '/settings/channels/edit/:channelId',
ALL_ERROR: '/exceptions',
ERROR_DETAIL: '/error-detail',
VERSION: '/status',
MY_SETTINGS: '/my-settings',
SETTINGS: '/settings',
MY_SETTINGS: '/settings/my-settings',
ORG_SETTINGS: '/settings/org-settings',
CUSTOM_DOMAIN_SETTINGS: '/settings/custom-domain-settings',
API_KEYS: '/settings/api-keys',
@@ -52,7 +52,7 @@ const ROUTES = {
LIST_LICENSES: '/licenses',
LOGS_INDEX_FIELDS: '/logs-explorer/index-fields',
TRACE_EXPLORER: '/trace-explorer',
BILLING: '/billing',
BILLING: '/settings/billing',
SUPPORT: '/support',
LOGS_SAVE_VIEWS: '/logs/saved-views',
TRACES_SAVE_VIEWS: '/traces/saved-views',
@@ -60,10 +60,12 @@ const ROUTES = {
TRACES_FUNNELS_DETAIL: '/traces/funnels/:funnelId',
WORKSPACE_LOCKED: '/workspace-locked',
WORKSPACE_SUSPENDED: '/workspace-suspended',
SHORTCUTS: '/shortcuts',
SHORTCUTS: '/settings/shortcuts',
INTEGRATIONS: '/integrations',
MESSAGING_QUEUES_BASE: '/messaging-queues',
MESSAGING_QUEUES_KAFKA: '/messaging-queues/kafka',
MESSAGING_QUEUES_KAFKA_DETAIL: '/messaging-queues/kafka/detail',
INFRASTRUCTURE_MONITORING_BASE: '/infrastructure-monitoring',
INFRASTRUCTURE_MONITORING_HOSTS: '/infrastructure-monitoring/hosts',
INFRASTRUCTURE_MONITORING_KUBERNETES: '/infrastructure-monitoring/kubernetes',
MESSAGING_QUEUES_CELERY_TASK: '/messaging-queues/celery-task',
@@ -71,6 +73,7 @@ const ROUTES = {
METRICS_EXPLORER: '/metrics-explorer/summary',
METRICS_EXPLORER_EXPLORER: '/metrics-explorer/explorer',
METRICS_EXPLORER_VIEWS: '/metrics-explorer/views',
API_MONITORING_BASE: '/api-monitoring',
API_MONITORING: '/api-monitoring/explorer',
METRICS_EXPLORER_BASE: '/metrics-explorer',
WORKSPACE_ACCESS_RESTRICTED: '/workspace-access-restricted',

View File

@@ -0,0 +1,4 @@
export const USER_PREFERENCES = {
SIDENAV_PINNED: 'sidenav_pinned',
NAV_SHORTCUTS: 'nav_shortcuts',
};

View File

@@ -21,9 +21,9 @@ function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
const [action] = useComponentPermission(['new_alert_action'], user.role);
const onClickEditHandler = useCallback((id: string) => {
history.replace(
history.push(
generatePath(ROUTES.CHANNELS_EDIT, {
id,
channelId: id,
}),
);
}, []);

View File

@@ -0,0 +1,4 @@
.alert-channels-container {
width: 90%;
margin: 12px auto;
}

View File

@@ -120,14 +120,19 @@ describe('Create Alert Channel', () => {
expect(screen.getByText('button_test_channel')).toBeInTheDocument();
expect(screen.getByText('button_return')).toBeInTheDocument();
});
it('Should check if saving the form without filling the name displays "Something went wrong"', async () => {
it('Should check if saving the form without filling the name displays error notification', async () => {
const saveButton = screen.getByRole('button', {
name: 'button_save_channel',
});
fireEvent.click(saveButton);
await waitFor(() => expect(showErrorModal).toHaveBeenCalled());
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'channel_name_required',
}),
);
});
it('Should check if clicking on Test button shows "An alert has been sent to this channel" success message if testing passes', async () => {
server.use(

View File

@@ -1,3 +1,5 @@
import './AllAlertChannels.styles.scss';
import { PlusOutlined } from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import getAll from 'api/channels/getAll';
@@ -56,7 +58,7 @@ function AlertChannels(): JSX.Element {
}
return (
<>
<div className="alert-channels-container">
<ButtonContainer>
<Paragraph ellipsis type="secondary">
{t('sending_channels_note')}
@@ -87,7 +89,7 @@ function AlertChannels(): JSX.Element {
</ButtonContainer>
<AlertChannelsComponent allChannels={data?.data || []} />
</>
</div>
);
}

View File

@@ -1,8 +1,34 @@
.app-banner-container {
position: relative;
width: 100%;
}
.app-layout {
position: relative;
height: 100%;
width: 100%;
&.isWorkspaceRestricted {
height: calc(100% - 32px);
// same styles as its either trial expired or payment failed
&.isTrialExpired {
height: calc(100% - 64px);
}
&.isPaymentFailed {
height: calc(100% - 64px);
}
}
&.isTrialExpired {
height: calc(100% - 32px);
}
&.isPaymentFailed {
height: calc(100% - 32px);
}
.app-content {
width: calc(100% - 64px); // width of the sidebar
z-index: 0;
@@ -22,6 +48,12 @@
width: 100%;
}
}
&.side-nav-pinned {
.app-content {
width: calc(100% - 240px);
}
}
}
.chat-support-gateway {
@@ -157,3 +189,9 @@
text-underline-offset: 2px;
}
}
.workspace-restricted-banner,
.trial-expiry-banner,
.payment-failed-banner {
height: 32px;
}

View File

@@ -7,6 +7,7 @@ import * as Sentry from '@sentry/react';
import { Flex } from 'antd';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import getChangelogByVersion from 'api/changelog/getChangelogByVersion';
import logEvent from 'api/common/logEvent';
import manageCreditCardApi from 'api/v1/portal/create';
import getUserLatestVersion from 'api/v1/version/getLatestVersion';
@@ -18,6 +19,7 @@ import { Events } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { USER_PREFERENCES } from 'constants/userPreferences';
import SideNav from 'container/SideNav';
import TopNav from 'container/TopNav';
import dayjs from 'dayjs';
@@ -27,7 +29,6 @@ import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { isNull } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { INTEGRATION_TYPES } from 'pages/Integrations/utils';
import { useAppContext } from 'providers/App/App';
import {
ReactNode,
@@ -40,9 +41,10 @@ import {
import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueries } from 'react-query';
import { useDispatch } from 'react-redux';
import { matchPath, useLocation } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { Dispatch } from 'redux';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import {
UPDATE_CURRENT_ERROR,
@@ -50,15 +52,18 @@ import {
UPDATE_LATEST_VERSION,
UPDATE_LATEST_VERSION_ERROR,
} from 'types/actions/app';
import { SuccessResponseV2 } from 'types/api';
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import { ChangelogSchema } from 'types/api/changelog/getChangelogByVersion';
import APIError from 'types/api/error';
import {
LicenseEvent,
LicensePlatform,
LicenseState,
} from 'types/api/licensesV3/getActive';
import AppReducer from 'types/reducer/app';
import { USER_ROLES } from 'types/roles';
import { checkVersionState } from 'utils/app';
import { eventEmitter } from 'utils/getEventEmitter';
import {
getFormattedDate,
@@ -80,6 +85,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
featureFlags,
isFetchingFeatureFlags,
featureFlagsFetchError,
userPreferences,
updateChangelog,
} = useAppContext();
const { notifications } = useNotifications();
@@ -91,6 +98,15 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const [showSlowApiWarning, setShowSlowApiWarning] = useState(false);
const [slowApiWarningShown, setSlowApiWarningShown] = useState(false);
const [shouldFetchChangelog, setShouldFetchChangelog] = useState<boolean>(
false,
);
const { currentVersion, latestVersion } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
const isLatestVersion = checkVersionState(currentVersion, latestVersion);
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
@@ -128,7 +144,11 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const [getUserVersionResponse, getUserLatestVersionResponse] = useQueries([
const [
getUserVersionResponse,
getUserLatestVersionResponse,
getChangelogByVersionResponse,
] = useQueries([
{
queryFn: getUserVersion,
queryKey: ['getUserVersion', user?.accessJwt],
@@ -139,6 +159,12 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
queryKey: ['getUserLatestVersion', user?.accessJwt],
enabled: isLoggedIn,
},
{
queryFn: (): Promise<SuccessResponse<ChangelogSchema> | ErrorResponse> =>
getChangelogByVersion(latestVersion),
queryKey: ['getChangelogByVersion', latestVersion],
enabled: isLoggedIn && !isCloudUserVal && shouldFetchChangelog,
},
]);
useEffect(() => {
@@ -241,6 +267,30 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
notifications,
]);
useEffect(() => {
if (!isLatestVersion) {
setShouldFetchChangelog(true);
}
}, [isLatestVersion]);
useEffect(() => {
if (
getChangelogByVersionResponse.isFetched &&
getChangelogByVersionResponse.isSuccess &&
getChangelogByVersionResponse.data &&
getChangelogByVersionResponse.data.payload
) {
updateChangelog(getChangelogByVersionResponse.data.payload);
}
}, [
updateChangelog,
getChangelogByVersionResponse.isFetched,
getChangelogByVersionResponse.isLoading,
getChangelogByVersionResponse.isError,
getChangelogByVersionResponse.data,
getChangelogByVersionResponse.isSuccess,
]);
const isToDisplayLayout = isLoggedIn;
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
@@ -330,53 +380,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
});
}, [manageCreditCard]);
const isHome = (): boolean => routeKey === 'HOME';
const isLogsView = (): boolean =>
routeKey === 'LOGS' ||
routeKey === 'LOGS_EXPLORER' ||
routeKey === 'LOGS_PIPELINES' ||
routeKey === 'LOGS_SAVE_VIEWS';
const isApiMonitoringView = (): boolean => routeKey === 'API_MONITORING';
const isExceptionsView = (): boolean => routeKey === 'ALL_ERROR';
const isTracesView = (): boolean =>
routeKey === 'TRACES_EXPLORER' || routeKey === 'TRACES_SAVE_VIEWS';
const isMessagingQueues = (): boolean =>
routeKey === 'MESSAGING_QUEUES_KAFKA' ||
routeKey === 'MESSAGING_QUEUES_KAFKA_DETAIL' ||
routeKey === 'MESSAGING_QUEUES_CELERY_TASK' ||
routeKey === 'MESSAGING_QUEUES_OVERVIEW';
const isCloudIntegrationPage = (): boolean =>
routeKey === 'INTEGRATIONS' &&
new URLSearchParams(window.location.search).get('integration') ===
INTEGRATION_TYPES.AWS_INTEGRATION;
const isDashboardListView = (): boolean => routeKey === 'ALL_DASHBOARD';
const isAlertHistory = (): boolean => routeKey === 'ALERT_HISTORY';
const isAlertOverview = (): boolean => routeKey === 'ALERT_OVERVIEW';
const isInfraMonitoring = (): boolean =>
routeKey === 'INFRASTRUCTURE_MONITORING_HOSTS' ||
routeKey === 'INFRASTRUCTURE_MONITORING_KUBERNETES';
const isTracesFunnels = (): boolean => routeKey === 'TRACES_FUNNELS';
const isTracesFunnelDetails = (): boolean =>
!!matchPath(pathname, ROUTES.TRACES_FUNNELS_DETAIL);
const isPathMatch = (regex: RegExp): boolean => regex.test(pathname);
const isDashboardView = (): boolean =>
isPathMatch(/^\/dashboard\/[a-zA-Z0-9_-]+$/);
const isDashboardWidgetView = (): boolean =>
isPathMatch(/^\/dashboard\/[a-zA-Z0-9_-]+\/new$/);
const isTraceDetailsView = (): boolean =>
isPathMatch(/^\/trace\/[a-zA-Z0-9]+(\?.*)?$/);
useEffect(() => {
if (isDarkMode) {
document.body.classList.remove('lightMode');
@@ -593,61 +596,84 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
</div>
);
const sideNavPinned = userPreferences?.find(
(preference) => preference.name === USER_PREFERENCES.SIDENAV_PINNED,
)?.value as boolean;
const SHOW_TRIAL_EXPIRY_BANNER =
showTrialExpiryBanner && !showPaymentFailedWarning;
const SHOW_WORKSPACE_RESTRICTED_BANNER = showWorkspaceRestricted;
const SHOW_PAYMENT_FAILED_BANNER =
!showTrialExpiryBanner && showPaymentFailedWarning;
return (
<Layout className={cx(isDarkMode ? 'darkMode dark' : 'lightMode')}>
<Helmet>
<title>{pageTitle}</title>
</Helmet>
{showTrialExpiryBanner && !showPaymentFailedWarning && (
<div className="trial-expiry-banner">
You are in free trial period. Your free trial will end on{' '}
<span>{getFormattedDate(trialInfo?.trialEnd || Date.now())}.</span>
{user.role === USER_ROLES.ADMIN ? (
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleUpgrade}>
upgrade
</a>
to continue using SigNoz features.
</span>
) : (
'Please contact your administrator for upgrading to a paid plan.'
{isLoggedIn && (
<div className={cx('app-banner-container')}>
{SHOW_TRIAL_EXPIRY_BANNER && (
<div className="trial-expiry-banner">
You are in free trial period. Your free trial will end on{' '}
<span>{getFormattedDate(trialInfo?.trialEnd || Date.now())}.</span>
{user.role === USER_ROLES.ADMIN ? (
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleUpgrade}>
upgrade
</a>
to continue using SigNoz features.
</span>
) : (
'Please contact your administrator for upgrading to a paid plan.'
)}
</div>
)}
</div>
)}
{showWorkspaceRestricted && renderWorkspaceRestrictedBanner()}
{SHOW_WORKSPACE_RESTRICTED_BANNER && renderWorkspaceRestrictedBanner()}
{!showTrialExpiryBanner && showPaymentFailedWarning && (
<div className="payment-failed-banner">
Your bill payment has failed. Your workspace will get suspended on{' '}
<span>
{getFormattedDateWithMinutes(
dayjs(activeLicense?.event_queue?.scheduled_at).unix() || Date.now(),
)}
.
</span>
{user.role === USER_ROLES.ADMIN ? (
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleFailedPayment}>
pay the bill
</a>
to continue using SigNoz features.
</span>
) : (
' Please contact your administrator to pay the bill.'
{SHOW_PAYMENT_FAILED_BANNER && (
<div className="payment-failed-banner">
Your bill payment has failed. Your workspace will get suspended on{' '}
<span>
{getFormattedDateWithMinutes(
dayjs(activeLicense?.event_queue?.scheduled_at).unix() || Date.now(),
)}
.
</span>
{user.role === USER_ROLES.ADMIN ? (
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleFailedPayment}>
pay the bill
</a>
to continue using SigNoz features.
</span>
) : (
' Please contact your administrator to pay the bill.'
)}
</div>
)}
</div>
)}
<Flex
className={cx('app-layout', isDarkMode ? 'darkMode dark' : 'lightMode')}
className={cx(
'app-layout',
isDarkMode ? 'darkMode dark' : 'lightMode',
sideNavPinned ? 'side-nav-pinned' : '',
SHOW_WORKSPACE_RESTRICTED_BANNER ? 'isWorkspaceRestricted' : '',
SHOW_TRIAL_EXPIRY_BANNER ? 'isTrialExpired' : '',
SHOW_PAYMENT_FAILED_BANNER ? 'isPaymentFailed' : '',
)}
>
{isToDisplayLayout && !renderFullScreen && <SideNav />}
{isToDisplayLayout && !renderFullScreen && (
<SideNav isPinned={sideNavPinned} />
)}
<div
className={cx('app-content', {
'full-screen-content': renderFullScreen,
@@ -657,32 +683,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<LayoutContent data-overlayscrollbars-initialize>
<OverlayScrollbar>
<ChildrenContainer
style={{
margin:
isHome() ||
isLogsView() ||
isTracesView() ||
isDashboardView() ||
isDashboardWidgetView() ||
isDashboardListView() ||
isAlertHistory() ||
isAlertOverview() ||
isMessagingQueues() ||
isCloudIntegrationPage() ||
isInfraMonitoring() ||
isApiMonitoringView() ||
isExceptionsView()
? 0
: '0 1rem',
...(isTraceDetailsView() ||
isTracesFunnels() ||
isTracesFunnelDetails()
? { margin: 0 }
: {}),
}}
>
<ChildrenContainer>
{isToDisplayLayout && !renderFullScreen && <TopNav />}
{children}
</ChildrenContainer>

View File

@@ -1,7 +1,8 @@
.billing-container {
margin-bottom: 40px;
padding-top: 36px;
width: 65%;
width: 90%;
margin: 0 auto;
.billing-summary {
margin: 24px 8px;

View File

@@ -14,7 +14,7 @@ function CloudIntegrationPage(): JSX.Element {
<HeroSection />
<RequestIntegrationBtn
type={IntegrationType.AWS_SERVICES}
message="Cannot find the AWS service you're looking for? Request more integrations"
message="Can't find the AWS service you're looking for? Request more integrations"
/>
<ServicesTabs />
</div>

View File

@@ -60,26 +60,30 @@ function CloudServiceDataCollected({
return (
<div className="cloud-service-data-collected">
<div className="cloud-service-data-collected__table">
<div className="cloud-service-data-collected__table-heading">Logs</div>
<Table
columns={logsColumns}
dataSource={logsData}
// eslint-disable-next-line react/jsx-props-no-spreading
{...tableProps}
className="cloud-service-data-collected__table-logs"
/>
</div>
<div className="cloud-service-data-collected__table">
<div className="cloud-service-data-collected__table-heading">Metrics</div>
<Table
columns={metricsColumns}
dataSource={metricsData}
// eslint-disable-next-line react/jsx-props-no-spreading
{...tableProps}
className="cloud-service-data-collected__table-metrics"
/>
</div>
{logsData && logsData.length > 0 && (
<div className="cloud-service-data-collected__table">
<div className="cloud-service-data-collected__table-heading">Logs</div>
<Table
columns={logsColumns}
dataSource={logsData}
// eslint-disable-next-line react/jsx-props-no-spreading
{...tableProps}
className="cloud-service-data-collected__table-logs"
/>
</div>
)}
{metricsData && metricsData.length > 0 && (
<div className="cloud-service-data-collected__table">
<div className="cloud-service-data-collected__table-heading">Metrics</div>
<Table
columns={metricsColumns}
dataSource={metricsData}
// eslint-disable-next-line react/jsx-props-no-spreading
{...tableProps}
className="cloud-service-data-collected__table-metrics"
/>
</div>
)}
</div>
);
}

View File

@@ -51,6 +51,33 @@ function ServiceStatus({
return <div className={`service-status ${className}`}>{text}</div>;
}
function getTabItems(serviceDetailsData: any): TabsProps['items'] {
const dashboards = serviceDetailsData?.assets.dashboards || [];
const dataCollected = serviceDetailsData?.data_collected || {};
const items: TabsProps['items'] = [];
if (dashboards.length) {
items.push({
key: 'dashboards',
label: `Dashboards (${dashboards.length})`,
children: <CloudServiceDashboards service={serviceDetailsData} />,
});
}
items.push({
key: 'data-collected',
label: 'Data Collected',
children: (
<CloudServiceDataCollected
logsData={dataCollected.logs || []}
metricsData={dataCollected.metrics || []}
/>
),
});
return items;
}
function ServiceDetails(): JSX.Element | null {
const urlQuery = useUrlQuery();
const cloudAccountId = urlQuery.get('cloudAccountId');
@@ -106,23 +133,7 @@ function ServiceDetails(): JSX.Element | null {
return null;
}
const tabItems: TabsProps['items'] = [
{
key: 'dashboards',
label: `Dashboards (${serviceDetailsData?.assets.dashboards.length})`,
children: <CloudServiceDashboards service={serviceDetailsData} />,
},
{
key: 'data-collected',
label: 'Data Collected',
children: (
<CloudServiceDataCollected
logsData={serviceDetailsData?.data_collected.logs || []}
metricsData={serviceDetailsData?.data_collected.metrics || []}
/>
),
},
];
const tabItems = getTabItems(serviceDetailsData);
return (
<div className="service-details">

View File

@@ -34,7 +34,7 @@ describe('Request AWS integration', () => {
expect(
screen.getByText(
/cannot find what youre looking for\? request more integrations/i,
/can't find what youre looking for\? request more integrations/i,
),
).toBeInTheDocument();

View File

@@ -0,0 +1,15 @@
.create-alert-channels-container {
width: 90%;
margin: 12px auto;
border: 1px solid var(--Slate-500, #161922);
background: var(--Ink-400, #121317);
border-radius: 3px;
padding: 16px;
.form-alert-channels-title {
margin-top: 0px;
margin-bottom: 16px;
}
}

View File

@@ -1,3 +1,5 @@
import './CreateAlertChannels.styles.scss';
import { Form } from 'antd';
import createEmail from 'api/channels/createEmail';
import createMsTeamsApi from 'api/channels/createMsTeams';
@@ -136,6 +138,14 @@ function CreateAlertChannels({
);
const onSlackHandler = useCallback(async () => {
if (!selectedConfig.api_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return;
}
setSavingState(true);
try {
@@ -152,7 +162,7 @@ function CreateAlertChannels({
} finally {
setSavingState(false);
}
}, [prepareSlackRequest, notifications, t, showErrorModal]);
}, [selectedConfig, notifications, t, prepareSlackRequest, showErrorModal]);
const prepareWebhookRequest = useCallback(() => {
// initial api request without auth params
@@ -190,6 +200,14 @@ function CreateAlertChannels({
}, [notifications, t, selectedConfig]);
const onWebhookHandler = useCallback(async () => {
if (!selectedConfig.api_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return;
}
setSavingState(true);
try {
const request = prepareWebhookRequest();
@@ -206,7 +224,13 @@ function CreateAlertChannels({
} finally {
setSavingState(false);
}
}, [prepareWebhookRequest, notifications, t, showErrorModal]);
}, [
selectedConfig.api_url,
notifications,
t,
prepareWebhookRequest,
showErrorModal,
]);
const preparePagerRequest = useCallback(() => {
const validationError = ValidatePagerChannel(selectedConfig as PagerChannel);
@@ -270,6 +294,14 @@ function CreateAlertChannels({
);
const onOpsgenieHandler = useCallback(async () => {
if (!selectedConfig.api_key) {
notifications.error({
message: 'Error',
description: t('api_key_required'),
});
return;
}
setSavingState(true);
try {
await createOpsgenie(prepareOpsgenieRequest());
@@ -285,7 +317,13 @@ function CreateAlertChannels({
} finally {
setSavingState(false);
}
}, [prepareOpsgenieRequest, notifications, t, showErrorModal]);
}, [
selectedConfig.api_key,
notifications,
t,
prepareOpsgenieRequest,
showErrorModal,
]);
const prepareEmailRequest = useCallback(
() => ({
@@ -299,6 +337,14 @@ function CreateAlertChannels({
);
const onEmailHandler = useCallback(async () => {
if (!selectedConfig.to) {
notifications.error({
message: 'Error',
description: t('to_required'),
});
return;
}
setSavingState(true);
try {
const request = prepareEmailRequest();
@@ -315,7 +361,7 @@ function CreateAlertChannels({
} finally {
setSavingState(false);
}
}, [prepareEmailRequest, notifications, t, showErrorModal]);
}, [prepareEmailRequest, notifications, t, showErrorModal, selectedConfig.to]);
const prepareMsTeamsRequest = useCallback(
() => ({
@@ -329,6 +375,14 @@ function CreateAlertChannels({
);
const onMsTeamsHandler = useCallback(async () => {
if (!selectedConfig.webhook_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return;
}
setSavingState(true);
try {
@@ -345,10 +399,24 @@ function CreateAlertChannels({
} finally {
setSavingState(false);
}
}, [prepareMsTeamsRequest, notifications, t, showErrorModal]);
}, [
selectedConfig.webhook_url,
notifications,
t,
prepareMsTeamsRequest,
showErrorModal,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
if (!selectedConfig.name) {
notifications.error({
message: 'Error',
description: t('channel_name_required'),
});
return;
}
const functionMapper = {
[ChannelType.Slack]: onSlackHandler,
[ChannelType.Webhook]: onWebhookHandler,
@@ -477,26 +545,28 @@ function CreateAlertChannels({
);
return (
<FormAlertChannels
{...{
formInstance,
onTypeChangeHandler,
setSelectedConfig,
type,
onTestHandler,
onSaveHandler,
savingState,
testingState,
title: t('page_title_create'),
initialValue: {
<div className="create-alert-channels-container">
<FormAlertChannels
{...{
formInstance,
onTypeChangeHandler,
setSelectedConfig,
type,
...selectedConfig,
...PagerInitialConfig,
...OpsgenieInitialConfig,
...EmailInitialConfig,
},
}}
/>
onTestHandler,
onSaveHandler,
savingState,
testingState,
title: t('page_title_create'),
initialValue: {
type,
...selectedConfig,
...PagerInitialConfig,
...OpsgenieInitialConfig,
...EmailInitialConfig,
},
}}
/>
</div>
);
}

View File

@@ -28,7 +28,6 @@ import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import APIError from 'types/api/error';
function EditAlertChannels({
@@ -53,7 +52,11 @@ function EditAlertChannels({
const [savingState, setSavingState] = useState<boolean>(false);
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
const { id } = useParams<{ id: string }>();
// Extract channelId from URL pathname since useParams doesn't work in nested routing
const { pathname } = window.location;
const channelIdMatch = pathname.match(/\/settings\/channels\/edit\/([^/]+)/);
const id = channelIdMatch ? channelIdMatch[1] : '';
const [type, setType] = useState<ChannelType>(
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,

View File

@@ -57,7 +57,9 @@ function FormAlertChannels({
return (
<>
<Typography.Title level={3}>{title}</Typography.Title>
<Typography.Title level={4} className="form-alert-channels-title">
{title}
</Typography.Title>
<Form initialValues={initialValue} layout="vertical" form={formInstance}>
<Form.Item label={t('field_channel_name')} labelAlign="left" name="name">
@@ -147,7 +149,7 @@ function FormAlertChannels({
</Button>
<Button
onClick={(): void => {
history.replace(ROUTES.SETTINGS);
history.replace(ROUTES.ALL_CHANNELS);
}}
>
{t('button_return')}

View File

@@ -85,7 +85,13 @@ function LabelSelect({
}, [handleBlur]);
const handleLabelChange = (event: ChangeEvent<HTMLInputElement>): void => {
setCurrentVal(event.target?.value.replace(':', ''));
// Remove the colon if it's the last character.
// As the colon is used to separate the key and value in the query.
setCurrentVal(
event.target?.value.endsWith(':')
? event.target?.value.slice(0, -1)
: event.target?.value,
);
};
const handleClose = (key: string): void => {

View File

@@ -109,7 +109,7 @@ describe('GridCardLayout Utils', () => {
builder: {
queryData: [
{
stepInterval: 30,
stepInterval: 60,
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
queryName: 'A',
@@ -181,7 +181,7 @@ describe('GridCardLayout Utils', () => {
expect(result.builder.queryData).toHaveLength(2);
expect(result.builder.queryData[0].stepInterval).toBe(180);
expect(result.builder.queryData[1].stepInterval).toBe(180);
expect(result.builder.queryData[1].stepInterval).toBe(45); // 45 is the stepInterval of the second query - custom value
});
it('should use calculated stepInterval when original is undefined', () => {

View File

@@ -2,7 +2,7 @@ import { FORMULA_REGEXP } from 'constants/regExp';
import { isEmpty, isEqual } from 'lodash-es';
import { Layout } from 'react-grid-layout';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
export const removeUndefinedValuesFromLayout = (layout: Layout[]): Layout[] =>
layout.map((obj) =>
@@ -99,6 +99,12 @@ export function updateStepInterval(
): Query {
const stepIntervalPoints = getStepIntervalPoints(minTime, maxTime);
// if user haven't enter anything manually, that is we have default value of 60 then do the interval adjustment for bar otherwise apply the user's value
const getSteps = (queryData: IBuilderQuery): number =>
queryData.stepInterval === 60
? stepIntervalPoints || 60
: queryData?.stepInterval || 60;
return {
...query,
builder: {
@@ -106,7 +112,7 @@ export function updateStepInterval(
queryData: [
...(query?.builder?.queryData ?? []).map((queryData) => ({
...queryData,
stepInterval: stepIntervalPoints || queryData?.stepInterval || 60,
stepInterval: getSteps(queryData),
})),
],
},

View File

@@ -12,6 +12,7 @@ import Header from 'components/Header/Header';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
@@ -184,18 +185,25 @@ export default function Home(): JSX.Element {
);
const processUserPreferences = (userPreferences: UserPreference[]): void => {
const checklistSkipped = userPreferences?.find(
(preference) => preference.name === 'welcome_checklist_do_later',
)?.value;
const checklistSkipped = Boolean(
userPreferences?.find(
(preference) =>
preference.name === ORG_PREFERENCES.WELCOME_CHECKLIST_DO_LATER,
)?.value,
);
const updatedChecklistItems = cloneDeep(checklistItems);
const newChecklistItems = updatedChecklistItems.map((item) => {
const newItem = { ...item };
newItem.isSkipped =
const isSkipped = Boolean(
userPreferences?.find(
(preference) => preference.name === item.skippedPreferenceKey,
)?.value || false;
)?.value,
);
newItem.isSkipped = isSkipped || false;
return newItem;
});
@@ -239,7 +247,7 @@ export default function Home(): JSX.Element {
setUpdatingUserPreferences(true);
updateUserPreference({
name: 'welcome_checklist_do_later',
name: ORG_PREFERENCES.WELCOME_CHECKLIST_DO_LATER,
value: true,
});
};

View File

@@ -1,17 +1,19 @@
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { ChecklistItem } from './HomeChecklist/HomeChecklist';
export const checkListStepToPreferenceKeyMap = {
WILL_DO_LATER: 'welcome_checklist_do_later',
SEND_LOGS: 'welcome_checklist_send_logs_skipped',
SEND_TRACES: 'welcome_checklist_send_traces_skipped',
SEND_INFRA_METRICS: 'welcome_checklist_send_infra_metrics_skipped',
SETUP_DASHBOARDS: 'welcome_checklist_setup_dashboards_skipped',
SETUP_ALERTS: 'welcome_checklist_setup_alerts_skipped',
SETUP_SAVED_VIEWS: 'welcome_checklist_setup_saved_view_skipped',
SETUP_WORKSPACE: 'welcome_checklist_setup_workspace_skipped',
ADD_DATA_SOURCE: 'welcome_checklist_add_data_source_skipped',
WILL_DO_LATER: ORG_PREFERENCES.WELCOME_CHECKLIST_DO_LATER,
SEND_LOGS: ORG_PREFERENCES.WELCOME_CHECKLIST_SEND_LOGS_SKIPPED,
SEND_TRACES: ORG_PREFERENCES.WELCOME_CHECKLIST_SEND_TRACES_SKIPPED,
SEND_INFRA_METRICS:
ORG_PREFERENCES.WELCOME_CHECKLIST_SEND_INFRA_METRICS_SKIPPED,
SETUP_DASHBOARDS: ORG_PREFERENCES.WELCOME_CHECKLIST_SETUP_DASHBOARDS_SKIPPED,
SETUP_ALERTS: ORG_PREFERENCES.WELCOME_CHECKLIST_SETUP_ALERTS_SKIPPED,
SETUP_SAVED_VIEWS: ORG_PREFERENCES.WELCOME_CHECKLIST_SETUP_SAVED_VIEW_SKIPPED,
SETUP_WORKSPACE: ORG_PREFERENCES.WELCOME_CHECKLIST_SETUP_WORKSPACE_SKIPPED,
ADD_DATA_SOURCE: ORG_PREFERENCES.WELCOME_CHECKLIST_ADD_DATA_SOURCE_SKIPPED,
};
export const DOCS_LINKS = {

View File

@@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react';
import HostsEmptyOrIncorrectMetrics from '../HostsEmptyOrIncorrectMetrics';
describe('HostsEmptyOrIncorrectMetrics', () => {
it('shows no data message when noData is true', () => {
render(<HostsEmptyOrIncorrectMetrics noData incorrectData={false} />);
expect(
screen.getByText('No host metrics data received yet.'),
).toBeInTheDocument();
expect(
screen.getByText(/Infrastructure monitoring requires the/),
).toBeInTheDocument();
});
it('shows incorrect data message when incorrectData is true', () => {
render(<HostsEmptyOrIncorrectMetrics noData={false} incorrectData />);
expect(
screen.getByText(
'To see host metrics, upgrade to the latest version of SigNoz k8s-infra chart. Please contact support if you need help.',
),
).toBeInTheDocument();
});
it('does not show no data message when noData is false', () => {
render(<HostsEmptyOrIncorrectMetrics noData={false} incorrectData={false} />);
expect(
screen.queryByText('No host metrics data received yet.'),
).not.toBeInTheDocument();
expect(
screen.queryByText(/Infrastructure monitoring requires the/),
).not.toBeInTheDocument();
});
it('does not show incorrect data message when incorrectData is false', () => {
render(<HostsEmptyOrIncorrectMetrics noData={false} incorrectData={false} />);
expect(
screen.queryByText(
'To see host metrics, upgrade to the latest version of SigNoz k8s-infra chart. Please contact support if you need help.',
),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,166 @@
/* eslint-disable react/button-has-type */
import { render } from '@testing-library/react';
import ROUTES from 'constants/routes';
import * as useGetHostListHooks from 'hooks/infraMonitoring/useGetHostList';
import * as appContextHooks from 'providers/App/App';
import * as timezoneHooks from 'providers/Timezone';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import store from 'store';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
import HostsList from '../HostsList';
jest.mock('lib/getMinMax', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => ({
minTime: 1713734400000,
maxTime: 1713738000000,
isValidTimeFormat: jest.fn().mockReturnValue(true),
})),
}));
jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
__esModule: true,
default: ({ onSelect, selectedTime, selectedValue }: any): JSX.Element => (
<div data-testid="custom-time-picker">
<button onClick={(): void => onSelect('custom')}>
{selectedTime} - {selectedValue}
</button>
</div>
),
}));
const queryClient = new QueryClient();
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): any => ({
globalTime: {
selectedTime: {
startTime: 1713734400000,
endTime: 1713738000000,
},
maxTime: 1713738000000,
minTime: 1713734400000,
},
}),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn().mockReturnValue({
pathname: ROUTES.INFRASTRUCTURE_MONITORING_HOSTS,
}),
}));
jest.mock('react-router-dom-v5-compat', () => {
const actual = jest.requireActual('react-router-dom-v5-compat');
return {
...actual,
useSearchParams: jest
.fn()
.mockReturnValue([
{ get: jest.fn(), entries: jest.fn().mockReturnValue([]) },
jest.fn(),
]),
useNavigationType: (): any => 'PUSH',
};
});
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
jest.spyOn(timezoneHooks, 'useTimezone').mockReturnValue({
timezone: {
offset: 0,
},
browserTimezone: {
offset: 0,
},
} as any);
jest.spyOn(useGetHostListHooks, 'useGetHostList').mockReturnValue({
data: {
payload: {
data: {
records: [
{
hostName: 'test-host',
active: true,
cpu: 0.75,
memory: 0.65,
wait: 0.03,
},
],
isSendingK8SAgentMetrics: false,
sentAnyHostMetricsData: true,
},
},
},
isLoading: false,
isError: false,
} as any);
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
},
activeLicenseV3: {
event_queue: {
created_at: '0',
event: LicenseEvent.NO_EVENT,
scheduled_at: '0',
status: '',
updated_at: '0',
},
license: {
license_key: 'test-license-key',
license_type: 'trial',
org_id: 'test-org-id',
plan_id: 'test-plan-id',
plan_name: 'test-plan-name',
plan_type: 'trial',
plan_version: 'test-plan-version',
},
},
} as any);
describe('HostsList', () => {
it('renders hosts list table', () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<HostsList />
</Provider>
</MemoryRouter>
</QueryClientProvider>,
);
expect(container.querySelector('.hosts-list-table')).toBeInTheDocument();
});
it('renders filters', () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<HostsList />
</Provider>
</MemoryRouter>
</QueryClientProvider>,
);
expect(container.querySelector('.filters')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react';
import HostsListControls from '../HostsListControls';
jest.mock('container/QueryBuilder/filters/QueryBuilderSearch', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="query-builder-search">Search</div>
),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="date-time-selection">Date Time</div>
),
}));
describe('HostsListControls', () => {
const mockHandleFiltersChange = jest.fn();
const mockFilters = {
items: [],
op: 'AND',
};
it('renders search and date time filters', () => {
render(
<HostsListControls
handleFiltersChange={mockHandleFiltersChange}
filters={mockFilters}
/>,
);
expect(screen.getByTestId('query-builder-search')).toBeInTheDocument();
expect(screen.getByTestId('date-time-selection')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,139 @@
/* eslint-disable react/jsx-props-no-spreading */
import { render, screen } from '@testing-library/react';
import HostsListTable from '../HostsListTable';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const EMPTY_STATE_CONTAINER_CLASS = '.hosts-empty-state-container';
describe('HostsListTable', () => {
const mockHost = {
hostName: 'test-host-1',
active: true,
cpu: 0.75,
memory: 0.65,
wait: 0.03,
load15: 1.5,
os: 'linux',
};
const mockTableData = {
payload: {
data: {
hosts: [mockHost],
},
},
};
const mockOnHostClick = jest.fn();
const mockSetCurrentPage = jest.fn();
const mockSetOrderBy = jest.fn();
const mockSetPageSize = jest.fn();
const mockProps = {
isLoading: false,
isError: false,
isFetching: false,
tableData: mockTableData,
hostMetricsData: [mockHost],
filters: {
items: [],
op: 'AND',
},
onHostClick: mockOnHostClick,
currentPage: 1,
setCurrentPage: mockSetCurrentPage,
pageSize: 10,
setOrderBy: mockSetOrderBy,
setPageSize: mockSetPageSize,
} as any;
it('renders loading state if isLoading is true', () => {
const { container } = render(<HostsListTable {...mockProps} isLoading />);
expect(container.querySelector('.hosts-list-loading-state')).toBeTruthy();
});
it('renders loading state if isFetching is true', () => {
const { container } = render(<HostsListTable {...mockProps} isFetching />);
expect(container.querySelector('.hosts-list-loading-state')).toBeTruthy();
});
it('renders error state if isError is true', () => {
render(<HostsListTable {...mockProps} isError />);
expect(screen.getByText('Something went wrong')).toBeTruthy();
});
it('renders empty state if no hosts are found', () => {
const { container } = render(<HostsListTable {...mockProps} />);
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy();
});
it('renders empty state if sentAnyHostMetricsData is false', () => {
const { container } = render(
<HostsListTable
{...mockProps}
tableData={{
...mockTableData,
payload: {
...mockTableData.payload,
data: {
...mockTableData.payload.data,
sentAnyHostMetricsData: false,
},
},
}}
/>,
);
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy();
});
it('renders empty state if isSendingIncorrectK8SAgentMetrics is true', () => {
const { container } = render(
<HostsListTable
{...mockProps}
tableData={{
...mockTableData,
payload: {
...mockTableData.payload,
data: {
...mockTableData.payload.data,
isSendingIncorrectK8SAgentMetrics: true,
},
},
}}
/>,
);
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy();
});
it('renders table data', () => {
const { container } = render(
<HostsListTable
{...mockProps}
tableData={{
...mockTableData,
payload: {
...mockTableData.payload,
data: {
...mockTableData.payload.data,
isSendingIncorrectK8SAgentMetrics: false,
sentAnyHostMetricsData: true,
},
},
}}
/>,
);
expect(container.querySelector('.hosts-list-table')).toBeTruthy();
});
});

View File

@@ -0,0 +1,104 @@
import { render } from '@testing-library/react';
import { formatDataForTable, GetHostsQuickFiltersConfig } from '../utils';
const PROGRESS_BAR_CLASS = '.progress-bar';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('InfraMonitoringHosts utils', () => {
describe('formatDataForTable', () => {
it('should format host data correctly', () => {
const mockData = [
{
hostName: 'test-host',
active: true,
cpu: 0.95,
memory: 0.85,
wait: 0.05,
load15: 2.5,
os: 'linux',
},
] as any;
const result = formatDataForTable(mockData);
expect(result[0].hostName).toBe('test-host');
expect(result[0].wait).toBe('5%');
expect(result[0].load15).toBe(2.5);
// Test active tag rendering
const activeTag = render(result[0].active as JSX.Element);
expect(activeTag.container.textContent).toBe('ACTIVE');
expect(activeTag.container.querySelector('.active')).toBeTruthy();
// Test CPU progress bar
const cpuProgress = render(result[0].cpu as JSX.Element);
const cpuProgressBar = cpuProgress.container.querySelector(
PROGRESS_BAR_CLASS,
);
expect(cpuProgressBar).toBeTruthy();
// Test memory progress bar
const memoryProgress = render(result[0].memory as JSX.Element);
const memoryProgressBar = memoryProgress.container.querySelector(
PROGRESS_BAR_CLASS,
);
expect(memoryProgressBar).toBeTruthy();
});
it('should handle inactive hosts', () => {
const mockData = [
{
hostName: 'test-host',
active: false,
cpu: 0.3,
memory: 0.4,
wait: 0.02,
load15: 1.2,
os: 'linux',
cpuTimeSeries: [],
memoryTimeSeries: [],
waitTimeSeries: [],
load15TimeSeries: [],
},
] as any;
const result = formatDataForTable(mockData);
const inactiveTag = render(result[0].active as JSX.Element);
expect(inactiveTag.container.textContent).toBe('INACTIVE');
expect(inactiveTag.container.querySelector('.inactive')).toBeTruthy();
});
});
describe('GetHostsQuickFiltersConfig', () => {
it('should return correct config when dotMetricsEnabled is true', () => {
const result = GetHostsQuickFiltersConfig(true);
expect(result[0].attributeKey.key).toBe('host.name');
expect(result[1].attributeKey.key).toBe('os.type');
expect(result[0].aggregateAttribute).toBe('system.cpu.load_average.15m');
});
it('should return correct config when dotMetricsEnabled is false', () => {
const result = GetHostsQuickFiltersConfig(false);
expect(result[0].attributeKey.key).toBe('host_name');
expect(result[1].attributeKey.key).toBe('os_type');
expect(result[0].aggregateAttribute).toBe('system_cpu_load_average_15m');
});
});
});

View File

@@ -210,6 +210,10 @@ export function GetHostsQuickFiltersConfig(
? 'system.cpu.load_average.15m'
: 'system_cpu_load_average_15m';
const environmentKey = dotMetricsEnabled
? 'deployment.environment'
: 'deployment_environment';
return [
{
type: FiltersType.CHECKBOX,
@@ -241,5 +245,18 @@ export function GetHostsQuickFiltersConfig(
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: environmentKey,
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
dataSource: DataSource.METRICS,
defaultOpen: true,
},
];
}

View File

@@ -90,6 +90,9 @@ export function GetPodsQuickFiltersConfig(
? 'k8s.daemonset.name'
: 'k8s_daemonset_name';
const jobKey = dotMetricsEnabled ? 'k8s.job.name' : 'k8s_job_name';
const environmentKey = dotMetricsEnabled
? 'deployment.environment'
: 'deployment_environment';
// Define aggregate attribute (metric) name
const cpuUtilizationMetric = dotMetricsEnabled
@@ -225,6 +228,19 @@ export function GetPodsQuickFiltersConfig(
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: environmentKey,
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
dataSource: DataSource.METRICS,
defaultOpen: true,
},
];
}
@@ -239,6 +255,9 @@ export function GetNodesQuickFiltersConfig(
const cpuUtilMetric = dotMetricsEnabled
? 'k8s.node.cpu.utilization'
: 'k8s_node_cpu_utilization';
const environmentKey = dotMetricsEnabled
? 'deployment.environment'
: 'deployment_environment';
return [
{
@@ -273,6 +292,19 @@ export function GetNodesQuickFiltersConfig(
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: environmentKey,
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
dataSource: DataSource.METRICS,
defaultOpen: true,
},
];
}
@@ -286,6 +318,9 @@ export function GetNamespaceQuickFiltersConfig(
const cpuUtilMetric = dotMetricsEnabled
? 'k8s.pod.cpu.utilization'
: 'k8s_pod_cpu_utilization';
const environmentKey = dotMetricsEnabled
? 'deployment.environment'
: 'deployment_environment';
return [
{
@@ -320,6 +355,19 @@ export function GetNamespaceQuickFiltersConfig(
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: environmentKey,
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
dataSource: DataSource.METRICS,
defaultOpen: true,
},
];
}
@@ -330,6 +378,9 @@ export function GetClustersQuickFiltersConfig(
const cpuUtilMetric = dotMetricsEnabled
? 'k8s.node.cpu.utilization'
: 'k8s_node_cpu_utilization';
const environmentKey = dotMetricsEnabled
? 'deployment.environment'
: 'deployment_environment';
return [
{
@@ -348,6 +399,19 @@ export function GetClustersQuickFiltersConfig(
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: environmentKey,
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
dataSource: DataSource.METRICS,
defaultOpen: true,
},
];
}
@@ -357,6 +421,9 @@ export function GetContainersQuickFiltersConfig(
const containerKey = dotMetricsEnabled
? 'k8s.container.name'
: 'k8s_container_name';
const environmentKey = dotMetricsEnabled
? 'deployment.environment'
: 'deployment_environment';
return [
{
@@ -372,6 +439,19 @@ export function GetContainersQuickFiltersConfig(
},
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: environmentKey,
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
dataSource: DataSource.METRICS,
defaultOpen: true,
},
];
}
@@ -388,6 +468,9 @@ export function GetVolumesQuickFiltersConfig(
const volumeMetric = dotMetricsEnabled
? 'k8s.volume.capacity'
: 'k8s_volume_capacity';
const environmentKey = dotMetricsEnabled
? 'deployment.environment'
: 'deployment_environment';
return [
{
@@ -438,6 +521,19 @@ export function GetVolumesQuickFiltersConfig(
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: environmentKey,
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
dataSource: DataSource.METRICS,
defaultOpen: true,
},
];
}
@@ -454,6 +550,9 @@ export function GetDeploymentsQuickFiltersConfig(
const metric = dotMetricsEnabled
? 'k8s.pod.cpu.utilization'
: 'k8s_pod_cpu_utilization';
const environmentKey = dotMetricsEnabled
? 'deployment.environment'
: 'deployment_environment';
return [
{
@@ -504,6 +603,19 @@ export function GetDeploymentsQuickFiltersConfig(
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: environmentKey,
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
dataSource: DataSource.METRICS,
defaultOpen: true,
},
];
}
@@ -520,6 +632,9 @@ export function GetStatefulsetsQuickFiltersConfig(
const metric = dotMetricsEnabled
? 'k8s.pod.cpu.utilization'
: 'k8s_pod_cpu_utilization';
const environmentKey = dotMetricsEnabled
? 'deployment.environment'
: 'deployment_environment';
return [
{
@@ -570,6 +685,19 @@ export function GetStatefulsetsQuickFiltersConfig(
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: environmentKey,
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
dataSource: DataSource.METRICS,
defaultOpen: true,
},
];
}
@@ -586,6 +714,9 @@ export function GetDaemonsetsQuickFiltersConfig(
const metricName = dotMetricsEnabled
? 'k8s.pod.cpu.utilization'
: 'k8s_pod_cpu_utilization';
const environmentKey = dotMetricsEnabled
? 'deployment.environment'
: 'deployment_environment';
return [
{
@@ -634,6 +765,19 @@ export function GetDaemonsetsQuickFiltersConfig(
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: environmentKey,
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
dataSource: DataSource.METRICS,
defaultOpen: true,
},
];
}
@@ -648,6 +792,9 @@ export function GetJobsQuickFiltersConfig(
const metricName = dotMetricsEnabled
? 'k8s.pod.cpu.utilization'
: 'k8s_pod_cpu_utilization';
const environmentKey = dotMetricsEnabled
? 'deployment.environment'
: 'deployment_environment';
return [
{
@@ -696,6 +843,19 @@ export function GetJobsQuickFiltersConfig(
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: environmentKey,
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
dataSource: DataSource.METRICS,
defaultOpen: true,
},
];
}

View File

@@ -71,7 +71,7 @@ import {
PaginationProps,
} from 'types/api/ingestionKeys/types';
import { USER_ROLES } from 'types/roles';
import { hasDatePassed } from 'utils/timeUtils';
import { getDaysUntilExpiry } from 'utils/timeUtils';
const { Option } = Select;
@@ -1242,23 +1242,37 @@ function MultiIngestionSettings(): JSX.Element {
<div className="ingestion-key-details">
<div className="ingestion-key-last-used-at">
<CalendarClock size={14} />
{((): JSX.Element | null => {
const daysToExpiry = getDaysUntilExpiry(expiresOn);
const isNoExpiry = expiresOn === 'No Expiry';
{hasDatePassed(expiresOn) ? (
<>
Expired on <Minus size={12} />
<Typography.Text>{expiresOn}</Typography.Text>
</>
) : (
<>
{expiresOn !== 'No Expiry' && (
<>
Expires on <Minus size={12} />
</>
)}
<Typography.Text>{expiresOn}</Typography.Text>
</>
)}
if (!isNoExpiry && daysToExpiry < 0) {
return (
<div className="ingestion-key-expires-in danger">
<CalendarClock size={14} /> Expired on
<Minus size={12} /> {expiresOn}
</div>
);
}
if (!isNoExpiry && daysToExpiry <= 3) {
return (
<div className="ingestion-key-expires-in warning">
<CalendarClock size={14} /> Expires on
<Minus size={12} /> {expiresOn}
</div>
);
}
return (
<>
{!isNoExpiry && (
<>
<CalendarClock size={14} /> Expires on <Minus size={12} />
</>
)}
<Typography.Text>{expiresOn}</Typography.Text>
</>
);
})()}
</div>
</div>
</div>

View File

@@ -0,0 +1,91 @@
.licenses-page {
max-height: 100vh;
overflow: hidden;
.licenses-page-header {
border-bottom: 1px solid var(--Slate-500, #161922);
background: rgba(11, 12, 14, 0.7);
backdrop-filter: blur(20px);
.licenses-page-header-title {
color: var(--Vanilla-100, #fff);
text-align: center;
font-family: Inter;
font-size: 13px;
font-style: normal;
line-height: 14px;
letter-spacing: 0.4px;
display: flex;
align-items: center;
gap: 8px;
padding: 16px;
}
}
.licenses-page-content-container {
display: flex;
flex-direction: row;
align-items: flex-start;
.licenses-page-content {
flex: 1;
height: calc(100vh - 48px);
background: var(--Ink-500, #0b0c0e);
padding: 10px 8px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
}
}
}
.lightMode {
.licenses-page {
.licenses-page-header {
border-bottom: 1px solid var(--bg-vanilla-300);
background: #fff;
backdrop-filter: blur(20px);
.licenses-page-header-title {
color: var(--bg-ink-400);
background: var(--bg-vanilla-100);
border-right: 1px solid var(--bg-vanilla-300);
}
}
.licenses-page-content-container {
.licenses-page-content {
background: var(--bg-vanilla-100);
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
}
}
}
}

View File

@@ -1,5 +1,7 @@
import { Tabs } from 'antd';
import './Licenses.styles.scss';
import Spinner from 'components/Spinner';
import { Wrench } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useTranslation } from 'react-i18next';
@@ -13,16 +15,19 @@ function Licenses(): JSX.Element {
return <Spinner tip={t('loading_licenses')} height="90vh" />;
}
const tabs = [
{
label: t('tab_current_license'),
key: 'licenses',
children: <ApplyLicenseForm licenseRefetch={activeLicenseRefetch} />,
},
];
return (
<Tabs destroyInactiveTabPane defaultActiveKey="licenses" items={tabs} />
<div className="licenses-page">
<header className="licenses-page-header">
<div className="licenses-page-header-title">
<Wrench size={16} />
License
</div>
</header>
<div className="licenses-page-content-container">
<ApplyLicenseForm licenseRefetch={activeLicenseRefetch} />
</div>
</div>
);
}

View File

@@ -3,8 +3,7 @@ import styled from 'styled-components';
export const ApplyFormContainer = styled.div`
&&& {
padding-top: 1em;
padding-bottom: 1em;
padding: 16px;
}
`;

View File

@@ -58,7 +58,14 @@ export function RequestDashboardBtn(): JSX.Element {
return (
<div className="request-entity-container">
<Typography.Text>
Can&apos;t find the dashboard you need? Request a new Dashboard.
<a
href="https://github.com/SigNoz/dashboards"
target="_blank"
rel="noopener noreferrer"
>
Browse dashboard templates
</a>{' '}
or Request new template
</Typography.Text>
<div className="form-section">

View File

@@ -3,6 +3,7 @@ import { Button, Col, Popover } from 'antd';
import { OPERATORS } from 'constants/queryBuilder';
import { removeJSONStringifyQuotes } from 'lib/removeJSONStringifyQuotes';
import { memo, useCallback, useMemo } from 'react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
function ActionItem({
fieldKey,
@@ -55,6 +56,8 @@ export interface ActionItemProps {
fieldKey: string,
fieldValue: string,
operator: string,
isJSON?: boolean,
dataType?: DataTypes,
) => void;
}

View File

@@ -33,7 +33,12 @@ import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { ActionItemProps } from './ActionItem';
import FieldRenderer from './FieldRenderer';
import { TableViewActions } from './TableView/TableViewActions';
import { filterKeyForField, findKeyPath, flattenObject } from './utils';
import {
filterKeyForField,
findKeyPath,
flattenObject,
getFieldAttributes,
} from './utils';
interface TableViewProps {
logData: ILog;
@@ -107,10 +112,17 @@ function TableView({
operator: string,
fieldKey: string,
fieldValue: string,
dataType: string | undefined,
): void => {
const validatedFieldValue = removeJSONStringifyQuotes(fieldValue);
if (onClickActionItem) {
onClickActionItem(fieldKey, validatedFieldValue, operator);
onClickActionItem(
fieldKey,
validatedFieldValue,
operator,
undefined,
dataType as DataTypes,
);
}
};
@@ -118,8 +130,9 @@ function TableView({
operator: string,
fieldKey: string,
fieldValue: string,
dataType: string | undefined,
) => (): void => {
handleClick(operator, fieldKey, fieldValue);
handleClick(operator, fieldKey, fieldValue, dataType);
if (operator === OPERATORS['=']) {
setIsFilterInLoading(true);
}
@@ -247,6 +260,7 @@ function TableView({
}
const fieldFilterKey = filterKeyForField(field);
const { dataType } = getFieldAttributes(field);
if (!RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey)) {
return (
<AddToQueryHOC
@@ -254,6 +268,7 @@ function TableView({
fieldValue={flattenLogData[field]}
onAddToQuery={onAddToQuery}
fontSize={FontSize.SMALL}
dataType={dataType as DataTypes}
>
{renderedField}
</AddToQueryHOC>

View File

@@ -23,6 +23,7 @@ import { DataType } from '../TableView';
import {
escapeHtml,
filterKeyForField,
getFieldAttributes,
jsonToDataNodes,
parseFieldValue,
recursiveParseJSON,
@@ -45,6 +46,7 @@ interface ITableViewActionsProps {
operator: string,
fieldKey: string,
fieldValue: string,
dataType: string | undefined,
) => () => void;
}
@@ -64,6 +66,7 @@ export function TableViewActions(
} = props;
const { pathname } = useLocation();
const { dataType } = getFieldAttributes(record.field);
// there is no option for where clause in old logs explorer and live logs page
const isOldLogsExplorerOrLiveLogsPage = useMemo(
@@ -161,6 +164,7 @@ export function TableViewActions(
OPERATORS['='],
fieldFilterKey,
parseFieldValue(fieldData.value),
dataType,
)}
/>
</Tooltip>
@@ -178,6 +182,7 @@ export function TableViewActions(
OPERATORS['!='],
fieldFilterKey,
parseFieldValue(fieldData.value),
dataType,
)}
/>
</Tooltip>

View File

@@ -58,7 +58,8 @@ import {
useRef,
useState,
} from 'react';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { Dashboard } from 'types/api/dashboard/getAll';
import { ILog } from 'types/api/logs/log';
@@ -95,6 +96,7 @@ function LogsExplorerViews({
chartQueryKeyRef: MutableRefObject<any>;
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const dispatch = useDispatch();
// this is to respect the panel type present in the URL rather than defaulting it to list always.
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
@@ -106,9 +108,10 @@ function LogsExplorerViews({
DEFAULT_PER_PAGE_VALUE,
);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { minTime, maxTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const currentMinTimeRef = useRef<number>(minTime);
@@ -134,6 +137,7 @@ function LogsExplorerViews({
const [showFormatMenuItems, setShowFormatMenuItems] = useState(false);
const [queryId, setQueryId] = useState<string>(v4());
const [queryStats, setQueryStats] = useState<WsDataEvent>();
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
const listQuery = useMemo(() => {
if (!stagedQuery || stagedQuery.builder.queryData.length < 1) return null;
@@ -170,8 +174,11 @@ function LogsExplorerViews({
return logs.length >= listQuery.limit;
}, [logs.length, listQuery]);
const listChartQuery = useMemo(() => {
if (!stagedQuery || !listQuery) return null;
useEffect(() => {
if (!stagedQuery || !listQuery) {
setListChartQuery(null);
return;
}
const modifiedQueryData: IBuilderQuery = {
...listQuery,
@@ -220,7 +227,7 @@ function LogsExplorerViews({
},
};
return modifiedQuery;
setListChartQuery(modifiedQuery);
}, [stagedQuery, listQuery, activeLogId]);
const exportDefaultQuery = useMemo(
@@ -256,6 +263,8 @@ function LogsExplorerViews({
{},
undefined,
chartQueryKeyRef,
undefined,
'custom',
);
const {
@@ -502,6 +511,16 @@ function LogsExplorerViews({
requestData?.id !== stagedQuery?.id ||
currentMinTimeRef.current !== minTime
) {
// Recalculate global time when query changes i.e. stage and run query clicked
if (
!!requestData?.id &&
stagedQuery?.id &&
requestData?.id !== stagedQuery?.id &&
selectedTime !== 'custom'
) {
dispatch(UpdateTimeInterval(selectedTime));
}
const newRequestData = getRequestData(stagedQuery, {
filters: listQuery?.filters || initialFilters,
page: 1,
@@ -523,6 +542,9 @@ function LogsExplorerViews({
activeLogId,
panelType,
selectedView,
dispatch,
selectedTime,
maxTime,
]);
const chartData = useMemo(() => {

View File

@@ -8,7 +8,7 @@ import { SELECTED_VIEWS } from 'pages/LogsExplorer/utils';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { VirtuosoMockContext } from 'react-virtuoso';
import { fireEvent, render, RenderResult } from 'tests/test-utils';
import { fireEvent, render, RenderResult, waitFor } from 'tests/test-utils';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import LogsExplorerViews from '..';
@@ -197,12 +197,16 @@ describe('LogsExplorerViews -', () => {
).toBeInTheDocument();
});
it('should add activeLogId filter when present in URL', () => {
it('should add activeLogId filter when present in URL', async () => {
// Mock useCopyLogLink to return an activeLogId
(useCopyLogLink as jest.Mock).mockReturnValue({
activeLogId: ACTIVE_LOG_ID,
});
const originalFiltersLength =
mockQueryBuilderContextValue.currentQuery.builder.queryData[0].filters?.items
.length || 0;
lodsQueryServerRequest();
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue}>
@@ -218,27 +222,36 @@ describe('LogsExplorerViews -', () => {
</QueryBuilderContext.Provider>,
);
// Get the query data from the first call to useGetExplorerQueryRange
const {
queryData,
} = (useGetExplorerQueryRange as jest.Mock).mock.calls[0][0].builder;
const firstQuery = queryData[0];
await waitFor(() => {
const listCall = (useGetExplorerQueryRange as jest.Mock).mock.calls.find(
(call) =>
call[0] &&
call[0].builder.queryData[0].filters.items.length ===
originalFiltersLength + 1,
);
// Get the original number of filters from mock data
const originalFiltersLength =
mockQueryBuilderContextValue.currentQuery.builder.queryData[0].filters?.items
.length || 0;
const expectedFiltersLength = originalFiltersLength + 1; // +1 for activeLogId filter
expect(listCall).toBeDefined();
// Verify that the activeLogId filter is present
expect(
firstQuery.filters?.items.some(
(item: TagFilterItem) =>
item.key?.key === 'id' && item.op === '<=' && item.value === ACTIVE_LOG_ID,
),
).toBe(true);
if (listCall) {
const { queryData } = listCall[0].builder;
// Verify the total number of filters (original + 1 new activeLogId filter)
expect(firstQuery.filters?.items.length).toBe(expectedFiltersLength);
const firstQuery = queryData[0];
const expectedFiltersLength = originalFiltersLength + 1; // +1 for activeLogId filter
// Verify that the activeLogId filter is present
expect(
firstQuery.filters?.items.some(
(item: TagFilterItem) =>
item.key?.key === 'id' &&
item.op === '<=' &&
item.value === ACTIVE_LOG_ID,
),
).toBe(true);
// Verify the total number of filters (original + 1 new activeLogId filter)
expect(firstQuery.filters?.items.length).toBe(expectedFiltersLength);
}
});
});
});

View File

@@ -611,9 +611,7 @@ export const errorPercentage = ({
{
id: '',
key: {
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.StatusCodeNorm,
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
dataType: DataTypes.Int64,
isColumn: false,
type: MetricsType.Tag,

View File

@@ -1,3 +1,12 @@
.my-settings-container {
display: flex;
flex-direction: column;
gap: 48px;
width: 80%;
margin: 12px auto;
}
.flexBtn {
display: flex;
align-items: center;
@@ -8,4 +17,163 @@
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
}
.logout-button {
display: inline-flex;
}
.user-info-section {
display: flex;
flex-direction: column;
gap: 16px;
.user-info-section-header {
display: flex;
flex-direction: column;
gap: 4px;
.user-info-section-title {
color: #fff;
font-family: Inter;
font-size: 16px;
font-style: normal;
line-height: 24px; /* 155.556% */
letter-spacing: -0.08px;
}
.user-info-section-subtitle {
color: var(--Vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 12px;
font-style: normal;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
.user-preference-section {
display: flex;
flex-direction: column;
gap: 16px;
.user-preference-section-header {
display: flex;
flex-direction: column;
gap: 4px;
.user-preference-section-title {
color: #fff;
font-family: Inter;
font-size: 16px;
font-style: normal;
line-height: 24px; /* 155.556% */
letter-spacing: -0.08px;
}
.user-preference-section-subtitle {
color: var(--Vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 12px;
font-style: normal;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.user-preference-section-content {
display: flex;
flex-direction: column;
gap: 16px;
.user-preference-section-content-item {
padding: 16px;
border-radius: 4px 4px 0px 0px;
border: 1px solid var(--Slate-500, #161922);
background: var(--Ink-400, #121317);
border-radius: 3px;
.user-preference-section-content-item-title-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--Vanilla-300, #eee);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: normal;
letter-spacing: -0.07px;
margin-bottom: 8px;
}
.user-preference-section-content-item-description {
color: var(--Vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 12px;
font-style: normal;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
}
.reset-password-card {
border-radius: 0px 0px 4px 4px;
border: 1px solid var(--Slate-500, #161922);
background: var(--Ink-400, #121317);
border-radius: 3px;
}
.lightMode {
.user-info-section {
.user-info-section-header {
.user-info-section-title {
color: var(--bg-ink-400);
}
.user-info-section-subtitle {
color: var(--bg-ink-300);
}
}
}
.user-preference-section {
.user-preference-section-header {
.user-preference-section-title {
color: var(--bg-ink-400);
}
.user-preference-section-subtitle {
color: var(--bg-ink-300);
}
}
.user-preference-section-content {
.user-preference-section-content-item {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.user-preference-section-content-item-title-action {
color: var(--bg-ink-400);
}
.user-preference-section-content-item-description {
color: var(--bg-ink-300);
}
}
}
}
.reset-password-card {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
}
}

View File

@@ -73,7 +73,7 @@ function PasswordContainer(): JSX.Element {
currentPassword === updatePassword;
return (
<Card>
<Card className="reset-password-card">
<Space direction="vertical" size="small">
<Typography.Title
level={4}

View File

@@ -1,8 +1,11 @@
.timezone-adaption {
padding: 16px;
background: var(--bg-ink-400);
border: 1px solid var(--bg-ink-500);
border-radius: 4px;
border-radius: 4px 4px 0px 0px;
border: 1px solid var(--Slate-500, #161922);
background: var(--Ink-400, #121317);
border-radius: 3px;
&__header {
display: flex;
@@ -20,7 +23,7 @@
&__description {
color: var(--bg-vanilla-400);
font-size: 14px;
font-size: 12px;
line-height: 20px;
margin: 0 0 12px 0;
}
@@ -52,7 +55,7 @@
align-items: center;
gap: 4px;
color: var(--bg-robin-400);
font-size: 14px;
font-size: 12px;
line-height: 20px;
}
&__note-text-overridden {

View File

@@ -28,14 +28,16 @@ function TimezoneAdaptation(): JSX.Element {
const handleOverrideClear = (): void => {
updateTimezone(browserTimezone);
logEvent('Settings: Timezone override cleared', {});
logEvent('Account Settings: Timezone override cleared', {});
};
const handleSwitchChange = (): void => {
setIsAdaptationEnabled((prev) => {
const isEnabled = !prev;
logEvent(
`Settings: Timezone adaptation ${isEnabled ? 'enabled' : 'disabled'}`,
`Account Settings: Timezone adaptation ${
isEnabled ? 'enabled' : 'disabled'
}`,
{},
);
return isEnabled;

View File

@@ -5,3 +5,231 @@
.userInfo-value {
min-width: 20rem;
}
.user-info-container {
border: 1px solid var(--Slate-500, #161922);
background: var(--Ink-400, #121317);
border-radius: 3px;
padding: 16px;
.user-info-card {
display: flex;
flex-direction: row;
gap: 16px;
}
.user-info-header {
font-size: 13px;
font-weight: 600;
}
.user-info {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
.user-name {
color: var(--Vanilla-100, #fff);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.user-info-subsection {
display: flex;
flex-direction: row;
gap: 20px;
.user-email {
display: flex;
align-items: center;
gap: 8px;
color: var(--Vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.user-role {
display: flex;
align-items: center;
gap: 8px;
text-transform: capitalize;
color: var(--Vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
.user-info-update-section {
display: flex;
flex-direction: row;
gap: 8px;
justify-content: flex-end;
flex-wrap: wrap;
flex: 1;
}
}
.update-name-modal,
.reset-password-modal {
width: 384px !important;
.ant-modal-content {
padding: 0;
border-radius: 4px;
border: 1px solid var(--bg-slate-500);
background: var(--bg-ink-400);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
.ant-modal-header {
padding: 16px;
background: var(--bg-ink-400);
border-bottom: 1px solid var(--bg-slate-500);
}
.ant-modal-body {
padding: 12px 16px 0px 16px;
.ant-typography {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
}
.update-name-input {
margin-top: 8px;
display: flex;
gap: 8px;
}
.reset-password-container {
display: flex;
flex-direction: column;
gap: 8px;
padding-bottom: 16px;
}
.ant-color-picker-trigger {
padding: 6px;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
width: 32px;
height: 32px;
.ant-color-picker-color-block {
border-radius: 50px;
width: 16px;
height: 16px;
flex-shrink: 0;
.ant-color-picker-color-block-inner {
display: flex;
justify-content: center;
align-items: center;
}
}
}
}
.ant-modal-footer {
display: flex;
justify-content: flex-end;
padding: 16px 16px;
margin: 0;
> button {
display: flex;
align-items: center;
border-radius: 2px;
background-color: var(--bg-robin-500) !important;
color: var(--bg-vanilla-100) !important;
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
}
}
.title {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
}
.lightMode {
.user-info-container {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.user-info {
.user-name {
color: var(--bg-ink-400);
}
.user-info-subsection {
.user-email {
color: var(--bg-ink-400);
}
.user-role {
color: var(--bg-ink-300);
}
}
}
}
.update-name-modal,
.reset-password-modal {
.ant-modal-content {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.ant-modal-header {
background: var(--bg-vanilla-100);
border-bottom: 1px solid var(--bg-vanilla-300);
}
.ant-modal-body {
.ant-typography {
color: var(--bg-ink-400);
}
.ant-color-picker-trigger {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
}
}
}
.title {
color: var(--bg-ink-400);
}
}
}

View File

@@ -1,35 +1,115 @@
import '../MySettings.styles.scss';
import './UserInfo.styles.scss';
import { Button, Card, Flex, Input, Space, Typography } from 'antd';
import { Button, Input, Modal, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import changeMyPassword from 'api/v1/factor_password/changeMyPassword';
import editUser from 'api/v1/user/id/update';
import { useNotifications } from 'hooks/useNotifications';
import { PencilIcon } from 'lucide-react';
import { Check, FileTerminal, MailIcon, UserIcon } from 'lucide-react';
import { isPasswordValid } from 'pages/SignUp/utils';
import { useAppContext } from 'providers/App/App';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import APIError from 'types/api/error';
import { NameInput } from '../styles';
function UserInfo(): JSX.Element {
const { user, org, updateUser } = useAppContext();
const { t } = useTranslation();
const { t } = useTranslation(['routes', 'settings', 'common']);
const { notifications } = useNotifications();
const [currentPassword, setCurrentPassword] = useState<string>('');
const [updatePassword, setUpdatePassword] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isPasswordPolicyError, setIsPasswordPolicyError] = useState<boolean>(
false,
);
const [changedName, setChangedName] = useState<string>(
user?.displayName || '',
);
const [loading, setLoading] = useState<boolean>(false);
const { notifications } = useNotifications();
const [isUpdateNameModalOpen, setIsUpdateNameModalOpen] = useState<boolean>(
false,
);
const [
isResetPasswordModalOpen,
setIsResetPasswordModalOpen,
] = useState<boolean>(false);
if (!user || !org) {
const defaultPlaceHolder = '*************';
useEffect(() => {
if (currentPassword && !isPasswordValid(currentPassword)) {
setIsPasswordPolicyError(true);
} else {
setIsPasswordPolicyError(false);
}
}, [currentPassword]);
if (!user) {
return <div />;
}
const onClickUpdateHandler = async (): Promise<void> => {
const hideUpdateNameModal = (): void => {
setIsUpdateNameModalOpen(false);
};
const hideResetPasswordModal = (): void => {
setIsResetPasswordModalOpen(false);
};
const onChangePasswordClickHandler = async (): Promise<void> => {
try {
setLoading(true);
setIsLoading(true);
if (!isPasswordValid(currentPassword)) {
setIsPasswordPolicyError(true);
setIsLoading(false);
return;
}
await changeMyPassword({
newPassword: updatePassword,
oldPassword: currentPassword,
userId: user.id,
});
notifications.success({
message: t('success', {
ns: 'common',
}),
});
hideResetPasswordModal();
setIsLoading(false);
} catch (error) {
setIsLoading(false);
notifications.error({
message: (error as APIError).error.error.code,
description: (error as APIError).error.error.message,
});
}
};
const isResetPasswordDisabled =
isLoading ||
currentPassword.length === 0 ||
updatePassword.length === 0 ||
isPasswordPolicyError ||
currentPassword === updatePassword;
const onSaveHandler = async (): Promise<void> => {
logEvent('Account Settings: Name Updated', {
name: changedName,
});
logEvent(
'Account Settings: Name Updated',
{
name: changedName,
},
'identify',
);
try {
setIsLoading(true);
await editUser({
displayName: changedName,
userId: user.id,
@@ -44,80 +124,143 @@ function UserInfo(): JSX.Element {
...user,
displayName: changedName,
});
setLoading(false);
setIsLoading(false);
hideUpdateNameModal();
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
}
setLoading(false);
setIsLoading(false);
};
if (!user || !org) {
return <div />;
}
return (
<Card>
<Space direction="vertical" size="middle">
<Flex gap={8}>
<Typography.Title level={4} style={{ marginTop: 0 }}>
User Details
</Typography.Title>
</Flex>
<div className="user-info-card">
<div className="user-info">
<div className="user-name">{user.displayName}</div>
<Flex gap={16}>
<Space>
<Typography className="userInfo-label" data-testid="name-label">
Name
</Typography>
<NameInput
data-testid="name-textbox"
placeholder="Your Name"
onChange={(event): void => {
setChangedName(event.target.value);
}}
value={changedName}
disabled={loading}
/>
</Space>
<div className="user-info-subsection">
<div className="user-email">
<MailIcon size={16} /> {user.email}
</div>
<div className="user-role">
<UserIcon size={16} /> {user.role.toLowerCase()}
</div>
</div>
</div>
<div className="user-info-update-section">
<Button
type="default"
className="periscope-btn secondary"
icon={<FileTerminal size={16} />}
onClick={(): void => setIsUpdateNameModalOpen(true)}
>
Update name
</Button>
<Button
type="default"
className="periscope-btn secondary"
icon={<FileTerminal size={16} />}
onClick={(): void => setIsResetPasswordModalOpen(true)}
>
Reset password
</Button>
</div>
<Modal
className="update-name-modal"
title={<span className="title">Update name</span>}
open={isUpdateNameModalOpen}
closable
onCancel={hideUpdateNameModal}
footer={[
<Button
className="flexBtn"
loading={loading}
disabled={loading}
onClick={onClickUpdateHandler}
data-testid="update-name-button"
key="submit"
type="primary"
icon={<Check size={16} />}
onClick={onSaveHandler}
disabled={isLoading}
data-testid="update-name-btn"
>
<PencilIcon size={12} /> Update
</Button>
</Flex>
<Space>
<Typography className="userInfo-label" data-testid="email-label">
{' '}
Email{' '}
</Typography>
Update name
</Button>,
]}
>
<Typography.Text>Name</Typography.Text>
<div className="update-name-input">
<Input
className="userInfo-value"
data-testid="email-textbox"
value={user.email}
disabled
placeholder="e.g. John Doe"
value={changedName}
onChange={(e): void => setChangedName(e.target.value)}
/>
</Space>
</div>
</Modal>
<Space>
<Typography className="userInfo-label" data-testid="role-label">
{' '}
Role{' '}
</Typography>
<Input
className="userInfo-value"
value={user.role || ''}
disabled
data-testid="role-textbox"
/>
</Space>
</Space>
</Card>
<Modal
className="reset-password-modal"
title={<span className="title">Reset password</span>}
open={isResetPasswordModalOpen}
closable
onCancel={hideResetPasswordModal}
footer={[
<Button
key="submit"
className={`periscope-btn ${
isResetPasswordDisabled ? 'secondary' : 'primary'
}`}
icon={<Check size={16} />}
onClick={onChangePasswordClickHandler}
disabled={isLoading || isResetPasswordDisabled}
data-testid="reset-password-btn"
>
Reset password
</Button>,
]}
>
<div className="reset-password-container">
<div className="current-password-input">
<Typography.Text>Current password</Typography.Text>
<Input.Password
data-testid="current-password-textbox"
disabled={isLoading}
placeholder={defaultPlaceHolder}
onChange={(event): void => {
setCurrentPassword(event.target.value);
}}
value={currentPassword}
type="password"
autoComplete="off"
visibilityToggle
/>
</div>
<div className="new-password-input">
<Typography.Text>New password</Typography.Text>
<Input.Password
data-testid="new-password-textbox"
disabled={isLoading}
placeholder={defaultPlaceHolder}
onChange={(event): void => {
const updatedValue = event.target.value;
setUpdatePassword(updatedValue);
}}
value={updatePassword}
type="password"
autoComplete="off"
visibilityToggle={false}
/>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -2,17 +2,21 @@ import MySettingsContainer from 'container/MySettings';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
const toggleThemeFunction = jest.fn();
const logEventFunction = jest.fn();
jest.mock('hooks/useDarkMode', () => ({
__esModule: true,
useIsDarkMode: jest.fn(() => ({
toggleTheme: toggleThemeFunction,
})),
useIsDarkMode: jest.fn(() => true),
default: jest.fn(() => ({
toggleTheme: toggleThemeFunction,
})),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn((eventName, data) => logEventFunction(eventName, data)),
}));
const errorNotification = jest.fn();
const successNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
@@ -25,90 +29,97 @@ jest.mock('hooks/useNotifications', () => ({
})),
}));
enum ThemeOptions {
Dark = 'Dark',
Light = 'Light Beta',
}
const THEME_SELECTOR_TEST_ID = 'theme-selector';
const RESET_PASSWORD_BUTTON_TEXT = 'Reset password';
const CURRENT_PASSWORD_TEST_ID = 'current-password-textbox';
const NEW_PASSWORD_TEST_ID = 'new-password-textbox';
const UPDATE_NAME_BUTTON_TEST_ID = 'update-name-btn';
const RESET_PASSWORD_BUTTON_TEST_ID = 'reset-password-btn';
const UPDATE_NAME_BUTTON_TEXT = 'Update name';
const PASSWORD_VALIDATION_MESSAGE_TEST_ID = 'password-validation-message';
describe('MySettings Flows', () => {
beforeEach(() => {
jest.clearAllMocks();
render(<MySettingsContainer />);
});
describe('Dark/Light Theme Switch', () => {
it('Should display Dark and Light theme buttons properly', async () => {
it('Should display Dark and Light theme options properly', async () => {
// Check Dark theme option
expect(screen.getByText('Dark')).toBeInTheDocument();
const darkThemeIcon = screen.getByTestId('dark-theme-icon');
expect(darkThemeIcon).toBeInTheDocument();
expect(darkThemeIcon.tagName).toBe('svg');
// Check Light theme option
expect(screen.getByText('Light')).toBeInTheDocument();
const lightThemeIcon = screen.getByTestId('light-theme-icon');
expect(lightThemeIcon).toBeInTheDocument();
expect(lightThemeIcon.tagName).toBe('svg');
expect(screen.getByText('Beta')).toBeInTheDocument();
});
it('Should activate Dark and Light buttons on click', async () => {
const initialSelectedOption = screen.getByRole('radio', {
name: ThemeOptions.Dark,
});
expect(initialSelectedOption).toBeChecked();
const newThemeOption = screen.getByRole('radio', {
name: ThemeOptions.Light,
});
fireEvent.click(newThemeOption);
expect(newThemeOption).toBeChecked();
it('Should have Dark theme selected by default', async () => {
const themeSelector = screen.getByTestId(THEME_SELECTOR_TEST_ID);
const darkOption = themeSelector.querySelector(
'input[value="dark"]',
) as HTMLInputElement;
expect(darkOption).toBeChecked();
});
it('Should switch the them on clicking Light theme', async () => {
const lightThemeOption = screen.getByRole('radio', {
name: /light/i,
});
fireEvent.click(lightThemeOption);
it('Should switch theme and log event when Light theme is selected', async () => {
const themeSelector = screen.getByTestId(THEME_SELECTOR_TEST_ID);
const lightOption = themeSelector.querySelector(
'input[value="light"]',
) as HTMLInputElement;
fireEvent.click(lightOption);
await waitFor(() => {
expect(toggleThemeFunction).toBeCalled();
expect(toggleThemeFunction).toHaveBeenCalled();
expect(logEventFunction).toHaveBeenCalledWith(
'Account Settings: Theme Changed',
{
theme: 'light',
},
);
});
});
});
describe('User Details Form', () => {
it('Should properly display the User Details Form', () => {
const userDetailsHeader = screen.getByRole('heading', {
name: /user details/i,
});
const nameLabel = screen.getByTestId('name-label');
const nameTextbox = screen.getByTestId('name-textbox');
const updateNameButton = screen.getByTestId('update-name-button');
const emailLabel = screen.getByTestId('email-label');
const emailTextbox = screen.getByTestId('email-textbox');
const roleLabel = screen.getByTestId('role-label');
const roleTextbox = screen.getByTestId('role-textbox');
// Open the Update name modal first
const updateNameButton = screen.getByText(UPDATE_NAME_BUTTON_TEXT);
fireEvent.click(updateNameButton);
// Find the label with class 'ant-typography' and text 'Name'
const nameLabels = screen.getAllByText('Name');
const nameLabel = nameLabels.find((el) =>
el.className.includes('ant-typography'),
);
const nameTextbox = screen.getByPlaceholderText('e.g. John Doe');
const modalUpdateNameButton = screen.getByTestId(UPDATE_NAME_BUTTON_TEST_ID);
expect(userDetailsHeader).toBeInTheDocument();
expect(nameLabel).toBeInTheDocument();
expect(nameTextbox).toBeInTheDocument();
expect(updateNameButton).toBeInTheDocument();
expect(emailLabel).toBeInTheDocument();
expect(emailTextbox).toBeInTheDocument();
expect(roleLabel).toBeInTheDocument();
expect(roleTextbox).toBeInTheDocument();
expect(modalUpdateNameButton).toBeInTheDocument();
});
it('Should update the name on clicking Update button', async () => {
const nameTextbox = screen.getByTestId('name-textbox');
const updateNameButton = screen.getByTestId('update-name-button');
// Open the Update name modal first
const updateNameButton = screen.getByText(UPDATE_NAME_BUTTON_TEXT);
fireEvent.click(updateNameButton);
const nameTextbox = screen.getByPlaceholderText('e.g. John Doe');
const modalUpdateNameButton = screen.getByTestId(UPDATE_NAME_BUTTON_TEST_ID);
act(() => {
fireEvent.change(nameTextbox, { target: { value: 'New Name' } });
});
fireEvent.click(updateNameButton);
fireEvent.click(modalUpdateNameButton);
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
@@ -119,92 +130,53 @@ describe('MySettings Flows', () => {
});
describe('Reset password', () => {
let currentPasswordTextbox: Node | Window;
let newPasswordTextbox: Node | Window;
let submitButtonElement: HTMLElement;
it('Should open password reset modal when clicking Reset password button', async () => {
const resetPasswordButtons = screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT);
// The first button is the one in the user info section
fireEvent.click(resetPasswordButtons[0]);
beforeEach(() => {
currentPasswordTextbox = screen.getByTestId('current-password-textbox');
newPasswordTextbox = screen.getByTestId('new-password-textbox');
submitButtonElement = screen.getByTestId('update-password-button');
});
it('Should properly display the Password Reset Form', () => {
const passwordResetHeader = screen.getByTestId('change-password-header');
expect(passwordResetHeader).toBeInTheDocument();
const currentPasswordLabel = screen.getByTestId('current-password-label');
expect(currentPasswordLabel).toBeInTheDocument();
expect(currentPasswordTextbox).toBeInTheDocument();
const newPasswordLabel = screen.getByTestId('new-password-label');
expect(newPasswordLabel).toBeInTheDocument();
expect(newPasswordTextbox).toBeInTheDocument();
expect(submitButtonElement).toBeInTheDocument();
const savePasswordIcon = screen.getByTestId('update-password-icon');
expect(savePasswordIcon).toBeInTheDocument();
expect(savePasswordIcon.tagName).toBe('svg');
// Check if modal is opened (look for modal title)
expect(
screen.getByText((content, element) =>
Boolean(
element &&
'className' in element &&
typeof element.className === 'string' &&
element.className.includes('title') &&
content === RESET_PASSWORD_BUTTON_TEXT,
),
),
).toBeInTheDocument();
expect(screen.getByTestId(CURRENT_PASSWORD_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(NEW_PASSWORD_TEST_ID)).toBeInTheDocument();
});
it('Should display validation error if password is less than 8 characters', async () => {
const currentPasswordTextbox = screen.getByTestId(
'current-password-textbox',
);
const resetPasswordButtons = screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT);
fireEvent.click(resetPasswordButtons[0]);
const currentPasswordTextbox = screen.getByTestId(CURRENT_PASSWORD_TEST_ID);
act(() => {
fireEvent.change(currentPasswordTextbox, { target: { value: '123' } });
});
const validationMessage = await screen.findByTestId('validation-message');
await waitFor(() => {
expect(validationMessage).toHaveTextContent(
'Password must a have minimum of 8 characters',
);
// Use getByTestId for the validation message (if present in your modal/component)
if (screen.queryByTestId(PASSWORD_VALIDATION_MESSAGE_TEST_ID)) {
expect(
screen.getByTestId(PASSWORD_VALIDATION_MESSAGE_TEST_ID),
).toBeInTheDocument();
}
});
});
test("Should display 'inavlid credentials' error if different current and new passwords are provided", async () => {
act(() => {
fireEvent.change(currentPasswordTextbox, {
target: { value: '123456879' },
});
it('Should disable reset button when current and new passwords are the same', async () => {
const resetPasswordButtons = screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT);
fireEvent.click(resetPasswordButtons[0]);
fireEvent.change(newPasswordTextbox, { target: { value: '123456789' } });
});
fireEvent.click(submitButtonElement);
await waitFor(() => expect(errorNotification).toHaveBeenCalled());
});
it('Should check if the "Change Password" button is disabled in case current / new password is less than 8 characters', () => {
act(() => {
fireEvent.change(currentPasswordTextbox, {
target: { value: '123' },
});
fireEvent.change(newPasswordTextbox, { target: { value: '123' } });
});
expect(submitButtonElement).toBeDisabled();
});
test("Should check if 'Change Password' button is enabled when password is at least 8 characters ", async () => {
expect(submitButtonElement).toBeDisabled();
act(() => {
fireEvent.change(currentPasswordTextbox, {
target: { value: '123456789' },
});
fireEvent.change(newPasswordTextbox, { target: { value: '1234567890' } });
});
expect(submitButtonElement).toBeEnabled();
});
test("Should check if 'Change Password' button is disabled when current and new passwords are the same ", async () => {
expect(submitButtonElement).toBeDisabled();
const currentPasswordTextbox = screen.getByTestId(CURRENT_PASSWORD_TEST_ID);
const newPasswordTextbox = screen.getByTestId(NEW_PASSWORD_TEST_ID);
const submitButton = screen.getByTestId(RESET_PASSWORD_BUTTON_TEST_ID);
act(() => {
fireEvent.change(currentPasswordTextbox, {
@@ -213,7 +185,25 @@ describe('MySettings Flows', () => {
fireEvent.change(newPasswordTextbox, { target: { value: '123456789' } });
});
expect(submitButtonElement).toBeDisabled();
expect(submitButton).toBeDisabled();
});
it('Should enable reset button when passwords are valid and different', async () => {
const resetPasswordButtons = screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT);
fireEvent.click(resetPasswordButtons[0]);
const currentPasswordTextbox = screen.getByTestId(CURRENT_PASSWORD_TEST_ID);
const newPasswordTextbox = screen.getByTestId(NEW_PASSWORD_TEST_ID);
const submitButton = screen.getByTestId(RESET_PASSWORD_BUTTON_TEST_ID);
act(() => {
fireEvent.change(currentPasswordTextbox, {
target: { value: '123456789' },
});
fireEvent.change(newPasswordTextbox, { target: { value: '987654321' } });
});
expect(submitButton).not.toBeDisabled();
});
});
});

View File

@@ -1,18 +1,52 @@
import './MySettings.styles.scss';
import { Button, Radio, RadioChangeEvent, Space, Tag, Typography } from 'antd';
import { Logout } from 'api/utils';
import { Radio, RadioChangeEvent, Switch, Tag } from 'antd';
import logEvent from 'api/common/logEvent';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import { USER_PREFERENCES } from 'constants/userPreferences';
import useThemeMode, { useIsDarkMode } from 'hooks/useDarkMode';
import { LogOut, Moon, Sun } from 'lucide-react';
import { useState } from 'react';
import { useNotifications } from 'hooks/useNotifications';
import { Moon, Sun } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useEffect, useState } from 'react';
import { useMutation } from 'react-query';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import Password from './Password';
import TimezoneAdaptation from './TimezoneAdaptation/TimezoneAdaptation';
import UserInfo from './UserInfo';
function MySettings(): JSX.Element {
const isDarkMode = useIsDarkMode();
const { toggleTheme } = useThemeMode();
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { notifications } = useNotifications();
const [sideNavPinned, setSideNavPinned] = useState(false);
useEffect(() => {
if (userPreferences) {
setSideNavPinned(
userPreferences.find(
(preference) => preference.name === USER_PREFERENCES.SIDENAV_PINNED,
)?.value as boolean,
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userPreferences]);
const {
mutate: updateUserPreferenceMutation,
isLoading: isUpdatingUserPreference,
} = useMutation(updateUserPreference, {
onSuccess: () => {
// No need to do anything on success since we've already updated the state optimistically
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
});
const themeOptions = [
{
@@ -39,57 +73,112 @@ function MySettings(): JSX.Element {
const [theme, setTheme] = useState(isDarkMode ? 'dark' : 'light');
const handleThemeChange = ({ target: { value } }: RadioChangeEvent): void => {
logEvent('Account Settings: Theme Changed', {
theme: value,
});
setTheme(value);
toggleTheme();
};
const handleSideNavPinnedChange = (checked: boolean): void => {
logEvent('Account Settings: Sidebar Pinned Changed', {
pinned: checked,
});
// Optimistically update the UI
setSideNavPinned(checked);
// Update the context immediately
const save = {
name: USER_PREFERENCES.SIDENAV_PINNED,
value: checked,
};
updateUserPreferenceInContext(save as UserPreference);
// Make the API call in the background
updateUserPreferenceMutation(
{
name: USER_PREFERENCES.SIDENAV_PINNED,
value: checked,
},
{
onError: (error) => {
// Revert the state if the API call fails
setSideNavPinned(!checked);
updateUserPreferenceInContext({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: !checked,
} as UserPreference);
showErrorNotification(notifications, error as AxiosError);
},
},
);
};
return (
<Space
direction="vertical"
size="large"
style={{
margin: '16px 0',
}}
>
<div className="theme-selector">
<Typography.Title
level={5}
style={{
margin: '0 0 16px 0',
}}
>
{' '}
Theme{' '}
</Typography.Title>
<Radio.Group
options={themeOptions}
onChange={handleThemeChange}
value={theme}
optionType="button"
buttonStyle="solid"
data-testid="theme-selector"
/>
<div className="my-settings-container">
<div className="user-info-section">
<div className="user-info-section-header">
<div className="user-info-section-title">General </div>
<div className="user-info-section-subtitle">
Manage your account settings.
</div>
</div>
<div className="user-info-container">
<UserInfo />
</div>
</div>
<div className="user-info-container">
<UserInfo />
<div className="user-preference-section">
<div className="user-preference-section-header">
<div className="user-preference-section-title">User Preferences</div>
<div className="user-preference-section-subtitle">
Tailor the SigNoz console to work according to your needs.
</div>
</div>
<div className="user-preference-section-content">
<div className="user-preference-section-content-item theme-selector">
<div className="user-preference-section-content-item-title-action">
Select your theme
<Radio.Group
options={themeOptions}
onChange={handleThemeChange}
value={theme}
optionType="button"
buttonStyle="solid"
data-testid="theme-selector"
size="small"
/>
</div>
<div className="user-preference-section-content-item-description">
Select if SigNoz&apos;s appearance should be light or dark
</div>
</div>
<TimezoneAdaptation />
<div className="user-preference-section-content-item">
<div className="user-preference-section-content-item-title-action">
Keep the primary sidebar always open{' '}
<Switch
checked={sideNavPinned}
onChange={handleSideNavPinnedChange}
loading={isUpdatingUserPreference}
/>
</div>
<div className="user-preference-section-content-item-description">
Keep the primary sidebar always open by default, unless collapsed with
the keyboard shortcut
</div>
</div>
</div>
</div>
<div className="password-reset-container">
<Password />
</div>
<TimezoneAdaptation />
<Button
className="flexBtn"
onClick={(): void => Logout()}
type="primary"
data-testid="logout-button"
>
<LogOut size={12} /> Logout
</Button>
</Space>
</div>
);
}

View File

@@ -0,0 +1,17 @@
.new-explorer-cta {
display: flex;
align-items: center;
color: var(--bg-vanilla-400);
/* Bifrost (Ancient)/Content/sm */
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.ant-btn-icon {
margin-inline-end: 0px;
}
}

View File

@@ -5,8 +5,8 @@ export const RIBBON_STYLES = {
};
export const buttonText: Record<string, string> = {
[ROUTES.LOGS_EXPLORER]: 'Switch to Old Logs Explorer',
[ROUTES.TRACE]: 'Try new Traces Explorer',
[ROUTES.OLD_LOGS_EXPLORER]: 'Switch to New Logs Explorer',
[ROUTES.TRACES_EXPLORER]: 'Switch to Old Trace Explorer',
[ROUTES.LOGS_EXPLORER]: 'Old Explorer',
[ROUTES.TRACE]: 'New Explorer',
[ROUTES.OLD_LOGS_EXPLORER]: 'New Explorer',
[ROUTES.TRACES_EXPLORER]: 'Old Explorer',
};

View File

@@ -1,7 +1,9 @@
import { CompassOutlined } from '@ant-design/icons';
import './NewExplorerCTA.styles.scss';
import { Badge, Button } from 'antd';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { Undo } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
@@ -34,11 +36,11 @@ function NewExplorerCTA(): JSX.Element | null {
const button = useMemo(
() => (
<Button
icon={<CompassOutlined />}
icon={<Undo size={16} />}
onClick={onClickHandler}
danger
data-testid="newExplorerCTA"
type="primary"
type="text"
className="periscope-btn link"
>
{buttonText[location.pathname]}
</Button>

View File

@@ -0,0 +1,55 @@
import { Checkbox, Empty } from 'antd';
import Spinner from 'components/Spinner';
type ExplorerAttributeColumnsProps = {
isLoading: boolean;
data: any;
searchText: string;
isAttributeKeySelected: (key: string) => boolean;
handleCheckboxChange: (key: string) => void;
};
function ExplorerAttributeColumns({
isLoading,
data,
searchText,
isAttributeKeySelected,
handleCheckboxChange,
}: ExplorerAttributeColumnsProps): JSX.Element {
if (isLoading) {
return (
<div className="attribute-columns">
<Spinner size="large" tip="Loading..." height="2vh" />
</div>
);
}
const filteredAttributeKeys =
data?.payload?.attributeKeys?.filter((attributeKey: any) =>
attributeKey.key.toLowerCase().includes(searchText.toLowerCase()),
) || [];
if (filteredAttributeKeys.length === 0) {
return (
<div className="attribute-columns">
<Empty description="No columns found" />
</div>
);
}
return (
<div className="attribute-columns">
{filteredAttributeKeys.map((attributeKey: any) => (
<Checkbox
checked={isAttributeKeySelected(attributeKey.key)}
onChange={(): void => handleCheckboxChange(attributeKey.key)}
style={{ padding: 0 }}
key={attributeKey.key}
>
{attributeKey.key}
</Checkbox>
))}
</div>
);
}
export default ExplorerAttributeColumns;

View File

@@ -3,21 +3,13 @@
import './ExplorerColumnsRenderer.styles.scss';
import { Color } from '@signozhq/design-tokens';
import {
Button,
Checkbox,
Divider,
Dropdown,
Input,
Tooltip,
Typography,
} from 'antd';
import { Button, Divider, Dropdown, Input, Tooltip, Typography } from 'antd';
import { MenuProps } from 'antd/lib';
import Spinner from 'components/Spinner';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import {
AlertCircle,
GripVertical,
@@ -25,7 +17,7 @@ import {
Search,
Trash2,
} from 'lucide-react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
DragDropContext,
Draggable,
@@ -35,6 +27,7 @@ import {
import { DataSource } from 'types/common/queryBuilder';
import { WidgetGraphProps } from '../types';
import ExplorerAttributeColumns from './ExplorerAttributeColumns';
type LogColumnsRendererProps = {
setSelectedLogFields: WidgetGraphProps['setSelectedLogFields'];
@@ -51,6 +44,7 @@ function ExplorerColumnsRenderer({
}: LogColumnsRendererProps): JSX.Element {
const { currentQuery } = useQueryBuilder();
const [searchText, setSearchText] = useState<string>('');
const [querySearchText, setQuerySearchText] = useState<string>('');
const [open, setOpen] = useState<boolean>(false);
const initialDataSource = currentQuery.builder.queryData[0].dataSource;
@@ -60,13 +54,14 @@ function ExplorerColumnsRenderer({
aggregateAttribute: '',
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateOperator: currentQuery.builder.queryData[0].aggregateOperator,
searchText: '',
searchText: querySearchText,
tagType: '',
},
{
queryKey: [
currentQuery.builder.queryData[0].dataSource,
currentQuery.builder.queryData[0].aggregateOperator,
querySearchText,
],
},
);
@@ -120,8 +115,20 @@ function ExplorerColumnsRenderer({
setOpen(false);
};
const debouncedSetQuerySearchText = useDebouncedFn((value) => {
setQuerySearchText(value as string);
}, 400);
useEffect(
() => (): void => {
debouncedSetQuerySearchText.cancel();
},
[debouncedSetQuerySearchText],
);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
setSearchText(e.target.value);
debouncedSetQuerySearchText(e.target.value);
};
const items: MenuProps['items'] = [
@@ -141,22 +148,13 @@ function ExplorerColumnsRenderer({
{
key: 'columns',
label: (
<div className="attribute-columns">
{data?.payload?.attributeKeys
?.filter((attributeKey) =>
attributeKey.key.toLowerCase().includes(searchText.toLowerCase()),
)
?.map((attributeKey) => (
<Checkbox
checked={isAttributeKeySelected(attributeKey.key)}
onChange={(): void => handleCheckboxChange(attributeKey.key)}
style={{ padding: 0 }}
key={attributeKey.key}
>
{attributeKey.key}
</Checkbox>
))}
</div>
<ExplorerAttributeColumns
isLoading={isLoading}
data={data}
searchText={searchText}
isAttributeKeySelected={isAttributeKeySelected}
handleCheckboxChange={handleCheckboxChange}
/>
),
},
];
@@ -220,17 +218,13 @@ function ExplorerColumnsRenderer({
const isDarkMode = useIsDarkMode();
if (isLoading) {
return <Spinner size="large" tip="Loading..." height="4vh" />;
}
return (
<div className="explorer-columns-renderer">
<div className="title">
<Typography.Text>Columns</Typography.Text>
{isError && (
<Tooltip title={SOMETHING_WENT_WRONG}>
<AlertCircle size={16} />
<AlertCircle size={16} data-testid="alert-circle-icon" />
</Tooltip>
)}
</div>
@@ -265,6 +259,7 @@ function ExplorerColumnsRenderer({
size={12}
color="red"
onClick={(): void => removeSelectedLogField(field.name)}
data-testid="trash-icon"
/>
</div>
)}
@@ -290,6 +285,7 @@ function ExplorerColumnsRenderer({
size={12}
color="red"
onClick={(): void => removeSelectedLogField(field.key)}
data-testid="trash-icon"
/>
</div>
)}

View File

@@ -0,0 +1,394 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable react/jsx-props-no-spreading */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import React from 'react';
import { DropResult } from 'react-beautiful-dnd';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import ExplorerColumnsRenderer from '../ExplorerColumnsRenderer';
// Mock hooks
jest.mock('hooks/queryBuilder/useQueryBuilder');
jest.mock('hooks/queryBuilder/useGetAggregateKeys');
// Mock react-beautiful-dnd
let onDragEndMock: ((result: DropResult) => void) | undefined;
jest.mock('react-beautiful-dnd', () => ({
DragDropContext: jest.fn(
({
children,
onDragEnd,
}: {
children: React.ReactNode;
onDragEnd: (result: any) => void;
}) => {
onDragEndMock = onDragEnd;
return children;
},
),
Droppable: jest.fn(
({ children }: { children: (provided: any) => React.ReactNode }) =>
children({
draggableProps: { style: {} },
innerRef: jest.fn(),
placeholder: null,
}),
),
Draggable: jest.fn(
({ children }: { children: (provided: any) => React.ReactNode }) =>
children({
draggableProps: { style: {} },
innerRef: jest.fn(),
dragHandleProps: {},
}),
),
}));
describe('ExplorerColumnsRenderer', () => {
const mockSetSelectedLogFields = jest.fn();
const mockSetSelectedTracesFields = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Reset mock implementations for useQueryBuilder and useGetAggregateKeys before each test
// to ensure a clean state for each test case unless explicitly overridden.
(useQueryBuilder as jest.Mock).mockReturnValue({
currentQuery: {
builder: {
queryData: [
{
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
},
],
},
},
});
(useGetAggregateKeys as jest.Mock).mockReturnValue({
data: {
payload: {
attributeKeys: [
{ key: 'attribute1', dataType: 'string', type: '' },
{ key: 'attribute2', dataType: 'string', type: '' },
{ key: 'another_attribute', dataType: 'string', type: '' },
],
},
},
isLoading: false,
isError: false,
});
});
it('renders correctly with default props and displays "Columns" title', () => {
render(
<ExplorerColumnsRenderer
selectedLogFields={[]}
setSelectedLogFields={mockSetSelectedLogFields}
selectedTracesFields={[]}
setSelectedTracesFields={mockSetSelectedTracesFields}
/>,
);
expect(screen.getByText('Columns')).toBeInTheDocument();
expect(screen.getByTestId('add-columns-button')).toBeInTheDocument();
});
it('displays error message when data fetching fails', () => {
(useGetAggregateKeys as jest.Mock).mockReturnValueOnce({
data: undefined,
isLoading: false,
isError: true,
});
render(
<ExplorerColumnsRenderer
selectedLogFields={[]}
setSelectedLogFields={mockSetSelectedLogFields}
selectedTracesFields={[]}
setSelectedTracesFields={mockSetSelectedTracesFields}
/>,
);
expect(screen.getByTestId('alert-circle-icon')).toBeInTheDocument();
});
it('opens and closes the dropdown', async () => {
render(
<ExplorerColumnsRenderer
selectedLogFields={[]}
setSelectedLogFields={mockSetSelectedLogFields}
selectedTracesFields={[]}
setSelectedTracesFields={mockSetSelectedTracesFields}
/>,
);
const addButton = screen.getByTestId('add-columns-button');
await userEvent.click(addButton);
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
expect(screen.getByText('attribute1')).toBeInTheDocument();
await userEvent.click(addButton);
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});
});
it('filters attribute keys based on search text', async () => {
render(
<ExplorerColumnsRenderer
selectedLogFields={[]}
setSelectedLogFields={mockSetSelectedLogFields}
selectedTracesFields={[]}
setSelectedTracesFields={mockSetSelectedTracesFields}
/>,
);
await userEvent.click(screen.getByTestId('add-columns-button'));
const searchInput = screen.getByPlaceholderText('Search');
await userEvent.type(searchInput, 'another');
await waitFor(() => {
expect(screen.queryByText('attribute1')).not.toBeInTheDocument();
expect(screen.getByText('another_attribute')).toBeInTheDocument();
});
await userEvent.clear(searchInput);
await userEvent.type(searchInput, 'attribute');
await waitFor(() => {
expect(screen.getByText('attribute1')).toBeInTheDocument();
expect(screen.getByText('attribute2')).toBeInTheDocument();
expect(screen.getByText('another_attribute')).toBeInTheDocument();
});
});
describe('Log Data Source', () => {
it('adds a log field when checkbox is checked', async () => {
render(
<ExplorerColumnsRenderer
selectedLogFields={[]}
setSelectedLogFields={mockSetSelectedLogFields}
selectedTracesFields={[]}
setSelectedTracesFields={mockSetSelectedTracesFields}
/>,
);
await userEvent.click(screen.getByTestId('add-columns-button'));
const checkbox = screen.getByLabelText('attribute1');
await userEvent.click(checkbox);
expect(mockSetSelectedLogFields).toHaveBeenCalledWith([
{ dataType: 'string', name: 'attribute1', type: '' },
]);
});
it('removes a log field when checkbox is unchecked', async () => {
render(
<ExplorerColumnsRenderer
selectedLogFields={[{ dataType: 'string', name: 'attribute1', type: '' }]}
setSelectedLogFields={mockSetSelectedLogFields}
selectedTracesFields={[]}
setSelectedTracesFields={mockSetSelectedTracesFields}
/>,
);
await userEvent.click(screen.getByTestId('add-columns-button'));
const checkbox = screen.getByLabelText('attribute1');
await userEvent.click(checkbox);
expect(mockSetSelectedLogFields).toHaveBeenCalledWith([]);
});
it('removes a log field using the trash icon', async () => {
render(
<ExplorerColumnsRenderer
selectedLogFields={[{ dataType: 'string', name: 'attribute1', type: '' }]}
setSelectedLogFields={mockSetSelectedLogFields}
selectedTracesFields={[]}
setSelectedTracesFields={mockSetSelectedTracesFields}
/>,
);
expect(screen.getByText('attribute1')).toBeInTheDocument();
const trashIcon = screen.getByTestId('trash-icon');
await userEvent.click(trashIcon);
expect(mockSetSelectedLogFields).toHaveBeenCalledWith([]);
});
it('reorders log fields on drag and drop', () => {
const initialSelectedFields = [
{ dataType: 'string', name: 'field1', type: '' },
{ dataType: 'string', name: 'field2', type: '' },
];
render(
<ExplorerColumnsRenderer
selectedLogFields={initialSelectedFields}
setSelectedLogFields={mockSetSelectedLogFields}
selectedTracesFields={[]}
setSelectedTracesFields={mockSetSelectedTracesFields}
/>,
);
const field1Element = screen.getByText('field1');
const dragDropContext = field1Element.closest('.explorer-columns');
if (dragDropContext && onDragEndMock) {
// Simulate onDragEnd directly
onDragEndMock({
source: { index: 0, droppableId: 'drag-drop-list' },
destination: { index: 1, droppableId: 'drag-drop-list' },
draggableId: '0',
type: 'DEFAULT',
reason: 'DROP',
combine: undefined,
mode: 'FLUID',
});
expect(mockSetSelectedLogFields).toHaveBeenCalledWith([
{ dataType: 'string', name: 'field2', type: '' },
{ dataType: 'string', name: 'field1', type: '' },
]);
} else {
fail('DragDropContext or onDragEndMock not found');
}
});
});
describe('Trace Data Source', () => {
beforeEach(() => {
(useQueryBuilder as jest.Mock).mockReturnValue({
currentQuery: {
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
},
],
},
},
});
(useGetAggregateKeys as jest.Mock).mockReturnValue({
data: {
payload: {
attributeKeys: [
{ key: 'trace_attribute1', dataType: 'string', type: 'tag' },
{ key: 'trace_attribute2', dataType: 'string', type: 'tag' },
],
},
},
isLoading: false,
isError: false,
});
});
it('adds a trace field when checkbox is checked', async () => {
render(
<ExplorerColumnsRenderer
selectedLogFields={[]}
setSelectedLogFields={mockSetSelectedLogFields}
selectedTracesFields={[]}
setSelectedTracesFields={mockSetSelectedTracesFields}
/>,
);
await userEvent.click(screen.getByTestId('add-columns-button'));
const checkbox = screen.getByLabelText('trace_attribute1');
await userEvent.click(checkbox);
expect(mockSetSelectedTracesFields).toHaveBeenCalledWith([
{ key: 'trace_attribute1', dataType: 'string', type: 'tag' },
]);
});
it('removes a trace field when checkbox is unchecked', async () => {
render(
<ExplorerColumnsRenderer
selectedLogFields={[]}
setSelectedLogFields={mockSetSelectedLogFields}
selectedTracesFields={[
{ key: 'trace_attribute1', dataType: DataTypes.String, type: 'tag' },
]}
setSelectedTracesFields={mockSetSelectedTracesFields}
/>,
);
await userEvent.click(screen.getByTestId('add-columns-button'));
const checkbox = screen.getByLabelText('trace_attribute1');
await userEvent.click(checkbox);
expect(mockSetSelectedTracesFields).toHaveBeenCalledWith([]);
});
it('removes a trace field using the trash icon', async () => {
render(
<ExplorerColumnsRenderer
selectedLogFields={[]}
setSelectedLogFields={mockSetSelectedLogFields}
selectedTracesFields={[
{ key: 'trace_attribute1', dataType: DataTypes.String, type: 'tag' },
]}
setSelectedTracesFields={mockSetSelectedTracesFields}
/>,
);
expect(screen.getByText('trace_attribute1')).toBeInTheDocument();
const trashIcon = screen.getByTestId('trash-icon');
await userEvent.click(trashIcon);
expect(mockSetSelectedTracesFields).toHaveBeenCalledWith([]);
});
it('reorders trace fields on drag and drop', () => {
const initialSelectedFields = [
{ key: 'trace_field1', dataType: 'string', type: 'tag' },
{ key: 'trace_field2', dataType: 'string', type: 'tag' },
];
render(
<ExplorerColumnsRenderer
selectedLogFields={[]}
setSelectedLogFields={mockSetSelectedLogFields}
selectedTracesFields={initialSelectedFields as BaseAutocompleteData[]}
setSelectedTracesFields={mockSetSelectedTracesFields}
/>,
);
const traceField1Element = screen.getByText('trace_field1');
const dragDropContext = traceField1Element.closest('.explorer-columns');
if (dragDropContext && onDragEndMock) {
// Simulate onDragEnd directly
onDragEndMock({
source: { index: 0, droppableId: 'drag-drop-list' },
destination: { index: 1, droppableId: 'drag-drop-list' },
draggableId: '0',
type: 'DEFAULT',
reason: 'DROP',
combine: undefined,
mode: 'FLUID',
});
expect(mockSetSelectedTracesFields).toHaveBeenCalledWith([
{ key: 'trace_field2', dataType: 'string', type: 'tag' },
{ key: 'trace_field1', dataType: 'string', type: 'tag' },
]);
} else {
fail('DragDropContext or onDragEndMock not found');
}
});
});
});

View File

@@ -421,6 +421,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
formatForWeb:
getGraphTypeForFormat(selectedGraph || selectedWidget.panelTypes) ===
PANEL_TYPES.TABLE,
originalGraphType: selectedGraph || selectedWidget.panelTypes,
}));
}
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -3,15 +3,15 @@ import '../OnboardingQuestionaire.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button, Input, Typography } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import logEvent from 'api/common/logEvent';
import { ArrowLeft, ArrowRight, CheckCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
export interface SignozDetails {
hearAboutSignoz: string | null;
interestInSignoz: string | null;
otherInterestInSignoz: string | null;
otherAboutSignoz: string | null;
discoverSignoz: string | null;
}
interface AboutSigNozQuestionsProps {
@@ -21,15 +21,6 @@ interface AboutSigNozQuestionsProps {
onBack: () => void;
}
const hearAboutSignozOptions: Record<string, string> = {
search: 'Google / Search',
hackerNews: 'Hacker News',
linkedin: 'LinkedIn',
twitter: 'Twitter',
reddit: 'Reddit',
colleaguesFriends: 'Colleagues / Friends',
};
const interestedInOptions: Record<string, string> = {
savingCosts: 'Saving costs',
otelNativeStack: 'Interested in Otel-native stack',
@@ -42,24 +33,20 @@ export function AboutSigNozQuestions({
onNext,
onBack,
}: AboutSigNozQuestionsProps): JSX.Element {
const [hearAboutSignoz, setHearAboutSignoz] = useState<string | null>(
signozDetails?.hearAboutSignoz || null,
);
const [otherAboutSignoz, setOtherAboutSignoz] = useState<string>(
signozDetails?.otherAboutSignoz || '',
);
const [interestInSignoz, setInterestInSignoz] = useState<string | null>(
signozDetails?.interestInSignoz || null,
);
const [otherInterestInSignoz, setOtherInterestInSignoz] = useState<string>(
signozDetails?.otherInterestInSignoz || '',
);
const [discoverSignoz, setDiscoverSignoz] = useState<string>(
signozDetails?.discoverSignoz || '',
);
const [isNextDisabled, setIsNextDisabled] = useState<boolean>(true);
useEffect((): void => {
if (
hearAboutSignoz !== null &&
(hearAboutSignoz !== 'Others' || otherAboutSignoz !== '') &&
discoverSignoz !== '' &&
interestInSignoz !== null &&
(interestInSignoz !== 'Others' || otherInterestInSignoz !== '')
) {
@@ -67,24 +54,17 @@ export function AboutSigNozQuestions({
} else {
setIsNextDisabled(true);
}
}, [
hearAboutSignoz,
otherAboutSignoz,
interestInSignoz,
otherInterestInSignoz,
]);
}, [interestInSignoz, otherInterestInSignoz, discoverSignoz]);
const handleOnNext = (): void => {
setSignozDetails({
hearAboutSignoz,
otherAboutSignoz,
discoverSignoz,
interestInSignoz,
otherInterestInSignoz,
});
logEvent('Org Onboarding: Answered', {
hearAboutSignoz,
otherAboutSignoz,
discoverSignoz,
interestInSignoz,
otherInterestInSignoz,
});
@@ -94,8 +74,7 @@ export function AboutSigNozQuestions({
const handleOnBack = (): void => {
setSignozDetails({
hearAboutSignoz,
otherAboutSignoz,
discoverSignoz,
interestInSignoz,
otherInterestInSignoz,
});
@@ -115,52 +94,16 @@ export function AboutSigNozQuestions({
<div className="questions-form-container">
<div className="questions-form">
<div className="form-group">
<div className="question">Where did you hear about SigNoz?</div>
<div className="two-column-grid">
{Object.keys(hearAboutSignozOptions).map((option: string) => (
<Button
key={option}
type="primary"
className={`onboarding-questionaire-button ${
hearAboutSignoz === option ? 'active' : ''
}`}
onClick={(): void => setHearAboutSignoz(option)}
>
{hearAboutSignozOptions[option]}
{hearAboutSignoz === option && (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
)}
</Button>
))}
<div className="question">How did you first come across SigNoz?</div>
{hearAboutSignoz === 'Others' ? (
<Input
type="text"
className="onboarding-questionaire-other-input"
placeholder="How you got to know about us"
value={otherAboutSignoz}
autoFocus
addonAfter={
otherAboutSignoz !== '' ? (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
) : (
''
)
}
onChange={(e): void => setOtherAboutSignoz(e.target.value)}
/>
) : (
<Button
type="primary"
className={`onboarding-questionaire-button ${
hearAboutSignoz === 'Others' ? 'active' : ''
}`}
onClick={(): void => setHearAboutSignoz('Others')}
>
Others
</Button>
)}
</div>
<TextArea
className="discover-signoz-input"
placeholder="e.g., Google Search, Hacker News, Reddit, a friend, ChatGPT, a blog post, a conference, etc."
value={discoverSignoz}
autoFocus
rows={4}
onChange={(e): void => setDiscoverSignoz(e.target.value)}
/>
</div>
<div className="form-group">

View File

@@ -84,6 +84,23 @@
}
}
.discover-signoz-input {
width: 100%;
height: 100px;
resize: none;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
color: var(--bg-vanilla-100);
border-radius: 4px;
font-size: 14px;
padding: 12px;
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 1;
}
}
&.invite-team-members-form {
min-height: calc(420px - 24px);
max-height: calc(420px - 24px);
@@ -123,7 +140,7 @@
height: 32px;
background: var(--Ink-300, #16181d);
border: 1px solid var(--bg-slate-400);
border: 1px solid var(--Greyscale-Slate-400, #1d212d);
color: var(--bg-vanilla-400);
}
@@ -445,6 +462,17 @@
}
}
.discover-signoz-input {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
color: var(--text-ink-300);
&::placeholder {
color: var(--bg-slate-400);
opacity: 1;
}
}
&.invite-team-members-form {
.invite-team-members-container {
max-height: 260px;

View File

@@ -21,7 +21,7 @@ export interface OrgDetails {
usesObservability: boolean | null;
observabilityTool: string | null;
otherTool: string | null;
familiarity: string | null;
usesOtel: boolean | null;
}
interface OrgQuestionsProps {
@@ -40,13 +40,6 @@ const observabilityTools = {
Honeycomb: 'Honeycomb',
};
const o11yFamiliarityOptions: Record<string, string> = {
beginner: 'Beginner',
intermediate: 'Intermediate',
expert: 'Expert',
notFamiliar: "I'm not familiar with it",
};
function OrgQuestions({
currentOrgData,
orgDetails,
@@ -69,9 +62,6 @@ function OrgQuestions({
const [otherTool, setOtherTool] = useState<string>(
orgDetails?.otherTool || '',
);
const [familiarity, setFamiliarity] = useState<string | null>(
orgDetails?.familiarity || null,
);
const [isNextDisabled, setIsNextDisabled] = useState<boolean>(true);
useEffect(() => {
@@ -80,6 +70,10 @@ function OrgQuestions({
const [isLoading, setIsLoading] = useState<boolean>(false);
const [usesOtel, setUsesOtel] = useState<boolean | null>(
orgDetails?.usesOtel || null,
);
const handleOrgNameUpdate = async (): Promise<void> => {
/* Early bailout if orgData is not set or if the organisation name is not set or if the organisation name is empty or if the organisation name is the same as the one in the orgData */
if (
@@ -92,7 +86,7 @@ function OrgQuestions({
usesObservability,
observabilityTool,
otherTool,
familiarity,
usesOtel,
});
onNext({
@@ -100,7 +94,7 @@ function OrgQuestions({
usesObservability,
observabilityTool,
otherTool,
familiarity,
usesOtel,
});
return;
@@ -123,7 +117,7 @@ function OrgQuestions({
usesObservability,
observabilityTool,
otherTool,
familiarity,
usesOtel,
});
onNext({
@@ -131,7 +125,7 @@ function OrgQuestions({
usesObservability,
observabilityTool,
otherTool,
familiarity,
usesOtel,
});
} else {
logEvent('Org Onboarding: Org Name Update Failed', {
@@ -177,7 +171,7 @@ function OrgQuestions({
useEffect(() => {
const isValidObservability = isValidUsesObservability();
if (organisationName !== '' && familiarity !== null && isValidObservability) {
if (organisationName !== '' && usesOtel !== null && isValidObservability) {
setIsNextDisabled(false);
} else {
setIsNextDisabled(true);
@@ -186,7 +180,7 @@ function OrgQuestions({
}, [
organisationName,
usesObservability,
familiarity,
usesOtel,
observabilityTool,
otherTool,
]);
@@ -317,25 +311,37 @@ function OrgQuestions({
)}
<div className="form-group">
<div className="question">
Are you familiar with setting up observability (o11y)?
</div>
<div className="question">Do you already use OpenTelemetry?</div>
<div className="two-column-grid">
{Object.keys(o11yFamiliarityOptions).map((option: string) => (
<Button
key={option}
type="primary"
className={`onboarding-questionaire-button ${
familiarity === option ? 'active' : ''
}`}
onClick={(): void => setFamiliarity(option)}
>
{o11yFamiliarityOptions[option]}
{familiarity === option && (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
)}
</Button>
))}
<Button
type="primary"
name="usesObservability"
className={`onboarding-questionaire-button ${
usesOtel === true ? 'active' : ''
}`}
onClick={(): void => {
setUsesOtel(true);
}}
>
Yes{' '}
{usesOtel === true && (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
)}
</Button>
<Button
type="primary"
className={`onboarding-questionaire-button ${
usesOtel === false ? 'active' : ''
}`}
onClick={(): void => {
setUsesOtel(false);
}}
>
No{' '}
{usesOtel === false && (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
)}
</Button>
</div>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import updateOrgPreferenceAPI from 'api/v1/org/preferences/name/update';
import { AxiosError } from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { FeatureKeys } from 'constants/features';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { InviteTeamMembersProps } from 'container/OrganizationSettings/PendingInvitesContainer';
import { useNotifications } from 'hooks/useNotifications';
@@ -41,14 +42,13 @@ const INITIAL_ORG_DETAILS: OrgDetails = {
usesObservability: true,
observabilityTool: '',
otherTool: '',
familiarity: '',
usesOtel: null,
};
const INITIAL_SIGNOZ_DETAILS: SignozDetails = {
hearAboutSignoz: '',
interestInSignoz: '',
otherInterestInSignoz: '',
otherAboutSignoz: '',
discoverSignoz: '',
};
const INITIAL_OPTIMISE_SIGNOZ_DETAILS: OptimiseSignozDetails = {
@@ -167,22 +167,17 @@ function OnboardingQuestionaire(): JSX.Element {
});
updateProfile({
familiarity_with_observability: orgDetails?.familiarity as string,
uses_otel: orgDetails?.usesOtel as boolean,
has_existing_observability_tool: orgDetails?.usesObservability as boolean,
existing_observability_tool:
orgDetails?.observabilityTool === 'Others'
? (orgDetails?.otherTool as string)
: (orgDetails?.observabilityTool as string),
where_did_you_discover_signoz: signozDetails?.discoverSignoz as string,
reasons_for_interest_in_signoz:
signozDetails?.interestInSignoz === 'Others'
? (signozDetails?.otherInterestInSignoz as string)
: (signozDetails?.interestInSignoz as string),
where_did_you_hear_about_signoz:
signozDetails?.hearAboutSignoz === 'Others'
? (signozDetails?.otherAboutSignoz as string)
: (signozDetails?.hearAboutSignoz as string),
logs_scale_per_day_in_gb: optimiseSignozDetails?.logsPerDay as number,
number_of_hosts: optimiseSignozDetails?.hostsPerDay as number,
number_of_services: optimiseSignozDetails?.services as number,
@@ -196,7 +191,7 @@ function OnboardingQuestionaire(): JSX.Element {
setUpdatingOrgOnboardingStatus(true);
updateOrgPreference({
name: 'org_onboarding',
name: ORG_PREFERENCES.ORG_ONBOARDING,
value: true,
});
};

View File

@@ -298,8 +298,6 @@
}
.onboarding-v2 {
margin: 0px -1rem;
.onboarding-header-container {
display: flex;
justify-content: space-between;

View File

@@ -0,0 +1,21 @@
.organization-settings-container {
display: flex;
flex-direction: column;
gap: 16px;
margin: 16px auto;
padding: 16px;
width: 90%;
border: 1px solid var(--Slate-500, #161922);
background: var(--Ink-400, #121317);
border-radius: 3px;
}
.lightMode {
.organization-settings-container {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
}
}

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