Compare commits

...

27 Commits

Author SHA1 Message Date
vikrantgupta25
700249fae7 chore(test): test release 2025-06-18 13:10:00 +05:30
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
Shaheer Kochai
fff7f8fc76 feat: add span scope filter to trace details page (#8005)
* feat: add span scope filter to trace details page

* chore: add tests for the span scope selector flows when onchange and query are provided

* refactor: remove the unnecessary queryName prop and infer it from query

* fix: fix the failing span scope selector tests
2025-06-12 11:03:28 +00:00
Shaheer Kochai
8cfeef4521 fix: fix sentries (#8003)
* fix: handle potential undefined values in groupBy calculation in TracesExplorer

* fix: add optional chaining for aggregateAttribute key check in Query component

* fix: add optional chaining for filters in SpanScopeSelector to handle potential undefined values

* fix: fix the warning in logs chart by adding the missing date-time format option

* fix: improve trace graph allDataPoints null check

* chore: remove the keys.length from null check
2025-06-12 15:28:06 +04:30
Sahil Khan
d85a1a21ac feat: generalised preferences framework (#7903)
* feat: preferences framework generalised scaffolded

* feat: preferences framework integrated logs & saved logs views

* feat: fixed bugs in saved views for traces

* fix: removed unused file

* fix: wrapped metric explorer inside preferences context as it uses useoptions hook

* feat: added tests for preferences framework alongside some minor bugs improvements

* chore: added tests for traces loader and updater

* chore: fixed failing tests due to new context for preferences

* fix: minor saved views handling bug

* fix: minor pref fix

* fix: breaking tests

* fix: undo removal of pref context from live logs

* feat: split the logic of columns and formatting load in logs

* fix: breaking tests

* fix: pr comments

* fix: minor bug and pr comments regarding better resync

* fix: bugs in internal flows

* fix: url pref sync

* fix: minor bug fix

* fix: fixed failing tests

* fix: fixed failing tests
2025-06-11 10:06:01 +00:00
272 changed files with 13813 additions and 2610 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

@@ -5,6 +5,8 @@
<br>SigNoz
</h1>
ok
<p align="center">All your logs, metrics, and traces in one place. Monitor your application, spot issues before they occur and troubleshoot downtime quickly with rich context. SigNoz is a cost-effective open-source alternative to Datadog and New Relic. Visit <a href="https://signoz.io" target="_blank">signoz.io</a> for the full documentation, tutorials, and guide.</p>
<p align="center">

View File

@@ -224,3 +224,6 @@ statsreporter:
enabled: true
# The interval at which the stats are collected.
interval: 6h
collect:
# Whether to collect identities and traits (emails).
identities: true

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

@@ -122,10 +122,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
}
// initiate opamp
_, err = opAmpModel.InitDB(serverOptions.SigNoz.SQLStore.SQLxDB())
if err != nil {
return nil, err
}
opAmpModel.InitDB(serverOptions.SigNoz.SQLStore, serverOptions.SigNoz.Instrumentation.Logger(), serverOptions.SigNoz.Modules.OrgGetter)
integrationsController, err := integrations.NewController(serverOptions.SigNoz.SQLStore)
if err != nil {
@@ -143,7 +140,8 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
// ingestion pipelines manager
logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController(
serverOptions.SigNoz.SQLStore, integrationsController.GetPipelinesForInstalledIntegrations,
serverOptions.SigNoz.SQLStore,
integrationsController.GetPipelinesForInstalledIntegrations,
)
if err != nil {
return nil, err
@@ -151,7 +149,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
// initiate agent config handler
agentConfMgr, err := agentConf.Initiate(&agentConf.ManagerOptions{
DB: serverOptions.SigNoz.SQLStore.SQLxDB(),
Store: serverOptions.SigNoz.SQLStore,
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController},
})
if err != nil {

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"
@@ -134,8 +135,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(),

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

@@ -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",

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",

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();

View File

@@ -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,11 @@ export const AlertOverview = Loadable(
);
export const CreateAlertChannelAlerts = Loadable(
() =>
import(/* webpackChunkName: "Create Channels" */ 'pages/AlertChannelCreate'),
() => import(/* webpackChunkName: "Create Channels" */ 'pages/Settings'),
);
export const EditAlertChannelsAlerts = Loadable(
() => import(/* webpackChunkName: "Edit Channels" */ 'pages/ChannelsEdit'),
() => import(/* webpackChunkName: "Edit Channels" */ 'pages/Settings'),
);
export const AllAlertChannels = Loadable(
@@ -165,7 +164,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 +221,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 +248,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,12 +7,9 @@ import {
AlertOverview,
AllAlertChannels,
AllErrors,
APIKeys,
ApiMonitoring,
BillingPage,
CreateAlertChannelAlerts,
CreateNewAlerts,
CustomDomainSettings,
DashboardPage,
DashboardWidget,
EditAlertChannelsAlerts,
@@ -20,7 +17,6 @@ import {
ErrorDetails,
Home,
InfrastructureMonitoring,
IngestionSettings,
InstalledIntegrations,
LicensePage,
ListAllALertsPage,
@@ -31,12 +27,10 @@ import {
LogsIndexToFields,
LogsSaveViews,
MetricsExplorer,
MySettings,
NewDashboardPage,
OldLogsExplorer,
Onboarding,
OnboardingV2,
OrganizationSettings,
OrgOnboarding,
PasswordReset,
PipelinePage,
@@ -45,7 +39,6 @@ import {
ServicesTablePage,
ServiceTopLevelOperationsPage,
SettingsPage,
ShortcutsPage,
SignupPage,
SomethingWentWrong,
StatusPage,
@@ -150,7 +143,7 @@ const routes: AppRoutes[] = [
},
{
path: ROUTES.SETTINGS,
exact: true,
exact: false,
component: SettingsPage,
isPrivate: true,
key: 'SETTINGS',
@@ -295,41 +288,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 +351,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 +372,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

@@ -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,5 +1,6 @@
import { render, screen } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { DataSource } from 'types/common/queryBuilder';
@@ -52,11 +53,32 @@ jest.mock('hooks/saveViews/useDeleteView', () => ({
})),
}));
// Mock usePreferenceSync
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
usePreferenceSync: (): any => ({
preferences: {
columns: [],
formatting: {
maxLines: 2,
format: 'table',
fontSize: 'small',
version: 1,
},
},
loading: false,
error: null,
updateColumns: jest.fn(),
updateFormatting: jest.fn(),
}),
}));
describe('ExplorerCard', () => {
it('renders a card with a title and a description', () => {
render(
<MockQueryClientProvider>
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
<PreferenceContextProvider>
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
</PreferenceContextProvider>
</MockQueryClientProvider>,
);
expect(screen.queryByText('Query Builder')).not.toBeInTheDocument();
@@ -65,7 +87,9 @@ describe('ExplorerCard', () => {
it('renders a save view button', () => {
render(
<MockQueryClientProvider>
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
<PreferenceContextProvider>
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
</PreferenceContextProvider>
</MockQueryClientProvider>,
);
expect(screen.queryByText('Save view')).not.toBeInTheDocument();

View File

@@ -6,6 +6,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import isEqual from 'lodash-es/isEqual';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
DeleteViewHandlerProps,
@@ -106,7 +107,11 @@ export const isQueryUpdatedInView = ({
!isEqual(
options?.selectColumns,
extraData && JSON.parse(extraData)?.selectColumns,
)
) ||
(stagedQuery?.builder?.queryData?.[0]?.dataSource === DataSource.LOGS &&
(!isEqual(options?.format, extraData && JSON.parse(extraData)?.format) ||
!isEqual(options?.maxLines, extraData && JSON.parse(extraData)?.maxLines) ||
!isEqual(options?.fontSize, extraData && JSON.parse(extraData)?.fontSize)))
);
};

View File

@@ -74,6 +74,7 @@ const formatMap = {
'MM/dd HH:mm': DATE_TIME_FORMATS.SLASH_SHORT,
'MM/DD': DATE_TIME_FORMATS.DATE_SHORT,
'YY-MM': DATE_TIME_FORMATS.YEAR_MONTH,
'MMM d, yyyy, h:mm:ss aaaa': DATE_TIME_FORMATS.DASH_DATETIME,
YY: DATE_TIME_FORMATS.YEAR_SHORT,
};

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/:id',
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,7 +60,7 @@ 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_KAFKA: '/messaging-queues/kafka',
MESSAGING_QUEUES_KAFKA_DETAIL: '/messaging-queues/kafka/detail',

View File

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

View File

@@ -21,7 +21,7 @@ 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,
}),

View File

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

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

@@ -22,6 +22,12 @@
width: 100%;
}
}
&.side-nav-pinned {
.app-content {
width: calc(100% - 240px);
}
}
}
.chat-support-gateway {

View File

@@ -18,6 +18,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 +28,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,
@@ -41,7 +41,7 @@ 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 { useLocation } from 'react-router-dom';
import { Dispatch } from 'redux';
import AppActions from 'types/actions';
import {
@@ -80,6 +80,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
featureFlags,
isFetchingFeatureFlags,
featureFlagsFetchError,
userPreferences,
} = useAppContext();
const { notifications } = useNotifications();
@@ -330,53 +331,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,6 +547,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
</div>
);
const sideNavPinned = userPreferences?.find(
(preference) => preference.name === USER_PREFERENCES.SIDENAV_PINNED,
)?.value as boolean;
return (
<Layout className={cx(isDarkMode ? 'darkMode dark' : 'lightMode')}>
<Helmet>
@@ -645,9 +603,15 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
)}
<Flex
className={cx('app-layout', isDarkMode ? 'darkMode dark' : 'lightMode')}
className={cx(
'app-layout',
isDarkMode ? 'darkMode dark' : 'lightMode',
sideNavPinned ? 'side-nav-pinned' : '',
)}
>
{isToDisplayLayout && !renderFullScreen && <SideNav />}
{isToDisplayLayout && !renderFullScreen && (
<SideNav isPinned={sideNavPinned} />
)}
<div
className={cx('app-content', {
'full-screen-content': renderFullScreen,
@@ -657,32 +621,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

@@ -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';
@@ -477,26 +479,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

@@ -54,6 +54,7 @@ import {
X,
} from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { FormattingOptions } from 'providers/preferences/types';
import {
CSSProperties,
Dispatch,
@@ -270,17 +271,26 @@ function ExplorerOptions({
const getUpdatedExtraData = (
extraData: string | undefined,
newSelectedColumns: BaseAutocompleteData[],
formattingOptions?: FormattingOptions,
): string => {
let updatedExtraData;
if (extraData) {
const parsedExtraData = JSON.parse(extraData);
parsedExtraData.selectColumns = newSelectedColumns;
if (formattingOptions) {
parsedExtraData.format = formattingOptions.format;
parsedExtraData.maxLines = formattingOptions.maxLines;
parsedExtraData.fontSize = formattingOptions.fontSize;
}
updatedExtraData = JSON.stringify(parsedExtraData);
} else {
updatedExtraData = JSON.stringify({
color: Color.BG_SIENNA_500,
selectColumns: newSelectedColumns,
format: formattingOptions?.format,
maxLines: formattingOptions?.maxLines,
fontSize: formattingOptions?.fontSize,
});
}
return updatedExtraData;
@@ -289,6 +299,14 @@ function ExplorerOptions({
const updatedExtraData = getUpdatedExtraData(
extraData,
options?.selectColumns,
// pass this only for logs
sourcepage === DataSource.LOGS
? {
format: options?.format,
maxLines: options?.maxLines,
fontSize: options?.fontSize,
}
: undefined,
);
const {
@@ -517,6 +535,14 @@ function ExplorerOptions({
color,
selectColumns: options.selectColumns,
version: 1,
...// pass this only for logs
(sourcepage === DataSource.LOGS
? {
format: options?.format,
maxLines: options?.maxLines,
fontSize: options?.fontSize,
}
: {}),
}),
notifications,
panelType: panelType || PANEL_TYPES.LIST,

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

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

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

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

@@ -114,7 +114,6 @@ function LogsExplorerViews({
// Context
const {
initialDataSource,
currentQuery,
stagedQuery,
panelType,
@@ -144,7 +143,7 @@ function LogsExplorerViews({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: initialDataSource || DataSource.LOGS,
dataSource: DataSource.LOGS,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});

View File

@@ -5,6 +5,7 @@ import { logsQueryRangeSuccessResponse } from 'mocks-server/__mockdata__/logs_qu
import { server } from 'mocks-server/server';
import { rest } from 'msw';
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';
@@ -87,6 +88,25 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
// Mock usePreferenceSync
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
usePreferenceSync: (): any => ({
preferences: {
columns: [],
formatting: {
maxLines: 2,
format: 'table',
fontSize: 'small',
version: 1,
},
},
loading: false,
error: null,
updateColumns: jest.fn(),
updateFormatting: jest.fn(),
}),
}));
jest.mock('hooks/logs/useCopyLogLink', () => ({
useCopyLogLink: jest.fn().mockReturnValue({
activeLogId: ACTIVE_LOG_ID,
@@ -105,13 +125,15 @@ const renderer = (): RenderResult =>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 300, itemHeight: 100 }}
>
<LogsExplorerViews
selectedView={SELECTED_VIEWS.SEARCH}
showFrequencyChart
setIsLoadingQueries={(): void => {}}
listQueryKeyRef={{ current: {} }}
chartQueryKeyRef={{ current: {} }}
/>
<PreferenceContextProvider>
<LogsExplorerViews
selectedView={SELECTED_VIEWS.SEARCH}
showFrequencyChart
setIsLoadingQueries={(): void => {}}
listQueryKeyRef={{ current: {} }}
chartQueryKeyRef={{ current: {} }}
/>
</PreferenceContextProvider>
</VirtuosoMockContext.Provider>,
);
@@ -184,13 +206,15 @@ describe('LogsExplorerViews -', () => {
lodsQueryServerRequest();
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue}>
<LogsExplorerViews
selectedView={SELECTED_VIEWS.SEARCH}
showFrequencyChart
setIsLoadingQueries={(): void => {}}
listQueryKeyRef={{ current: {} }}
chartQueryKeyRef={{ current: {} }}
/>
<PreferenceContextProvider>
<LogsExplorerViews
selectedView={SELECTED_VIEWS.SEARCH}
showFrequencyChart
setIsLoadingQueries={(): void => {}}
listQueryKeyRef={{ current: {} }}
chartQueryKeyRef={{ current: {} }}
/>
</PreferenceContextProvider>
</QueryBuilderContext.Provider>,
);

View File

@@ -5,6 +5,7 @@ import { logsPaginationQueryRangeSuccessResponse } from 'mocks-server/__mockdata
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { I18nextProvider } from 'react-i18next';
import i18n from 'ReactI18';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
@@ -108,11 +109,13 @@ describe('LogsPanelComponent', () => {
render(
<I18nextProvider i18n={i18n}>
<DashboardProvider>
<NewWidget
selectedGraph={PANEL_TYPES.LIST}
fillSpans={undefined}
yAxisUnit={undefined}
/>
<PreferenceContextProvider>
<NewWidget
selectedGraph={PANEL_TYPES.LIST}
fillSpans={undefined}
yAxisUnit={undefined}
/>
</PreferenceContextProvider>
</DashboardProvider>
</I18nextProvider>,
);

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

@@ -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';
@@ -196,7 +197,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

@@ -1,7 +1,4 @@
import getFromLocalstorage from 'api/browser/localstorage/get';
import setToLocalstorage from 'api/browser/localstorage/set';
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
import { LOCALSTORAGE } from 'constants/localStorage';
import { LogViewMode } from 'container/LogsTable';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import useDebounce from 'hooks/useDebounce';
@@ -11,6 +8,7 @@ import {
AllTraceFilterKeys,
AllTraceFilterKeyValue,
} from 'pages/TracesExplorer/Filter/filterUtils';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueries } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
@@ -35,10 +33,10 @@ import {
import { getOptionsFromKeys } from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
dataSource: DataSource;
aggregateOperator: string;
initialOptions?: InitialOptions;
storageKey: LOCALSTORAGE;
}
interface UseOptionsMenu {
@@ -48,22 +46,21 @@ interface UseOptionsMenu {
}
const useOptionsMenu = ({
storageKey,
dataSource,
aggregateOperator,
initialOptions = {},
}: UseOptionsMenuProps): UseOptionsMenu => {
const { notifications } = useNotifications();
const {
preferences,
updateColumns,
updateFormatting,
} = usePreferenceContext();
const [searchText, setSearchText] = useState<string>('');
const [isFocused, setIsFocused] = useState<boolean>(false);
const debouncedSearchText = useDebounce(searchText, 300);
const localStorageOptionsQuery = useMemo(
() => getFromLocalstorage(storageKey),
[storageKey],
);
const initialQueryParams = useMemo(
() => ({
searchText: '',
@@ -77,7 +74,6 @@ const useOptionsMenu = ({
const {
query: optionsQuery,
queryData: optionsQueryData,
redirectWithQuery: redirectWithOptionsData,
} = useUrlQueryData<OptionsQuery>(URL_OPTIONS, defaultOptionsQuery);
@@ -105,7 +101,9 @@ const useOptionsMenu = ({
);
const initialSelectedColumns = useMemo(() => {
if (!isFetchedInitialAttributes) return [];
if (!isFetchedInitialAttributes) {
return [];
}
const attributesData = initialAttributesResult?.reduce(
(acc, attributeResponse) => {
@@ -142,14 +140,12 @@ const useOptionsMenu = ({
})
.filter(Boolean) as BaseAutocompleteData[];
// this is the last point where we can set the default columns and if uptil now also we have an empty array then we will set the default columns
if (!initialSelected || !initialSelected?.length) {
initialSelected = defaultTraceSelectedColumns;
}
}
return initialSelected || [];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isFetchedInitialAttributes,
initialOptions?.selectColumns,
@@ -171,7 +167,6 @@ const useOptionsMenu = ({
const searchedAttributeKeys = useMemo(() => {
if (searchedAttributesData?.payload?.attributeKeys?.length) {
if (dataSource === DataSource.LOGS) {
// add timestamp and body to the list of attributes
return [
...defaultLogsSelectedColumns,
...searchedAttributesData.payload.attributeKeys.filter(
@@ -188,32 +183,35 @@ const useOptionsMenu = ({
return [];
}, [dataSource, searchedAttributesData?.payload?.attributeKeys]);
const initialOptionsQuery: OptionsQuery = useMemo(
() => ({
const initialOptionsQuery: OptionsQuery = useMemo(() => {
let defaultColumns = defaultOptionsQuery.selectColumns;
if (dataSource === DataSource.TRACES) {
defaultColumns = defaultTraceSelectedColumns;
} else if (dataSource === DataSource.LOGS) {
defaultColumns = defaultLogsSelectedColumns;
}
const finalSelectColumns = initialOptions?.selectColumns
? initialSelectedColumns
: defaultColumns;
return {
...defaultOptionsQuery,
...initialOptions,
// eslint-disable-next-line no-nested-ternary
selectColumns: initialOptions?.selectColumns
? initialSelectedColumns
: dataSource === DataSource.TRACES
? defaultTraceSelectedColumns
: defaultOptionsQuery.selectColumns,
}),
[dataSource, initialOptions, initialSelectedColumns],
);
selectColumns: finalSelectColumns,
};
}, [dataSource, initialOptions, initialSelectedColumns]);
const selectedColumnKeys = useMemo(
() => optionsQueryData?.selectColumns?.map(({ id }) => id) || [],
[optionsQueryData],
() => preferences?.columns?.map(({ id }) => id) || [],
[preferences?.columns],
);
const optionsFromAttributeKeys = useMemo(() => {
const filteredAttributeKeys = searchedAttributeKeys.filter((item) => {
// For other data sources, only filter out 'body' if it exists
if (dataSource !== DataSource.LOGS) {
return item.key !== 'body';
}
// For LOGS, keep all keys
return true;
});
@@ -223,10 +221,8 @@ const useOptionsMenu = ({
const handleRedirectWithOptionsData = useCallback(
(newQueryData: OptionsQuery) => {
redirectWithOptionsData(newQueryData);
setToLocalstorage(storageKey, JSON.stringify(newQueryData));
},
[storageKey, redirectWithOptionsData],
[redirectWithOptionsData],
);
const handleSelectColumns = useCallback(
@@ -235,7 +231,7 @@ const useOptionsMenu = ({
const newSelectedColumns = newSelectedColumnKeys.reduce((acc, key) => {
const column = [
...searchedAttributeKeys,
...optionsQueryData.selectColumns,
...(preferences?.columns || []),
].find(({ id }) => id === key);
if (!column) return acc;
@@ -243,75 +239,116 @@ const useOptionsMenu = ({
}, [] as BaseAutocompleteData[]);
const optionsData: OptionsQuery = {
...optionsQueryData,
...defaultOptionsQuery,
selectColumns: newSelectedColumns,
format: preferences?.formatting?.format || defaultOptionsQuery.format,
maxLines: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
fontSize: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
};
updateColumns(newSelectedColumns);
handleRedirectWithOptionsData(optionsData);
},
[
searchedAttributeKeys,
selectedColumnKeys,
optionsQueryData,
preferences,
handleRedirectWithOptionsData,
updateColumns,
],
);
const handleRemoveSelectedColumn = useCallback(
(columnKey: string) => {
const newSelectedColumns = optionsQueryData?.selectColumns?.filter(
const newSelectedColumns = preferences?.columns?.filter(
({ id }) => id !== columnKey,
);
if (!newSelectedColumns.length && dataSource !== DataSource.LOGS) {
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
notifications.error({
message: 'There must be at least one selected column',
});
} else {
const optionsData: OptionsQuery = {
...optionsQueryData,
selectColumns: newSelectedColumns,
...defaultOptionsQuery,
selectColumns: newSelectedColumns || [],
format: preferences?.formatting?.format || defaultOptionsQuery.format,
maxLines:
preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
fontSize:
preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
};
updateColumns(newSelectedColumns || []);
handleRedirectWithOptionsData(optionsData);
}
},
[dataSource, notifications, optionsQueryData, handleRedirectWithOptionsData],
[
dataSource,
notifications,
preferences,
handleRedirectWithOptionsData,
updateColumns,
],
);
const handleFormatChange = useCallback(
(value: LogViewMode) => {
const optionsData: OptionsQuery = {
...optionsQueryData,
...defaultOptionsQuery,
selectColumns: preferences?.columns || [],
format: value,
maxLines: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
fontSize: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
};
updateFormatting({
maxLines: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
format: value,
fontSize: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
});
handleRedirectWithOptionsData(optionsData);
},
[handleRedirectWithOptionsData, optionsQueryData],
[handleRedirectWithOptionsData, preferences, updateFormatting],
);
const handleMaxLinesChange = useCallback(
(value: string | number | null) => {
const optionsData: OptionsQuery = {
...optionsQueryData,
...defaultOptionsQuery,
selectColumns: preferences?.columns || [],
format: preferences?.formatting?.format || defaultOptionsQuery.format,
maxLines: value as number,
fontSize: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
};
updateFormatting({
maxLines: value as number,
format: preferences?.formatting?.format || defaultOptionsQuery.format,
fontSize: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
});
handleRedirectWithOptionsData(optionsData);
},
[handleRedirectWithOptionsData, optionsQueryData],
[handleRedirectWithOptionsData, preferences, updateFormatting],
);
const handleFontSizeChange = useCallback(
(value: FontSize) => {
const optionsData: OptionsQuery = {
...optionsQueryData,
...defaultOptionsQuery,
selectColumns: preferences?.columns || [],
format: preferences?.formatting?.format || defaultOptionsQuery.format,
maxLines: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
fontSize: value,
};
updateFormatting({
maxLines: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
format: preferences?.formatting?.format || defaultOptionsQuery.format,
fontSize: value,
});
handleRedirectWithOptionsData(optionsData);
},
[handleRedirectWithOptionsData, optionsQueryData],
[handleRedirectWithOptionsData, preferences, updateFormatting],
);
const handleSearchAttribute = useCallback((value: string) => {
@@ -331,7 +368,7 @@ const useOptionsMenu = ({
() => ({
addColumn: {
isFetching: isSearchedAttributesFetching,
value: optionsQueryData?.selectColumns || defaultOptionsQuery.selectColumns,
value: preferences?.columns || defaultOptionsQuery.selectColumns,
options: optionsFromAttributeKeys || [],
onFocus: handleFocus,
onBlur: handleBlur,
@@ -340,24 +377,21 @@ const useOptionsMenu = ({
onSearch: handleSearchAttribute,
},
format: {
value: optionsQueryData.format || defaultOptionsQuery.format,
value: preferences?.formatting?.format || defaultOptionsQuery.format,
onChange: handleFormatChange,
},
maxLines: {
value: optionsQueryData.maxLines || defaultOptionsQuery.maxLines,
value: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
onChange: handleMaxLinesChange,
},
fontSize: {
value: optionsQueryData?.fontSize || defaultOptionsQuery.fontSize,
value: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
onChange: handleFontSizeChange,
},
}),
[
isSearchedAttributesFetching,
optionsQueryData?.selectColumns,
optionsQueryData.format,
optionsQueryData.maxLines,
optionsQueryData?.fontSize,
preferences,
optionsFromAttributeKeys,
handleSelectColumns,
handleRemoveSelectedColumn,
@@ -369,23 +403,25 @@ const useOptionsMenu = ({
);
useEffect(() => {
if (optionsQuery || !isFetchedInitialAttributes) return;
if (optionsQuery || !isFetchedInitialAttributes) {
return;
}
const nextOptionsQuery = localStorageOptionsQuery
? JSON.parse(localStorageOptionsQuery)
: initialOptionsQuery;
redirectWithOptionsData(nextOptionsQuery);
redirectWithOptionsData(initialOptionsQuery);
}, [
isFetchedInitialAttributes,
optionsQuery,
initialOptionsQuery,
localStorageOptionsQuery,
redirectWithOptionsData,
]);
return {
options: optionsQueryData,
options: {
selectColumns: preferences?.columns || [],
format: preferences?.formatting?.format || defaultOptionsQuery.format,
maxLines: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
fontSize: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
},
config: optionsMenuConfig,
handleOptionsChange: handleRedirectWithOptionsData,
};

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);
}
}

View File

@@ -14,19 +14,19 @@ function OrganizationSettings(): JSX.Element {
}
return (
<>
<div className="organization-settings-container">
<Space direction="vertical">
{org.map((e, index) => (
<DisplayName key={e.id} id={e.id} index={index} />
))}
</Space>
<Divider />
<PendingInvitesContainer />
<Divider />
<Members />
<Divider />
<AuthDomains />
</>
</div>
);
}

View File

@@ -18,6 +18,27 @@
background-color: yellow;
border-radius: 6px;
cursor: pointer;
.event-dot {
position: absolute;
top: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 6px;
height: 6px;
background-color: var(--bg-robin-500);
border: 1px solid var(--bg-robin-600);
cursor: pointer;
z-index: 1;
&.error {
background-color: var(--bg-cherry-500);
border-color: var(--bg-cherry-600);
}
&:hover {
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
}
}
}
}

View File

@@ -6,6 +6,7 @@ import { Tooltip } from 'antd';
import Color from 'color';
import TimelineV2 from 'components/TimelineV2/TimelineV2';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import {
@@ -20,6 +21,7 @@ import { useHistory, useLocation } from 'react-router-dom';
import { ListRange, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
interface ITraceMetadata {
startTime: number;
@@ -91,7 +93,31 @@ function Success(props: ISuccessProps): JSX.Element {
searchParams.set('spanId', span.spanId);
history.replace({ search: searchParams.toString() });
}}
/>
>
{span.event?.map((event) => {
const eventTimeMs = event.timeUnixNano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100;
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
const { isError } = event;
const { time, timeUnitName } = convertTimeToRelevantUnit(
eventTimeMs - span.timestamp,
);
return (
<Tooltip
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
title={`${event.name} @ ${toFixed(time, 2)} ${timeUnitName}`}
>
<div
className={`event-dot ${isError ? 'error' : ''}`}
style={{
left: `${clampedOffset}%`,
}}
/>
</Tooltip>
);
})}
</div>
</Tooltip>
);
})}

View File

@@ -9,7 +9,7 @@ export const pipelineData: Pipeline = {
active: false,
is_valid: false,
disabled: false,
deployStatus: 'DEPLOYED',
deployStatus: 'deployed',
deployResult: 'Deployment was successful',
lastHash: 'log_pipelines:24',
lastConf: 'oiwernveroi',
@@ -135,7 +135,7 @@ export const pipelineData: Pipeline = {
active: false,
isValid: false,
disabled: false,
deployStatus: 'DEPLOYED',
deployStatus: 'deployed',
deployResult: 'Deployment was successful',
lastHash: 'log_pipelines:24',
lastConf: 'eovineroiv',
@@ -150,7 +150,7 @@ export const pipelineData: Pipeline = {
active: false,
isValid: false,
disabled: false,
deployStatus: 'DEPLOYED',
deployStatus: 'deployed',
deployResult: 'Deployment was successful',
lastHash: 'log_pipelines:23',
lastConf: 'eivrounreovi',
@@ -169,7 +169,7 @@ export const pipelineDataHistory: Pipeline['history'] = [
active: false,
isValid: false,
disabled: false,
deployStatus: 'DEPLOYED',
deployStatus: 'deployed',
deployResult: 'Deployment was successful',
lastHash: 'log_pipelines:24',
lastConf: 'eovineroiv',
@@ -184,7 +184,7 @@ export const pipelineDataHistory: Pipeline['history'] = [
active: false,
isValid: false,
disabled: false,
deployStatus: 'IN_PROGRESS',
deployStatus: 'in_progress',
deployResult: 'Deployment is in progress',
lastHash: 'log_pipelines:23',
lastConf: 'eivrounreovi',
@@ -199,7 +199,7 @@ export const pipelineDataHistory: Pipeline['history'] = [
active: false,
isValid: false,
disabled: false,
deployStatus: 'DIRTY',
deployStatus: 'dirty',
deployResult: 'Deployment is dirty',
lastHash: 'log_pipelines:23',
lastConf: 'eivrounreovi',
@@ -214,7 +214,7 @@ export const pipelineDataHistory: Pipeline['history'] = [
active: false,
isValid: false,
disabled: false,
deployStatus: 'FAILED',
deployStatus: 'failed',
deployResult: 'Deployment failed',
lastHash: 'log_pipelines:23',
lastConf: 'eivrounreovi',
@@ -229,7 +229,7 @@ export const pipelineDataHistory: Pipeline['history'] = [
active: false,
isValid: false,
disabled: false,
deployStatus: 'UNKNOWN',
deployStatus: 'unknown',
deployResult: '',
lastHash: 'log_pipelines:23',
lastConf: 'eivrounreovi',

View File

@@ -9,15 +9,15 @@ import { Spin } from 'antd';
export function getDeploymentStage(value: string): string {
switch (value) {
case 'IN_PROGRESS':
case 'in_progress':
return 'In Progress';
case 'DEPLOYED':
case 'deployed':
return 'Deployed';
case 'DIRTY':
case 'dirty':
return 'Dirty';
case 'FAILED':
case 'failed':
return 'Failed';
case 'UNKNOWN':
case 'unknown':
return 'Unknown';
default:
return '';
@@ -26,17 +26,17 @@ export function getDeploymentStage(value: string): string {
export function getDeploymentStageIcon(value: string): JSX.Element {
switch (value) {
case 'IN_PROGRESS':
case 'in_progress':
return (
<Spin indicator={<LoadingOutlined style={{ fontSize: 15 }} spin />} />
);
case 'DEPLOYED':
case 'deployed':
return <CheckCircleFilled />;
case 'DIRTY':
case 'dirty':
return <ExclamationCircleFilled />;
case 'FAILED':
case 'failed':
return <CloseCircleFilled />;
case 'UNKNOWN':
case 'unknown':
return <MinusCircleFilled />;
default:
return <span />;

View File

@@ -1,5 +1,6 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { screen } from '@testing-library/react';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { findByText, fireEvent, render, waitFor } from 'tests/test-utils';
import { pipelineApiResponseMockData } from '../mocks/pipeline';
@@ -19,6 +20,18 @@ jest.mock('uplot', () => {
};
});
// Mock useUrlQuery hook
const mockUrlQuery = {
get: jest.fn(),
set: jest.fn(),
toString: jest.fn(() => ''),
};
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(() => mockUrlQuery),
}));
const samplePipelinePreviewResponse = {
isLoading: false,
logs: [
@@ -57,17 +70,38 @@ jest.mock(
}),
);
// Mock usePreferenceSync
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
usePreferenceSync: (): any => ({
preferences: {
columns: [],
formatting: {
maxLines: 2,
format: 'table',
fontSize: 'small',
version: 1,
},
},
loading: false,
error: null,
updateColumns: jest.fn(),
updateFormatting: jest.fn(),
}),
}));
describe('PipelinePage container test', () => {
it('should render PipelineListsView section', () => {
const { getByText, container } = render(
<PipelineListsView
setActionType={jest.fn()}
isActionMode="viewing-mode"
setActionMode={jest.fn()}
pipelineData={pipelineApiResponseMockData}
isActionType=""
refetchPipelineLists={jest.fn()}
/>,
<PreferenceContextProvider>
<PipelineListsView
setActionType={jest.fn()}
isActionMode="viewing-mode"
setActionMode={jest.fn()}
pipelineData={pipelineApiResponseMockData}
isActionType=""
refetchPipelineLists={jest.fn()}
/>
</PreferenceContextProvider>,
);
// table headers assertions
@@ -91,14 +125,16 @@ describe('PipelinePage container test', () => {
it('should render expanded content and edit mode correctly', async () => {
const { getByText } = render(
<PipelineListsView
setActionType={jest.fn()}
isActionMode="editing-mode"
setActionMode={jest.fn()}
pipelineData={pipelineApiResponseMockData}
isActionType=""
refetchPipelineLists={jest.fn()}
/>,
<PreferenceContextProvider>
<PipelineListsView
setActionType={jest.fn()}
isActionMode="editing-mode"
setActionMode={jest.fn()}
pipelineData={pipelineApiResponseMockData}
isActionType=""
refetchPipelineLists={jest.fn()}
/>
</PreferenceContextProvider>,
);
// content assertion
@@ -122,14 +158,16 @@ describe('PipelinePage container test', () => {
it('should be able to perform actions and edit on expanded view content', async () => {
render(
<PipelineListsView
setActionType={jest.fn()}
isActionMode="editing-mode"
setActionMode={jest.fn()}
pipelineData={pipelineApiResponseMockData}
isActionType=""
refetchPipelineLists={jest.fn()}
/>,
<PreferenceContextProvider>
<PipelineListsView
setActionType={jest.fn()}
isActionMode="editing-mode"
setActionMode={jest.fn()}
pipelineData={pipelineApiResponseMockData}
isActionType=""
refetchPipelineLists={jest.fn()}
/>
</PreferenceContextProvider>,
);
// content assertion
@@ -180,14 +218,16 @@ describe('PipelinePage container test', () => {
it('should be able to toggle and delete pipeline', async () => {
const { getByText } = render(
<PipelineListsView
setActionType={jest.fn()}
isActionMode="editing-mode"
setActionMode={jest.fn()}
pipelineData={pipelineApiResponseMockData}
isActionType=""
refetchPipelineLists={jest.fn()}
/>,
<PreferenceContextProvider>
<PipelineListsView
setActionType={jest.fn()}
isActionMode="editing-mode"
setActionMode={jest.fn()}
pipelineData={pipelineApiResponseMockData}
isActionType=""
refetchPipelineLists={jest.fn()}
/>
</PreferenceContextProvider>,
);
const addNewPipelineBtn = getByText('add_new_pipeline');
@@ -247,14 +287,16 @@ describe('PipelinePage container test', () => {
it('should have populated form fields when edit pipeline is clicked', async () => {
render(
<PipelineListsView
setActionType={jest.fn()}
isActionMode="editing-mode"
setActionMode={jest.fn()}
pipelineData={pipelineApiResponseMockData}
isActionType="edit-pipeline"
refetchPipelineLists={jest.fn()}
/>,
<PreferenceContextProvider>
<PipelineListsView
setActionType={jest.fn()}
isActionMode="editing-mode"
setActionMode={jest.fn()}
pipelineData={pipelineApiResponseMockData}
isActionType="edit-pipeline"
refetchPipelineLists={jest.fn()}
/>
</PreferenceContextProvider>,
);
// content assertion

View File

@@ -324,7 +324,7 @@ export const Query = memo(function Query({
]);
const disableOperatorSelector =
!query?.aggregateAttribute.key || query?.aggregateAttribute.key === '';
!query?.aggregateAttribute?.key || query?.aggregateAttribute?.key === '';
const isVersionV4 = version && version === ENTITY_VERSION_V4;

View File

@@ -1037,7 +1037,9 @@ function QueryBuilderSearchV2(
);
})}
</Select>
{!hideSpanScopeSelector && <SpanScopeSelector queryName={query.queryName} />}
{!hideSpanScopeSelector && (
<SpanScopeSelector query={query} onChange={onChange} />
)}
</div>
);
}

View File

@@ -2,7 +2,11 @@ import { Select } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { cloneDeep } from 'lodash-es';
import { useEffect, useState } from 'react';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import {
IBuilderQuery,
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
enum SpanScope {
@@ -17,7 +21,8 @@ interface SpanFilterConfig {
}
interface SpanScopeSelectorProps {
queryName: string;
onChange?: (value: TagFilter) => void;
query?: IBuilderQuery;
}
const SPAN_FILTER_CONFIG: Record<SpanScope, SpanFilterConfig | null> = {
@@ -50,7 +55,10 @@ const SELECT_OPTIONS = [
{ value: SpanScope.ENTRYPOINT_SPANS, label: 'Entrypoint Spans' },
];
function SpanScopeSelector({ queryName }: SpanScopeSelectorProps): JSX.Element {
function SpanScopeSelector({
onChange,
query,
}: SpanScopeSelectorProps): JSX.Element {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const [selectedScope, setSelectedScope] = useState<SpanScope>(
SpanScope.ALL_SPANS,
@@ -60,7 +68,7 @@ function SpanScopeSelector({ queryName }: SpanScopeSelectorProps): JSX.Element {
filters: TagFilterItem[] = [],
): SpanScope => {
const hasFilter = (key: string): boolean =>
filters.some(
filters?.some(
(filter) =>
filter.key?.type === 'spanSearchScope' &&
filter.key.key === key &&
@@ -71,15 +79,19 @@ function SpanScopeSelector({ queryName }: SpanScopeSelectorProps): JSX.Element {
if (hasFilter('isEntryPoint')) return SpanScope.ENTRYPOINT_SPANS;
return SpanScope.ALL_SPANS;
};
useEffect(() => {
const queryData = (currentQuery?.builder?.queryData || [])?.find(
(item) => item.queryName === queryName,
let queryData = (currentQuery?.builder?.queryData || [])?.find(
(item) => item.queryName === query?.queryName,
);
if (onChange && query) {
queryData = query;
}
const filters = queryData?.filters?.items;
const currentScope = getCurrentScopeFromFilters(filters);
setSelectedScope(currentScope);
}, [currentQuery, queryName]);
}, [currentQuery, onChange, query]);
const handleScopeChange = (newScope: SpanScope): void => {
const newQuery = cloneDeep(currentQuery);
@@ -108,14 +120,28 @@ function SpanScopeSelector({ queryName }: SpanScopeSelectorProps): JSX.Element {
...item,
filters: {
...item.filters,
items: getUpdatedFilters(item.filters?.items, item.queryName === queryName),
items: getUpdatedFilters(
item.filters?.items,
item.queryName === query?.queryName,
),
},
}));
redirectWithQueryBuilderData(newQuery);
if (onChange && query) {
onChange({
...query.filters,
items: getUpdatedFilters(
[...query.filters.items, ...newQuery.builder.queryData[0].filters.items],
true,
),
});
setSelectedScope(newScope);
} else {
redirectWithQueryBuilderData(newQuery);
}
};
//
return (
<Select
value={selectedScope}
@@ -127,4 +153,9 @@ function SpanScopeSelector({ queryName }: SpanScopeSelectorProps): JSX.Element {
);
}
SpanScopeSelector.defaultProps = {
onChange: undefined,
query: undefined,
};
export default SpanScopeSelector;

View File

@@ -6,7 +6,12 @@ import {
} from '@testing-library/react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import {
IBuilderQuery,
Query,
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import SpanScopeSelector from '../SpanScopeSelector';
@@ -23,6 +28,13 @@ const createSpanScopeFilter = (key: string): TagFilterItem => ({
value: 'true',
});
const createNonScopeFilter = (key: string, value: string): TagFilterItem => ({
id: `non-scope-${key}`,
key: { key, isColumn: false, type: 'tag' },
op: '=',
value,
});
const defaultQuery = {
...initialQueriesMap.traces,
builder: {
@@ -36,6 +48,12 @@ const defaultQuery = {
},
};
const defaultQueryBuilderQuery: IBuilderQuery = {
...initialQueriesMap.traces.builder.queryData[0],
queryName: 'A',
filters: { items: [], op: 'AND' },
};
// Helper to create query with filters
const createQueryWithFilters = (filters: TagFilterItem[]): Query => ({
...defaultQuery,
@@ -44,6 +62,7 @@ const createQueryWithFilters = (filters: TagFilterItem[]): Query => ({
queryData: [
{
...defaultQuery.builder.queryData[0],
queryName: 'A',
filters: {
items: filters,
op: 'AND',
@@ -54,8 +73,9 @@ const createQueryWithFilters = (filters: TagFilterItem[]): Query => ({
});
const renderWithContext = (
queryName = 'A',
initialQuery = defaultQuery,
onChangeProp?: (value: TagFilter) => void,
queryProp?: IBuilderQuery,
): RenderResult =>
render(
<QueryBuilderContext.Provider
@@ -67,10 +87,24 @@ const renderWithContext = (
} as any
}
>
<SpanScopeSelector queryName={queryName} />
<SpanScopeSelector onChange={onChangeProp} query={queryProp} />
</QueryBuilderContext.Provider>,
);
const selectOption = async (optionText: string): Promise<void> => {
const selector = screen.getByRole('combobox');
fireEvent.mouseDown(selector);
// Wait for dropdown to appear
await screen.findByRole('listbox');
// Find the option by its content text and click it
const option = await screen.findByText(optionText, {
selector: '.ant-select-item-option-content',
});
fireEvent.click(option);
};
describe('SpanScopeSelector', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -82,13 +116,6 @@ describe('SpanScopeSelector', () => {
});
describe('when selecting different options', () => {
const selectOption = (optionText: string): void => {
const selector = screen.getByRole('combobox');
fireEvent.mouseDown(selector);
const option = screen.getByText(optionText);
fireEvent.click(option);
};
const assertFilterAdded = (
updatedQuery: Query,
expectedKey: string,
@@ -106,13 +133,13 @@ describe('SpanScopeSelector', () => {
);
};
it('should remove span scope filters when selecting ALL_SPANS', () => {
it('should remove span scope filters when selecting ALL_SPANS', async () => {
const queryWithSpanScope = createQueryWithFilters([
createSpanScopeFilter('isRoot'),
]);
renderWithContext('A', queryWithSpanScope);
renderWithContext(queryWithSpanScope, undefined, defaultQueryBuilderQuery);
selectOption('All Spans');
await selectOption('All Spans');
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalled();
const updatedQuery = mockRedirectWithQueryBuilderData.mock.calls[0][0];
@@ -125,7 +152,8 @@ describe('SpanScopeSelector', () => {
});
it('should add isRoot filter when selecting ROOT_SPANS', async () => {
renderWithContext();
renderWithContext(defaultQuery, undefined, defaultQueryBuilderQuery);
// eslint-disable-next-line sonarjs/no-duplicate-string
await selectOption('Root Spans');
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalled();
@@ -135,9 +163,10 @@ describe('SpanScopeSelector', () => {
);
});
it('should add isEntryPoint filter when selecting ENTRYPOINT_SPANS', () => {
renderWithContext();
selectOption('Entrypoint Spans');
it('should add isEntryPoint filter when selecting ENTRYPOINT_SPANS', async () => {
renderWithContext(defaultQuery, undefined, defaultQueryBuilderQuery);
// eslint-disable-next-line sonarjs/no-duplicate-string
await selectOption('Entrypoint Spans');
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalled();
assertFilterAdded(
@@ -157,9 +186,180 @@ describe('SpanScopeSelector', () => {
const queryWithFilter = createQueryWithFilters([
createSpanScopeFilter(filterKey),
]);
renderWithContext('A', queryWithFilter);
renderWithContext(queryWithFilter, undefined, defaultQueryBuilderQuery);
expect(await screen.findByText(expectedText)).toBeInTheDocument();
},
);
});
describe('when onChange and query props are provided', () => {
const mockOnChange = jest.fn();
const createLocalQuery = (
filterItems: TagFilterItem[] = [],
op: 'AND' | 'OR' = 'AND',
): IBuilderQuery => ({
...defaultQueryBuilderQuery,
filters: { items: filterItems, op },
});
const assertOnChangePayload = (
callNumber: number, // To handle multiple calls if needed, usually 0 for single interaction
expectedScopeKey: string | null,
expectedNonScopeItems: TagFilterItem[] = [],
): void => {
expect(mockOnChange).toHaveBeenCalled();
const onChangeArg = mockOnChange.mock.calls[callNumber][0] as TagFilter;
const { items } = onChangeArg;
// Check for preservation of specific non-scope items
expectedNonScopeItems.forEach((nonScopeItem) => {
expect(items).toContainEqual(nonScopeItem);
});
const scopeFiltersInPayload = items.filter(
(filter) => filter.key?.type === 'spanSearchScope',
);
if (expectedScopeKey) {
expect(scopeFiltersInPayload.length).toBe(1);
expect(scopeFiltersInPayload[0].key?.key).toBe(expectedScopeKey);
expect(scopeFiltersInPayload[0].value).toBe('true');
expect(scopeFiltersInPayload[0].op).toBe('=');
} else {
expect(scopeFiltersInPayload.length).toBe(0);
}
const expectedTotalFilters =
expectedNonScopeItems.length + (expectedScopeKey ? 1 : 0);
expect(items.length).toBe(expectedTotalFilters);
};
beforeEach(() => {
mockOnChange.mockClear();
mockRedirectWithQueryBuilderData.mockClear();
});
it('should initialize with ALL_SPANS if query prop has no scope filters', async () => {
const localQuery = createLocalQuery();
renderWithContext(defaultQuery, mockOnChange, localQuery);
expect(await screen.findByText('All Spans')).toBeInTheDocument();
});
it('should initialize with ROOT_SPANS if query prop has isRoot filter', async () => {
const localQuery = createLocalQuery([createSpanScopeFilter('isRoot')]);
renderWithContext(defaultQuery, mockOnChange, localQuery);
expect(await screen.findByText('Root Spans')).toBeInTheDocument();
});
it('should initialize with ENTRYPOINT_SPANS if query prop has isEntryPoint filter', async () => {
const localQuery = createLocalQuery([createSpanScopeFilter('isEntryPoint')]);
renderWithContext(defaultQuery, mockOnChange, localQuery);
expect(await screen.findByText('Entrypoint Spans')).toBeInTheDocument();
});
it('should call onChange and not redirect when selecting ROOT_SPANS (from ALL_SPANS)', async () => {
const localQuery = createLocalQuery(); // Initially All Spans
const { container } = renderWithContext(
defaultQuery,
mockOnChange,
localQuery,
);
expect(await screen.findByText('All Spans')).toBeInTheDocument();
await selectOption('Root Spans');
expect(mockRedirectWithQueryBuilderData).not.toHaveBeenCalled();
assertOnChangePayload(0, 'isRoot', []);
expect(
container.querySelector('span[title="Root Spans"]'),
).toBeInTheDocument();
});
it('should call onChange with removed scope when selecting ALL_SPANS (from ROOT_SPANS)', async () => {
const initialRootFilter = createSpanScopeFilter('isRoot');
const localQuery = createLocalQuery([initialRootFilter]);
const { container } = renderWithContext(
defaultQuery,
mockOnChange,
localQuery,
);
expect(await screen.findByText('Root Spans')).toBeInTheDocument();
await selectOption('All Spans');
expect(mockRedirectWithQueryBuilderData).not.toHaveBeenCalled();
assertOnChangePayload(0, null, []);
expect(
container.querySelector('span[title="All Spans"]'),
).toBeInTheDocument();
});
it('should call onChange, replacing isRoot with isEntryPoint', async () => {
const initialRootFilter = createSpanScopeFilter('isRoot');
const localQuery = createLocalQuery([initialRootFilter]);
const { container } = renderWithContext(
defaultQuery,
mockOnChange,
localQuery,
);
expect(await screen.findByText('Root Spans')).toBeInTheDocument();
await selectOption('Entrypoint Spans');
expect(mockRedirectWithQueryBuilderData).not.toHaveBeenCalled();
assertOnChangePayload(0, 'isEntryPoint', []);
expect(
container.querySelector('span[title="Entrypoint Spans"]'),
).toBeInTheDocument();
});
it('should preserve non-scope filters from query prop when changing scope', async () => {
const nonScopeItem = createNonScopeFilter('customTag', 'customValue');
const initialRootFilter = createSpanScopeFilter('isRoot');
const localQuery = createLocalQuery([nonScopeItem, initialRootFilter], 'OR');
const { container } = renderWithContext(
defaultQuery,
mockOnChange,
localQuery,
);
expect(await screen.findByText('Root Spans')).toBeInTheDocument();
await selectOption('Entrypoint Spans');
expect(mockRedirectWithQueryBuilderData).not.toHaveBeenCalled();
assertOnChangePayload(0, 'isEntryPoint', [nonScopeItem]);
expect(
container.querySelector('span[title="Entrypoint Spans"]'),
).toBeInTheDocument();
});
it('should preserve non-scope filters when changing to ALL_SPANS', async () => {
const nonScopeItem1 = createNonScopeFilter('service', 'checkout');
const nonScopeItem2 = createNonScopeFilter('version', 'v1');
const initialEntryFilter = createSpanScopeFilter('isEntryPoint');
const localQuery = createLocalQuery([
nonScopeItem1,
initialEntryFilter,
nonScopeItem2,
]);
const { container } = renderWithContext(
defaultQuery,
mockOnChange,
localQuery,
);
expect(await screen.findByText('Entrypoint Spans')).toBeInTheDocument();
await selectOption('All Spans');
expect(mockRedirectWithQueryBuilderData).not.toHaveBeenCalled();
assertOnChangePayload(0, null, [nonScopeItem1, nonScopeItem2]);
expect(
container.querySelector('span[title="All Spans"]'),
).toBeInTheDocument();
});
});
});

View File

@@ -96,7 +96,7 @@ function ServiceMetricTable({
`${range[0]}-${range[1]} of ${total} items`,
};
return (
<>
<div className="service-metric-table-container">
{RPS > MAX_RPS_LIMIT && (
<Flex justify="left">
<Typography.Title level={5} type="warning" style={{ marginTop: 0 }}>
@@ -116,7 +116,7 @@ function ServiceMetricTable({
rowKey="serviceName"
className="service-metrics-table"
/>
</>
</div>
);
}

View File

@@ -53,7 +53,7 @@ function ServiceTraceTable({
`${range[0]}-${range[1]} of ${total} items`,
};
return (
<>
<div className="service-traces-table-container">
{RPS > MAX_RPS_LIMIT && (
<Flex justify="left">
<Typography.Title level={5} type="warning" style={{ marginTop: 0 }}>
@@ -73,7 +73,7 @@ function ServiceTraceTable({
rowKey="serviceName"
className="service-traces-table"
/>
</>
</div>
);
}

View File

@@ -5,13 +5,13 @@
flex-direction: row;
align-items: center;
height: 36px;
height: 32px;
margin-bottom: 4px;
cursor: pointer;
&.active {
.nav-item-active-marker {
background: #3f5ecc;
background: #4e74f8;
}
}
@@ -27,24 +27,24 @@
.nav-item-data {
color: white;
background: #121317;
background: var(--Slate-500, #161922);
}
}
&.active {
.nav-item-data {
color: white;
background: #121317;
background: var(--Slate-500, #161922);
// color: #3f5ecc;
}
}
.nav-item-active-marker {
margin: 8px 0;
margin: 4px 0;
width: 8px;
height: 24px;
background: transparent;
border-radius: 3px;
border-radius: 2px;
margin-left: -5px;
}
@@ -53,24 +53,25 @@
max-width: calc(100% - 24px);
display: flex;
margin: 0px 8px;
padding: 4px 12px;
padding: 2px 8px;
flex-direction: row;
align-items: center;
gap: 8px;
align-self: stretch;
color: #c0c1c3;
border-radius: 3px;
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
font-weight: 300;
line-height: 18px;
background: transparent;
transition: 0.2s all linear;
border-radius: 3px;
.nav-item-icon {
height: 16px;
}
@@ -80,6 +81,31 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #c0c1c3;
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 300;
line-height: normal;
letter-spacing: 0.14px;
}
.nav-item-pin-icon {
margin-left: auto;
cursor: pointer;
display: none;
}
&:hover {
.nav-item-label {
color: var(--Vanilla-100, #fff);
}
.nav-item-pin-icon {
display: block;
}
}
}
@@ -92,7 +118,7 @@
.nav-item {
&.active {
.nav-item-active-marker {
background: #3f5ecc;
background: #4e74f8;
}
}
@@ -115,6 +141,10 @@
.nav-item-data {
color: #121317;
.nav-item-label {
color: var(--bg-ink-400);
}
}
}
}

View File

@@ -4,6 +4,7 @@ import './NavItem.styles.scss';
import { Tag } from 'antd';
import cx from 'classnames';
import { Pin, PinOff } from 'lucide-react';
import { SidebarItem } from '../sideNav.types';
@@ -12,14 +13,27 @@ export default function NavItem({
isActive,
onClick,
isDisabled,
onTogglePin,
isPinned,
showIcon,
}: {
item: SidebarItem;
isActive: boolean;
onClick: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
isDisabled: boolean;
onTogglePin?: (item: SidebarItem) => void;
isPinned?: boolean;
showIcon?: boolean;
}): JSX.Element {
const { label, icon, isBeta, isNew } = item;
const handleTogglePinClick = (
event: React.MouseEvent<SVGSVGElement, MouseEvent>,
): void => {
event.stopPropagation();
onTogglePin?.(item);
};
return (
<div
className={cx(
@@ -34,15 +48,15 @@ export default function NavItem({
onClick(event);
}}
>
<div className="nav-item-active-marker" />
{showIcon && <div className="nav-item-active-marker" />}
<div className={cx('nav-item-data', isBeta ? 'beta-tag' : '')}>
<div className="nav-item-icon">{icon}</div>
{showIcon && <div className="nav-item-icon">{icon}</div>}
<div className="nav-item-label">{label}</div>
{isBeta && (
<div className="nav-item-beta">
<Tag bordered={false} color="geekblue">
<Tag bordered={false} className="sidenav-beta-tag">
Beta
</Tag>
</div>
@@ -55,7 +69,31 @@ export default function NavItem({
</Tag>
</div>
)}
{onTogglePin && !isPinned && (
<Pin
size={12}
className="nav-item-pin-icon"
onClick={handleTogglePinClick}
color="var(--Vanilla-400, #c0c1c3)"
/>
)}
{onTogglePin && isPinned && (
<PinOff
size={12}
className="nav-item-pin-icon"
onClick={handleTogglePinClick}
color="var(--Vanilla-400, #c0c1c3)"
/>
)}
</div>
</div>
);
}
NavItem.defaultProps = {
onTogglePin: undefined,
isPinned: false,
showIcon: false,
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,24 +4,30 @@ import {
BarChart2,
BellDot,
Binoculars,
Book,
Boxes,
BugIcon,
Cloudy,
DraftingCompass,
FileKey2,
Github,
Globe,
HardDrive,
Home,
Key,
Keyboard,
Layers2,
LayoutGrid,
ListMinus,
MessageSquare,
MessageSquareText,
Plus,
Receipt,
Route,
ScrollText,
Settings,
Slack,
Unplug,
// Unplug,
User,
UserPlus,
} from 'lucide-react';
@@ -60,11 +66,12 @@ export const manageLicenseMenuItem = {
export const helpSupportMenuItem = {
key: ROUTES.SUPPORT,
label: 'Help & Support',
icon: <MessageSquare size={16} />,
icon: <MessageSquareText size={16} />,
};
export const shortcutMenuItem = {
key: ROUTES.SHORTCUTS,
// eslint-disable-next-line sonarjs/no-duplicate-string
label: 'Keyboard Shortcuts',
icon: <Layers2 size={16} />,
};
@@ -86,79 +93,307 @@ const menuItems: SidebarItem[] = [
key: ROUTES.HOME,
label: 'Home',
icon: <Home size={16} />,
itemKey: 'home',
},
{
key: ROUTES.APPLICATION,
label: 'Services',
icon: <HardDrive size={16} />,
itemKey: 'services',
},
{
key: ROUTES.TRACES_EXPLORER,
label: 'Traces',
icon: <DraftingCompass size={16} />,
},
{
key: ROUTES.LOGS,
label: 'Logs',
icon: <ScrollText size={16} />,
itemKey: 'logs',
},
{
key: ROUTES.METRICS_EXPLORER,
label: 'Metrics',
icon: <BarChart2 size={16} />,
isNew: true,
itemKey: 'metrics',
},
{
key: ROUTES.INFRASTRUCTURE_MONITORING_HOSTS,
label: 'Infra Monitoring',
icon: <Boxes size={16} />,
itemKey: 'infrastructure',
},
{
key: ROUTES.ALL_DASHBOARD,
label: 'Dashboards',
icon: <LayoutGrid size={16} />,
itemKey: 'dashboards',
},
{
key: ROUTES.MESSAGING_QUEUES_OVERVIEW,
label: 'Messaging Queues',
icon: <ListMinus size={16} />,
itemKey: 'messaging-queues',
},
{
key: ROUTES.API_MONITORING,
label: 'External APIs',
icon: <Binoculars size={16} />,
isNew: true,
itemKey: 'external-apis',
},
{
key: ROUTES.LIST_ALL_ALERT,
label: 'Alerts',
icon: <BellDot size={16} />,
itemKey: 'alerts',
},
{
key: ROUTES.INTEGRATIONS,
label: 'Integrations',
icon: <Unplug size={16} />,
itemKey: 'integrations',
},
{
key: ROUTES.ALL_ERROR,
label: 'Exceptions',
icon: <BugIcon size={16} />,
itemKey: 'exceptions',
},
{
key: ROUTES.SERVICE_MAP,
label: 'Service Map',
icon: <Route size={16} />,
isBeta: true,
itemKey: 'service-map',
},
{
key: ROUTES.BILLING,
label: 'Billing',
icon: <Receipt size={16} />,
itemKey: 'billing',
},
{
key: ROUTES.SETTINGS,
label: 'Settings',
icon: <Settings size={16} />,
itemKey: 'settings',
},
];
export const primaryMenuItems: SidebarItem[] = [
{
key: ROUTES.HOME,
label: 'Home',
icon: <Home size={16} />,
itemKey: 'home',
},
{
key: ROUTES.LIST_ALL_ALERT,
label: 'Alerts',
icon: <BellDot size={16} />,
itemKey: 'alerts',
},
{
key: ROUTES.ALL_DASHBOARD,
label: 'Dashboards',
icon: <LayoutGrid size={16} />,
itemKey: 'dashboards',
},
];
export const defaultMoreMenuItems: SidebarItem[] = [
{
key: ROUTES.APPLICATION,
label: 'Services',
icon: <HardDrive size={16} />,
isPinned: true,
isEnabled: true,
itemKey: 'services',
},
{
key: ROUTES.LOGS,
label: 'Logs',
icon: <ScrollText size={16} />,
isPinned: true,
isEnabled: true,
itemKey: 'logs',
},
{
key: ROUTES.TRACES_EXPLORER,
label: 'Traces',
icon: <DraftingCompass size={16} />,
isPinned: true,
isEnabled: true,
itemKey: 'traces',
},
{
key: ROUTES.METRICS_EXPLORER,
label: 'Metrics',
icon: <BarChart2 size={16} />,
isNew: true,
isEnabled: true,
itemKey: 'metrics',
},
{
key: ROUTES.INFRASTRUCTURE_MONITORING_HOSTS,
label: 'Infrastructure',
icon: <Boxes size={16} />,
isPinned: true,
isEnabled: true,
itemKey: 'infrastructure',
},
{
key: ROUTES.INTEGRATIONS,
label: 'Integrations',
icon: <Unplug size={16} />,
isEnabled: true,
itemKey: 'integrations',
},
{
key: ROUTES.ALL_ERROR,
label: 'Exceptions',
icon: <BugIcon size={16} />,
isEnabled: true,
itemKey: 'exceptions',
},
{
key: ROUTES.API_MONITORING,
label: 'External APIs',
icon: <Binoculars size={16} />,
isNew: true,
isEnabled: true,
itemKey: 'external-apis',
},
{
key: ROUTES.MESSAGING_QUEUES_OVERVIEW,
label: 'Messaging Queues',
icon: <ListMinus size={16} />,
isEnabled: true,
itemKey: 'messaging-queues',
},
{
key: ROUTES.SERVICE_MAP,
label: 'Service Map',
icon: <Route size={16} />,
isEnabled: true,
itemKey: 'service-map',
},
];
export const settingsMenuItems: SidebarItem[] = [
{
key: ROUTES.SETTINGS,
label: 'General',
icon: <Settings size={16} />,
isEnabled: true,
itemKey: 'general',
},
{
key: ROUTES.BILLING,
label: 'Billing',
icon: <Receipt size={16} />,
isEnabled: false,
itemKey: 'billing',
},
{
key: ROUTES.ORG_SETTINGS,
label: 'Members & SSO',
icon: <User size={16} />,
isEnabled: false,
itemKey: 'members-sso',
},
{
key: ROUTES.CUSTOM_DOMAIN_SETTINGS,
label: 'Custom Domain',
icon: <Globe size={16} />,
isEnabled: false,
itemKey: 'custom-domain',
},
{
key: ROUTES.INTEGRATIONS,
label: 'Integrations',
icon: <Unplug size={16} />,
isEnabled: false,
itemKey: 'integrations',
},
{
key: ROUTES.ALL_CHANNELS,
label: 'Notification Channels',
icon: <FileKey2 size={16} />,
isEnabled: true,
itemKey: 'notification-channels',
},
{
key: ROUTES.API_KEYS,
label: 'API Keys',
icon: <Key size={16} />,
isEnabled: false,
itemKey: 'api-keys',
},
{
key: ROUTES.INGESTION_SETTINGS,
label: 'Ingestion',
icon: <RocketOutlined rotate={45} />,
isEnabled: false,
itemKey: 'ingestion',
},
{
key: ROUTES.MY_SETTINGS,
label: 'Account Settings',
icon: <User size={16} />,
isEnabled: true,
itemKey: 'account-settings',
},
{
key: ROUTES.SHORTCUTS,
label: 'Keyboard Shortcuts',
icon: <Layers2 size={16} />,
isEnabled: true,
itemKey: 'keyboard-shortcuts',
},
];
export const helpSupportDropdownMenuItems: SidebarItem[] = [
{
key: 'documentation',
label: 'Documentation',
icon: <Book size={14} />,
isExternal: true,
url: 'https://signoz.io/docs',
itemKey: 'documentation',
},
{
key: 'github',
label: 'GitHub',
icon: <Github size={14} />,
isExternal: true,
url: 'https://github.com/signoz/signoz',
itemKey: 'github',
},
{
key: 'slack',
label: 'Community Slack',
icon: <Slack size={14} />,
isExternal: true,
url: 'https://signoz.io/slack',
itemKey: 'community-slack',
},
{
key: 'chat-support',
label: 'Chat with Support',
icon: <MessageSquareText size={14} />,
itemKey: 'chat-support',
},
{
key: ROUTES.SHORTCUTS,
label: 'Keyboard Shortcuts',
icon: <Keyboard size={14} />,
itemKey: 'keyboard-shortcuts',
},
{
key: 'invite-collaborators',
label: 'Invite a Collaborator',
icon: <Plus size={14} />,
itemKey: 'invite-collaborators',
},
];

View File

@@ -8,12 +8,18 @@ export type SidebarMenu = MenuItem & {
};
export interface SidebarItem {
key: string | number;
icon?: ReactNode;
text?: ReactNode;
key: string | number;
label?: ReactNode;
isBeta?: boolean;
isNew?: boolean;
isPinned?: boolean;
children?: SidebarItem[];
isExternal?: boolean;
url?: string;
isEnabled?: boolean;
itemKey?: string;
}
export enum SecondaryMenuItemKey {

View File

@@ -56,7 +56,7 @@
gap: 6px;
.diamond {
fill: var(--bg-cherry-500);
fill: var(--bg-robin-500);
}
}
}

View File

@@ -3,8 +3,8 @@ import './Events.styles.scss';
import { Collapse, Input, Tooltip, Typography } from 'antd';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { Diamond } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Event, Span } from 'types/api/trace/getTraceV2';
import { useState } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import NoData from '../NoData/NoData';
@@ -17,14 +17,7 @@ interface IEventsTableProps {
function EventsTable(props: IEventsTableProps): JSX.Element {
const { span, startTime, isSearchVisible } = props;
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
const events: Event[] = useMemo(() => {
const tempEvents = [];
for (let i = 0; i < span.event?.length; i++) {
const parsedEvent = JSON.parse(span.event[i]);
tempEvents.push(parsedEvent);
}
return tempEvents;
}, [span.event]);
const events = span.event;
return (
<div className="events-table">
@@ -81,7 +74,18 @@ function EventsTable(props: IEventsTableProps): JSX.Element {
)}
</Typography.Text>
<Typography.Text className="timestamp-text">
after the start
since trace start
</Typography.Text>
</div>
<div className="timestamp-container">
<Typography.Text className="attribute-value">
{getYAxisFormattedValue(
`${(event.timeUnixNano || 0) / 1e6 - span.timestamp}`,
'ms',
)}
</Typography.Text>
<Typography.Text className="timestamp-text">
since span start
</Typography.Text>
</div>
</div>

View File

@@ -0,0 +1,71 @@
.no-linked-spans {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
.linked-spans-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
.item {
display: flex;
flex-direction: column;
gap: 8px;
justify-content: flex-start;
.item-key {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.value-wrapper {
display: flex;
padding: 2px 8px;
align-items: center;
width: fit-content;
max-width: 100%;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-slate-500);
.item-value {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.56px;
}
}
}
}
.lightMode {
.linked-spans-container {
.item {
.item-key {
color: var(--bg-ink-100);
}
.value-wrapper {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
.item-value {
color: var(--bg-ink-400);
}
}
}
}
}

View File

@@ -0,0 +1,77 @@
import './LinkedSpans.styles.scss';
import { Button, Tooltip, Typography } from 'antd';
import ROUTES from 'constants/routes';
import { formUrlParams } from 'container/TraceDetail/utils';
import { useCallback } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import NoData from '../NoData/NoData';
interface LinkedSpansProps {
span: Span;
}
interface SpanReference {
traceId: string;
spanId: string;
refType: string;
}
function LinkedSpans(props: LinkedSpansProps): JSX.Element {
const { span } = props;
const getLink = useCallback((item: SpanReference): string | null => {
if (!item.traceId || !item.spanId) {
return null;
}
return `${ROUTES.TRACE}/${item.traceId}${formUrlParams({
spanId: item.spanId,
levelUp: 0,
levelDown: 0,
})}`;
}, []);
// Filter out CHILD_OF references as they are parent-child relationships
const linkedSpans =
span.references?.filter((ref: SpanReference) => ref.refType !== 'CHILD_OF') ||
[];
if (linkedSpans.length === 0) {
return (
<div className="no-linked-spans">
<NoData name="linked spans" />
</div>
);
}
return (
<div className="linked-spans-container">
{linkedSpans.map((item: SpanReference) => {
const link = getLink(item);
return (
<div className="item" key={item.spanId}>
<Typography.Text className="item-key" ellipsis>
Linked Span ID
</Typography.Text>
<div className="value-wrapper">
<Tooltip title={item.spanId}>
{link ? (
<Typography.Link href={link} className="item-value" ellipsis>
{item.spanId}
</Typography.Link>
) : (
<Button type="link" className="item-value" disabled>
{item.spanId}
</Button>
)}
</Tooltip>
</div>
</div>
);
})}
</div>
);
}
export default LinkedSpans;

View File

@@ -158,20 +158,29 @@
border-bottom: 1px solid var(--bg-slate-400) !important;
}
.attributes-tab-btn {
.ant-tabs-tab {
margin: 0 !important;
padding: 0 2px !important;
min-width: 36px;
height: 36px;
display: flex;
align-items: center;
}
.attributes-tab-btn:hover {
background: unset;
justify-content: center;
}
.events-tab-btn {
.attributes-tab-btn,
.events-tab-btn,
.linked-spans-tab-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 4px 8px;
}
.events-tab-btn:hover {
.attributes-tab-btn:hover,
.events-tab-btn:hover,
.linked-spans-tab-btn:hover {
background: unset;
}
}
@@ -261,3 +270,9 @@
}
}
}
.linked-spans-tab-btn {
display: flex;
align-items: center;
gap: 0.5rem;
}

View File

@@ -10,13 +10,14 @@ import { getTraceToLogsQuery } from 'container/TraceDetail/SelectedSpanDetails/c
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { Anvil, Bookmark, PanelRight, Search } from 'lucide-react';
import { Anvil, Bookmark, Link2, PanelRight, Search } from 'lucide-react';
import { Dispatch, SetStateAction, useState } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { formatEpochTimestamp } from 'utils/timeUtils';
import Attributes from './Attributes/Attributes';
import Events from './Events/Events';
import LinkedSpans from './LinkedSpans/LinkedSpans';
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
interface ISpanDetailsDrawerProps {
@@ -74,6 +75,19 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
/>
),
},
{
label: (
<Button
type="text"
icon={<Link2 size="14" />}
className="linked-spans-tab-btn"
>
Links
</Button>
),
key: 'linked-spans',
children: <LinkedSpans span={span} />,
},
];
}
const onLogsHandler = (): void => {

View File

@@ -231,6 +231,9 @@ export const routesToSkip = [
ROUTES.CHANNELS_EDIT,
ROUTES.WORKSPACE_ACCESS_RESTRICTED,
ROUTES.ALL_ERROR,
ROUTES.UN_AUTHORIZED,
ROUTES.NOT_FOUND,
ROUTES.SOMETHING_WENT_WRONG,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -0,0 +1,4 @@
.top-nav-container {
padding: 0px 8px;
margin-bottom: 16px;
}

View File

@@ -1,3 +1,5 @@
import './TopNav.styles.scss';
import { Col, Row, Space } from 'antd';
import ROUTES from 'constants/routes';
import { useMemo } from 'react';
@@ -43,7 +45,7 @@ function TopNav(): JSX.Element | null {
}
return !isRouteToSkip ? (
<Row style={{ marginBottom: '1rem' }}>
<div className="top-nav-container">
<Col span={24} style={{ marginTop: '1rem' }}>
<Row justify="end">
<Space align="center" size={16} direction="horizontal">
@@ -54,7 +56,7 @@ function TopNav(): JSX.Element | null {
</Space>
</Row>
</Col>
</Row>
</div>
) : null;
}

View File

@@ -30,14 +30,15 @@ export const getChartData = (
};
const chartLabels: ChartData<'line'>['labels'] = [];
Object.keys(allDataPoints ?? {}).forEach((timestamp) => {
const key = allDataPoints[timestamp];
if (key.value) {
chartDataset.data.push(key.value);
const date = dayjs(key.timestamp / 1000000);
chartLabels.push(date.toDate().getTime());
}
});
if (allDataPoints && typeof allDataPoints === 'object')
Object.keys(allDataPoints).forEach((timestamp) => {
const key = allDataPoints[timestamp];
if (key.value) {
chartDataset.data.push(key.value);
const date = dayjs(key.timestamp / 1000000);
chartLabels.push(date.toDate().getTime());
}
});
return {
datasets: [

View File

@@ -241,6 +241,15 @@
&-title {
color: var(--bg-ink-500);
}
&-footer {
border-top-color: var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.add-span-to-funnel-modal__discard-button {
background: var(--bg-vanilla-200);
color: var(--bg-ink-500);
}
}
}
}

View File

@@ -72,7 +72,6 @@ function FunnelDetailsView({
funnel={funnel}
isTraceDetailsPage
span={span}
disableAutoSave
triggerAutoSave={triggerAutoSave}
showNotifications={showNotifications}
/>
@@ -143,13 +142,19 @@ function AddSpanToFunnelModal({
const handleSaveFunnel = (): void => {
setTriggerSave(true);
// Reset trigger after a brief moment to allow the save to be processed
setTimeout(() => setTriggerSave(false), 100);
setTimeout(() => {
setTriggerSave(false);
onClose();
}, 100);
};
const handleDiscard = (): void => {
setTriggerDiscard(true);
// Reset trigger after a brief moment
setTimeout(() => setTriggerDiscard(false), 100);
setTimeout(() => {
setTriggerDiscard(false);
onClose();
}, 100);
};
const renderListView = (): JSX.Element => (
@@ -239,9 +244,6 @@ function AddSpanToFunnelModal({
footer={
activeView === ModalView.DETAILS
? [
<Button key="close" onClick={onClose}>
Close
</Button>,
<Button
type="default"
key="discard"

View File

@@ -136,8 +136,12 @@ function Filters({
return (
<div className="filter-row">
<QueryBuilderSearchV2
query={BASE_FILTER_QUERY}
query={{
...BASE_FILTER_QUERY,
filters,
}}
onChange={handleFilterChange}
hideSpanScopeSelector={false}
/>
{filteredSpanIds.length > 0 && (
<div className="pre-next-toggle">

View File

@@ -273,6 +273,27 @@
border-radius: 6px;
}
.event-dot {
position: absolute;
top: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 6px;
height: 6px;
background-color: var(--bg-robin-500);
border: 1px solid var(--bg-robin-600);
cursor: pointer;
z-index: 1;
&.error {
background-color: var(--bg-cherry-500);
border-color: var(--bg-cherry-600);
}
&:hover {
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
}
}
.span-line-text {
position: relative;
top: 40%;

View File

@@ -240,8 +240,33 @@ export function SpanDuration({
left: `${leftOffset}%`,
width: `${width}%`,
backgroundColor: color,
position: 'relative',
}}
/>
>
{span.event?.map((event) => {
const eventTimeMs = event.timeUnixNano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100;
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
const { isError } = event;
const { time, timeUnitName } = convertTimeToRelevantUnit(
eventTimeMs - span.timestamp,
);
return (
<Tooltip
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
title={`${event.name} @ ${toFixed(time, 2)} ${timeUnitName}`}
>
<div
className={`event-dot ${isError ? 'error' : ''}`}
style={{
left: `${clampedOffset}%`,
}}
/>
</Tooltip>
);
})}
</div>
{hasActionButtons && <SpanLineActionButtons span={span} />}
<Tooltip title={`${toFixed(time, 2)} ${timeUnitName}`}>
<Typography.Text

View File

@@ -0,0 +1,122 @@
.version-container {
max-height: 100vh;
overflow: hidden;
.version-page-header {
border-bottom: 1px solid var(--Slate-500, #161922);
background: rgba(11, 12, 14, 0.7);
backdrop-filter: blur(20px);
.version-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;
}
}
.version-page-container {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
.version-card {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
border-radius: 4px 4px 0px 0px;
border: 1px solid var(--Slate-500, #161922);
background: var(--Ink-400, #121317);
border-radius: 3px;
}
.version-page-form {
display: flex;
}
.version-page-stale-version-container {
padding: 16px;
background-color: rgba(78, 116, 248, 0.1);
border-radius: 4px;
.version-page-stale-version-container-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-vanilla-100);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 14px; /* 150% */
letter-spacing: 0.4px;
}
}
.version-page-latest-version-container {
.version-page-latest-version-container-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--Vanilla-100, #fff);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 14px; /* 150% */
letter-spacing: 0.4px;
}
}
.version-page-upgrade-container {
display: flex;
justify-content: flex-start;
margin-top: 16px;
}
}
}
.lightMode {
.version-container {
.version-page-header {
border-bottom: 1px solid var(--bg-vanilla-300);
background: #fff;
.version-page-header-title {
color: var(--bg-ink-400);
}
}
.version-page-container {
.version-card {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
}
.version-page-stale-version-container {
.version-page-stale-version-container-title {
color: var(--text-ink-400);
}
}
.version-page-latest-version-container {
.version-page-latest-version-container-title {
color: var(--text-ink-400);
}
}
}
}
}

View File

@@ -1,5 +1,7 @@
import { WarningFilled } from '@ant-design/icons';
import { Button, Card, Form, Space, Typography } from 'antd';
import './Version.styles.scss';
import { Button, Form } from 'antd';
import { CheckCircle, CloudUpload, InfoIcon, Wrench } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
@@ -34,73 +36,82 @@ function Version(): JSX.Element {
);
return (
<Card style={{ margin: '16px 0' }}>
<Typography.Title ellipsis level={4} style={{ marginTop: 0 }}>
{t('version')}
</Typography.Title>
<Form
wrapperCol={{
span: 14,
}}
labelCol={{
span: 3,
}}
layout="horizontal"
form={form}
labelAlign="left"
>
<Form.Item label={t('current_version')}>
<InputComponent
readOnly
value={isCurrentVersionError ? t('n_a').toString() : currentVersion}
placeholder={t('current_version')}
/>
</Form.Item>
<Form.Item label={t('latest_version')}>
<InputComponent
readOnly
value={isLatestVersionError ? t('n_a').toString() : latestVersion}
placeholder={t('latest_version')}
/>
<Button href={latestVersionUrl} target="_blank" type="link">
{t('release_notes')}
</Button>
</Form.Item>
</Form>
{!isError && isLatestVersion && (
<div>
<Space align="start">
<span></span>
<Typography.Paragraph italic>
{t('latest_version_signoz')}
</Typography.Paragraph>
</Space>
<div className="version-container">
<header className="version-page-header">
<div className="version-page-header-title">
<Wrench size={16} />
Version
</div>
)}
</header>
{!isError && !isLatestVersion && (
<div>
<Space align="start">
<span>
<WarningFilled style={{ color: '#E87040' }} />
</span>
<Typography.Paragraph italic>{t('stale_version')}</Typography.Paragraph>
</Space>
<div className="version-page-container">
<div className="version-card">
<Form
wrapperCol={{
span: 14,
}}
labelCol={{
span: 3,
}}
layout="horizontal"
form={form}
labelAlign="left"
>
<Form.Item label={t('current_version')}>
<InputComponent
readOnly
value={isCurrentVersionError ? t('n_a').toString() : currentVersion}
placeholder={t('current_version')}
/>
</Form.Item>
<Form.Item label={t('latest_version')}>
<InputComponent
readOnly
value={isLatestVersionError ? t('n_a').toString() : latestVersion}
placeholder={t('latest_version')}
/>
<Button href={latestVersionUrl} target="_blank" type="link">
{t('release_notes')}
</Button>
</Form.Item>
</Form>
{!isError && isLatestVersion && (
<div className="version-page-latest-version-container">
<div className="version-page-latest-version-container-title">
<CheckCircle size={16} />
{t('latest_version_signoz')}
</div>
</div>
)}
{!isError && !isLatestVersion && (
<div className="version-page-stale-version-container">
<div className="version-page-stale-version-container-title">
<InfoIcon size={16} />
{t('stale_version')}
</div>
</div>
)}
{!isError && !isLatestVersion && (
<div className="version-page-upgrade-container">
<Button
href="https://signoz.io/docs/operate/docker-standalone/#upgrade"
target="_blank"
type="primary"
className="periscope-btn primary"
icon={<CloudUpload size={16} />}
>
{t('read_how_to_upgrade')}
</Button>
</div>
)}
</div>
)}
{!isError && !isLatestVersion && (
<Button
href="https://signoz.io/docs/operate/docker-standalone/#upgrade"
target="_blank"
>
{t('read_how_to_upgrade')}
</Button>
)}
</Card>
</div>
</div>
);
}

View File

@@ -1,10 +1,13 @@
import { LOCALSTORAGE } from 'constants/localStorage';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useDebounce from 'hooks/useDebounce';
import { useLocalStorage } from 'hooks/useLocalStorage';
import { useNotifications } from 'hooks/useNotifications';
import { isEqual } from 'lodash-es';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FunnelData, FunnelStepData } from 'types/api/traceFunnels';
import { useUpdateFunnelSteps } from './useFunnels';
@@ -13,22 +16,30 @@ interface UseFunnelConfiguration {
isPopoverOpen: boolean;
setIsPopoverOpen: (isPopoverOpen: boolean) => void;
steps: FunnelStepData[];
isSaving: boolean;
}
// Add this helper function
const normalizeSteps = (steps: FunnelStepData[]): FunnelStepData[] => {
export const normalizeSteps = (steps: FunnelStepData[]): FunnelStepData[] => {
if (steps.some((step) => !step.filters)) return steps;
return steps.map((step) => ({
...step,
filters: {
...step.filters,
items: step.filters.items.map((item) => ({
id: '',
key: item.key,
value: item.value,
op: item.op,
})),
items: step.filters.items.map((item) => {
const {
id: unusedId,
isIndexed,
...keyObj
} = item.key as BaseAutocompleteData;
return {
id: '',
key: keyObj,
value: item.value,
op: item.op,
};
}),
},
}));
};
@@ -36,22 +47,22 @@ const normalizeSteps = (steps: FunnelStepData[]): FunnelStepData[] => {
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function useFunnelConfiguration({
funnel,
disableAutoSave = false,
triggerAutoSave = false,
showNotifications = false,
}: {
funnel: FunnelData;
disableAutoSave?: boolean;
triggerAutoSave?: boolean;
showNotifications?: boolean;
}): UseFunnelConfiguration {
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const {
steps,
initialSteps,
hasIncompleteStepFields,
lastUpdatedSteps,
setLastUpdatedSteps,
handleRestoreSteps,
handleRunFunnel,
selectedTime,
setIsUpdatingFunnel,
} = useFunnelContext();
// State management
@@ -59,10 +70,6 @@ export default function useFunnelConfiguration({
const debouncedSteps = useDebounce(steps, 200);
const [lastValidatedSteps, setLastValidatedSteps] = useState<FunnelStepData[]>(
initialSteps,
);
// Mutation hooks
const updateStepsMutation = useUpdateFunnelSteps(
funnel.funnel_id,
@@ -71,6 +78,15 @@ export default function useFunnelConfiguration({
// Derived state
const lastSavedStepsStateRef = useRef<FunnelStepData[]>(steps);
const hasRestoredFromLocalStorage = useRef(false);
// localStorage hook for funnel steps
const localStorageKey = `${LOCALSTORAGE.FUNNEL_STEPS}_${funnel.funnel_id}`;
const [
localStorageSavedSteps,
setLocalStorageSavedSteps,
clearLocalStorageSavedSteps,
] = useLocalStorage<FunnelStepData[] | null>(localStorageKey, null);
const hasStepsChanged = useCallback(() => {
const normalizedLastSavedSteps = normalizeSteps(
@@ -80,6 +96,34 @@ export default function useFunnelConfiguration({
return !isEqual(normalizedDebouncedSteps, normalizedLastSavedSteps);
}, [debouncedSteps]);
// Handle localStorage for funnel steps
useEffect(() => {
// Restore from localStorage on first run if
if (!hasRestoredFromLocalStorage.current) {
const savedSteps = localStorageSavedSteps;
if (savedSteps) {
handleRestoreSteps(savedSteps);
hasRestoredFromLocalStorage.current = true;
return;
}
}
// Save steps to localStorage
if (hasStepsChanged()) {
setLocalStorageSavedSteps(debouncedSteps);
}
}, [
debouncedSteps,
funnel.funnel_id,
hasStepsChanged,
handleRestoreSteps,
localStorageSavedSteps,
setLocalStorageSavedSteps,
queryClient,
selectedTime,
lastUpdatedSteps,
]);
const hasFunnelStepDefinitionsChanged = useCallback(
(prevSteps: FunnelStepData[], nextSteps: FunnelStepData[]): boolean => {
if (prevSteps.length !== nextSteps.length) return true;
@@ -97,15 +141,6 @@ export default function useFunnelConfiguration({
[],
);
const hasFunnelLatencyTypeChanged = useCallback(
(prevSteps: FunnelStepData[], nextSteps: FunnelStepData[]): boolean =>
prevSteps.some((step, index) => {
const nextStep = nextSteps[index];
return step.latency_type !== nextStep.latency_type;
}),
[],
);
// Mutation payload preparation
const getUpdatePayload = useCallback(
() => ({
@@ -116,33 +151,19 @@ export default function useFunnelConfiguration({
[funnel.funnel_id, debouncedSteps],
);
const queryClient = useQueryClient();
const { selectedTime } = useFunnelContext();
const validateStepsQueryKey = useMemo(
() => [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnel.funnel_id, selectedTime],
[funnel.funnel_id, selectedTime],
);
// eslint-disable-next-line sonarjs/cognitive-complexity
useEffect(() => {
// Determine if we should save based on the mode
let shouldSave = false;
if (disableAutoSave) {
// Manual save mode: only save when explicitly triggered
shouldSave = triggerAutoSave;
} else {
// Auto-save mode: save when steps have changed and no incomplete fields
shouldSave = hasStepsChanged() && !hasIncompleteStepFields;
}
if (shouldSave && !isEqual(debouncedSteps, lastValidatedSteps)) {
if (triggerAutoSave && !isEqual(debouncedSteps, lastUpdatedSteps)) {
setIsUpdatingFunnel(true);
updateStepsMutation.mutate(getUpdatePayload(), {
onSuccess: (data) => {
const updatedFunnelSteps = data?.payload?.steps;
if (!updatedFunnelSteps) return;
// Clear localStorage since steps are saved successfully
clearLocalStorageSavedSteps();
queryClient.setQueryData(
[REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnel.funnel_id],
(oldData: any) => {
@@ -163,17 +184,9 @@ export default function useFunnelConfiguration({
(step) => step.service_name === '' || step.span_name === '',
);
if (hasFunnelLatencyTypeChanged(lastValidatedSteps, debouncedSteps)) {
handleRunFunnel();
setLastValidatedSteps(debouncedSteps);
}
// Only validate if funnel steps definitions
else if (
!hasIncompleteStepFields &&
hasFunnelStepDefinitionsChanged(lastValidatedSteps, debouncedSteps)
) {
queryClient.refetchQueries(validateStepsQueryKey);
setLastValidatedSteps(debouncedSteps);
if (!hasIncompleteStepFields) {
setLastUpdatedSteps(debouncedSteps);
}
// Show success notification only when requested
@@ -216,17 +229,18 @@ export default function useFunnelConfiguration({
getUpdatePayload,
hasFunnelStepDefinitionsChanged,
hasStepsChanged,
lastValidatedSteps,
lastUpdatedSteps,
queryClient,
validateStepsQueryKey,
triggerAutoSave,
showNotifications,
disableAutoSave,
localStorageSavedSteps,
clearLocalStorageSavedSteps,
]);
return {
isPopoverOpen,
setIsPopoverOpen,
steps,
isSaving: updateStepsMutation.isLoading,
};
}

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