Compare commits

..

11 Commits

Author SHA1 Message Date
SagarRajput-7
b9bcd2d4e1 feat: refactored the member status mapping 2026-03-18 23:42:53 +05:30
SagarRajput-7
34dcd79243 feat: updated the confirmation dialog description as now the we cant permanently delete the member 2026-03-18 21:06:43 +05:30
SagarRajput-7
145d6327a7 feat: feedback, refactor and test mock update 2026-03-18 20:29:11 +05:30
SagarRajput-7
61cfd33fc6 feat: test case and pagination fix 2026-03-18 20:29:11 +05:30
SagarRajput-7
b299d63263 feat: changed text for copy, cancel and ingeneral messaging for invited users 2026-03-18 20:29:11 +05:30
SagarRajput-7
94f3e6d6d7 feat: delete orphaned type files 2026-03-18 20:29:11 +05:30
SagarRajput-7
7ae0a23103 feat: removed deprecated invite endpoint apis 2026-03-18 20:29:11 +05:30
SagarRajput-7
2d91b5fd0b feat: updated members page with new status response and remove invite endpoint api 2026-03-18 20:29:11 +05:30
Karan Balani
ab1428d413 fix: allow pending user to be updated 2026-03-18 20:28:02 +05:30
Karan Balani
9c859e4d07 chore: add back validation for pending user in list user apis for integration tests 2026-03-18 20:25:39 +05:30
Karan Balani
d6de4d58f7 chore: deprecate old user invite apis 2026-03-18 20:25:39 +05:30
355 changed files with 2955 additions and 14731 deletions

View File

@@ -29,7 +29,6 @@ jobs:
- name: fmt
run: |
make py-fmt
git diff --exit-code -- tests/integration/
- name: lint
run: |
make py-lint
@@ -50,7 +49,6 @@ jobs:
- ttl
- alerts
- ingestionkeys
- rootuser
sqlstore-provider:
- postgres
- sqlite

View File

@@ -35,7 +35,7 @@ linters:
- identical
sloglint:
no-mixed-args: true
attr-only: true
kv-only: true
no-global: all
context: all
static-msg: true

View File

@@ -4,15 +4,12 @@ import (
"context"
"log/slog"
"github.com/spf13/cobra"
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/authz/openfgaauthz"
"github.com/SigNoz/signoz/pkg/authz/openfgaschema"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/gateway/noopgateway"
@@ -31,17 +28,18 @@ import (
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/SigNoz/signoz/pkg/zeus/noopzeus"
"github.com/spf13/cobra"
)
func registerServer(parentCmd *cobra.Command, logger *slog.Logger) {
var configFiles []string
var flags signoz.DeprecatedFlags
serverCmd := &cobra.Command{
Use: "server",
Short: "Run the SigNoz server",
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
RunE: func(currCmd *cobra.Command, args []string) error {
config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, configFiles)
config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, flags)
if err != nil {
return err
}
@@ -50,7 +48,7 @@ func registerServer(parentCmd *cobra.Command, logger *slog.Logger) {
},
}
serverCmd.Flags().StringArrayVar(&configFiles, "config", nil, "path to a YAML configuration file (can be specified multiple times, later files override earlier ones)")
flags.RegisterFlags(serverCmd)
parentCmd.AddCommand(serverCmd)
}
@@ -92,37 +90,37 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))
logger.ErrorContext(ctx, "failed to create signoz", "error", err)
return err
}
server, err := app.NewServer(config, signoz)
if err != nil {
logger.ErrorContext(ctx, "failed to create server", errors.Attr(err))
logger.ErrorContext(ctx, "failed to create server", "error", err)
return err
}
if err := server.Start(ctx); err != nil {
logger.ErrorContext(ctx, "failed to start server", errors.Attr(err))
logger.ErrorContext(ctx, "failed to start server", "error", err)
return err
}
signoz.Start(ctx)
if err := signoz.Wait(ctx); err != nil {
logger.ErrorContext(ctx, "failed to start signoz", errors.Attr(err))
logger.ErrorContext(ctx, "failed to start signoz", "error", err)
return err
}
err = server.Stop(ctx)
if err != nil {
logger.ErrorContext(ctx, "failed to stop server", errors.Attr(err))
logger.ErrorContext(ctx, "failed to stop server", "error", err)
return err
}
err = signoz.Stop(ctx)
if err != nil {
logger.ErrorContext(ctx, "failed to stop signoz", errors.Attr(err))
logger.ErrorContext(ctx, "failed to stop signoz", "error", err)
return err
}

View File

@@ -10,23 +10,18 @@ import (
"github.com/SigNoz/signoz/pkg/signoz"
)
func NewSigNozConfig(ctx context.Context, logger *slog.Logger, configFiles []string) (signoz.Config, error) {
uris := make([]string, 0, len(configFiles)+1)
for _, f := range configFiles {
uris = append(uris, "file:"+f)
}
uris = append(uris, "env:")
func NewSigNozConfig(ctx context.Context, logger *slog.Logger, flags signoz.DeprecatedFlags) (signoz.Config, error) {
config, err := signoz.NewConfig(
ctx,
logger,
config.ResolverConfig{
Uris: uris,
Uris: []string{"env:"},
ProviderFactories: []config.ProviderFactory{
envprovider.NewFactory(),
fileprovider.NewFactory(),
},
},
flags,
)
if err != nil {
return signoz.Config{}, err

View File

@@ -1,86 +0,0 @@
package cmd
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewSigNozConfig_NoConfigFiles(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
config, err := NewSigNozConfig(context.Background(), logger, nil)
require.NoError(t, err)
assert.NotZero(t, config)
}
func TestNewSigNozConfig_SingleConfigFile(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
err := os.WriteFile(configPath, []byte(`
cache:
provider: "redis"
`), 0644)
require.NoError(t, err)
logger := slog.New(slog.DiscardHandler)
config, err := NewSigNozConfig(context.Background(), logger, []string{configPath})
require.NoError(t, err)
assert.Equal(t, "redis", config.Cache.Provider)
}
func TestNewSigNozConfig_MultipleConfigFiles_LaterOverridesEarlier(t *testing.T) {
dir := t.TempDir()
basePath := filepath.Join(dir, "base.yaml")
err := os.WriteFile(basePath, []byte(`
cache:
provider: "memory"
sqlstore:
provider: "sqlite"
`), 0644)
require.NoError(t, err)
overridePath := filepath.Join(dir, "override.yaml")
err = os.WriteFile(overridePath, []byte(`
cache:
provider: "redis"
`), 0644)
require.NoError(t, err)
logger := slog.New(slog.DiscardHandler)
config, err := NewSigNozConfig(context.Background(), logger, []string{basePath, overridePath})
require.NoError(t, err)
// Later file overrides earlier
assert.Equal(t, "redis", config.Cache.Provider)
// Value from base file that wasn't overridden persists
assert.Equal(t, "sqlite", config.SQLStore.Provider)
}
func TestNewSigNozConfig_EnvOverridesConfigFile(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
err := os.WriteFile(configPath, []byte(`
cache:
provider: "fromfile"
`), 0644)
require.NoError(t, err)
t.Setenv("SIGNOZ_CACHE_PROVIDER", "fromenv")
logger := slog.New(slog.DiscardHandler)
config, err := NewSigNozConfig(context.Background(), logger, []string{configPath})
require.NoError(t, err)
// Env should override file
assert.Equal(t, "fromenv", config.Cache.Provider)
}
func TestNewSigNozConfig_NonexistentFile(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
_, err := NewSigNozConfig(context.Background(), logger, []string{"/nonexistent/config.yaml"})
assert.Error(t, err)
}

View File

@@ -5,18 +5,16 @@ import (
"log/slog"
"time"
"github.com/spf13/cobra"
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
eequerier "github.com/SigNoz/signoz/ee/querier"
"github.com/SigNoz/signoz/ee/authz/openfgaschema"
"github.com/SigNoz/signoz/ee/gateway/httpgateway"
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
eequerier "github.com/SigNoz/signoz/ee/querier"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
"github.com/SigNoz/signoz/ee/sqlschema/postgressqlschema"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
@@ -25,7 +23,6 @@ import (
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/licensing"
@@ -41,17 +38,18 @@ import (
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/spf13/cobra"
)
func registerServer(parentCmd *cobra.Command, logger *slog.Logger) {
var configFiles []string
var flags signoz.DeprecatedFlags
serverCmd := &cobra.Command{
Use: "server",
Short: "Run the SigNoz server",
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
RunE: func(currCmd *cobra.Command, args []string) error {
config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, configFiles)
config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, flags)
if err != nil {
return err
}
@@ -60,7 +58,7 @@ func registerServer(parentCmd *cobra.Command, logger *slog.Logger) {
},
}
serverCmd.Flags().StringArrayVar(&configFiles, "config", nil, "path to a YAML configuration file (can be specified multiple times, later files override earlier ones)")
flags.RegisterFlags(serverCmd)
parentCmd.AddCommand(serverCmd)
}
@@ -71,7 +69,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
// add enterprise sqlstore factories to the community sqlstore factories
sqlstoreFactories := signoz.NewSQLStoreProviderFactories()
if err := sqlstoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory(), sqlstorehook.NewInstrumentationFactory())); err != nil {
logger.ErrorContext(ctx, "failed to add postgressqlstore factory", errors.Attr(err))
logger.ErrorContext(ctx, "failed to add postgressqlstore factory", "error", err)
return err
}
@@ -134,37 +132,37 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))
logger.ErrorContext(ctx, "failed to create signoz", "error", err)
return err
}
server, err := enterpriseapp.NewServer(config, signoz)
if err != nil {
logger.ErrorContext(ctx, "failed to create server", errors.Attr(err))
logger.ErrorContext(ctx, "failed to create server", "error", err)
return err
}
if err := server.Start(ctx); err != nil {
logger.ErrorContext(ctx, "failed to start server", errors.Attr(err))
logger.ErrorContext(ctx, "failed to start server", "error", err)
return err
}
signoz.Start(ctx)
if err := signoz.Wait(ctx); err != nil {
logger.ErrorContext(ctx, "failed to start signoz", errors.Attr(err))
logger.ErrorContext(ctx, "failed to start signoz", "error", err)
return err
}
err = server.Stop(ctx)
if err != nil {
logger.ErrorContext(ctx, "failed to stop server", errors.Attr(err))
logger.ErrorContext(ctx, "failed to stop server", "error", err)
return err
}
err = signoz.Stop(ctx)
if err != nil {
logger.ErrorContext(ctx, "failed to stop signoz", errors.Attr(err))
logger.ErrorContext(ctx, "failed to stop signoz", "error", err)
return err
}

View File

@@ -4,10 +4,8 @@ import (
"log/slog"
"os"
"github.com/spf13/cobra"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/version"
"github.com/spf13/cobra"
)
var RootCmd = &cobra.Command{
@@ -22,7 +20,7 @@ var RootCmd = &cobra.Command{
func Execute(logger *slog.Logger) {
err := RootCmd.Execute()
if err != nil {
logger.ErrorContext(RootCmd.Context(), "error running command", errors.Attr(err))
logger.ErrorContext(RootCmd.Context(), "error running command", "error", err)
os.Exit(1)
}
}

4
conf/cache-config.yml Normal file
View File

@@ -0,0 +1,4 @@
provider: "inmemory"
inmemory:
ttl: 60m
cleanupInterval: 10m

View File

@@ -39,13 +39,6 @@ instrumentation:
host: "0.0.0.0"
port: 9090
##################### PProf #####################
pprof:
# Whether to enable the pprof server.
enabled: true
# The address on which the pprof server listens.
address: 0.0.0.0:6060
##################### Web #####################
web:
# Whether to enable the web frontend
@@ -335,18 +328,15 @@ user:
##################### IdentN #####################
identn:
tokenizer:
# toggle tokenizer identN
# toggle the identN resolver
enabled: true
# headers to use for tokenizer identN resolver
headers:
- Authorization
- Sec-WebSocket-Protocol
apikey:
# toggle apikey identN
# toggle the identN resolver
enabled: true
# headers to use for apikey identN resolver
headers:
- SIGNOZ-API-KEY
impersonation:
# toggle impersonation identN, when enabled, all requests will impersonate the root user
enabled: false

25
conf/prometheus.yml Normal file
View File

@@ -0,0 +1,25 @@
# my global config
global:
scrape_interval: 5s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
# scrape_timeout is set to the global default (10s).
# Alertmanager configuration
alerting:
alertmanagers:
- static_configs:
- targets:
- 127.0.0.1:9093
# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
rule_files:
# - "first_rules.yml"
# - "second_rules.yml"
- 'alerts.yml'
# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs: []
remote_read:
- url: tcp://localhost:9000/signoz_metrics

File diff suppressed because it is too large Load Diff

View File

@@ -74,37 +74,37 @@ func (p *BaseSeasonalProvider) getResults(ctx context.Context, orgID valuer.UUID
instrumentationtypes.CodeFunctionName: "getResults",
})
// TODO(srikanthccv): parallelize this?
p.logger.InfoContext(ctx, "fetching results for current period", slog.Any("anomaly_current_period_query", params.CurrentPeriodQuery))
p.logger.InfoContext(ctx, "fetching results for current period", "anomaly_current_period_query", params.CurrentPeriodQuery)
currentPeriodResults, err := p.querier.QueryRange(ctx, orgID, &params.CurrentPeriodQuery)
if err != nil {
return nil, err
}
p.logger.InfoContext(ctx, "fetching results for past period", slog.Any("anomaly_past_period_query", params.PastPeriodQuery))
p.logger.InfoContext(ctx, "fetching results for past period", "anomaly_past_period_query", params.PastPeriodQuery)
pastPeriodResults, err := p.querier.QueryRange(ctx, orgID, &params.PastPeriodQuery)
if err != nil {
return nil, err
}
p.logger.InfoContext(ctx, "fetching results for current season", slog.Any("anomaly_current_season_query", params.CurrentSeasonQuery))
p.logger.InfoContext(ctx, "fetching results for current season", "anomaly_current_season_query", params.CurrentSeasonQuery)
currentSeasonResults, err := p.querier.QueryRange(ctx, orgID, &params.CurrentSeasonQuery)
if err != nil {
return nil, err
}
p.logger.InfoContext(ctx, "fetching results for past season", slog.Any("anomaly_past_season_query", params.PastSeasonQuery))
p.logger.InfoContext(ctx, "fetching results for past season", "anomaly_past_season_query", params.PastSeasonQuery)
pastSeasonResults, err := p.querier.QueryRange(ctx, orgID, &params.PastSeasonQuery)
if err != nil {
return nil, err
}
p.logger.InfoContext(ctx, "fetching results for past 2 season", slog.Any("anomaly_past_2season_query", params.Past2SeasonQuery))
p.logger.InfoContext(ctx, "fetching results for past 2 season", "anomaly_past_2season_query", params.Past2SeasonQuery)
past2SeasonResults, err := p.querier.QueryRange(ctx, orgID, &params.Past2SeasonQuery)
if err != nil {
return nil, err
}
p.logger.InfoContext(ctx, "fetching results for past 3 season", slog.Any("anomaly_past_3season_query", params.Past3SeasonQuery))
p.logger.InfoContext(ctx, "fetching results for past 3 season", "anomaly_past_3season_query", params.Past3SeasonQuery)
past3SeasonResults, err := p.querier.QueryRange(ctx, orgID, &params.Past3SeasonQuery)
if err != nil {
return nil, err
@@ -212,17 +212,17 @@ func (p *BaseSeasonalProvider) getPredictedSeries(
if predictedValue < 0 {
// this should not happen (except when the data has extreme outliers)
// we will use the moving avg of the previous period series in this case
p.logger.WarnContext(ctx, "predicted value is less than 0 for series", slog.Float64("anomaly_predicted_value", predictedValue), slog.Any("anomaly_labels", series.Labels))
p.logger.WarnContext(ctx, "predicted value is less than 0 for series", "anomaly_predicted_value", predictedValue, "anomaly_labels", series.Labels)
predictedValue = p.getMovingAvg(prevSeries, movingAvgWindowSize, idx)
}
p.logger.DebugContext(ctx, "predicted value for series",
slog.Float64("anomaly_moving_avg", movingAvg),
slog.Float64("anomaly_avg", avg),
slog.Float64("anomaly_mean", mean),
slog.Any("anomaly_labels", series.Labels),
slog.Float64("anomaly_predicted_value", predictedValue),
slog.Float64("anomaly_curr", curr.Value),
"anomaly_moving_avg", movingAvg,
"anomaly_avg", avg,
"anomaly_mean", mean,
"anomaly_labels", series.Labels,
"anomaly_predicted_value", predictedValue,
"anomaly_curr", curr.Value,
)
predictedSeries.Values = append(predictedSeries.Values, &qbtypes.TimeSeriesValue{
Timestamp: curr.Timestamp,
@@ -412,7 +412,7 @@ func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UU
past3SeasonSeries := p.getMatchingSeries(ctx, past3SeasonResult, series)
stdDev := p.getStdDev(currentSeasonSeries)
p.logger.InfoContext(ctx, "calculated standard deviation for series", slog.Float64("anomaly_std_dev", stdDev), slog.Any("anomaly_labels", series.Labels))
p.logger.InfoContext(ctx, "calculated standard deviation for series", "anomaly_std_dev", stdDev, "anomaly_labels", series.Labels)
prevSeriesAvg := p.getAvg(pastPeriodSeries)
currentSeasonSeriesAvg := p.getAvg(currentSeasonSeries)
@@ -420,12 +420,12 @@ func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UU
past2SeasonSeriesAvg := p.getAvg(past2SeasonSeries)
past3SeasonSeriesAvg := p.getAvg(past3SeasonSeries)
p.logger.InfoContext(ctx, "calculated mean for series",
slog.Float64("anomaly_prev_series_avg", prevSeriesAvg),
slog.Float64("anomaly_current_season_series_avg", currentSeasonSeriesAvg),
slog.Float64("anomaly_past_season_series_avg", pastSeasonSeriesAvg),
slog.Float64("anomaly_past_2season_series_avg", past2SeasonSeriesAvg),
slog.Float64("anomaly_past_3season_series_avg", past3SeasonSeriesAvg),
slog.Any("anomaly_labels", series.Labels),
"anomaly_prev_series_avg", prevSeriesAvg,
"anomaly_current_season_series_avg", currentSeasonSeriesAvg,
"anomaly_past_season_series_avg", pastSeasonSeriesAvg,
"anomaly_past_2season_series_avg", past2SeasonSeriesAvg,
"anomaly_past_3season_series_avg", past3SeasonSeriesAvg,
"anomaly_labels", series.Labels,
)
predictedSeries := p.getPredictedSeries(

View File

@@ -3,7 +3,6 @@ package oidccallbackauthn
import (
"context"
"fmt"
"log/slog"
"net/url"
"github.com/SigNoz/signoz/pkg/authn"
@@ -151,7 +150,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
// Some IDPs return a single group as a string instead of an array
groups = append(groups, g)
default:
a.settings.Logger().WarnContext(ctx, "oidc: unsupported groups type", slog.String("type", fmt.Sprintf("%T", claimValue)))
a.settings.Logger().WarnContext(ctx, "oidc: unsupported groups type", "type", fmt.Sprintf("%T", claimValue))
}
}
}

View File

@@ -3,11 +3,8 @@ package httplicensing
import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/tidwall/gjson"
"github.com/SigNoz/signoz/ee/licensing/licensingstore/sqllicensingstore"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/errors"
@@ -19,6 +16,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/tidwall/gjson"
)
type provider struct {
@@ -57,7 +55,7 @@ func (provider *provider) Start(ctx context.Context) error {
err := provider.Validate(ctx)
if err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to validate license from upstream server", errors.Attr(err))
provider.settings.Logger().ErrorContext(ctx, "failed to validate license from upstream server", "error", err)
}
for {
@@ -67,7 +65,7 @@ func (provider *provider) Start(ctx context.Context) error {
case <-tick.C:
err := provider.Validate(ctx)
if err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to validate license from upstream server", errors.Attr(err))
provider.settings.Logger().ErrorContext(ctx, "failed to validate license from upstream server", "error", err)
}
}
}
@@ -135,7 +133,7 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI
if errors.Ast(err, errors.TypeNotFound) {
return nil
}
provider.settings.Logger().ErrorContext(ctx, "license validation failed", slog.String("org_id", organizationID.StringValue()))
provider.settings.Logger().ErrorContext(ctx, "license validation failed", "org_id", organizationID.StringValue())
return err
}
@@ -200,10 +198,7 @@ func (provider *provider) Checkout(ctx context.Context, organizationID valuer.UU
response, err := provider.zeus.GetCheckoutURL(ctx, activeLicense.Key, body)
if err != nil {
if errors.Ast(err, errors.TypeAlreadyExists) {
return nil, errors.WithAdditionalf(err, "checkout has already been completed for this account. Please click 'Refresh Status' to sync your subscription")
}
return nil, err
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to generate checkout session")
}
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
@@ -222,7 +217,7 @@ func (provider *provider) Portal(ctx context.Context, organizationID valuer.UUID
response, err := provider.zeus.GetPortalURL(ctx, activeLicense.Key, body)
if err != nil {
return nil, err
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to generate portal session")
}
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"io"
"net/http"
"runtime/debug"
anomalyV2 "github.com/SigNoz/signoz/ee/anomaly"
"github.com/SigNoz/signoz/pkg/errors"
@@ -53,6 +54,26 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
return
}
defer func() {
if r := recover(); r != nil {
stackTrace := string(debug.Stack())
queryJSON, _ := json.Marshal(queryRangeRequest)
h.set.Logger.ErrorContext(ctx, "panic in QueryRange",
"error", r,
"user", claims.UserID,
"payload", string(queryJSON),
"stacktrace", stackTrace,
)
render.Error(rw, errors.NewInternalf(
errors.CodeInternal,
"Something went wrong on our end. It's not you, it's us. Our team is notified about it. Reach out to support if issue persists.",
))
}
}()
if err := queryRangeRequest.Validate(); err != nil {
render.Error(rw, err)
return

View File

@@ -10,8 +10,6 @@ import (
"strings"
"time"
"log/slog"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -20,6 +18,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"log/slog"
)
type CloudIntegrationConnectionParamsResponse struct {
@@ -127,7 +126,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
))
}
allPats, err := ah.Signoz.Modules.UserSetter.ListAPIKeys(ctx, orgIdUUID)
allPats, err := ah.Signoz.Modules.User.ListAPIKeys(ctx, orgIdUUID)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't list PATs: %w", err,
@@ -155,7 +154,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
))
}
err = ah.Signoz.Modules.UserSetter.CreateAPIKey(ctx, newPAT)
err = ah.Signoz.Modules.User.CreateAPIKey(ctx, newPAT)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't create cloud integration PAT: %w", err,
@@ -170,19 +169,14 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, valuer.MustNewUUID(orgId), types.UserStatusActive)
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId), types.UserStatusActive)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
}
password := types.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue())
cloudIntegrationUser, err = ah.Signoz.Modules.UserSetter.GetOrCreateUser(
ctx,
cloudIntegrationUser,
user.WithFactorPassword(password),
user.WithRoleNames([]string{authtypes.SigNozViewerRoleName}),
)
cloudIntegrationUser, err = ah.Signoz.Modules.User.GetOrCreateUser(ctx, cloudIntegrationUser, user.WithFactorPassword(password))
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't look for integration user: %w", err))
}

View File

@@ -4,15 +4,10 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
signozerrors "github.com/SigNoz/signoz/pkg/errors"
"log/slog"
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/http/render"
@@ -20,6 +15,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/valuer"
"log/slog"
)
func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
@@ -42,7 +38,7 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
slog.DebugContext(ctx, "fetching license")
license, err := ah.Signoz.Licensing.GetActive(ctx, orgID)
if err != nil {
slog.ErrorContext(ctx, "failed to fetch license", signozerrors.Attr(err))
slog.ErrorContext(ctx, "failed to fetch license", "error", err)
} else if license == nil {
slog.DebugContext(ctx, "no active license found")
} else {
@@ -55,7 +51,7 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
// merge featureSet and zeusFeatures in featureSet with higher priority to zeusFeatures
featureSet = MergeFeatureSets(zeusFeatures, featureSet)
} else {
slog.ErrorContext(ctx, "failed to fetch zeus features", signozerrors.Attr(err))
slog.ErrorContext(ctx, "failed to fetch zeus features", "error", err)
}
}
}

View File

@@ -7,7 +7,6 @@ import (
"net/http"
"github.com/SigNoz/signoz/ee/query-service/anomaly"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/queryBuilder"
@@ -36,7 +35,7 @@ func (aH *APIHandler) queryRangeV4(w http.ResponseWriter, r *http.Request) {
queryRangeParams, apiErrorObj := baseapp.ParseQueryRangeParams(r)
if apiErrorObj != nil {
slog.ErrorContext(r.Context(), "error parsing metric query range params", errors.Attr(apiErrorObj.Err))
slog.ErrorContext(r.Context(), "error parsing metric query range params", "error", apiErrorObj.Err)
RespondError(w, apiErrorObj, nil)
return
}
@@ -45,7 +44,7 @@ func (aH *APIHandler) queryRangeV4(w http.ResponseWriter, r *http.Request) {
// add temporality for each metric
temporalityErr := aH.PopulateTemporality(r.Context(), orgID, queryRangeParams)
if temporalityErr != nil {
slog.ErrorContext(r.Context(), "error while adding temporality for metrics", errors.Attr(temporalityErr))
slog.ErrorContext(r.Context(), "error while adding temporality for metrics", "error", temporalityErr)
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: temporalityErr}, nil)
return
}

View File

@@ -5,23 +5,19 @@ import (
"fmt"
"net"
"net/http"
_ "net/http/pprof" // http profiler
"slices"
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
"go.opentelemetry.io/otel/propagation"
"github.com/SigNoz/signoz/pkg/cache/memorycache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
"go.opentelemetry.io/otel/propagation"
"github.com/gorilla/handlers"
"github.com/rs/cors"
"github.com/soheilhy/cmux"
"github.com/SigNoz/signoz/ee/query-service/app/api"
"github.com/SigNoz/signoz/ee/query-service/rules"
"github.com/SigNoz/signoz/ee/query-service/usage"
@@ -35,8 +31,8 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/web"
"log/slog"
"github.com/rs/cors"
"github.com/soheilhy/cmux"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
@@ -51,6 +47,7 @@ import (
baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"log/slog"
)
// Server runs HTTP, Mux and a grpc server
@@ -210,7 +207,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
r := baseapp.NewRouter()
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)
r.Use(middleware.NewRecovery(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(otelmux.Middleware(
"apiserver",
otelmux.WithMeterProvider(s.signoz.Instrumentation.MeterProvider()),
@@ -219,6 +215,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
otelmux.WithFilter(func(r *http.Request) bool {
return !slices.Contains([]string{"/api/v1/health"}, r.URL.Path)
}),
otelmux.WithPublicEndpoint(),
))
r.Use(middleware.NewIdentN(s.signoz.IdentNResolver, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
@@ -307,16 +304,25 @@ func (s *Server) Start(ctx context.Context) error {
case nil, http.ErrServerClosed, cmux.ErrListenerClosed:
// normal exit, nothing to do
default:
slog.Error("Could not start HTTP server", errors.Attr(err))
slog.Error("Could not start HTTP server", "error", err)
}
s.unavailableChannel <- healthcheck.Unavailable
}()
go func() {
slog.Info("Starting pprof server", "addr", baseconst.DebugHttpPort)
err = http.ListenAndServe(baseconst.DebugHttpPort, nil)
if err != nil {
slog.Error("Could not start pprof server", "error", err)
}
}()
go func() {
slog.Info("Starting OpAmp Websocket server", "addr", baseconst.OpAmpWsEndpoint)
err := s.opampServer.Start(baseconst.OpAmpWsEndpoint)
if err != nil {
slog.Error("opamp ws server failed to start", errors.Attr(err))
slog.Error("opamp ws server failed to start", "error", err)
s.unavailableChannel <- healthcheck.Unavailable
}
}()

View File

@@ -12,7 +12,6 @@ import (
"github.com/SigNoz/signoz/ee/query-service/anomaly"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/common"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/transition"
@@ -309,7 +308,7 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
filteredSeries, filterErr := r.BaseRule.FilterNewSeries(ctx, ts, seriesToProcess)
// In case of error we log the error and continue with the original series
if filterErr != nil {
r.logger.ErrorContext(ctx, "Error filtering new series, ", errors.Attr(filterErr), "rule_name", r.Name())
r.logger.ErrorContext(ctx, "Error filtering new series, ", "error", filterErr, "rule_name", r.Name())
} else {
seriesToProcess = filteredSeries
}
@@ -392,7 +391,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
result, err := tmpl.Expand()
if err != nil {
result = fmt.Sprintf("<error expanding template: %s>", err)
r.logger.ErrorContext(ctx, "Expanding alert template failed", errors.Attr(err), "data", tmplData, "rule_name", r.Name())
r.logger.ErrorContext(ctx, "Expanding alert template failed", "error", err, "data", tmplData, "rule_name", r.Name())
}
return result
}
@@ -468,7 +467,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
for fp, a := range r.Active {
labelsJSON, err := json.Marshal(a.QueryResultLables)
if err != nil {
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err), "labels", a.Labels)
r.logger.ErrorContext(ctx, "error marshaling labels", "error", err, "labels", a.Labels)
}
if _, ok := resultFPs[fp]; !ok {
// If the alert was previously firing, keep it around for a given

View File

@@ -6,16 +6,14 @@ import (
"time"
"log/slog"
"github.com/google/uuid"
"github.com/SigNoz/signoz/pkg/errors"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
"log/slog"
)
func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) {
@@ -153,7 +151,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
)
if err != nil {
slog.Error("failed to prepare a new threshold rule for test", "name", alertname, errors.Attr(err))
slog.Error("failed to prepare a new threshold rule for test", "name", alertname, "error", err)
return 0, basemodel.BadRequest(err)
}
@@ -175,7 +173,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
)
if err != nil {
slog.Error("failed to prepare a new promql rule for test", "name", alertname, errors.Attr(err))
slog.Error("failed to prepare a new promql rule for test", "name", alertname, "error", err)
return 0, basemodel.BadRequest(err)
}
} else if parsedRule.RuleType == ruletypes.RuleTypeAnomaly {
@@ -195,7 +193,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
baserules.WithMetadataStore(opts.ManagerOpts.MetadataStore),
)
if err != nil {
slog.Error("failed to prepare a new anomaly rule for test", "name", alertname, errors.Attr(err))
slog.Error("failed to prepare a new anomaly rule for test", "name", alertname, "error", err)
return 0, basemodel.BadRequest(err)
}
} else {
@@ -207,7 +205,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
alertsFound, err := rule.Eval(ctx, ts)
if err != nil {
slog.Error("evaluating rule failed", "rule", rule.Name(), errors.Attr(err))
slog.Error("evaluating rule failed", "rule", rule.Name(), "error", err)
return 0, basemodel.InternalError(fmt.Errorf("rule evaluation failed"))
}
rule.SendAlerts(ctx, ts, 0, time.Minute, opts.NotifyFunc)

View File

@@ -15,7 +15,6 @@ import (
"github.com/google/uuid"
"github.com/SigNoz/signoz/ee/query-service/model"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/query-service/utils/encryption"
@@ -77,14 +76,14 @@ func (lm *Manager) Start(ctx context.Context) error {
func (lm *Manager) UploadUsage(ctx context.Context) {
organizations, err := lm.orgGetter.ListByOwnedKeyRange(ctx)
if err != nil {
slog.ErrorContext(ctx, "failed to get organizations", errors.Attr(err))
slog.ErrorContext(ctx, "failed to get organizations", "error", err)
return
}
for _, organization := range organizations {
// check if license is present or not
license, err := lm.licenseService.GetActive(ctx, organization.ID)
if err != nil {
slog.ErrorContext(ctx, "failed to get active license", errors.Attr(err))
slog.ErrorContext(ctx, "failed to get active license", "error", err)
return
}
if license == nil {
@@ -116,7 +115,7 @@ func (lm *Manager) UploadUsage(ctx context.Context) {
dbusages := []model.UsageDB{}
err := lm.clickhouseConn.Select(ctx, &dbusages, fmt.Sprintf(query, db, db), time.Now().Add(-(24 * time.Hour)))
if err != nil && !strings.Contains(err.Error(), "doesn't exist") {
slog.ErrorContext(ctx, "failed to get usage from clickhouse", errors.Attr(err))
slog.ErrorContext(ctx, "failed to get usage from clickhouse", "error", err)
return
}
for _, u := range dbusages {
@@ -136,14 +135,14 @@ func (lm *Manager) UploadUsage(ctx context.Context) {
for _, usage := range usages {
usageDataBytes, err := encryption.Decrypt([]byte(usage.ExporterID[:32]), []byte(usage.Data))
if err != nil {
slog.ErrorContext(ctx, "error while decrypting usage data", errors.Attr(err))
slog.ErrorContext(ctx, "error while decrypting usage data", "error", err)
return
}
usageData := model.Usage{}
err = json.Unmarshal(usageDataBytes, &usageData)
if err != nil {
slog.ErrorContext(ctx, "error while unmarshalling usage data", errors.Attr(err))
slog.ErrorContext(ctx, "error while unmarshalling usage data", "error", err)
return
}
@@ -164,13 +163,13 @@ func (lm *Manager) UploadUsage(ctx context.Context) {
body, errv2 := json.Marshal(payload)
if errv2 != nil {
slog.ErrorContext(ctx, "error while marshalling usage payload", errors.Attr(errv2))
slog.ErrorContext(ctx, "error while marshalling usage payload", "error", errv2)
return
}
errv2 = lm.zeus.PutMeters(ctx, payload.LicenseKey.String(), body)
if errv2 != nil {
slog.ErrorContext(ctx, "failed to upload usage", errors.Attr(errv2))
slog.ErrorContext(ctx, "failed to upload usage", "error", errv2)
// not returning error here since it is captured in the failed count
return
}

View File

@@ -4,12 +4,11 @@ import (
"context"
"database/sql"
"github.com/uptrace/bun"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
)
type provider struct {
@@ -114,7 +113,7 @@ WHERE
defer func() {
if err := constraintsRows.Close(); err != nil {
provider.settings.Logger().ErrorContext(ctx, "error closing rows", errors.Attr(err))
provider.settings.Logger().ErrorContext(ctx, "error closing rows", "error", err)
}
}()
@@ -175,7 +174,7 @@ WHERE
defer func() {
if err := foreignKeyConstraintsRows.Close(); err != nil {
provider.settings.Logger().ErrorContext(ctx, "error closing rows", errors.Attr(err))
provider.settings.Logger().ErrorContext(ctx, "error closing rows", "error", err)
}
}()
@@ -244,7 +243,7 @@ ORDER BY index_name, column_position`, string(name))
defer func() {
if err := rows.Close(); err != nil {
provider.settings.Logger().ErrorContext(ctx, "error closing rows", errors.Attr(err))
provider.settings.Logger().ErrorContext(ctx, "error closing rows", "error", err)
}
}()

View File

@@ -101,7 +101,7 @@ func (provider *provider) WrapNotFoundErrf(err error, code errors.Code, format s
func (provider *provider) WrapAlreadyExistsErrf(err error, code errors.Code, format string, args ...any) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && (pgErr.Code == "23505" || pgErr.Code == "23503") {
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return errors.Wrapf(err, errors.TypeAlreadyExists, code, format, args...)
}

View File

@@ -1,97 +0,0 @@
---
globs: **/*.store.ts
alwaysApply: false
---
# State Management: React Query, nuqs, Zustand
Use the following stack. Do **not** introduce or recommend Redux or React Context for shared/global state.
## Server state → React Query
- **Use for:** API responses, time-series data, caching, background refetch, retries, stale/refresh.
- **Do not use Redux/Context** to store or mirror data that comes from React Query (e.g. do not dispatch API results into Redux).
- Prefer generated React Query hooks from `frontend/src/api/generated` when available.
- Keep server state in React Query; expose it via hooks that return the query result (and optionally memoized derived values). Do not duplicate it in Redux or Context.
```tsx
// ✅ GOOD: single source of truth from React Query
export function useAppStateHook() {
const { data, isError } = useQuery(...)
const memoizedConfigs = useMemo(() => ({ ... }), [data?.configs])
return { configs: memoizedConfigs, isError, ... }
}
// ❌ BAD: copying React Query result into Redux
dispatch({ type: UPDATE_LATEST_VERSION, payload: queryResponse.data })
```
## URL state → nuqs
- **Use for:** shareable state, filters, time range, selected values, pagination, view state that belongs in the URL.
- **Do not use Redux/Context** for state that should be shareable or reflected in the URL.
- Use [nuqs](https://nuqs.dev/docs/basic-usage) for typed, type-safe URL search params. Avoid ad-hoc `useSearchParams` encoding/decoding.
- Keep URL payload small; respect browser URL length limits (e.g. Chrome ~2k chars). Do not put large datasets or sensitive data in query params.
```tsx
// ✅ GOOD: nuqs for filters / time range / selection
const [timeRange, setTimeRange] = useQueryState('timeRange', parseAsString.withDefault('1h'))
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
// ❌ BAD: Redux/Context for shareable or URL-synced state
const { timeRange } = useContext(SomeContext)
```
## Client state → Zustand
- **Use for:** global/client state, cross-component state, feature flags, complex or large client objects (e.g. dashboard state, query builder state).
- **Do not use Redux or React Context** for global or feature-level client state.
- Prefer small, domain-scoped stores (e.g. DashboardStore, QueryBuilderStore).
### Zustand best practices (align with eslint-plugin-zustand-rules)
- **One store per module.** Do not define multiple `create()` calls in the same file; use one store per module (or compose slices into one store).
- **Always use selectors.** Call the store hook with a selector so only the used slice triggers re-renders. Never use `useStore()` with no selector.
```tsx
// ✅ GOOD: selector — re-renders only when isDashboardLocked changes
const isLocked = useDashboardStore(state => state.isDashboardLocked)
// ❌ BAD: no selector — re-renders on any store change
const state = useDashboardStore()
```
- **Never mutate state directly.** Update only via `set` or `setState` (or `getState()` + `set` for reads). No `state.foo = x` or `state.bears += 1` inside actions.
```tsx
// ✅ GOOD: use set
increment: () => set(state => ({ bears: state.bears + 1 }))
// ❌ BAD: direct mutation
increment: () => { state.bears += 1 }
```
- **State properties before actions.** In the store object, list all state fields first, then action functions.
- **Split into slices when state is large.** If a store has many top-level properties (e.g. more than 510), split into slice factories and combine with one `create()`.
```tsx
// ✅ GOOD: slices for large state
const createBearSlice = set => ({ bears: 0, addBear: () => set(s => ({ bears: s.bears + 1 })) })
const createFishSlice = set => ({ fish: 0, addFish: () => set(s => ({ fish: s.fish + 1 })) })
const useStore = create(set => ({ ...createBearSlice(set), ...createFishSlice(set) }))
```
- **In projects using Zustand:** add `eslint-plugin-zustand-rules` and extend `plugin:zustand-rules/recommended` to enforce these rules automatically.
## Local state → React state only
- **Use useState/useReducer** for: component-local UI state, form inputs, toggles, hover state, data that never leaves the component.
- Do not use Zustand, Redux, or Context for state that is purely local to one component or a small subtree.
## Summary
| State type | Use | Avoid |
|-------------------|------------------|--------------------|
| Server / API | React Query | Redux, Context |
| URL / shareable | nuqs | Redux, Context |
| Global client | Zustand | Redux, Context |
| Local UI | useState/useReducer | Zustand, Redux, Context |

View File

@@ -1,150 +0,0 @@
---
name: migrate-state-management
description: Migrate Redux or React Context to the correct state option (React Query for server state, nuqs for URL/shareable state, Zustand for global client state). Use when refactoring away from Redux/Context, moving state to the right store, or when the user asks to migrate state management.
---
# Migrate State: Redux/Context → React Query, nuqs, Zustand
Do **not** introduce or recommend Redux or React Context. Migrate existing usage to the stack below.
## 1. Classify the state
Before changing code, classify what the state represents:
| If the state is… | Migrate to | Do not use |
|------------------|------------|------------|
| From API / server (versions, configs, fetched lists, time-series) | **React Query** | Redux, Context |
| Shareable via URL (filters, time range, page, selected ids) | **nuqs** | Redux, Context |
| Global/client UI (dashboard lock, query builder, feature flags, large client objects) | **Zustand** | Redux, Context |
| Local to one component (inputs, toggles, hover) | **useState / useReducer** | Zustand, Redux, Context |
If one slice mixes concerns (e.g. Redux has both API data and pagination), split: API → React Query, pagination → nuqs, rest → Zustand or local state.
## 2. Migrate to React Query (server state)
**When:** State comes from or mirrors an API response (e.g. `currentVersion`, `latestVersion`, `configs`, lists).
**Steps:**
1. Find where the data is fetched (existing `useQuery`/API call) and where it is dispatched or set in Context/Redux.
2. Remove the dispatch/set that writes API results into Redux/Context.
3. Expose a single hook that uses the query and returns the same shape consumers expect (use `useMemo` for derived objects like `configs` to avoid unnecessary re-renders).
4. Replace Redux/Context consumption with the new hook. Prefer generated React Query hooks from `frontend/src/api/generated` when available.
5. Configure cache/refetch (e.g. `refetchOnMount: false`, `staleTime`) so behavior matches previous “single source” expectations.
**Before (Redux mirroring React Query):**
```tsx
if (getUserLatestVersionResponse.isFetched && getUserLatestVersionResponse.isSuccess && getUserLatestVersionResponse.data?.payload) {
dispatch({ type: UPDATE_LATEST_VERSION, payload: { latestVersion: getUserLatestVersionResponse.data.payload.tag_name } })
}
```
**After (single source in React Query):**
```tsx
export function useAppStateHook() {
const { data, isError } = useQuery(...)
const memoizedConfigs = useMemo(() => ({ ... }), [data?.configs])
return {
latestVersion: data?.payload?.tag_name,
configs: memoizedConfigs,
isError,
}
}
```
Consumers use `useAppStateHook()` instead of `useSelector` or Context. Do not copy React Query result into Redux or Context.
## 3. Migrate to nuqs (URL / shareable state)
**When:** State should be in the URL: filters, time range, pagination, selected values, view state. Keep payload small (e.g. Chrome ~2k chars); no large datasets or sensitive data.
**Steps:**
1. Identify which Redux/Context fields are shareable or already reflected in the URL (e.g. `currentPage`, `timeRange`, `selectedFilter`).
2. Add nuqs (or use existing): `useQueryState('param', parseAsString.withDefault('…'))` (or `parseAsInteger`, etc.).
3. Replace reads/writes of those fields with nuqs hooks. Use typed parsers; avoid ad-hoc `useSearchParams` encoding/decoding.
4. Remove the same fields from Redux/Context and their reducers/providers.
**Before (Context/Redux):**
```tsx
const { timeRange } = useContext(SomeContext)
const [page, setPage] = useDispatch(...)
```
**After (nuqs):**
```tsx
const [timeRange, setTimeRange] = useQueryState('timeRange', parseAsString.withDefault('1h'))
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
```
## 4. Migrate to Zustand (global client state)
**When:** State is global or cross-component client state: feature flags, dashboard state, query builder state, complex/large client objects (e.g. up to ~1.52MB). Not for server cache or local-only UI.
**Steps:**
1. Create one store per domain (e.g. `DashboardStore`, `QueryBuilderStore`). One `create()` per module; for large state use slice factories and combine.
2. Put state properties first, then actions. Use `set` (or `setState` / `getState()` + `set`) for updates; never mutate state directly.
3. Replace Context/Redux consumption with the store hook **and a selector** so only the used slice triggers re-renders.
4. Remove the old Context provider / Redux slice and related dispatches.
**Selector (required):**
```tsx
const isLocked = useDashboardStore(state => state.isDashboardLocked)
```
Never use `useStore()` with no selector. Never do `state.foo = x` inside actions; use `set(state => ({ ... }))`.
**Before (Context/Redux):**
```tsx
const { isDashboardLocked, setLocked } = useContext(DashboardContext)
```
**After (Zustand):**
```tsx
const isLocked = useDashboardStore(state => state.isDashboardLocked)
const setLocked = useDashboardStore(state => state.setLocked)
```
For large stores (many top-level fields), split into slices and combine:
```tsx
const createBearSlice = set => ({ bears: 0, addBear: () => set(s => ({ bears: s.bears + 1 })) })
const useStore = create(set => ({ ...createBearSlice(set), ...createFishSlice(set) }))
```
Add `eslint-plugin-zustand-rules` with `plugin:zustand-rules/recommended` to enforce selectors and no direct mutation.
## 5. Migrate to local state (useState / useReducer)
**When:** State is used only inside one component or a small subtree (form inputs, toggles, hover, panel selection). No URL sync, no cross-feature sharing.
**Steps:**
1. Move the state into the component that owns it (or the smallest common parent).
2. Use `useState` or `useReducer` (useReducer when multiple related fields change together).
3. Remove from Redux/Context and any provider/slice.
Do not use Zustand, Redux, or Context for purely local UI state.
## 6. Migration checklist
- [ ] Classify each piece of state (server / URL / global client / local).
- [ ] Server state: move to React Query; expose via hook; remove Redux/Context mirroring.
- [ ] URL state: move to nuqs; remove from Redux/Context; keep URL payload small.
- [ ] Global client state: move to Zustand with selectors and immutable updates; one store per domain.
- [ ] Local state: move to useState/useReducer in the owning component.
- [ ] Remove old Redux slices / Context providers and all dispatches/consumers for migrated state.
- [ ] Do not duplicate the same data in multiple places (e.g. React Query + Redux).
## Additional resources
- Project rule: [.cursor/rules/state-management.mdc](../../rules/state-management.mdc)
- Detailed patterns and rationale: [reference.md](reference.md)

View File

@@ -1,50 +0,0 @@
# State migration reference
## Why migrate
- **Context:** Re-renders all consumers on any change; no granular subscriptions; becomes brittle at scale.
- **Redux:** Heavy boilerplate (actions, reducers, selectors, Provider); slower onboarding; often used to mirror React Query or URL state.
- **Goal:** Fewer mechanisms, domain isolation, granular subscriptions, single source of truth per state type.
## React Query migration (server state)
Typical anti-pattern: API is called via React Query, then result is dispatched to Redux. Flow becomes: Component → useQueries → API → dispatch → Reducer → Redux state → useSelector.
Correct flow: Component → useQuery (or custom hook wrapping it) → same component reads from hook. No Redux/Context in between.
- Prefer generated hooks from `frontend/src/api/generated`.
- For “app state” that is just API data (versions, configs), one hook that returns `{ ...data, configs: useMemo(...) }` is enough. No selectors needed for plain data; useMemo only where the value is used as dependency (e.g. in useState).
- Set `staleTime` / `refetchOnMount` etc. so refetch behavior matches previous expectations.
## nuqs migration (URL state)
Redux/Context often hold pagination, filters, time range, selected values that are shareable. Those belong in the URL.
- Use [nuqs](https://nuqs.dev/docs/basic-usage) for typed search params. Avoid ad-hoc `useSearchParams` + manual encoding.
- Browser limits: Chrome ~2k chars practical; keep payload small; no large datasets or secrets in query params.
- If the app uses TanStack Router, search params can be handled there; otherwise nuqs is the standard.
## Zustand migration (client state)
- One store per domain (e.g. DashboardStore, QueryBuilderStore). Multiple `create()` in one file is disallowed; use one store or composed slices.
- Always use a selector: `useStore(s => s.field)` so only that field drives re-renders.
- Never mutate: update only via `set(state => ({ ... }))` or `setState` / `getState()` + `set`.
- State properties first, then actions. For 510+ top-level fields, split into slice factories and combine with one `create()`.
- Large client objects: Zustand is for “large” in the ~1.52MB range; above that, optimize at API/store design.
- Testing: no Provider; stores are plain functions; easy to reset and mock.
## What not to use
- **Redux / Context** for new or migrated shared/global state.
- **Redux / Context** to store or mirror React Query results.
- **Redux / Context** for state that should live in the URL (use nuqs).
- **Zustand / Redux / Context** for component-local UI (use useState/useReducer).
## Summary table
| State type | Use | Avoid |
|-------------|--------------------|-----------------|
| Server/API | React Query | Redux, Context |
| URL/shareable | nuqs | Redux, Context |
| Global client | Zustand | Redux, Context |
| Local UI | useState/useReducer | Zustand, Redux, Context |

View File

@@ -193,16 +193,6 @@ module.exports = {
],
},
],
'no-restricted-syntax': [
'error',
{
selector:
// TODO: Make this generic on removal of redux
"CallExpression[callee.property.name='getState'][callee.object.name=/^use/]",
message:
'Avoid calling .getState() directly. Export a standalone action from the store instead.',
},
],
},
overrides: [
{
@@ -227,13 +217,5 @@ module.exports = {
'@typescript-eslint/no-unused-vars': 'warn',
},
},
{
// Store definition files are the only place .getState() is permitted —
// they are the canonical source for standalone action exports.
files: ['**/*Store.{ts,tsx}'],
rules: {
'no-restricted-syntax': 'off',
},
},
],
};

View File

@@ -137,7 +137,6 @@
"react-full-screen": "1.1.1",
"react-grid-layout": "^1.3.4",
"react-helmet-async": "1.3.0",
"react-hook-form": "7.71.2",
"react-i18next": "^11.16.1",
"react-lottie": "1.2.10",
"react-markdown": "8.0.7",

View File

@@ -15,6 +15,5 @@
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles",
"role_details": "Role Details",
"members": "Members",
"service_accounts": "Service Accounts"
"members": "Members"
}

View File

@@ -50,8 +50,5 @@
"INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring",
"METER_EXPLORER": "SigNoz | Meter Explorer",
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
"METER": "SigNoz | Meter"
}

View File

@@ -15,6 +15,5 @@
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles",
"role_details": "Role Details",
"members": "Members",
"service_accounts": "Service Accounts"
"members": "Members"
}

View File

@@ -75,6 +75,5 @@
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
"MEMBERS_SETTINGS": "SigNoz | Members"
}

View File

@@ -776,45 +776,6 @@ export interface GatewaytypesUpdatableIngestionKeyLimitDTO {
tags?: string[] | null;
}
export interface GlobaltypesAPIKeyConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface GlobaltypesConfigDTO {
/**
* @type string
*/
external_url?: string;
identN?: GlobaltypesIdentNConfigDTO;
/**
* @type string
*/
ingestion_url?: string;
}
export interface GlobaltypesIdentNConfigDTO {
apikey?: GlobaltypesAPIKeyConfigDTO;
impersonation?: GlobaltypesImpersonationConfigDTO;
tokenizer?: GlobaltypesTokenizerConfigDTO;
}
export interface GlobaltypesImpersonationConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface GlobaltypesTokenizerConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface MetricsexplorertypesListMetricDTO {
/**
* @type string
@@ -2384,47 +2345,6 @@ export interface TypesChangePasswordRequestDTO {
userId?: string;
}
export interface TypesDeprecatedUserDTO {
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
displayName?: string;
/**
* @type string
*/
email?: string;
/**
* @type string
*/
id: string;
/**
* @type boolean
*/
isRoot?: boolean;
/**
* @type string
*/
orgId?: string;
/**
* @type string
*/
role?: string;
/**
* @type string
*/
status?: string;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
}
export interface TypesGettableAPIKeyDTO {
/**
* @type string
@@ -2482,6 +2402,17 @@ export interface TypesGettableAPIKeyDTO {
userId?: string;
}
export interface TypesGettableGlobalConfigDTO {
/**
* @type string
*/
external_url?: string;
/**
* @type string
*/
ingestion_url?: string;
}
export interface TypesIdentifiableDTO {
/**
* @type string
@@ -2723,6 +2654,10 @@ export interface TypesUserDTO {
* @type string
*/
orgId?: string;
/**
* @type string
*/
role?: string;
/**
* @type string
*/
@@ -3072,7 +3007,7 @@ export type GetResetPasswordToken200 = {
};
export type GetGlobalConfig200 = {
data: GlobaltypesConfigDTO;
data: TypesGettableGlobalConfigDTO;
/**
* @type string
*/
@@ -3303,7 +3238,7 @@ export type ListUsers200 = {
/**
* @type array
*/
data: TypesDeprecatedUserDTO[];
data: TypesUserDTO[];
/**
* @type string
*/
@@ -3317,7 +3252,7 @@ export type GetUserPathParameters = {
id: string;
};
export type GetUser200 = {
data: TypesDeprecatedUserDTO;
data: TypesUserDTO;
/**
* @type string
*/
@@ -3328,7 +3263,7 @@ export type UpdateUserPathParameters = {
id: string;
};
export type UpdateUser200 = {
data: TypesDeprecatedUserDTO;
data: TypesUserDTO;
/**
* @type string
*/
@@ -3336,7 +3271,7 @@ export type UpdateUser200 = {
};
export type GetMyUser200 = {
data: TypesDeprecatedUserDTO;
data: TypesUserDTO;
/**
* @type string
*/

View File

@@ -34,13 +34,13 @@ import type {
RenderErrorResponseDTO,
RevokeAPIKeyPathParameters,
TypesChangePasswordRequestDTO,
TypesDeprecatedUserDTO,
TypesPostableAPIKeyDTO,
TypesPostableBulkInviteRequestDTO,
TypesPostableForgotPasswordDTO,
TypesPostableInviteDTO,
TypesPostableResetPasswordDTO,
TypesStorableAPIKeyDTO,
TypesUserDTO,
UpdateAPIKeyPathParameters,
UpdateUser200,
UpdateUserPathParameters,
@@ -1093,13 +1093,13 @@ export const invalidateGetUser = async (
*/
export const updateUser = (
{ id }: UpdateUserPathParameters,
typesDeprecatedUserDTO: BodyType<TypesDeprecatedUserDTO>,
typesUserDTO: BodyType<TypesUserDTO>,
) => {
return GeneratedAPIInstance<UpdateUser200>({
url: `/api/v1/user/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: typesDeprecatedUserDTO,
data: typesUserDTO,
});
};
@@ -1110,19 +1110,13 @@ export const getUpdateUserMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateUser>>,
TError,
{
pathParams: UpdateUserPathParameters;
data: BodyType<TypesDeprecatedUserDTO>;
},
{ pathParams: UpdateUserPathParameters; data: BodyType<TypesUserDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateUser>>,
TError,
{
pathParams: UpdateUserPathParameters;
data: BodyType<TypesDeprecatedUserDTO>;
},
{ pathParams: UpdateUserPathParameters; data: BodyType<TypesUserDTO> },
TContext
> => {
const mutationKey = ['updateUser'];
@@ -1136,10 +1130,7 @@ export const getUpdateUserMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateUser>>,
{
pathParams: UpdateUserPathParameters;
data: BodyType<TypesDeprecatedUserDTO>;
}
{ pathParams: UpdateUserPathParameters; data: BodyType<TypesUserDTO> }
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -1152,7 +1143,7 @@ export const getUpdateUserMutationOptions = <
export type UpdateUserMutationResult = NonNullable<
Awaited<ReturnType<typeof updateUser>>
>;
export type UpdateUserMutationBody = BodyType<TypesDeprecatedUserDTO>;
export type UpdateUserMutationBody = BodyType<TypesUserDTO>;
export type UpdateUserMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -1165,19 +1156,13 @@ export const useUpdateUser = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateUser>>,
TError,
{
pathParams: UpdateUserPathParameters;
data: BodyType<TypesDeprecatedUserDTO>;
},
{ pathParams: UpdateUserPathParameters; data: BodyType<TypesUserDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateUser>>,
TError,
{
pathParams: UpdateUserPathParameters;
data: BodyType<TypesDeprecatedUserDTO>;
},
{ pathParams: UpdateUserPathParameters; data: BodyType<TypesUserDTO> },
TContext
> => {
const mutationOptions = getUpdateUserMutationOptions(options);

View File

@@ -81,8 +81,7 @@ export const interceptorRejected = async (
response.config.url !== '/sessions/email_password' &&
!(
response.config.url === '/sessions' && response.config.method === 'delete'
) &&
response.config.url !== '/authz/check'
)
) {
try {
const accessToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN);

View File

@@ -1,152 +0,0 @@
import axios, { AxiosHeaders, AxiosResponse } from 'axios';
import { interceptorRejected } from './index';
jest.mock('api/browser/localstorage/get', () => ({
__esModule: true,
default: jest.fn(() => 'mock-token'),
}));
jest.mock('api/v2/sessions/rotate/post', () => ({
__esModule: true,
default: jest.fn(() =>
Promise.resolve({
data: { accessToken: 'new-token', refreshToken: 'new-refresh' },
}),
),
}));
jest.mock('AppRoutes/utils', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('axios', () => {
const actualAxios = jest.requireActual('axios');
const mockAxios = jest.fn().mockResolvedValue({ data: 'success' });
return {
...actualAxios,
default: Object.assign(mockAxios, {
...actualAxios.default,
isAxiosError: jest.fn().mockReturnValue(true),
create: actualAxios.create,
}),
__esModule: true,
};
});
describe('interceptorRejected', () => {
beforeEach(() => {
jest.clearAllMocks();
((axios as unknown) as jest.Mock).mockResolvedValue({ data: 'success' });
((axios.isAxiosError as unknown) as jest.Mock).mockReturnValue(true);
});
it('should preserve array payload structure when retrying a 401 request', async () => {
const arrayPayload = [
{ relation: 'assignee', object: { resource: { name: 'role' } } },
{ relation: 'assignee', object: { resource: { name: 'editor' } } },
];
const error = ({
response: {
status: 401,
config: {
url: '/some-endpoint',
method: 'POST',
baseURL: 'http://localhost/',
headers: new AxiosHeaders(),
data: JSON.stringify(arrayPayload),
},
},
config: {
url: '/some-endpoint',
method: 'POST',
baseURL: 'http://localhost/',
headers: new AxiosHeaders(),
data: JSON.stringify(arrayPayload),
},
} as unknown) as AxiosResponse;
try {
await interceptorRejected(error);
} catch {
// Expected to reject after retry
}
const mockAxiosFn = (axios as unknown) as jest.Mock;
expect(mockAxiosFn.mock.calls.length).toBe(1);
const retryCallConfig = mockAxiosFn.mock.calls[0][0];
expect(Array.isArray(JSON.parse(retryCallConfig.data))).toBe(true);
expect(JSON.parse(retryCallConfig.data)).toEqual(arrayPayload);
});
it('should preserve object payload structure when retrying a 401 request', async () => {
const objectPayload = { key: 'value', nested: { data: 123 } };
const error = ({
response: {
status: 401,
config: {
url: '/some-endpoint',
method: 'POST',
baseURL: 'http://localhost/',
headers: new AxiosHeaders(),
data: JSON.stringify(objectPayload),
},
},
config: {
url: '/some-endpoint',
method: 'POST',
baseURL: 'http://localhost/',
headers: new AxiosHeaders(),
data: JSON.stringify(objectPayload),
},
} as unknown) as AxiosResponse;
try {
await interceptorRejected(error);
} catch {
// Expected to reject after retry
}
const mockAxiosFn = (axios as unknown) as jest.Mock;
expect(mockAxiosFn.mock.calls.length).toBe(1);
const retryCallConfig = mockAxiosFn.mock.calls[0][0];
expect(JSON.parse(retryCallConfig.data)).toEqual(objectPayload);
});
it('should handle undefined data gracefully when retrying', async () => {
const error = ({
response: {
status: 401,
config: {
url: '/some-endpoint',
method: 'GET',
baseURL: 'http://localhost/',
headers: new AxiosHeaders(),
data: undefined,
},
},
config: {
url: '/some-endpoint',
method: 'GET',
baseURL: 'http://localhost/',
headers: new AxiosHeaders(),
data: undefined,
},
} as unknown) as AxiosResponse;
try {
await interceptorRejected(error);
} catch {
// Expected to reject after retry
}
const mockAxiosFn = (axios as unknown) as jest.Mock;
expect(mockAxiosFn.mock.calls.length).toBe(1);
const retryCallConfig = mockAxiosFn.mock.calls[0][0];
expect(retryCallConfig.data).toBeUndefined();
});
});

View File

@@ -8,32 +8,42 @@ export const downloadExportData = async (
props: ExportRawDataProps,
): Promise<void> => {
try {
const response = await axios.post<Blob>(
`export_raw_data?format=${encodeURIComponent(props.format)}`,
props.body,
{
responseType: 'blob',
decompress: true,
headers: {
Accept: 'application/octet-stream',
'Content-Type': 'application/json',
},
timeout: 0,
},
);
const queryParams = new URLSearchParams();
queryParams.append('start', String(props.start));
queryParams.append('end', String(props.end));
queryParams.append('filter', props.filter);
props.columns.forEach((col) => {
queryParams.append('columns', col);
});
queryParams.append('order_by', props.orderBy);
queryParams.append('limit', String(props.limit));
queryParams.append('format', props.format);
const response = await axios.get<Blob>(`export_raw_data?${queryParams}`, {
responseType: 'blob', // Important: tell axios to handle response as blob
decompress: true, // Enable automatic decompression
headers: {
Accept: 'application/octet-stream', // Tell server we expect binary data
},
timeout: 0,
});
// Only proceed if the response status is 200
if (response.status !== 200) {
throw new Error(
`Failed to download data: server returned status ${response.status}`,
);
}
// Create blob URL from response data
const blob = new Blob([response.data], { type: 'application/octet-stream' });
const url = window.URL.createObjectURL(blob);
// Create and configure download link
const link = document.createElement('a');
link.href = url;
// Get filename from Content-Disposition header or generate timestamped default
const filename =
response.headers['content-disposition']
?.split('filename=')[1]
@@ -41,6 +51,7 @@ export const downloadExportData = async (
link.setAttribute('download', filename);
// Trigger download
document.body.appendChild(link);
link.click();
link.remove();

View File

@@ -0,0 +1,20 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Props } from 'types/api/user/deleteUser';
const deleteUser = async (props: Props): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.delete(`/user/${props.userId}`);
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default deleteUser;

View File

@@ -1,14 +1,8 @@
function UnAuthorized({
width = 137,
height = 137,
}: {
height?: number;
width?: number;
}): JSX.Element {
function UnAuthorized(): JSX.Element {
return (
<svg
width={width}
height={height}
width="137"
height="137"
viewBox="0 0 137 137"
fill="none"
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,97 +0,0 @@
.announcement-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--padding-2) var(--padding-4);
height: 40px;
font-family: var(--font-sans), sans-serif;
font-size: var(--label-base-500-font-size);
line-height: var(--label-base-500-line-height);
font-weight: var(--label-base-500-font-weight);
letter-spacing: -0.065px;
&--warning {
background-color: var(--callout-warning-background);
color: var(--callout-warning-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-warning-border);
}
}
&--info {
background-color: var(--callout-primary-background);
color: var(--callout-primary-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-primary-border);
}
}
&--error {
background-color: var(--callout-error-background);
color: var(--callout-error-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-error-border);
}
}
&--success {
background-color: var(--callout-success-background);
color: var(--callout-success-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-success-border);
}
}
&__body {
display: flex;
align-items: center;
gap: var(--spacing-4);
flex: 1;
min-width: 0;
}
&__icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
&__message {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: var(--line-height-normal);
strong {
font-weight: var(--font-weight-semibold);
}
}
&__action {
height: 24px;
font-size: var(--label-small-500-font-size);
color: currentColor;
&:hover {
opacity: 0.8;
}
}
&__dismiss {
width: 24px;
height: 24px;
padding: 0;
color: currentColor;
&:hover {
opacity: 0.8;
}
}
}

View File

@@ -1,89 +0,0 @@
import { render, screen, userEvent } from 'tests/test-utils';
import {
AnnouncementBanner,
AnnouncementBannerProps,
PersistedAnnouncementBanner,
} from './index';
const STORAGE_KEY = 'test-banner-dismissed';
function renderBanner(props: Partial<AnnouncementBannerProps> = {}): void {
render(<AnnouncementBanner message="Test message" {...props} />);
}
afterEach(() => {
localStorage.removeItem(STORAGE_KEY);
});
describe('AnnouncementBanner', () => {
it('renders message and default warning variant', () => {
renderBanner({ message: <strong>Heads up</strong> });
const alert = screen.getByRole('alert');
expect(alert).toHaveClass('announcement-banner--warning');
expect(alert).toHaveTextContent('Heads up');
});
it.each(['warning', 'info', 'success', 'error'] as const)(
'renders %s variant correctly',
(type) => {
renderBanner({ type, message: 'Test message' });
const alert = screen.getByRole('alert');
expect(alert).toHaveClass(`announcement-banner--${type}`);
},
);
it('calls action onClick when action button is clicked', async () => {
const onClick = jest.fn() as jest.MockedFunction<() => void>;
renderBanner({ action: { label: 'Go to Settings', onClick } });
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /go to settings/i }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('hides dismiss button when onClose is not provided and hides icon when icon is null', () => {
renderBanner({ onClose: undefined, icon: null });
expect(
screen.queryByRole('button', { name: /dismiss/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('alert')?.querySelector('.announcement-banner__icon'),
).not.toBeInTheDocument();
});
});
describe('PersistedAnnouncementBanner', () => {
it('dismisses on click, calls onDismiss, and persists to localStorage', async () => {
const onDismiss = jest.fn() as jest.MockedFunction<() => void>;
render(
<PersistedAnnouncementBanner
message="Test message"
storageKey={STORAGE_KEY}
onDismiss={onDismiss}
/>,
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /dismiss/i }));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(STORAGE_KEY)).toBe('true');
});
it('does not render when storageKey is already set in localStorage', () => {
localStorage.setItem(STORAGE_KEY, 'true');
render(
<PersistedAnnouncementBanner
message="Test message"
storageKey={STORAGE_KEY}
/>,
);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});

View File

@@ -1,84 +0,0 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/button';
import {
CircleAlert,
CircleCheckBig,
Info,
TriangleAlert,
X,
} from '@signozhq/icons';
import cx from 'classnames';
import './AnnouncementBanner.styles.scss';
export type AnnouncementBannerType = 'warning' | 'info' | 'error' | 'success';
export interface AnnouncementBannerAction {
label: string;
onClick: () => void;
}
export interface AnnouncementBannerProps {
message: ReactNode;
type?: AnnouncementBannerType;
icon?: ReactNode | null;
action?: AnnouncementBannerAction;
onClose?: () => void;
className?: string;
}
const DEFAULT_ICONS: Record<AnnouncementBannerType, ReactNode> = {
warning: <TriangleAlert size={14} />,
info: <Info size={14} />,
error: <CircleAlert size={14} />,
success: <CircleCheckBig size={14} />,
};
export default function AnnouncementBanner({
message,
type = 'warning',
icon,
action,
onClose,
className,
}: AnnouncementBannerProps): JSX.Element {
const resolvedIcon = icon === null ? null : icon ?? DEFAULT_ICONS[type];
return (
<div
role="alert"
className={cx(
'announcement-banner',
`announcement-banner--${type}`,
className,
)}
>
<div className="announcement-banner__body">
{resolvedIcon && (
<span className="announcement-banner__icon">{resolvedIcon}</span>
)}
<span className="announcement-banner__message">{message}</span>
{action && (
<Button
type="button"
className="announcement-banner__action"
onClick={action.onClick}
>
{action.label}
</Button>
)}
</div>
{onClose && (
<Button
type="button"
aria-label="Dismiss"
className="announcement-banner__dismiss"
onClick={onClose}
>
<X size={14} />
</Button>
)}
</div>
);
}

View File

@@ -1,34 +0,0 @@
import { useState } from 'react';
import AnnouncementBanner, {
AnnouncementBannerProps,
} from './AnnouncementBanner';
interface PersistedAnnouncementBannerProps extends AnnouncementBannerProps {
storageKey: string;
onDismiss?: () => void;
}
function isDismissed(storageKey: string): boolean {
return localStorage.getItem(storageKey) === 'true';
}
export default function PersistedAnnouncementBanner({
storageKey,
onDismiss,
...props
}: PersistedAnnouncementBannerProps): JSX.Element | null {
const [visible, setVisible] = useState(() => !isDismissed(storageKey));
if (!visible) {
return null;
}
const handleClose = (): void => {
localStorage.setItem(storageKey, 'true');
setVisible(false);
onDismiss?.();
};
return <AnnouncementBanner {...props} onClose={handleClose} />;
}

View File

@@ -1,12 +0,0 @@
import AnnouncementBanner from './AnnouncementBanner';
import PersistedAnnouncementBanner from './PersistedAnnouncementBanner';
export type {
AnnouncementBannerAction,
AnnouncementBannerProps,
AnnouncementBannerType,
} from './AnnouncementBanner';
export { AnnouncementBanner, PersistedAnnouncementBanner };
export default AnnouncementBanner;

View File

@@ -1,106 +0,0 @@
.create-sa-modal {
max-width: 530px;
background: var(--popover);
border: 1px solid var(--secondary);
border-radius: 4px;
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
[data-slot='dialog-header'] {
padding: var(--padding-4);
border-bottom: 1px solid var(--secondary);
flex-shrink: 0;
background: transparent;
margin: 0;
}
[data-slot='dialog-title'] {
font-size: var(--label-base-400-font-size);
font-weight: var(--label-base-400-font-weight);
line-height: var(--label-base-400-line-height);
letter-spacing: -0.065px;
color: var(--bg-base-white);
margin: 0;
}
[data-slot='dialog-description'] {
padding: 0;
.create-sa-modal__content {
padding: var(--padding-4);
}
}
}
.create-sa-form {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
&__item {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
margin-bottom: var(--spacing-4);
> label {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
letter-spacing: -0.07px;
}
}
&__input {
height: 32px;
color: var(--l1-foreground);
background-color: var(--l2-background);
border-color: var(--border);
font-size: var(--paragraph-base-400-font-size);
border-radius: 2px;
width: 100%;
&::placeholder {
color: var(--l3-foreground);
}
&:focus {
border-color: var(--primary);
box-shadow: none;
}
}
&__error {
font-size: var(--paragraph-small-400-font-size);
color: var(--destructive);
line-height: var(--paragraph-small-400-line-height);
margin: 0;
}
&__helper {
font-size: var(--paragraph-small-400-font-size);
color: var(--l3-foreground);
margin: calc(var(--spacing-2) * -1) 0 var(--spacing-4) 0;
line-height: var(--paragraph-small-400-line-height);
}
}
.create-sa-modal__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
padding: 0 var(--padding-4);
height: 56px;
min-height: 56px;
border-top: 1px solid var(--secondary);
gap: var(--spacing-4);
flex-shrink: 0;
}
.lightMode {
.create-sa-modal {
[data-slot='dialog-title'] {
color: var(--bg-base-black);
}
}
}

View File

@@ -1,230 +0,0 @@
import { Controller, useForm } from 'react-hook-form';
import { useQueryClient } from 'react-query';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { X } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
invalidateListServiceAccounts,
useCreateServiceAccount,
} from 'api/generated/services/serviceaccount';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import RolesSelect, { useRoles } from 'components/RolesSelect';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { EMAIL_REGEX } from 'utils/app';
import './CreateServiceAccountModal.styles.scss';
interface FormValues {
name: string;
email: string;
roles: string[];
}
function CreateServiceAccountModal(): JSX.Element {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useQueryState(
SA_QUERY_PARAMS.CREATE_SA,
parseAsBoolean.withDefault(false),
);
const {
control,
handleSubmit,
reset,
formState: { isValid, errors },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: {
name: '',
email: '',
roles: [],
},
});
const {
mutate: createServiceAccount,
isLoading: isSubmitting,
} = useCreateServiceAccount({
mutation: {
onSuccess: async () => {
toast.success('Service account created successfully', {
richColors: true,
});
reset();
await setIsOpen(null);
await invalidateListServiceAccounts(queryClient);
},
onError: (err) => {
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
toast.error(`Failed to create service account: ${errMessage}`, {
richColors: true,
});
},
},
});
const {
roles,
isLoading: rolesLoading,
isError: rolesError,
error: rolesErrorObj,
refetch: refetchRoles,
} = useRoles();
function handleClose(): void {
reset();
setIsOpen(null);
}
function handleCreate(values: FormValues): void {
createServiceAccount({
data: {
name: values.name.trim(),
email: values.email.trim(),
roles: values.roles,
},
});
}
return (
<DialogWrapper
title="New Service Account"
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
handleClose();
}
}}
showCloseButton
width="narrow"
className="create-sa-modal"
disableOutsideClick={false}
>
<div className="create-sa-modal__content">
<form
id="create-sa-form"
className="create-sa-form"
onSubmit={handleSubmit(handleCreate)}
>
<div className="create-sa-form__item">
<label htmlFor="sa-name">Name</label>
<Controller
name="name"
control={control}
rules={{ required: 'Name is required' }}
render={({ field }): JSX.Element => (
<Input
id="sa-name"
placeholder="Enter a name"
className="create-sa-form__input"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
{errors.name && (
<p className="create-sa-form__error">{errors.name.message}</p>
)}
</div>
<div className="create-sa-form__item">
<label htmlFor="sa-email">Email Address</label>
<Controller
name="email"
control={control}
rules={{
required: 'Email Address is required',
pattern: {
value: EMAIL_REGEX,
message: 'Please enter a valid email address',
},
}}
render={({ field }): JSX.Element => (
<Input
id="sa-email"
type="email"
placeholder="email@example.com"
className="create-sa-form__input"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
{errors.email && (
<p className="create-sa-form__error">{errors.email.message}</p>
)}
</div>
<p className="create-sa-form__helper">
Used only for notifications about this service account. It is not used for
authentication.
</p>
<div className="create-sa-form__item">
<label htmlFor="sa-roles">Roles</label>
<Controller
name="roles"
control={control}
rules={{
validate: (value): string | true =>
value.length > 0 || 'At least one role is required',
}}
render={({ field }): JSX.Element => (
<RolesSelect
id="sa-roles"
mode="multiple"
roles={roles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={refetchRoles}
placeholder="Select roles"
value={field.value}
onChange={field.onChange}
/>
)}
/>
{errors.roles && (
<p className="create-sa-form__error">{errors.roles.message}</p>
)}
</div>
</form>
</div>
<DialogFooter className="create-sa-modal__footer">
<Button
type="button"
variant="solid"
color="secondary"
size="sm"
onClick={handleClose}
>
<X size={12} />
Cancel
</Button>
<Button
type="submit"
form="create-sa-form"
variant="solid"
color="primary"
size="sm"
loading={isSubmitting}
disabled={!isValid}
>
Create Service Account
</Button>
</DialogFooter>
</DialogWrapper>
);
}
export default CreateServiceAccountModal;

View File

@@ -1,179 +0,0 @@
import { toast } from '@signozhq/sonner';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CreateServiceAccountModal from '../CreateServiceAccountModal';
jest.mock('@signozhq/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const mockToast = jest.mocked(toast);
const ROLES_ENDPOINT = '*/api/v1/roles';
const SERVICE_ACCOUNTS_ENDPOINT = '*/api/v1/service_accounts';
function renderModal(): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter searchParams={{ 'create-sa': 'true' }} hasMemory>
<CreateServiceAccountModal />
</NuqsTestingAdapter>,
);
}
describe('CreateServiceAccountModal', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: {} })),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('submit button is disabled when form is empty', () => {
renderModal();
expect(
screen.getByRole('button', { name: /Create Service Account/i }),
).toBeDisabled();
});
it('submit button remains disabled when email is invalid', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await user.type(screen.getByPlaceholderText('Enter a name'), 'My Bot');
await user.type(
screen.getByPlaceholderText('email@example.com'),
'not-an-email',
);
await user.click(screen.getByText('Select roles'));
await user.click(await screen.findByTitle('signoz-admin'));
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Service Account/i }),
).toBeDisabled(),
);
});
it('successful submit shows toast.success and closes modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await user.type(screen.getByPlaceholderText('Enter a name'), 'Deploy Bot');
await user.type(
screen.getByPlaceholderText('email@example.com'),
'deploy@acme.io',
);
await user.click(screen.getByText('Select roles'));
await user.click(await screen.findByTitle('signoz-admin'));
const submitBtn = screen.getByRole('button', {
name: /Create Service Account/i,
});
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
await waitFor(() => {
expect(mockToast.success).toHaveBeenCalledWith(
'Service account created successfully',
expect.anything(),
);
});
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: /New Service Account/i }),
).not.toBeInTheDocument();
});
});
it('shows toast.error on API error and keeps modal open', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(500),
ctx.json({ status: 'error', error: 'Internal Server Error' }),
),
),
);
renderModal();
await user.type(screen.getByPlaceholderText('Enter a name'), 'Dupe Bot');
await user.type(
screen.getByPlaceholderText('email@example.com'),
'dupe@acme.io',
);
await user.click(screen.getByText('Select roles'));
await user.click(await screen.findByTitle('signoz-admin'));
const submitBtn = screen.getByRole('button', {
name: /Create Service Account/i,
});
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith(
expect.stringMatching(/Failed to create service account/i),
expect.anything(),
);
});
expect(
screen.getByRole('dialog', { name: /New Service Account/i }),
).toBeInTheDocument();
});
it('Cancel button closes modal without submitting', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByRole('dialog', { name: /New Service Account/i });
await user.click(screen.getByRole('button', { name: /Cancel/i }));
expect(
screen.queryByRole('dialog', { name: /New Service Account/i }),
).not.toBeInTheDocument();
});
it('shows "Name is required" after clearing the name field', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const nameInput = screen.getByPlaceholderText('Enter a name');
await user.type(nameInput, 'Bot');
await user.clear(nameInput);
await screen.findByText('Name is required');
});
it('shows "Please enter a valid email address" for a malformed email', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await user.type(
screen.getByPlaceholderText('email@example.com'),
'not-an-email',
);
await screen.findByText('Please enter a valid email address');
});
});

View File

@@ -1,331 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { message } from 'antd';
import configureStore from 'redux-mock-store';
import store from 'store';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import '@testing-library/jest-dom';
import { DownloadFormats, DownloadRowCounts } from './constants';
import DownloadOptionsMenu from './DownloadOptionsMenu';
const mockDownloadExportData = jest.fn().mockResolvedValue(undefined);
jest.mock('api/v1/download/downloadExportData', () => ({
downloadExportData: (...args: any[]): any => mockDownloadExportData(...args),
default: (...args: any[]): any => mockDownloadExportData(...args),
}));
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
return {
...actual,
message: {
success: jest.fn(),
error: jest.fn(),
},
};
});
const mockUseQueryBuilder = jest.fn();
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => mockUseQueryBuilder(),
}));
const mockStore = configureStore([]);
const createMockReduxStore = (): any =>
mockStore({
...store.getState(),
});
const createMockStagedQuery = (dataSource: DataSource): Query => ({
id: 'test-query-id',
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [
{
queryName: 'A',
dataSource,
aggregateOperator: StringOperators.NOOP,
aggregateAttribute: {
id: '',
dataType: '' as any,
key: '',
type: '',
},
aggregations: [{ expression: 'count()' }],
functions: [],
filter: { expression: 'status = 200' },
filters: { items: [], op: 'AND' },
groupBy: [],
expression: 'A',
disabled: false,
having: { expression: '' } as any,
limit: null,
stepInterval: null,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
legend: '',
selectColumns: [],
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
});
const renderWithStore = (dataSource: DataSource): void => {
const mockReduxStore = createMockReduxStore();
render(
<Provider store={mockReduxStore}>
<DownloadOptionsMenu dataSource={dataSource} />
</Provider>,
);
};
describe.each([
[DataSource.LOGS, 'logs'],
[DataSource.TRACES, 'traces'],
])('DownloadOptionsMenu for %s', (dataSource, signal) => {
const testId = `periscope-btn-download-${dataSource}`;
beforeEach(() => {
mockDownloadExportData.mockReset().mockResolvedValue(undefined);
(message.success as jest.Mock).mockReset();
(message.error as jest.Mock).mockReset();
mockUseQueryBuilder.mockReturnValue({
stagedQuery: createMockStagedQuery(dataSource),
});
});
it('renders download button', () => {
renderWithStore(dataSource);
const button = screen.getByTestId(testId);
expect(button).toBeInTheDocument();
expect(button).toHaveClass('periscope-btn', 'ghost');
});
it('shows popover with export options when download button is clicked', () => {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByText('Number of Rows')).toBeInTheDocument();
expect(screen.getByText('Columns')).toBeInTheDocument();
});
it('allows changing export format', () => {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
const csvRadio = screen.getByRole('radio', { name: 'csv' });
const jsonlRadio = screen.getByRole('radio', { name: 'jsonl' });
expect(csvRadio).toBeChecked();
fireEvent.click(jsonlRadio);
expect(jsonlRadio).toBeChecked();
expect(csvRadio).not.toBeChecked();
});
it('allows changing row limit', () => {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
const tenKRadio = screen.getByRole('radio', { name: '10k' });
const fiftyKRadio = screen.getByRole('radio', { name: '50k' });
expect(tenKRadio).toBeChecked();
fireEvent.click(fiftyKRadio);
expect(fiftyKRadio).toBeChecked();
expect(tenKRadio).not.toBeChecked();
});
it('allows changing columns scope', () => {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
const allColumnsRadio = screen.getByRole('radio', { name: 'All' });
const selectedColumnsRadio = screen.getByRole('radio', { name: 'Selected' });
expect(allColumnsRadio).toBeChecked();
fireEvent.click(selectedColumnsRadio);
expect(selectedColumnsRadio).toBeChecked();
expect(allColumnsRadio).not.toBeChecked();
});
it('calls downloadExportData with correct format and POST body', async () => {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(mockDownloadExportData).toHaveBeenCalledTimes(1);
const callArgs = mockDownloadExportData.mock.calls[0][0];
expect(callArgs.format).toBe(DownloadFormats.CSV);
expect(callArgs.body).toBeDefined();
expect(callArgs.body.requestType).toBe('raw');
expect(callArgs.body.compositeQuery.queries).toHaveLength(1);
const query = callArgs.body.compositeQuery.queries[0];
expect(query.type).toBe('builder_query');
expect(query.spec.signal).toBe(signal);
expect(query.spec.limit).toBe(DownloadRowCounts.TEN_K);
});
});
it('clears groupBy and having in the export payload', async () => {
const mockQuery = createMockStagedQuery(dataSource);
mockQuery.builder.queryData[0].groupBy = [
{ key: 'service', dataType: 'string' as any, type: '' },
];
mockQuery.builder.queryData[0].having = {
expression: 'count() > 10',
} as any;
mockUseQueryBuilder.mockReturnValue({ stagedQuery: mockQuery });
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(mockDownloadExportData).toHaveBeenCalledTimes(1);
const callArgs = mockDownloadExportData.mock.calls[0][0];
const query = callArgs.body.compositeQuery.queries[0];
expect(query.spec.groupBy).toBeUndefined();
expect(query.spec.having).toEqual({ expression: '' });
});
});
it('keeps selectColumns when column scope is Selected', async () => {
const mockQuery = createMockStagedQuery(dataSource);
mockQuery.builder.queryData[0].selectColumns = [
{ name: 'http.status', fieldDataType: 'int64', fieldContext: 'attribute' },
] as any;
mockUseQueryBuilder.mockReturnValue({ stagedQuery: mockQuery });
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(mockDownloadExportData).toHaveBeenCalledTimes(1);
const callArgs = mockDownloadExportData.mock.calls[0][0];
const query = callArgs.body.compositeQuery.queries[0];
expect(query.spec.selectFields).toEqual([
expect.objectContaining({
name: 'http.status',
fieldDataType: 'int64',
}),
]);
});
});
it('sends no selectFields when column scope is All', async () => {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByRole('radio', { name: 'All' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(mockDownloadExportData).toHaveBeenCalledTimes(1);
const callArgs = mockDownloadExportData.mock.calls[0][0];
const query = callArgs.body.compositeQuery.queries[0];
expect(query.spec.selectFields).toBeUndefined();
});
});
it('handles successful export with success message', async () => {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(message.success).toHaveBeenCalledWith(
'Export completed successfully',
);
});
});
it('handles export failure with error message', async () => {
mockDownloadExportData.mockRejectedValueOnce(new Error('Server error'));
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(message.error).toHaveBeenCalledWith(
`Failed to export ${dataSource}. Please try again.`,
);
});
});
it('handles UI state correctly during export process', async () => {
let resolveDownload: () => void;
mockDownloadExportData.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveDownload = resolve;
}),
);
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
expect(screen.getByRole('dialog')).toBeInTheDocument();
fireEvent.click(screen.getByText('Export'));
expect(screen.getByTestId(testId)).toBeDisabled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
resolveDownload!();
await waitFor(() => {
expect(screen.getByTestId(testId)).not.toBeDisabled();
});
});
});
describe('DownloadOptionsMenu for traces with queryTraceOperator', () => {
const dataSource = DataSource.TRACES;
const testId = `periscope-btn-download-${dataSource}`;
beforeEach(() => {
mockDownloadExportData.mockReset().mockResolvedValue(undefined);
(message.success as jest.Mock).mockReset();
});
it('applies limit and clears groupBy on queryTraceOperator entries', async () => {
const query = createMockStagedQuery(dataSource);
query.builder.queryTraceOperator = [
{
...query.builder.queryData[0],
queryName: 'TraceOp1',
expression: 'TraceOp1',
groupBy: [{ key: 'service', dataType: 'string' as any, type: '' }],
},
];
mockUseQueryBuilder.mockReturnValue({ stagedQuery: query });
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByRole('radio', { name: '50k' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(mockDownloadExportData).toHaveBeenCalledTimes(1);
const callArgs = mockDownloadExportData.mock.calls[0][0];
const queries = callArgs.body.compositeQuery.queries;
const traceOpQuery = queries.find((q: any) => q.spec.name === 'TraceOp1');
if (traceOpQuery) {
expect(traceOpQuery.spec.limit).toBe(DownloadRowCounts.FIFTY_K);
expect(traceOpQuery.spec.groupBy).toBeUndefined();
}
});
});
});

View File

@@ -15,21 +15,15 @@ import {
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { Select } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import {
getResetPasswordToken,
useDeleteUser,
useUpdateUser,
} from 'api/generated/services/users';
import { AxiosError } from 'axios';
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
import deleteUser from 'api/v1/user/id/delete';
import update from 'api/v1/user/id/update';
import { MemberRow } from 'components/MembersTable/MembersTable';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { MemberStatus } from 'container/MembersSettings/utils';
import { capitalize } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { ROLES } from 'types/roles';
import { popupContainer } from 'utils/selectPopupContainer';
import './EditMemberDrawer.styles.scss';
@@ -51,6 +45,8 @@ function EditMemberDrawer({
const [displayName, setDisplayName] = useState('');
const [selectedRole, setSelectedRole] = useState<ROLES>('VIEWER');
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [resetLink, setResetLink] = useState<string | null>(null);
@@ -60,49 +56,6 @@ function EditMemberDrawer({
const isInvited = member?.status === MemberStatus.Invited;
const { mutate: updateUser, isLoading: isSaving } = useUpdateUser({
mutation: {
onSuccess: (): void => {
toast.success('Member details updated successfully', { richColors: true });
onComplete();
onClose();
},
onError: (err): void => {
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
toast.error(`Failed to update member details: ${errMessage}`, {
richColors: true,
});
},
},
});
const { mutate: deleteUser, isLoading: isDeleting } = useDeleteUser({
mutation: {
onSuccess: (): void => {
toast.success(
isInvited ? 'Invite revoked successfully' : 'Member deleted successfully',
{ richColors: true },
);
setShowDeleteConfirm(false);
onComplete();
onClose();
},
onError: (err): void => {
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
const prefix = isInvited
? 'Failed to revoke invite'
: 'Failed to delete member';
toast.error(`${prefix}: ${errMessage}`, { richColors: true });
},
},
});
useEffect(() => {
if (member) {
setDisplayName(member.name ?? '');
@@ -128,24 +81,46 @@ function EditMemberDrawer({
[formatTimezoneAdjustedTimestamp],
);
const handleSave = useCallback((): void => {
const handleSave = useCallback(async (): Promise<void> => {
if (!member || !isDirty) {
return;
}
updateUser({
pathParams: { id: member.id },
data: { id: member.id, displayName, role: selectedRole },
});
}, [member, isDirty, displayName, selectedRole, updateUser]);
setIsSaving(true);
try {
await update({ userId: member.id, displayName, role: selectedRole });
toast.success('Member details updated successfully', { richColors: true });
onComplete();
onClose();
} catch {
toast.error('Failed to update member details', { richColors: true });
} finally {
setIsSaving(false);
}
}, [member, isDirty, displayName, selectedRole, onComplete, onClose]);
const handleDelete = useCallback((): void => {
const handleDelete = useCallback(async (): Promise<void> => {
if (!member) {
return;
}
deleteUser({
pathParams: { id: member.id },
});
}, [member, deleteUser]);
setIsDeleting(true);
try {
await deleteUser({ userId: member.id });
toast.success(
isInvited ? 'Invite revoked successfully' : 'Member deleted successfully',
{ richColors: true },
);
setShowDeleteConfirm(false);
onComplete();
onClose();
} catch {
toast.error(
isInvited ? 'Failed to revoke invite' : 'Failed to delete member',
{ richColors: true },
);
} finally {
setIsDeleting(false);
}
}, [member, isInvited, onComplete, onClose]);
const handleGenerateResetLink = useCallback(async (): Promise<void> => {
if (!member) {
@@ -153,7 +128,7 @@ function EditMemberDrawer({
}
setIsGeneratingLink(true);
try {
const response = await getResetPasswordToken({ id: member.id });
const response = await getResetPasswordToken({ userId: member.id });
if (response?.data?.token) {
const link = `${window.location.origin}/password-reset?token=${response.data.token}`;
setResetLink(link);
@@ -243,7 +218,10 @@ function EditMemberDrawer({
onChange={(role): void => setSelectedRole(role as ROLES)}
className="edit-member-drawer__role-select"
suffixIcon={<ChevronDown size={14} />}
getPopupContainer={popupContainer}
getPopupContainer={(triggerNode): HTMLElement =>
(triggerNode?.closest('.edit-member-drawer') as HTMLElement) ||
document.body
}
>
<Select.Option value="ADMIN">{capitalize('ADMIN')}</Select.Option>
<Select.Option value="EDITOR">{capitalize('EDITOR')}</Select.Option>

View File

@@ -1,11 +1,8 @@
import type { ReactNode } from 'react';
import { toast } from '@signozhq/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getResetPasswordToken,
useDeleteUser,
useUpdateUser,
} from 'api/generated/services/users';
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
import deleteUser from 'api/v1/user/id/delete';
import update from 'api/v1/user/id/update';
import { MemberStatus } from 'container/MembersSettings/utils';
import {
fireEvent,
@@ -48,16 +45,9 @@ jest.mock('@signozhq/dialog', () => ({
),
}));
jest.mock('api/generated/services/users', () => ({
useDeleteUser: jest.fn(),
useUpdateUser: jest.fn(),
getResetPasswordToken: jest.fn(),
}));
jest.mock('api/ErrorResponseHandlerForGeneratedAPIs', () => ({
convertToApiError: jest.fn(),
}));
jest.mock('api/v1/user/id/update');
jest.mock('api/v1/user/id/delete');
jest.mock('api/v1/factor_password/getResetPasswordToken');
jest.mock('@signozhq/sonner', () => ({
toast: {
success: jest.fn(),
@@ -65,8 +55,8 @@ jest.mock('@signozhq/sonner', () => ({
},
}));
const mockUpdateMutate = jest.fn();
const mockDeleteMutate = jest.fn();
const mockUpdate = jest.mocked(update);
const mockDeleteUser = jest.mocked(deleteUser);
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
const activeMember = {
@@ -105,14 +95,8 @@ function renderDrawer(
describe('EditMemberDrawer', () => {
beforeEach(() => {
jest.clearAllMocks();
(useUpdateUser as jest.Mock).mockReturnValue({
mutate: mockUpdateMutate,
isLoading: false,
});
(useDeleteUser as jest.Mock).mockReturnValue({
mutate: mockDeleteMutate,
isLoading: false,
});
mockUpdate.mockResolvedValue({ httpStatusCode: 200, data: null });
mockDeleteUser.mockResolvedValue({ httpStatusCode: 200, data: null });
});
it('renders active member details and disables Save when form is not dirty', () => {
@@ -130,13 +114,6 @@ describe('EditMemberDrawer', () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
(useUpdateUser as jest.Mock).mockImplementation((options) => ({
mutate: mockUpdateMutate.mockImplementation(() => {
options?.mutation?.onSuccess?.();
}),
isLoading: false,
}));
renderDrawer({ onComplete });
const nameInput = screen.getByDisplayValue('Alice Smith');
@@ -149,10 +126,10 @@ describe('EditMemberDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(mockUpdateMutate).toHaveBeenCalledWith(
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
pathParams: { id: 'user-1' },
data: expect.objectContaining({ displayName: 'Alice Updated' }),
userId: 'user-1',
displayName: 'Alice Updated',
}),
);
expect(onComplete).toHaveBeenCalled();
@@ -163,13 +140,6 @@ describe('EditMemberDrawer', () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
mutate: mockDeleteMutate.mockImplementation(() => {
options?.mutation?.onSuccess?.();
}),
isLoading: false,
}));
renderDrawer({ onComplete });
await user.click(screen.getByRole('button', { name: /delete member/i }));
@@ -182,9 +152,7 @@ describe('EditMemberDrawer', () => {
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(mockDeleteMutate).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
});
expect(mockDeleteUser).toHaveBeenCalledWith({ userId: 'user-1' });
expect(onComplete).toHaveBeenCalled();
});
});
@@ -209,13 +177,6 @@ describe('EditMemberDrawer', () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
mutate: mockDeleteMutate.mockImplementation(() => {
options?.mutation?.onSuccess?.();
}),
isLoading: false,
}));
renderDrawer({ member: invitedMember, onComplete });
await user.click(screen.getByRole('button', { name: /revoke invite/i }));
@@ -228,9 +189,7 @@ describe('EditMemberDrawer', () => {
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(mockDeleteMutate).toHaveBeenCalledWith({
pathParams: { id: 'abc123' },
});
expect(mockDeleteUser).toHaveBeenCalledWith({ userId: 'abc123' });
expect(onComplete).toHaveBeenCalled();
});
});
@@ -239,13 +198,6 @@ describe('EditMemberDrawer', () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
(useUpdateUser as jest.Mock).mockImplementation((options) => ({
mutate: mockUpdateMutate.mockImplementation(() => {
options?.mutation?.onSuccess?.();
}),
isLoading: false,
}));
renderDrawer({ member: { ...invitedMember, name: 'Bob' }, onComplete });
const nameInput = screen.getByDisplayValue('Bob');
@@ -257,109 +209,13 @@ describe('EditMemberDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(mockUpdateMutate).toHaveBeenCalledWith(
expect.objectContaining({
pathParams: { id: 'abc123' },
data: expect.objectContaining({ displayName: 'Bob Updated' }),
}),
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'abc123', displayName: 'Bob Updated' }),
);
expect(onComplete).toHaveBeenCalled();
});
});
describe('error handling', () => {
const mockConvertToApiError = jest.mocked(convertToApiError);
beforeEach(() => {
mockConvertToApiError.mockReturnValue({
getErrorMessage: (): string => 'Something went wrong on server',
} as ReturnType<typeof convertToApiError>);
});
it('shows API error message when updateUser fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
(useUpdateUser as jest.Mock).mockImplementation((options) => ({
mutate: mockUpdateMutate.mockImplementation(() => {
options?.mutation?.onError?.({});
}),
isLoading: false,
}));
renderDrawer();
const nameInput = screen.getByDisplayValue('Alice Smith');
await user.clear(nameInput);
await user.type(nameInput, 'Alice Updated');
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to update member details: Something went wrong on server',
expect.anything(),
);
});
});
it('shows API error message when deleteUser fails for active member', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
mutate: mockDeleteMutate.mockImplementation(() => {
options?.mutation?.onError?.({});
}),
isLoading: false,
}));
renderDrawer();
await user.click(screen.getByRole('button', { name: /delete member/i }));
const confirmBtns = screen.getAllByRole('button', {
name: /delete member/i,
});
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to delete member: Something went wrong on server',
expect.anything(),
);
});
});
it('shows API error message when deleteUser fails for invited member', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
mutate: mockDeleteMutate.mockImplementation(() => {
options?.mutation?.onError?.({});
}),
isLoading: false,
}));
renderDrawer({ member: invitedMember });
await user.click(screen.getByRole('button', { name: /revoke invite/i }));
const confirmBtns = screen.getAllByRole('button', {
name: /revoke invite/i,
});
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to revoke invite: Something went wrong on server',
expect.anything(),
);
});
});
});
describe('Generate Password Reset Link', () => {
const mockWriteText = jest.fn().mockResolvedValue(undefined);
let clipboardSpy: jest.SpyInstance | undefined;
@@ -378,8 +234,8 @@ describe('EditMemberDrawer', () => {
.spyOn(navigator.clipboard, 'writeText')
.mockImplementation(mockWriteText);
mockGetResetPasswordToken.mockResolvedValue({
status: 'success',
data: { token: 'reset-tok-abc', id: 'user-1' },
httpStatusCode: 200,
data: { token: 'reset-tok-abc', userId: 'user-1' },
});
});
@@ -400,7 +256,7 @@ describe('EditMemberDrawer', () => {
name: /password reset link/i,
});
expect(mockGetResetPasswordToken).toHaveBeenCalledWith({
id: 'user-1',
userId: 'user-1',
});
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveTextContent('reset-tok-abc');

View File

@@ -13,7 +13,6 @@ import { cloneDeep, debounce } from 'lodash-es';
import APIError from 'types/api/error';
import { ROLES } from 'types/roles';
import { EMAIL_REGEX } from 'utils/app';
import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import './InviteMembersModal.styles.scss';
@@ -255,8 +254,6 @@ function InviteMembersModal({
value={row.email}
onChange={(e): void => updateEmail(row.id, e.target.value)}
className="team-member-email-input"
name={`invite-email-${row.id}`}
autoComplete="email"
/>
{emailValidity[row.id] === false && row.email.trim() !== '' && (
<span className="email-error-message">Invalid email address</span>
@@ -269,7 +266,10 @@ function InviteMembersModal({
className="team-member-role-select"
placeholder="Select roles"
suffixIcon={<ChevronDown size={14} />}
getPopupContainer={popupContainer}
getPopupContainer={(triggerNode): HTMLElement =>
(triggerNode?.closest('.invite-members-modal') as HTMLElement) ||
document.body
}
>
<Select.Option value="VIEWER">Viewer</Select.Option>
<Select.Option value="EDITOR">Editor</Select.Option>

View File

@@ -1,11 +1,11 @@
.download-popover {
.logs-download-popover {
.ant-popover-inner {
border-radius: 4px;
border: 1px solid var(--l3-border);
border: 1px solid var(--bg-slate-400);
background: linear-gradient(
139deg,
var(--l2-background) 0%,
var(--l3-background) 98.68%
var(--bg-ink-400) 0%,
var(--bg-ink-500) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
@@ -19,7 +19,7 @@
.title {
display: flex;
color: var(--l3-foreground);
color: var(--bg-slate-50);
font-family: Inter;
font-size: 11px;
font-style: normal;
@@ -38,7 +38,7 @@
flex-direction: column;
:global(.ant-radio-wrapper) {
color: var(--foreground);
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 13px;
}
@@ -46,7 +46,7 @@
.horizontal-line {
height: 1px;
background: var(--l3-border);
background: var(--bg-slate-400);
}
.export-button {
@@ -59,27 +59,27 @@
}
.lightMode {
.download-popover {
.logs-download-popover {
.ant-popover-inner {
border: 1px solid var(--l2-border);
border: 1px solid var(--bg-vanilla-300);
background: linear-gradient(
139deg,
var(--background) 0%,
var(--l1-background) 98.68%
var(--bg-vanilla-100) 0%,
var(--bg-vanilla-300) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(255, 255, 255, 0.2);
}
.export-options-container {
.title {
color: var(--l2-foreground);
color: var(--bg-ink-200);
}
:global(.ant-radio-wrapper) {
color: var(--foreground);
color: var(--bg-ink-400);
}
.horizontal-line {
background: var(--l2-border);
background: var(--bg-vanilla-300);
}
}
}

View File

@@ -0,0 +1,341 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { message } from 'antd';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import '@testing-library/jest-dom';
import { DownloadFormats, DownloadRowCounts } from './constants';
import LogsDownloadOptionsMenu from './LogsDownloadOptionsMenu';
// Mock antd message
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
return {
...actual,
message: {
success: jest.fn(),
error: jest.fn(),
},
};
});
const TEST_IDS = {
DOWNLOAD_BUTTON: 'periscope-btn-download-options',
} as const;
interface TestProps {
startTime: number;
endTime: number;
filter: string;
columns: TelemetryFieldKey[];
orderBy: string;
}
const createTestProps = (): TestProps => ({
startTime: 1631234567890,
endTime: 1631234567999,
filter: 'status = 200',
columns: [
{
name: 'http.status',
fieldContext: 'attribute',
fieldDataType: 'int64',
} as TelemetryFieldKey,
],
orderBy: 'timestamp:desc',
});
const testRenderContent = (props: TestProps): void => {
render(
<LogsDownloadOptionsMenu
startTime={props.startTime}
endTime={props.endTime}
filter={props.filter}
columns={props.columns}
orderBy={props.orderBy}
/>,
);
};
const testSuccessResponse = (res: any, ctx: any): any =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/octet-stream'),
ctx.set('Content-Disposition', 'attachment; filename="export.csv"'),
ctx.body('id,value\n1,2\n'),
);
describe('LogsDownloadOptionsMenu', () => {
const BASE_URL = ENVIRONMENT.baseURL;
const EXPORT_URL = `${BASE_URL}/api/v1/export_raw_data`;
let requestSpy: jest.Mock<any, any>;
const setupDefaultServer = (): void => {
server.use(
rest.get(EXPORT_URL, (req, res, ctx) => {
const params = req.url.searchParams;
const payload = {
start: Number(params.get('start')),
end: Number(params.get('end')),
filter: params.get('filter'),
columns: params.getAll('columns'),
order_by: params.get('order_by'),
limit: Number(params.get('limit')),
format: params.get('format'),
};
requestSpy(payload);
return testSuccessResponse(res, ctx);
}),
);
};
// Mock URL.createObjectURL used by download logic
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
beforeEach(() => {
requestSpy = jest.fn();
setupDefaultServer();
(message.success as jest.Mock).mockReset();
(message.error as jest.Mock).mockReset();
// jsdom doesn't implement it by default
((URL as unknown) as {
createObjectURL: (b: Blob) => string;
}).createObjectURL = jest.fn(() => 'blob:mock');
((URL as unknown) as {
revokeObjectURL: (u: string) => void;
}).revokeObjectURL = jest.fn();
});
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
// restore
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
});
it('renders download button', () => {
const props = createTestProps();
testRenderContent(props);
const button = screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON);
expect(button).toBeInTheDocument();
expect(button).toHaveClass('periscope-btn', 'ghost');
});
it('shows popover with export options when download button is clicked', () => {
const props = createTestProps();
render(
<LogsDownloadOptionsMenu
startTime={props.startTime}
endTime={props.endTime}
filter={props.filter}
columns={props.columns}
orderBy={props.orderBy}
/>,
);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByText('Number of Rows')).toBeInTheDocument();
expect(screen.getByText('Columns')).toBeInTheDocument();
});
it('allows changing export format', () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
const csvRadio = screen.getByRole('radio', { name: 'csv' });
const jsonlRadio = screen.getByRole('radio', { name: 'jsonl' });
expect(csvRadio).toBeChecked();
fireEvent.click(jsonlRadio);
expect(jsonlRadio).toBeChecked();
expect(csvRadio).not.toBeChecked();
});
it('allows changing row limit', () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
const tenKRadio = screen.getByRole('radio', { name: '10k' });
const fiftyKRadio = screen.getByRole('radio', { name: '50k' });
expect(tenKRadio).toBeChecked();
fireEvent.click(fiftyKRadio);
expect(fiftyKRadio).toBeChecked();
expect(tenKRadio).not.toBeChecked();
});
it('allows changing columns scope', () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
const allColumnsRadio = screen.getByRole('radio', { name: 'All' });
const selectedColumnsRadio = screen.getByRole('radio', { name: 'Selected' });
expect(allColumnsRadio).toBeChecked();
fireEvent.click(selectedColumnsRadio);
expect(selectedColumnsRadio).toBeChecked();
expect(allColumnsRadio).not.toBeChecked();
});
it('calls downloadExportData with correct parameters when export button is clicked (Selected columns)', async () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(requestSpy).toHaveBeenCalledWith(
expect.objectContaining({
start: props.startTime,
end: props.endTime,
columns: ['attribute.http.status:int64'],
filter: props.filter,
order_by: props.orderBy,
format: DownloadFormats.CSV,
limit: DownloadRowCounts.TEN_K,
}),
);
});
});
it('calls downloadExportData with correct parameters when export button is clicked', async () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByRole('radio', { name: 'All' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(requestSpy).toHaveBeenCalledWith(
expect.objectContaining({
start: props.startTime,
end: props.endTime,
columns: [],
filter: props.filter,
order_by: props.orderBy,
format: DownloadFormats.CSV,
limit: DownloadRowCounts.TEN_K,
}),
);
});
});
it('handles successful export with success message', async () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(message.success).toHaveBeenCalledWith(
'Export completed successfully',
);
});
});
it('handles export failure with error message', async () => {
// Override handler to return 500 for this test
server.use(rest.get(EXPORT_URL, (_req, res, ctx) => res(ctx.status(500))));
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(message.error).toHaveBeenCalledWith(
'Failed to export logs. Please try again.',
);
});
});
it('handles UI state correctly during export process', async () => {
server.use(
rest.get(EXPORT_URL, (_req, res, ctx) => testSuccessResponse(res, ctx)),
);
const props = createTestProps();
testRenderContent(props);
// Open popover
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Start export
fireEvent.click(screen.getByText('Export'));
// Check button is disabled during export
expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).toBeDisabled();
// Check popover is closed immediately after export starts
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// Wait for export to complete and verify button is enabled again
await waitFor(() => {
expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).not.toBeDisabled();
});
});
it('uses filename from Content-Disposition and triggers download click', async () => {
server.use(
rest.get(EXPORT_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/octet-stream'),
ctx.set('Content-Disposition', 'attachment; filename="report.jsonl"'),
ctx.body('row\n'),
),
),
);
const originalCreateElement = document.createElement.bind(document);
const anchorEl = originalCreateElement('a') as HTMLAnchorElement;
const setAttrSpy = jest.spyOn(anchorEl, 'setAttribute');
const clickSpy = jest.spyOn(anchorEl, 'click');
const removeSpy = jest.spyOn(anchorEl, 'remove');
const createElSpy = jest
.spyOn(document, 'createElement')
.mockImplementation((tagName: any): any =>
tagName === 'a' ? anchorEl : originalCreateElement(tagName),
);
const appendSpy = jest.spyOn(document.body, 'appendChild');
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(appendSpy).toHaveBeenCalledWith(anchorEl);
expect(setAttrSpy).toHaveBeenCalledWith('download', 'report.jsonl');
expect(clickSpy).toHaveBeenCalled();
expect(removeSpy).toHaveBeenCalled();
});
expect(anchorEl.getAttribute('download')).toBe('report.jsonl');
createElSpy.mockRestore();
appendSpy.mockRestore();
});
});

View File

@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState } from 'react';
import { Button, Popover, Radio, Tooltip, Typography } from 'antd';
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
import { Button, message, Popover, Radio, Tooltip, Typography } from 'antd';
import { downloadExportData } from 'api/v1/download/downloadExportData';
import { Download, DownloadIcon, Loader2 } from 'lucide-react';
import { DataSource } from 'types/common/queryBuilder';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import {
DownloadColumnsScopes,
@@ -10,34 +10,75 @@ import {
DownloadRowCounts,
} from './constants';
import './DownloadOptionsMenu.styles.scss';
import './LogsDownloadOptionsMenu.styles.scss';
interface DownloadOptionsMenuProps {
dataSource: DataSource;
function convertTelemetryFieldKeyToText(key: TelemetryFieldKey): string {
const prefix = key.fieldContext ? `${key.fieldContext}.` : '';
const suffix = key.fieldDataType ? `:${key.fieldDataType}` : '';
return `${prefix}${key.name}${suffix}`;
}
export default function DownloadOptionsMenu({
dataSource,
}: DownloadOptionsMenuProps): JSX.Element {
interface LogsDownloadOptionsMenuProps {
startTime: number;
endTime: number;
filter: string;
columns: TelemetryFieldKey[];
orderBy: string;
}
export default function LogsDownloadOptionsMenu({
startTime,
endTime,
filter,
columns,
orderBy,
}: LogsDownloadOptionsMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
const [columnsScope, setColumnsScope] = useState<string>(
DownloadColumnsScopes.ALL,
);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const { isDownloading, handleExportRawData } = useExportRawData({
dataSource,
});
const handleExport = useCallback(async (): Promise<void> => {
const handleExportRawData = useCallback(async (): Promise<void> => {
setIsPopoverOpen(false);
await handleExportRawData({
format: exportFormat,
rowLimit,
clearSelectColumns: columnsScope === DownloadColumnsScopes.ALL,
});
}, [exportFormat, rowLimit, columnsScope, handleExportRawData]);
try {
setIsDownloading(true);
const downloadOptions = {
source: 'logs',
start: startTime,
end: endTime,
columns:
columnsScope === DownloadColumnsScopes.SELECTED
? columns.map((col) => convertTelemetryFieldKeyToText(col))
: [],
filter,
orderBy,
format: exportFormat,
limit: rowLimit,
};
await downloadExportData(downloadOptions);
message.success('Export completed successfully');
} catch (error) {
console.error('Error exporting logs:', error);
message.error('Failed to export logs. Please try again.');
} finally {
setIsDownloading(false);
}
}, [
startTime,
endTime,
columnsScope,
columns,
filter,
orderBy,
exportFormat,
rowLimit,
setIsDownloading,
setIsPopoverOpen,
]);
const popoverContent = useMemo(
() => (
@@ -88,7 +129,7 @@ export default function DownloadOptionsMenu({
<Button
type="primary"
icon={<Download size={16} />}
onClick={handleExport}
onClick={handleExportRawData}
className="export-button"
disabled={isDownloading}
loading={isDownloading}
@@ -97,7 +138,7 @@ export default function DownloadOptionsMenu({
</Button>
</div>
),
[exportFormat, rowLimit, columnsScope, isDownloading, handleExport],
[exportFormat, rowLimit, columnsScope, isDownloading, handleExportRawData],
);
return (
@@ -108,19 +149,19 @@ export default function DownloadOptionsMenu({
arrow={false}
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
rootClassName="download-popover"
rootClassName="logs-download-popover"
>
<Tooltip title="Download" placement="top">
<Button
className="periscope-btn ghost"
icon={
isDownloading ? (
<Loader2 size={14} className="animate-spin" />
<Loader2 size={18} className="animate-spin" />
) : (
<DownloadIcon size={14} />
<DownloadIcon size={15} />
)
}
data-testid={`periscope-btn-download-${dataSource}`}
data-testid="periscope-btn-download-options"
disabled={isDownloading}
/>
</Tooltip>

View File

@@ -162,7 +162,7 @@
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
margin: 0;
line-height: var(--paragraph-base-400-line-height);
line-height: var(--paragraph-base-400-font-height);
strong {
font-weight: var(--font-weight-medium);

View File

@@ -1,90 +0,0 @@
.roles-select {
width: 100%;
// todo: styles should easeup once upgrade to select from periscope
.ant-select-selector {
min-height: 32px;
background-color: var(--l2-background) !important;
border: 1px solid var(--border) !important;
border-radius: 2px;
padding: 2px var(--padding-2) !important;
display: flex;
align-items: center;
flex-wrap: wrap;
}
.ant-select-selection-overflow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-1);
padding: 2px 0;
}
.ant-select-selection-overflow-item {
display: flex;
align-items: center;
}
.ant-select-selection-item {
display: flex;
align-items: center;
height: 22px;
font-size: var(--font-size-sm);
color: var(--l1-foreground);
background: var(--l3-background);
border: 1px solid var(--border);
border-radius: 2px;
padding: 0 var(--padding-1) 0 6px;
line-height: var(--line-height-20);
letter-spacing: -0.07px;
margin: 0;
}
.ant-select-selection-item-remove {
display: flex;
align-items: center;
color: var(--foreground);
margin-left: 2px;
}
.ant-select-selection-placeholder {
font-size: var(--font-size-sm);
color: var(--l3-foreground);
}
.ant-select-arrow {
color: var(--foreground);
}
&.ant-select-focused .ant-select-selector,
&:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--primary) !important;
}
}
.roles-select-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-3);
padding: var(--padding-1) var(--padding-2);
color: var(--destructive);
font-size: var(--font-size-xs);
&__msg {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
&__retry-btn {
display: flex;
align-items: center;
background: none;
border: none;
cursor: pointer;
padding: 2px;
color: var(--destructive);
}
}

View File

@@ -1,173 +0,0 @@
import { CircleAlert, RefreshCw } from '@signozhq/icons';
import { Checkbox, Select } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRoles } from 'api/generated/services/role';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import APIError from 'types/api/error';
import { popupContainer } from 'utils/selectPopupContainer';
import './RolesSelect.styles.scss';
export interface RoleOption {
label: string;
value: string;
}
export function useRoles(): {
roles: AuthtypesRoleDTO[];
isLoading: boolean;
isError: boolean;
error: APIError | undefined;
refetch: () => void;
} {
const { data, isLoading, isError, error, refetch } = useListRoles();
return {
roles: data?.data ?? [],
isLoading,
isError,
error: convertToApiError(error),
refetch,
};
}
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
return roles.map((role) => ({
label: role.name ?? '',
value: role.name ?? '',
}));
}
function ErrorContent({
error,
onRefetch,
}: {
error?: APIError;
onRefetch?: () => void;
}): JSX.Element {
const errorMessage = error?.message || 'Failed to load roles';
return (
<div className="roles-select-error">
<span className="roles-select-error__msg">
<CircleAlert size={12} />
{errorMessage}
</span>
{onRefetch && (
<button
type="button"
onClick={(e): void => {
e.stopPropagation();
onRefetch();
}}
className="roles-select-error__retry-btn"
title="Retry"
>
<RefreshCw size={12} />
</button>
)}
</div>
);
}
interface BaseProps {
id?: string;
placeholder?: string;
className?: string;
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
roles?: AuthtypesRoleDTO[];
loading?: boolean;
isError?: boolean;
error?: APIError;
onRefetch?: () => void;
}
interface SingleProps extends BaseProps {
mode?: 'single';
value?: string;
onChange?: (role: string) => void;
}
interface MultipleProps extends BaseProps {
mode: 'multiple';
value?: string[];
onChange?: (roles: string[]) => void;
}
export type RolesSelectProps = SingleProps | MultipleProps;
function RolesSelect(props: RolesSelectProps): JSX.Element {
const externalRoles = props.roles;
const {
data,
isLoading: internalLoading,
isError: internalError,
error: internalErrorObj,
refetch: internalRefetch,
} = useListRoles({
query: { enabled: externalRoles === undefined },
});
const roles = externalRoles ?? data?.data ?? [];
const options = getRoleOptions(roles);
const {
mode,
id,
placeholder = 'Select role',
className,
getPopupContainer = popupContainer,
loading = internalLoading,
isError = internalError,
error = convertToApiError(internalErrorObj),
onRefetch = externalRoles === undefined ? internalRefetch : undefined,
} = props;
const notFoundContent = isError ? (
<ErrorContent error={error} onRefetch={onRefetch} />
) : undefined;
if (mode === 'multiple') {
const { value = [], onChange } = props as MultipleProps;
return (
<Select
id={id}
mode="multiple"
value={value}
onChange={onChange}
placeholder={placeholder}
className={cx('roles-select', className)}
loading={loading}
notFoundContent={notFoundContent}
options={options}
optionRender={(option): JSX.Element => (
<Checkbox
checked={value.includes(option.value as string)}
style={{ pointerEvents: 'none' }}
>
{option.label}
</Checkbox>
)}
getPopupContainer={getPopupContainer}
/>
);
}
const { value, onChange } = props as SingleProps;
return (
<Select
id={id}
value={value}
onChange={onChange}
placeholder={placeholder}
className={cx('roles-select', className)}
loading={loading}
notFoundContent={notFoundContent}
options={options}
getPopupContainer={getPopupContainer}
/>
);
}
export default RolesSelect;

View File

@@ -1,2 +0,0 @@
export type { RoleOption, RolesSelectProps } from './RolesSelect';
export { default, getRoleOptions, useRoles } from './RolesSelect';

View File

@@ -1,179 +0,0 @@
.add-key-modal {
[data-slot='dialog-description'] {
padding: 0;
}
&__form {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4);
}
&__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
&__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
&__expiry-toggle {
width: 60%;
display: flex;
border: 1px solid var(--border);
border-radius: 2px;
overflow: hidden;
padding: 0;
gap: 0;
[data-slot='toggle-group'] {
width: 100%;
display: flex;
}
&-btn {
flex: 1;
height: 32px;
border-radius: 0;
font-size: var(--label-small-400-font-size);
font-weight: var(--label-small-400-font-weight);
line-height: var(--label-small-400-line-height);
justify-content: center;
background: transparent;
border: none;
border-right: 1px solid var(--border);
color: var(--foreground);
&:last-child {
border-right: none;
}
&[data-state='on'] {
background: var(--l2-background);
color: var(--l1-foreground);
}
}
}
&__datepicker {
width: 100%;
height: 32px;
.ant-picker {
background: var(--l2-background);
border-color: var(--border);
border-radius: 2px;
width: 100%;
height: 32px;
input {
color: var(--l1-foreground);
font-size: var(--font-size-sm);
}
.ant-picker-suffix {
color: var(--foreground);
}
}
.add-key-modal-datepicker-popup {
border-radius: 4px;
border: 1px solid var(--secondary);
background: var(--popover);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
}
}
&__key-display {
display: flex;
align-items: center;
height: 32px;
background: var(--l2-background);
border: 1px solid var(--border);
border-radius: 2px;
overflow: hidden;
}
&__key-text {
flex: 1;
min-width: 0;
padding: 0 var(--padding-2);
font-size: var(--font-size-sm);
color: var(--l1-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: monospace;
}
&__copy-btn {
flex-shrink: 0;
height: 32px;
border-radius: 0 2px 2px 0;
border-top: none;
border-right: none;
border-bottom: none;
border-left: 1px solid var(--border);
min-width: 40px;
}
&__expiry-meta {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
&__expiry-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
}
&__footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--padding-4);
border-top: 1px solid var(--secondary);
}
&__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
&__learn-more {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
color: var(--primary);
font-size: var(--font-size-sm);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -1,52 +0,0 @@
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { Callout } from '@signozhq/callout';
import { Check, Copy } from '@signozhq/icons';
import type { ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO } from 'api/generated/services/sigNoz.schemas';
export interface KeyCreatedPhaseProps {
createdKey: ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO;
hasCopied: boolean;
expiryLabel: string;
onCopy: () => void;
}
function KeyCreatedPhase({
createdKey,
hasCopied,
expiryLabel,
onCopy,
}: KeyCreatedPhaseProps): JSX.Element {
return (
<div className="add-key-modal__form">
<div className="add-key-modal__field">
<span className="add-key-modal__label">Key</span>
<div className="add-key-modal__key-display">
<span className="add-key-modal__key-text">{createdKey.key}</span>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={onCopy}
className="add-key-modal__copy-btn"
>
{hasCopied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</div>
</div>
<div className="add-key-modal__expiry-meta">
<span className="add-key-modal__expiry-label">Expiration</span>
<Badge color="vanilla">{expiryLabel}</Badge>
</div>
<Callout
type="info"
showIcon
message="Store the key securely. This is the only time it will be displayed."
/>
</div>
);
}
export default KeyCreatedPhase;

View File

@@ -1,130 +0,0 @@
import type { Control, UseFormRegister } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { Button } from '@signozhq/button';
import { Input } from '@signozhq/input';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { DatePicker } from 'antd';
import { popupContainer } from 'utils/selectPopupContainer';
import { disabledDate } from '../utils';
import type { FormValues } from './types';
import { ExpiryMode, FORM_ID } from './types';
export interface KeyFormPhaseProps {
register: UseFormRegister<FormValues>;
control: Control<FormValues>;
expiryMode: ExpiryMode;
isSubmitting: boolean;
isValid: boolean;
onSubmit: () => void;
onClose: () => void;
}
function KeyFormPhase({
register,
control,
expiryMode,
isSubmitting,
isValid,
onSubmit,
onClose,
}: KeyFormPhaseProps): JSX.Element {
return (
<>
<form id={FORM_ID} className="add-key-modal__form" onSubmit={onSubmit}>
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="key-name">
Name <span style={{ color: 'var(--destructive)' }}>*</span>
</label>
<Input
id="key-name"
placeholder="Enter key name e.g.: Service Owner"
className="add-key-modal__input"
{...register('keyName', {
required: true,
validate: (v) => !!v.trim(),
})}
/>
</div>
<div className="add-key-modal__field">
<span className="add-key-modal__label">Expiration</span>
<Controller
name="expiryMode"
control={control}
render={({ field }): JSX.Element => (
<ToggleGroup
type="single"
value={field.value}
onValueChange={(val): void => {
if (val) {
field.onChange(val);
}
}}
className="add-key-modal__expiry-toggle"
>
<ToggleGroupItem
value={ExpiryMode.NONE}
className="add-key-modal__expiry-toggle-btn"
>
No Expiration
</ToggleGroupItem>
<ToggleGroupItem
value={ExpiryMode.DATE}
className="add-key-modal__expiry-toggle-btn"
>
Set Expiration Date
</ToggleGroupItem>
</ToggleGroup>
)}
/>
</div>
{expiryMode === ExpiryMode.DATE && (
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="expiry-date">
Expiration Date
</label>
<div className="add-key-modal__datepicker">
<Controller
name="expiryDate"
control={control}
render={({ field }): JSX.Element => (
<DatePicker
id="expiry-date"
value={field.value}
onChange={field.onChange}
popupClassName="add-key-modal-datepicker-popup"
getPopupContainer={popupContainer}
disabledDate={disabledDate}
/>
)}
/>
</div>
</div>
)}
</form>
<div className="add-key-modal__footer">
<div className="add-key-modal__footer-right">
<Button variant="solid" color="secondary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
form={FORM_ID}
variant="solid"
color="primary"
size="sm"
loading={isSubmitting}
disabled={!isValid}
>
Create Key
</Button>
</div>
</div>
</>
);
}
export default KeyFormPhase;

View File

@@ -1,175 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useQueryClient } from 'react-query';
import { DialogWrapper } from '@signozhq/dialog';
import { toast } from '@signozhq/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
invalidateListServiceAccountKeys,
useCreateServiceAccountKey,
} from 'api/generated/services/serviceaccount';
import type {
RenderErrorResponseDTO,
ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsBoolean, useQueryState } from 'nuqs';
import KeyCreatedPhase from './KeyCreatedPhase';
import KeyFormPhase from './KeyFormPhase';
import type { FormValues } from './types';
import { DEFAULT_FORM_VALUES, ExpiryMode, Phase, PHASE_TITLES } from './types';
import './AddKeyModal.styles.scss';
function AddKeyModal(): JSX.Element {
const queryClient = useQueryClient();
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [isAddKeyOpen, setIsAddKeyOpen] = useQueryState(
SA_QUERY_PARAMS.ADD_KEY,
parseAsBoolean.withDefault(false),
);
const open = isAddKeyOpen && !!accountId;
const [phase, setPhase] = useState<Phase>(Phase.FORM);
const [
createdKey,
setCreatedKey,
] = useState<ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO | null>(null);
const [hasCopied, setHasCopied] = useState(false);
const {
control,
register,
handleSubmit,
reset,
watch,
formState: { isValid },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: DEFAULT_FORM_VALUES,
});
const expiryMode = watch('expiryMode');
const expiryDate = watch('expiryDate');
useEffect(() => {
if (open) {
setPhase(Phase.FORM);
setCreatedKey(null);
setHasCopied(false);
reset();
}
}, [open, reset]);
const {
mutate: createKey,
isLoading: isSubmitting,
} = useCreateServiceAccountKey({
mutation: {
onSuccess: async (response) => {
const keyData = response?.data;
if (keyData) {
setCreatedKey(keyData);
setPhase(Phase.CREATED);
if (accountId) {
await invalidateListServiceAccountKeys(queryClient, { id: accountId });
}
}
},
onError: (error) => {
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to create key';
toast.error(errMessage, { richColors: true });
},
},
});
function handleCreate({
keyName,
expiryMode: mode,
expiryDate: date,
}: FormValues): void {
if (!accountId) {
return;
}
const expiresAt =
mode === ExpiryMode.DATE && date ? date.endOf('day').unix() : 0;
createKey({
pathParams: { id: accountId },
data: { name: keyName.trim(), expiresAt },
});
}
const handleCopy = useCallback(async (): Promise<void> => {
if (!createdKey?.key) {
return;
}
try {
await navigator.clipboard.writeText(createdKey.key);
setHasCopied(true);
setTimeout(() => setHasCopied(false), 2000);
toast.success('Key copied to clipboard', { richColors: true });
} catch {
toast.error('Failed to copy key', { richColors: true });
}
}, [createdKey]);
const handleClose = useCallback((): void => {
setIsAddKeyOpen(null);
}, [setIsAddKeyOpen]);
function getExpiryLabel(): string {
if (expiryMode === ExpiryMode.NONE || !expiryDate) {
return 'Never';
}
try {
return expiryDate.format(DATE_TIME_FORMATS.MONTH_DATE);
} catch {
return 'Never';
}
}
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
title={PHASE_TITLES[phase]}
width="base"
className="add-key-modal"
showCloseButton
disableOutsideClick={false}
>
{phase === Phase.FORM && (
<KeyFormPhase
register={register}
control={control}
expiryMode={expiryMode}
isSubmitting={isSubmitting}
isValid={isValid}
onSubmit={handleSubmit(handleCreate)}
onClose={handleClose}
/>
)}
{phase === Phase.CREATED && createdKey && (
<KeyCreatedPhase
createdKey={createdKey}
hasCopied={hasCopied}
expiryLabel={getExpiryLabel()}
onCopy={handleCopy}
/>
)}
</DialogWrapper>
);
}
export default AddKeyModal;

View File

@@ -1,30 +0,0 @@
import type { Dayjs } from 'dayjs';
export const enum Phase {
FORM = 'form',
CREATED = 'created',
}
export const enum ExpiryMode {
NONE = 'none',
DATE = 'date',
}
export const FORM_ID = 'add-key-form';
export const PHASE_TITLES: Record<Phase, string> = {
[Phase.FORM]: 'Add a New Key',
[Phase.CREATED]: 'Key Created Successfully',
};
export interface FormValues {
keyName: string;
expiryMode: ExpiryMode;
expiryDate: Dayjs | null;
}
export const DEFAULT_FORM_VALUES: FormValues = {
keyName: '',
expiryMode: ExpiryMode.NONE,
expiryDate: null,
};

View File

@@ -1,109 +0,0 @@
import { useQueryClient } from 'react-query';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { PowerOff, X } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getGetServiceAccountQueryKey,
invalidateListServiceAccounts,
useUpdateServiceAccountStatus,
} from 'api/generated/services/serviceaccount';
import type {
RenderErrorResponseDTO,
ServiceaccounttypesServiceAccountDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsBoolean, useQueryState } from 'nuqs';
function DisableAccountModal(): JSX.Element {
const queryClient = useQueryClient();
const [accountId, setAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [isDisableOpen, setIsDisableOpen] = useQueryState(
SA_QUERY_PARAMS.DISABLE_SA,
parseAsBoolean.withDefault(false),
);
const open = !!isDisableOpen && !!accountId;
const cachedAccount = accountId
? queryClient.getQueryData<{
data: ServiceaccounttypesServiceAccountDTO;
}>(getGetServiceAccountQueryKey({ id: accountId }))
: null;
const accountName = cachedAccount?.data?.name;
const {
mutate: updateStatus,
isLoading: isDisabling,
} = useUpdateServiceAccountStatus({
mutation: {
onSuccess: async () => {
toast.success('Service account disabled', { richColors: true });
await setIsDisableOpen(null);
await setAccountId(null);
await invalidateListServiceAccounts(queryClient);
},
onError: (error) => {
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to disable service account';
toast.error(errMessage, { richColors: true });
},
},
});
function handleConfirm(): void {
if (!accountId) {
return;
}
updateStatus({
pathParams: { id: accountId },
data: { status: 'DISABLED' },
});
}
function handleCancel(): void {
setIsDisableOpen(null);
}
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleCancel();
}
}}
title={`Disable service account ${accountName ?? ''}?`}
width="narrow"
className="alert-dialog sa-disable-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="sa-disable-dialog__body">
Disabling this service account will revoke access for all its keys. Any
systems using this account will lose access immediately.
</p>
<DialogFooter className="sa-disable-dialog__footer">
<Button variant="solid" color="secondary" size="sm" onClick={handleCancel}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
loading={isDisabling}
onClick={handleConfirm}
>
<PowerOff size={12} />
Disable
</Button>
</DialogFooter>
</DialogWrapper>
);
}
export default DisableAccountModal;

View File

@@ -1,165 +0,0 @@
import type { Control, UseFormRegister } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { LockKeyhole, Trash2, X } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { DatePicker } from 'antd';
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { popupContainer } from 'utils/selectPopupContainer';
import { disabledDate, formatLastObservedAt } from '../utils';
import type { FormValues } from './types';
import { ExpiryMode, FORM_ID } from './types';
export interface EditKeyFormProps {
register: UseFormRegister<FormValues>;
control: Control<FormValues>;
expiryMode: ExpiryMode;
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null;
isSaving: boolean;
isDirty: boolean;
onSubmit: () => void;
onClose: () => void;
onRevokeClick: () => void;
formatTimezoneAdjustedTimestamp: (ts: string, format: string) => string;
}
function EditKeyForm({
register,
control,
expiryMode,
keyItem,
isSaving,
isDirty,
onSubmit,
onClose,
onRevokeClick,
formatTimezoneAdjustedTimestamp,
}: EditKeyFormProps): JSX.Element {
return (
<>
<form id={FORM_ID} className="edit-key-modal__form" onSubmit={onSubmit}>
<div className="edit-key-modal__field">
<label className="edit-key-modal__label" htmlFor="edit-key-name">
Name
</label>
<Input
id="edit-key-name"
className="edit-key-modal__input"
placeholder="Enter key name"
{...register('name')}
/>
</div>
<div className="edit-key-modal__field">
<label className="edit-key-modal__label" htmlFor="edit-key-display">
Key
</label>
<div id="edit-key-display" className="edit-key-modal__key-display">
<span className="edit-key-modal__key-text">********************</span>
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
</div>
</div>
<div className="edit-key-modal__field">
<span className="edit-key-modal__label">Expiration</span>
<Controller
name="expiryMode"
control={control}
render={({ field }): JSX.Element => (
<ToggleGroup
type="single"
value={field.value}
onValueChange={(val): void => {
if (val) {
field.onChange(val);
}
}}
className="edit-key-modal__expiry-toggle"
>
<ToggleGroupItem
value={ExpiryMode.NONE}
className="edit-key-modal__expiry-toggle-btn"
>
No Expiration
</ToggleGroupItem>
<ToggleGroupItem
value={ExpiryMode.DATE}
className="edit-key-modal__expiry-toggle-btn"
>
Set Expiration Date
</ToggleGroupItem>
</ToggleGroup>
)}
/>
</div>
{expiryMode === ExpiryMode.DATE && (
<div className="edit-key-modal__field">
<label className="edit-key-modal__label" htmlFor="edit-key-datepicker">
Expiration Date
</label>
<div className="edit-key-modal__datepicker">
<Controller
name="expiresAt"
control={control}
render={({ field }): JSX.Element => (
<DatePicker
value={field.value}
id="edit-key-datepicker"
onChange={field.onChange}
popupClassName="edit-key-modal-datepicker-popup"
getPopupContainer={popupContainer}
disabledDate={disabledDate}
/>
)}
/>
</div>
</div>
)}
<div className="edit-key-modal__meta">
<span className="edit-key-modal__meta-label">Last Observed At</span>
<Badge color="vanilla">
{formatLastObservedAt(
keyItem?.lastObservedAt ?? null,
formatTimezoneAdjustedTimestamp,
)}
</Badge>
</div>
</form>
<div className="edit-key-modal__footer">
<Button
type="button"
className="edit-key-modal__footer-danger"
onClick={onRevokeClick}
>
<Trash2 size={12} />
Revoke Key
</Button>
<div className="edit-key-modal__footer-right">
<Button variant="solid" color="secondary" size="sm" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<Button
type="submit"
form={FORM_ID}
variant="solid"
color="primary"
size="sm"
loading={isSaving}
disabled={!isDirty}
>
Save Changes
</Button>
</div>
</div>
</>
);
}
export default EditKeyForm;

View File

@@ -1,188 +0,0 @@
.edit-key-modal {
[data-slot='dialog-description'] {
padding: 0;
}
&__form {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4);
}
&__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
&__label {
font-size: 13px;
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
&__key-display {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--border);
cursor: not-allowed;
opacity: 0.8;
}
&__key-text {
font-size: 13px;
font-family: monospace;
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
letter-spacing: 2px;
}
&__lock-icon {
color: var(--foreground);
flex-shrink: 0;
margin-left: 6px;
opacity: 0.6;
}
&__expiry-toggle {
width: 60%;
display: flex;
border: 1px solid var(--border);
border-radius: 2px;
overflow: hidden;
padding: 0;
gap: 0;
[data-slot='toggle-group'] {
width: 100%;
display: flex;
}
&-btn {
flex: 1;
height: 32px;
border-radius: 0;
font-size: var(--label-small-400-font-size);
font-weight: var(--label-small-400-font-weight);
line-height: var(--label-small-400-line-height);
justify-content: center;
background: transparent;
border: none;
border-right: 1px solid var(--border);
color: var(--foreground);
white-space: nowrap;
&:last-child {
border-right: none;
}
&[data-state='on'] {
background: var(--l2-background);
color: var(--l1-foreground);
}
}
}
&__datepicker {
width: 100%;
height: 32px;
.ant-picker {
background: var(--l2-background);
border-color: var(--border);
border-radius: 2px;
width: 100%;
height: 32px;
input {
color: var(--l1-foreground);
font-size: 13px;
}
.ant-picker-suffix {
color: var(--foreground);
}
}
.edit-key-modal-datepicker-popup {
border-radius: 4px;
border: 1px solid var(--secondary);
background: var(--popover);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
}
}
&__meta {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
&__meta-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--padding-4);
border-top: 1px solid var(--secondary);
}
&__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
&__footer-danger {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: 0;
background: transparent;
border: none;
cursor: pointer;
color: var(--destructive);
font-size: var(--label-small-400-font-size);
font-weight: var(--label-small-400-font-weight);
transition: opacity 0.15s ease;
&:hover {
opacity: 0.8;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}

View File

@@ -1,189 +0,0 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useQueryClient } from 'react-query';
import { DialogWrapper } from '@signozhq/dialog';
import { toast } from '@signozhq/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
invalidateListServiceAccountKeys,
useRevokeServiceAccountKey,
useUpdateServiceAccountKey,
} from 'api/generated/services/serviceaccount';
import type {
RenderErrorResponseDTO,
ServiceaccounttypesFactorAPIKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import dayjs from 'dayjs';
import { parseAsString, useQueryState } from 'nuqs';
import { useTimezone } from 'providers/Timezone';
import { RevokeKeyContent } from '../RevokeKeyModal';
import EditKeyForm from './EditKeyForm';
import type { FormValues } from './types';
import { DEFAULT_FORM_VALUES, ExpiryMode } from './types';
import './EditKeyModal.styles.scss';
export interface EditKeyModalProps {
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null;
}
function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
const queryClient = useQueryClient();
const [selectedAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [editKeyId, setEditKeyId] = useQueryState(
SA_QUERY_PARAMS.EDIT_KEY,
parseAsString.withDefault(''),
);
const open = !!editKeyId && !!selectedAccountId;
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false);
const {
register,
control,
reset,
watch,
formState: { isDirty },
handleSubmit,
} = useForm<FormValues>({
defaultValues: DEFAULT_FORM_VALUES,
});
useEffect(() => {
if (keyItem) {
reset({
name: keyItem.name ?? '',
expiryMode: keyItem.expiresAt === 0 ? ExpiryMode.NONE : ExpiryMode.DATE,
expiresAt: keyItem.expiresAt === 0 ? null : dayjs.unix(keyItem.expiresAt),
});
}
}, [keyItem?.id, reset]); // eslint-disable-line react-hooks/exhaustive-deps
const expiryMode = watch('expiryMode');
const { mutate: updateKey, isLoading: isSaving } = useUpdateServiceAccountKey({
mutation: {
onSuccess: async () => {
toast.success('Key updated successfully', { richColors: true });
await setEditKeyId(null);
if (selectedAccountId) {
await invalidateListServiceAccountKeys(queryClient, {
id: selectedAccountId,
});
}
},
onError: (error) => {
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to update key';
toast.error(errMessage, { richColors: true });
},
},
});
const {
mutate: revokeKey,
isLoading: isRevoking,
} = useRevokeServiceAccountKey({
mutation: {
onSuccess: async () => {
toast.success('Key revoked successfully', { richColors: true });
setIsRevokeConfirmOpen(false);
await setEditKeyId(null);
if (selectedAccountId) {
await invalidateListServiceAccountKeys(queryClient, {
id: selectedAccountId,
});
}
},
onError: (error) => {
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to revoke key';
toast.error(errMessage, { richColors: true });
},
},
});
function handleClose(): void {
setEditKeyId(null);
setIsRevokeConfirmOpen(false);
}
const onSubmit = handleSubmit(
({ name, expiryMode: mode, expiresAt }): void => {
if (!keyItem || !selectedAccountId) {
return;
}
const currentExpiresAt =
mode === ExpiryMode.NONE || !expiresAt ? 0 : expiresAt.endOf('day').unix();
updateKey({
pathParams: { id: selectedAccountId, fid: keyItem.id },
data: { name, expiresAt: currentExpiresAt },
});
},
);
function handleRevoke(): void {
if (!keyItem || !selectedAccountId) {
return;
}
revokeKey({ pathParams: { id: selectedAccountId, fid: keyItem.id } });
}
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
if (isRevokeConfirmOpen) {
setIsRevokeConfirmOpen(false);
} else {
handleClose();
}
}
}}
title={
isRevokeConfirmOpen
? `Revoke ${keyItem?.name ?? 'key'}?`
: 'Edit Key Details'
}
width={isRevokeConfirmOpen ? 'narrow' : 'base'}
className={
isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal'
}
showCloseButton={!isRevokeConfirmOpen}
disableOutsideClick={false}
>
{isRevokeConfirmOpen ? (
<RevokeKeyContent
isRevoking={isRevoking}
onCancel={(): void => setIsRevokeConfirmOpen(false)}
onConfirm={handleRevoke}
/>
) : (
<EditKeyForm
register={register}
control={control}
expiryMode={expiryMode}
keyItem={keyItem}
isSaving={isSaving}
isDirty={isDirty}
onSubmit={onSubmit}
onClose={handleClose}
onRevokeClick={(): void => setIsRevokeConfirmOpen(true)}
formatTimezoneAdjustedTimestamp={formatTimezoneAdjustedTimestamp}
/>
)}
</DialogWrapper>
);
}
export default EditKeyModal;

View File

@@ -1,20 +0,0 @@
import type { Dayjs } from 'dayjs';
export const enum ExpiryMode {
NONE = 'none',
DATE = 'date',
}
export const FORM_ID = 'edit-key-form';
export interface FormValues {
name: string;
expiryMode: ExpiryMode;
expiresAt: Dayjs | null;
}
export const DEFAULT_FORM_VALUES: FormValues = {
name: '',
expiryMode: ExpiryMode.NONE,
expiresAt: null,
};

View File

@@ -1,237 +0,0 @@
import { useCallback, useMemo } from 'react';
import { Button } from '@signozhq/button';
import { KeyRound, X } from '@signozhq/icons';
import { Skeleton, Table, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs';
import { useTimezone } from 'providers/Timezone';
import EditKeyModal from './EditKeyModal';
import RevokeKeyModal from './RevokeKeyModal';
import { formatLastObservedAt } from './utils';
interface KeysTabProps {
keys: ServiceaccounttypesFactorAPIKeyDTO[];
isLoading: boolean;
isDisabled?: boolean;
currentPage: number;
pageSize: number;
}
interface BuildColumnsParams {
isDisabled: boolean;
onRevokeClick: (keyId: string) => void;
handleformatLastObservedAt: (
lastObservedAt: Date | null | undefined,
) => string;
}
function formatExpiry(expiresAt: number): JSX.Element {
if (expiresAt === 0) {
return <span className="keys-tab__expiry--never">Never</span>;
}
const expiryDate = dayjs.unix(expiresAt);
if (expiryDate.isBefore(dayjs())) {
return <span className="keys-tab__expiry--expired">Expired</span>;
}
return <span>{expiryDate.format(DATE_TIME_FORMATS.MONTH_DATE)}</span>;
}
function buildColumns({
isDisabled,
onRevokeClick,
handleformatLastObservedAt,
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesFactorAPIKeyDTO> {
return [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
className: 'keys-tab__name-column',
sorter: (a, b): number => (a.name ?? '').localeCompare(b.name ?? ''),
render: (_, record): JSX.Element => (
<span className="keys-tab__name-text">{record.name ?? '—'}</span>
),
},
{
title: 'Expiry',
dataIndex: 'expiresAt',
key: 'expiry',
width: 160,
align: 'right' as const,
sorter: (a, b): number => {
const aVal = a.expiresAt === 0 ? Infinity : a.expiresAt;
const bVal = b.expiresAt === 0 ? Infinity : b.expiresAt;
return aVal - bVal;
},
render: (expiresAt: number): JSX.Element => formatExpiry(expiresAt),
},
{
title: 'Last Observed At',
dataIndex: 'lastObservedAt',
key: 'lastObservedAt',
width: 220,
align: 'right' as const,
sorter: (a, b): number => {
const aVal = a.lastObservedAt
? new Date(a.lastObservedAt).getTime()
: -Infinity;
const bVal = b.lastObservedAt
? new Date(b.lastObservedAt).getTime()
: -Infinity;
return aVal - bVal;
},
render: (lastObservedAt: Date | null | undefined): string =>
handleformatLastObservedAt(lastObservedAt),
},
{
title: '',
key: 'action',
width: 48,
align: 'right' as const,
render: (_, record): JSX.Element => (
<Tooltip title={isDisabled ? 'Service account disabled' : 'Revoke Key'}>
<Button
variant="ghost"
size="xs"
color="destructive"
disabled={isDisabled}
onClick={(e): void => {
e.stopPropagation();
onRevokeClick(record.id);
}}
className="keys-tab__revoke-btn"
>
<X size={12} />
</Button>
</Tooltip>
),
},
];
}
function KeysTab({
keys,
isLoading,
isDisabled = false,
currentPage,
pageSize,
}: KeysTabProps): JSX.Element {
const [, setIsAddKeyOpen] = useQueryState(
'add-key',
parseAsBoolean.withDefault(false),
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const [editKeyId, setEditKeyId] = useQueryState(
'edit-key',
parseAsString.withDefault(''),
);
const [, setRevokeKeyId] = useQueryState(
'revoke-key',
parseAsString.withDefault(''),
);
const editKey = keys.find((k) => k.id === editKeyId) ?? null;
const handleformatLastObservedAt = useCallback(
(lastObservedAt: Date | null | undefined): string =>
formatLastObservedAt(lastObservedAt, formatTimezoneAdjustedTimestamp),
[formatTimezoneAdjustedTimestamp],
);
const onRevokeClick = useCallback(
(keyId: string): void => {
setRevokeKeyId(keyId);
},
[setRevokeKeyId],
);
const columns = useMemo(
() => buildColumns({ isDisabled, onRevokeClick, handleformatLastObservedAt }),
[isDisabled, onRevokeClick, handleformatLastObservedAt],
);
if (isLoading) {
return (
<div className="keys-tab__loading">
<Skeleton active paragraph={{ rows: 4 }} />
</div>
);
}
if (keys.length === 0) {
return (
<div className="keys-tab__empty">
<KeyRound size={24} className="keys-tab__empty-icon" />
<p className="keys-tab__empty-text">No keys. Start by creating one.</p>
<Button
type="button"
className="keys-tab__learn-more"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
>
+ Add your first key
</Button>
</div>
);
}
return (
<>
{/* Todo: use new table component from periscope when ready */}
<Table<ServiceaccounttypesFactorAPIKeyDTO>
columns={columns}
dataSource={keys}
rowKey="id"
pagination={{
style: { display: 'none' },
current: currentPage,
pageSize,
}}
showSorterTooltip={false}
className={`keys-tab__table${
isDisabled ? ' keys-tab__table--disabled' : ''
}`}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'keys-tab__table-row--alt' : ''
}
onRow={(
record,
): {
onClick: () => void;
onKeyDown: (e: React.KeyboardEvent) => void;
role: string;
tabIndex: number;
'aria-label': string;
} => ({
onClick: async (): Promise<void> => {
if (!isDisabled) {
await setEditKeyId(record.id);
}
},
onKeyDown: async (e: React.KeyboardEvent): Promise<void> => {
if ((e.key === 'Enter' || e.key === ' ') && !isDisabled) {
if (e.key === ' ') {
e.preventDefault();
}
await setEditKeyId(record.id);
}
},
role: 'button',
tabIndex: 0,
'aria-label': `Edit key ${record.name || 'options'}`,
})}
/>
<EditKeyModal keyItem={editKey} />
<RevokeKeyModal />
</>
);
}
export default KeysTab;

View File

@@ -1,150 +0,0 @@
import { useCallback } from 'react';
import { Badge } from '@signozhq/badge';
import { LockKeyhole } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import RolesSelect from 'components/RolesSelect';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
interface OverviewTabProps {
account: ServiceAccountRow;
localName: string;
onNameChange: (v: string) => void;
localRoles: string[];
onRolesChange: (v: string[]) => void;
isDisabled: boolean;
availableRoles: AuthtypesRoleDTO[];
rolesLoading?: boolean;
rolesError?: boolean;
rolesErrorObj?: APIError | undefined;
onRefetchRoles?: () => void;
}
function OverviewTab({
account,
localName,
onNameChange,
localRoles,
onRolesChange,
isDisabled,
availableRoles,
rolesLoading,
rolesError,
rolesErrorObj,
onRefetchRoles,
}: OverviewTabProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
if (!ts) {
return '—';
}
const d = new Date(ts);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
},
[formatTimezoneAdjustedTimestamp],
);
return (
<>
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-name">
Name
</label>
{isDisabled ? (
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{localName || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
) : (
<Input
id="sa-name"
value={localName}
onChange={(e): void => onNameChange(e.target.value)}
className="sa-drawer__input"
placeholder="Enter name"
/>
)}
</div>
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-email">
Email Address
</label>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{account.email || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</div>
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-roles">
Roles
</label>
{isDisabled ? (
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<div className="sa-drawer__disabled-roles">
{localRoles.length > 0 ? (
localRoles.map((r) => (
<Badge key={r} color="vanilla">
{r}
</Badge>
))
) : (
<span className="sa-drawer__input-text"></span>
)}
</div>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
) : (
<RolesSelect
id="sa-roles"
mode="multiple"
roles={availableRoles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={onRefetchRoles}
value={localRoles}
onChange={onRolesChange}
placeholder="Select roles"
/>
)}
</div>
<div className="sa-drawer__meta">
<div className="sa-drawer__meta-item">
<span className="sa-drawer__meta-label">Status</span>
{account.status?.toUpperCase() === 'ACTIVE' ? (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : (
<Badge color="vanilla" variant="outline" className="sa-status-badge">
DISABLED
</Badge>
)}
</div>
<div className="sa-drawer__meta-item">
<span className="sa-drawer__meta-label">Created At</span>
<Badge color="vanilla">{formatTimestamp(account.createdAt)}</Badge>
</div>
<div className="sa-drawer__meta-item">
<span className="sa-drawer__meta-label">Updated At</span>
<Badge color="vanilla">{formatTimestamp(account.updatedAt)}</Badge>
</div>
</div>
</>
);
}
export default OverviewTab;

View File

@@ -1,129 +0,0 @@
import { useQueryClient } from 'react-query';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { Trash2, X } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getListServiceAccountKeysQueryKey,
invalidateListServiceAccountKeys,
useRevokeServiceAccountKey,
} from 'api/generated/services/serviceaccount';
import type {
RenderErrorResponseDTO,
ServiceaccounttypesFactorAPIKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsString, useQueryState } from 'nuqs';
export interface RevokeKeyContentProps {
isRevoking: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export function RevokeKeyContent({
isRevoking,
onCancel,
onConfirm,
}: RevokeKeyContentProps): JSX.Element {
return (
<>
<p className="delete-dialog__body">
Revoking this key will permanently invalidate it. Any systems using this key
will lose access immediately.
</p>
<DialogFooter className="delete-dialog__footer">
<Button variant="solid" color="secondary" size="sm" onClick={onCancel}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
loading={isRevoking}
onClick={onConfirm}
>
<Trash2 size={12} />
Revoke Key
</Button>
</DialogFooter>
</>
);
}
function RevokeKeyModal(): JSX.Element {
const queryClient = useQueryClient();
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [revokeKeyId, setRevokeKeyId] = useQueryState(
SA_QUERY_PARAMS.REVOKE_KEY,
parseAsString.withDefault(''),
);
const open = !!revokeKeyId && !!accountId;
const cachedKeys = accountId
? queryClient.getQueryData<{ data: ServiceaccounttypesFactorAPIKeyDTO[] }>(
getListServiceAccountKeysQueryKey({ id: accountId }),
)
: null;
const keyName = cachedKeys?.data?.find((k) => k.id === revokeKeyId)?.name;
const {
mutate: revokeKey,
isLoading: isRevoking,
} = useRevokeServiceAccountKey({
mutation: {
onSuccess: async () => {
toast.success('Key revoked successfully', { richColors: true });
await setRevokeKeyId(null);
if (accountId) {
await invalidateListServiceAccountKeys(queryClient, { id: accountId });
}
},
onError: (error) => {
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to revoke key';
toast.error(errMessage, { richColors: true });
},
},
});
function handleConfirm(): void {
if (!revokeKeyId || !accountId) {
return;
}
revokeKey({ pathParams: { id: accountId, fid: revokeKeyId } });
}
function handleCancel(): void {
setRevokeKeyId(null);
}
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleCancel();
}
}}
title={`Revoke ${keyName ?? 'key'}?`}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<RevokeKeyContent
isRevoking={isRevoking}
onCancel={handleCancel}
onConfirm={handleConfirm}
/>
</DialogWrapper>
);
}
export default RevokeKeyModal;

View File

@@ -1,460 +0,0 @@
.sa-drawer {
[data-slot='drawer-close'] + div {
border-left: 1px solid var(--l1-border);
padding-left: var(--padding-4);
margin-left: var(--margin-2);
}
&__layout {
display: flex;
flex-direction: column;
height: calc(100vh - 48px);
}
&__tabs {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--padding-3) var(--padding-4) var(--padding-2) var(--padding-4);
flex-shrink: 0;
}
&__tab-group {
[data-slot='toggle-group'] {
height: 32px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
gap: 0;
}
[data-slot='toggle-group-item'] {
height: 32px;
border-radius: 0;
border-left: 1px solid var(--l1-border);
background: transparent;
color: var(--l2-foreground);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-normal);
font-family: Inter, sans-serif;
padding: 0 var(--padding-7);
gap: var(--spacing-3);
box-shadow: none;
&:first-child {
border-left: none;
border-radius: 2px 0 0 2px;
}
&:last-child {
border-radius: 0 2px 2px 0;
}
&:hover {
background: rgba(171, 189, 255, 0.04);
color: var(--l1-foreground);
}
&[data-state='on'] {
background: var(--l1-border);
color: var(--l1-foreground);
}
}
}
&__tab {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-normal);
}
&__tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
padding: 0 6px;
border-radius: 50px;
background: var(--secondary);
font-size: var(--code-small-400-font-size);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
color: var(--foreground);
letter-spacing: -0.06px;
}
&__body {
flex: 1;
overflow-y: auto;
padding: var(--padding-5) var(--padding-4);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
&__footer {
height: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--padding-4);
border-top: 1px solid var(--secondary);
background: var(--card);
}
&__keys-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
width: 100%;
padding: var(--padding-2) 0;
.ant-pagination-total-text {
margin-right: auto;
}
}
&__pagination-range {
font-size: var(--font-size-xs);
color: var(--foreground);
font-weight: var(--font-weight-normal);
}
&__pagination-total {
font-size: var(--font-size-xs);
color: var(--foreground);
opacity: 0.5;
}
&__footer-btn {
padding-left: 0;
padding-right: 0;
}
&__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
&__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
&__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
cursor: default;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
&__input-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--border);
&--disabled {
cursor: not-allowed;
opacity: 0.8;
}
}
&__input-text {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
&__lock-icon {
color: var(--foreground);
flex-shrink: 0;
margin-left: 6px;
opacity: 0.6;
}
&__disabled-roles {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
}
&__meta {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
margin-top: var(--margin-1);
}
&__meta-item {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
[data-slot='badge'] {
padding: var(--padding-1) var(--padding-2);
align-items: center;
font-size: var(--uppercase-small-500-font-size);
font-weight: var(--uppercase-small-500-font-weight);
line-height: 100%;
letter-spacing: 0.44px;
text-transform: uppercase;
}
}
&__meta-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: 0.48px;
text-transform: uppercase;
}
}
.keys-tab {
&__loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--padding-8) var(--padding-4);
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--padding-8) var(--padding-4);
gap: var(--spacing-4);
text-align: center;
height: 80%;
}
&__empty-icon {
color: var(--l2-foreground);
}
&__empty-text {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
margin: 0;
}
&__learn-more {
background: transparent;
border: none;
color: var(--primary);
font-size: var(--font-size-sm);
cursor: pointer;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
// todo: styles should easeup by upgrading the component with table component from persiscope
&__table {
.ant-table {
background: transparent;
border: 1px solid var(--l1-border);
border-radius: 4px;
overflow: hidden;
}
.ant-table-thead > tr > th,
.ant-table-thead > tr > td {
height: 38px;
padding: 0 var(--padding-4);
background: transparent !important;
border-bottom: 1px solid var(--l1-border) !important;
font-size: var(--uppercase-small-500-font-size);
font-weight: var(--uppercase-small-500-font-weight);
color: var(--l2-foreground);
letter-spacing: 0.44px;
text-transform: uppercase;
&::before {
display: none !important;
}
.ant-table-column-sorter {
color: var(--l2-foreground);
opacity: 0.5;
}
&.ant-table-column-sort {
background: transparent !important;
color: var(--l1-foreground);
.ant-table-column-sorter {
color: var(--l1-foreground);
opacity: 1;
}
}
&:hover {
background: transparent !important;
color: var(--l1-foreground);
.ant-table-column-sorter {
opacity: 1;
}
}
}
.ant-table-tbody > tr > td {
height: 38px;
padding: 0 var(--padding-4);
border-bottom: 1px solid var(--l1-border);
font-size: 13px;
font-weight: var(--font-weight-normal);
color: var(--l2-foreground);
letter-spacing: -0.07px;
cursor: pointer;
background: transparent;
transition: none;
font-variant-numeric: lining-nums tabular-nums stacked-fractions ordinal
slashed-zero;
&.ant-table-column-sort {
background: transparent;
}
}
.ant-table-tbody > tr:last-child > td {
border-bottom: none;
}
.ant-table-tbody > tr {
background: transparent;
&:hover > td {
background: rgba(171, 189, 255, 0.06) !important;
}
&.keys-tab__table-row--alt > td {
background: rgba(171, 189, 255, 0.02);
&:hover {
background: rgba(171, 189, 255, 0.06) !important;
}
}
}
&--disabled {
.ant-table-tbody > tr {
cursor: not-allowed;
opacity: 0.6;
&:hover > td {
background: transparent !important;
}
}
}
.ant-table-cell-row-hover {
background: transparent !important;
}
}
&__name-column {
.ant-table-column-sorters {
justify-content: flex-start;
gap: var(--spacing-2);
}
.ant-table-column-title {
flex: none;
}
}
&__name-text {
font-size: 13px;
font-weight: var(--font-weight-normal);
color: var(--l2-foreground);
letter-spacing: -0.07px;
text-transform: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__expiry--never {
color: var(--l2-foreground);
}
&__expiry--expired {
color: var(--l3-foreground);
}
&__revoke-btn {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
}
.sa-disable-dialog {
background: var(--l2-background);
border: 1px solid var(--l2-border);
[data-slot='dialog-title'] {
color: var(--l1-foreground);
}
&__body {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--l2-foreground);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.065px;
margin: 0;
strong {
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
}
&__footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-4);
margin-top: var(--margin-6);
}
}

View File

@@ -1,369 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from '@signozhq/button';
import { DrawerWrapper } from '@signozhq/drawer';
import { Key, LayoutGrid, Plus, PowerOff, X } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Pagination, Skeleton } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
useGetServiceAccount,
useListServiceAccountKeys,
useUpdateServiceAccount,
} from 'api/generated/services/serviceaccount';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { useRoles } from 'components/RolesSelect';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import {
ServiceAccountRow,
toServiceAccountRow,
} from 'container/ServiceAccountsSettings/utils';
import {
parseAsBoolean,
parseAsInteger,
parseAsString,
parseAsStringEnum,
useQueryState,
} from 'nuqs';
import { toAPIError } from 'utils/errorUtils';
import AddKeyModal from './AddKeyModal';
import DisableAccountModal from './DisableAccountModal';
import KeysTab from './KeysTab';
import OverviewTab from './OverviewTab';
import { ServiceAccountDrawerTab } from './utils';
import './ServiceAccountDrawer.styles.scss';
export interface ServiceAccountDrawerProps {
onSuccess: (options?: { closeDrawer?: boolean }) => void;
}
const PAGE_SIZE = 15;
// eslint-disable-next-line sonarjs/cognitive-complexity
function ServiceAccountDrawer({
onSuccess,
}: ServiceAccountDrawerProps): JSX.Element {
const [selectedAccountId, setSelectedAccountId] = useQueryState(
SA_QUERY_PARAMS.ACCOUNT,
);
const open = !!selectedAccountId;
const [activeTab, setActiveTab] = useQueryState(
SA_QUERY_PARAMS.TAB,
parseAsStringEnum<ServiceAccountDrawerTab>(
Object.values(ServiceAccountDrawerTab),
).withDefault(ServiceAccountDrawerTab.Overview),
);
const [keysPage, setKeysPage] = useQueryState(
SA_QUERY_PARAMS.KEYS_PAGE,
parseAsInteger.withDefault(1),
);
const [, setEditKeyId] = useQueryState(
SA_QUERY_PARAMS.EDIT_KEY,
parseAsString.withDefault(''),
);
const [, setIsAddKeyOpen] = useQueryState(
SA_QUERY_PARAMS.ADD_KEY,
parseAsBoolean.withDefault(false),
);
const [, setIsDisableOpen] = useQueryState(
SA_QUERY_PARAMS.DISABLE_SA,
parseAsBoolean.withDefault(false),
);
const [localName, setLocalName] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const {
data: accountData,
isLoading: isAccountLoading,
isError: isAccountError,
error: accountError,
refetch: refetchAccount,
} = useGetServiceAccount(
{ id: selectedAccountId ?? '' },
{ query: { enabled: !!selectedAccountId } },
);
const account = useMemo(
(): ServiceAccountRow | null =>
accountData?.data ? toServiceAccountRow(accountData.data) : null,
[accountData],
);
useEffect(() => {
if (account) {
setLocalName(account.name ?? '');
setLocalRoles(account.roles ?? []);
setKeysPage(1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [account?.id]);
const isDisabled = account?.status?.toUpperCase() !== 'ACTIVE';
const isDirty =
account !== null &&
(localName !== (account.name ?? '') ||
JSON.stringify(localRoles) !== JSON.stringify(account.roles ?? []));
const {
roles: availableRoles,
isLoading: rolesLoading,
isError: rolesError,
error: rolesErrorObj,
refetch: refetchRoles,
} = useRoles();
const { data: keysData, isLoading: keysLoading } = useListServiceAccountKeys(
{ id: selectedAccountId ?? '' },
{ query: { enabled: !!selectedAccountId } },
);
const keys = keysData?.data ?? [];
useEffect(() => {
if (keysLoading) {
return;
}
const maxPage = Math.max(1, Math.ceil(keys.length / PAGE_SIZE));
if (keysPage > maxPage) {
setKeysPage(maxPage);
}
}, [keysLoading, keys.length, keysPage, setKeysPage]);
const { mutate: updateAccount, isLoading: isSaving } = useUpdateServiceAccount(
{
mutation: {
onSuccess: () => {
toast.success('Service account updated successfully', {
richColors: true,
});
refetchAccount();
onSuccess({ closeDrawer: false });
},
onError: (error) => {
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to update service account';
toast.error(errMessage, { richColors: true });
},
},
},
);
function handleSave(): void {
if (!account || !isDirty) {
return;
}
updateAccount({
pathParams: { id: account.id },
data: { name: localName, email: account.email, roles: localRoles },
});
}
const handleClose = useCallback((): void => {
setIsDisableOpen(null);
setIsAddKeyOpen(null);
setSelectedAccountId(null);
setActiveTab(null);
setKeysPage(null);
setEditKeyId(null);
}, [
setSelectedAccountId,
setActiveTab,
setKeysPage,
setEditKeyId,
setIsAddKeyOpen,
setIsDisableOpen,
]);
const drawerContent = (
<div className="sa-drawer__layout">
<div className="sa-drawer__tabs">
<ToggleGroup
type="single"
value={activeTab}
onValueChange={(val): void => {
if (val) {
setActiveTab(val as ServiceAccountDrawerTab);
if (val !== ServiceAccountDrawerTab.Keys) {
setKeysPage(null);
setEditKeyId(null);
}
}
}}
className="sa-drawer__tab-group"
>
<ToggleGroupItem
value={ServiceAccountDrawerTab.Overview}
className="sa-drawer__tab"
>
<LayoutGrid size={14} />
Overview
</ToggleGroupItem>
<ToggleGroupItem
value={ServiceAccountDrawerTab.Keys}
className="sa-drawer__tab"
>
<Key size={14} />
Keys
{keys.length > 0 && (
<span className="sa-drawer__tab-count">{keys.length}</span>
)}
</ToggleGroupItem>
</ToggleGroup>
{activeTab === ServiceAccountDrawerTab.Keys && (
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDisabled}
onClick={(): void => {
setIsAddKeyOpen(true);
}}
>
<Plus size={12} />
Add Key
</Button>
)}
</div>
<div
className={`sa-drawer__body${
activeTab === ServiceAccountDrawerTab.Keys ? ' sa-drawer__body--keys' : ''
}`}
>
{isAccountLoading && <Skeleton active paragraph={{ rows: 6 }} />}
{isAccountError && (
<ErrorInPlace
error={toAPIError(
accountError,
'An unexpected error occurred while fetching service account details.',
)}
/>
)}
{!isAccountLoading && !isAccountError && (
<>
{activeTab === ServiceAccountDrawerTab.Overview && account && (
<OverviewTab
account={account}
localName={localName}
onNameChange={setLocalName}
localRoles={localRoles}
onRolesChange={setLocalRoles}
isDisabled={isDisabled}
availableRoles={availableRoles}
rolesLoading={rolesLoading}
rolesError={rolesError}
rolesErrorObj={rolesErrorObj}
onRefetchRoles={refetchRoles}
/>
)}
{activeTab === ServiceAccountDrawerTab.Keys && (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDisabled}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
)}
</>
)}
</div>
<div className="sa-drawer__footer">
{activeTab === ServiceAccountDrawerTab.Keys ? (
<Pagination
current={keysPage}
pageSize={PAGE_SIZE}
total={keys.length}
showTotal={(total: number, range: number[]): JSX.Element => (
<>
<span className="sa-drawer__pagination-range">
{range[0]} &#8212; {range[1]}
</span>
<span className="sa-drawer__pagination-total"> of {total}</span>
</>
)}
showSizeChanger={false}
hideOnSinglePage
onChange={(page): void => {
void setKeysPage(page);
}}
className="sa-drawer__keys-pagination"
/>
) : (
<>
{!isDisabled && (
<Button
variant="ghost"
color="destructive"
className="sa-drawer__footer-btn"
onClick={(): void => {
setIsDisableOpen(true);
}}
>
<PowerOff size={12} />
Disable Service Account
</Button>
)}
{!isDisabled && (
<div className="sa-drawer__footer-right">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={handleClose}
>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
loading={isSaving}
disabled={!isDirty}
onClick={handleSave}
>
Save Changes
</Button>
</div>
)}
</>
)}
</div>
</div>
);
return (
<>
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
direction="right"
type="panel"
showCloseButton
showOverlay={false}
allowOutsideClick
header={{ title: 'Service Account Details' }}
content={drawerContent}
className="sa-drawer"
/>
<DisableAccountModal />
<AddKeyModal />
</>
);
}
export default ServiceAccountDrawer;

View File

@@ -1,139 +0,0 @@
import { toast } from '@signozhq/sonner';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import AddKeyModal from '../AddKeyModal';
jest.mock('@signozhq/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const mockToast = jest.mocked(toast);
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys';
const createdKeyResponse = {
data: {
id: 'key-1',
name: 'Deploy Key',
key: 'snz_abc123xyz456secret',
expiresAt: 0,
lastObservedAt: null,
},
};
function renderModal(): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter
searchParams={{ account: 'sa-1', 'add-key': 'true' }}
hasMemory
>
<AddKeyModal />
</NuqsTestingAdapter>,
);
}
describe('AddKeyModal', () => {
beforeAll(() => {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: jest.fn().mockResolvedValue(undefined) },
configurable: true,
writable: true,
});
});
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.post(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json(createdKeyResponse)),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('"Create Key" is disabled when name is empty; enabled after typing a name', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
expect(screen.getByRole('button', { name: /Create Key/i })).toBeDisabled();
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'My Key');
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Key/i }),
).not.toBeDisabled(),
);
});
it('successful creation transitions to phase 2 with key displayed and security callout', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Key/i }),
).not.toBeDisabled(),
);
await user.click(screen.getByRole('button', { name: /Create Key/i }));
await screen.findByText('snz_abc123xyz456secret');
expect(screen.getByText(/Store the key securely/i)).toBeInTheDocument();
await screen.findByRole('dialog', { name: /Key Created Successfully/i });
});
it('copy button writes key to clipboard and shows toast.success', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const writeTextSpy = jest
.spyOn(navigator.clipboard, 'writeText')
.mockResolvedValue(undefined);
renderModal();
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Key/i }),
).not.toBeDisabled(),
);
await user.click(screen.getByRole('button', { name: /Create Key/i }));
await screen.findByText('snz_abc123xyz456secret');
const copyBtn = screen
.getAllByRole('button')
.find((btn) => btn.querySelector('svg'));
if (!copyBtn) {
throw new Error('Copy button not found');
}
await user.click(copyBtn);
await waitFor(() => {
expect(writeTextSpy).toHaveBeenCalledWith('snz_abc123xyz456secret');
expect(mockToast.success).toHaveBeenCalledWith(
'Key copied to clipboard',
expect.anything(),
);
});
writeTextSpy.mockRestore();
});
it('Cancel button closes the modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByRole('dialog', { name: /Add a New Key/i });
await user.click(screen.getByRole('button', { name: /Cancel/i }));
expect(
screen.queryByRole('dialog', { name: /Add a New Key/i }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,151 +0,0 @@
import { toast } from '@signozhq/sonner';
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import EditKeyModal from '../EditKeyModal';
jest.mock('@signozhq/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const mockToast = jest.mocked(toast);
const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/key-1';
const mockKey: ServiceaccounttypesFactorAPIKeyDTO = {
id: 'key-1',
name: 'Original Key Name',
expiresAt: 0,
lastObservedAt: null as any,
key: 'snz_abc123',
serviceAccountId: 'sa-1',
};
function renderModal(
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null = mockKey,
searchParams: Record<string, string> = {
account: 'sa-1',
'edit-key': 'key-1',
},
): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<EditKeyModal keyItem={keyItem} />
</NuqsTestingAdapter>,
);
}
describe('EditKeyModal (URL-controlled)', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.put(SA_KEY_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders nothing when edit-key param is absent', () => {
renderModal(null, { account: 'sa-1' });
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
});
it('renders key data from prop when edit-key param is set', async () => {
renderModal();
expect(
await screen.findByDisplayValue('Original Key Name'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save Changes/i })).toBeDisabled();
});
it('save calls update API, shows toast, and closes modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const nameInput = await screen.findByPlaceholderText(/Enter key name/i);
await user.clear(nameInput);
await user.type(nameInput, 'Updated Key Name');
await user.click(screen.getByRole('button', { name: /Save Changes/i }));
await waitFor(() => {
expect(mockToast.success).toHaveBeenCalledWith(
'Key updated successfully',
expect.anything(),
);
});
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
});
});
it('cancel clears edit-key param and closes modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByDisplayValue('Original Key Name');
await user.click(screen.getByRole('button', { name: /Cancel/i }));
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
});
it('revoke flow: clicking Revoke Key shows confirmation inside same dialog', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByDisplayValue('Original Key Name');
await user.click(screen.getByRole('button', { name: /Revoke Key/i }));
// Same dialog, now showing revoke confirmation
expect(
await screen.findByRole('dialog', { name: /Revoke Original Key Name/i }),
).toBeInTheDocument();
expect(
screen.getByText(/Revoking this key will permanently invalidate it/i),
).toBeInTheDocument();
});
it('revoke flow: confirming revoke shows toast and closes modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByDisplayValue('Original Key Name');
await user.click(screen.getByRole('button', { name: /Revoke Key/i }));
const confirmBtn = await screen.findByRole('button', {
name: /^Revoke Key$/i,
});
await user.click(confirmBtn);
await waitFor(() => {
expect(mockToast.success).toHaveBeenCalledWith(
'Key revoked successfully',
expect.anything(),
);
});
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
});
});
});

View File

@@ -1,183 +0,0 @@
import { toast } from '@signozhq/sonner';
import { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import KeysTab from '../KeysTab';
jest.mock('@signozhq/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const mockToast = jest.mocked(toast);
const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/:fid';
const keys: ServiceaccounttypesFactorAPIKeyDTO[] = [
{
id: 'key-1',
name: 'Production Key',
expiresAt: 0,
lastObservedAt: null as any,
key: 'snz_prod_123',
serviceAccountId: 'sa-1',
},
{
id: 'key-2',
name: 'Staging Key',
expiresAt: 1924905600, // 2030-12-31
lastObservedAt: new Date('2026-03-10T10:00:00Z'),
key: 'snz_stag_456',
serviceAccountId: 'sa-1',
},
];
const defaultProps = {
keys,
isLoading: false,
isDisabled: false,
currentPage: 1,
pageSize: 10,
};
function renderKeysTab(
props: Partial<typeof defaultProps> = {},
searchParams: Record<string, string> = { account: 'sa-1' },
): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<KeysTab {...defaultProps} {...props} />
</NuqsTestingAdapter>,
);
}
describe('KeysTab', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders loading state', () => {
renderKeysTab({ isLoading: true });
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('renders empty state when no keys and clicking add sets add-key param', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onUrlUpdate = jest.fn();
render(
<NuqsTestingAdapter
searchParams={{ account: 'sa-1' }}
onUrlUpdate={onUrlUpdate}
>
<KeysTab {...defaultProps} keys={[]} />
</NuqsTestingAdapter>,
);
expect(
screen.getByText(/No keys. Start by creating one./i),
).toBeInTheDocument();
const addBtn = screen.getByRole('button', { name: /\+ Add your first key/i });
await user.click(addBtn);
expect(onUrlUpdate).toHaveBeenCalledWith(
expect.objectContaining({
queryString: expect.stringContaining('add-key=true'),
}),
);
});
it('renders table with keys', () => {
renderKeysTab();
expect(screen.getByText('Production Key')).toBeInTheDocument();
expect(screen.getByText('Staging Key')).toBeInTheDocument();
expect(screen.getByText('Never')).toBeInTheDocument();
expect(screen.getByText('Dec 31, 2030')).toBeInTheDocument();
});
it('clicking a row sets the edit-key URL param', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onUrlUpdate = jest.fn();
render(
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate}>
<KeysTab {...defaultProps} />
</NuqsTestingAdapter>,
);
const row = screen.getByText('Production Key').closest('tr');
if (!row) {
throw new Error('Row not found');
}
await user.click(row);
expect(onUrlUpdate).toHaveBeenCalledWith(
expect.objectContaining({
queryString: expect.stringContaining('edit-key=key-1'),
}),
);
});
it('clicking revoke icon sets revoke-key URL param', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onUrlUpdate = jest.fn();
render(
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate}>
<KeysTab {...defaultProps} />
</NuqsTestingAdapter>,
);
const revokeBtns = screen
.getAllByRole('button')
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
await user.click(revokeBtns[0]);
expect(onUrlUpdate).toHaveBeenCalledWith(
expect.objectContaining({
queryString: expect.stringContaining('revoke-key=key-1'),
}),
);
});
it('handles successful key revocation via RevokeKeyModal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderKeysTab();
// Seed the keys cache so RevokeKeyModal can read the key name
const revokeBtns = screen
.getAllByRole('button')
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
await user.click(revokeBtns[0]);
const confirmBtn = await screen.findByRole('button', { name: /Revoke Key/i });
await user.click(confirmBtn);
await waitFor(() => {
expect(mockToast.success).toHaveBeenCalledWith(
'Key revoked successfully',
expect.anything(),
);
});
});
it('disables actions when isDisabled is true', () => {
renderKeysTab({ isDisabled: true });
const revokeBtns = screen
.getAllByRole('button')
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
revokeBtns.forEach((btn) => expect(btn).toBeDisabled());
});
});

View File

@@ -1,250 +0,0 @@
import type { ReactNode } from 'react';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import ServiceAccountDrawer from '../ServiceAccountDrawer';
jest.mock('@signozhq/drawer', () => ({
DrawerWrapper: ({
content,
open,
}: {
content?: ReactNode;
open: boolean;
}): JSX.Element | null => (open ? <div>{content}</div> : null),
}));
jest.mock('@signozhq/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const ROLES_ENDPOINT = '*/api/v1/roles';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_STATUS_ENDPOINT = '*/api/v1/service_accounts/sa-1/status';
const activeAccountResponse = {
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
};
const disabledAccountResponse = {
...activeAccountResponse,
id: 'sa-2',
status: 'DISABLED',
};
function renderDrawer(
searchParams: Record<string, string> = { account: 'sa-1' },
): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<ServiceAccountDrawer onSuccess={jest.fn()} />
</NuqsTestingAdapter>,
);
}
describe('ServiceAccountDrawer', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: activeAccountResponse })),
),
rest.put(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.put(SA_STATUS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders Overview tab by default: editable name input, locked email, Save disabled when not dirty', async () => {
renderDrawer();
expect(await screen.findByDisplayValue('CI Bot')).toBeInTheDocument();
expect(screen.getByText('ci-bot@signoz.io')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save Changes/i })).toBeDisabled();
});
it('editing name enables Save; clicking Save sends correct payload and calls onSuccess', async () => {
const onSuccess = jest.fn();
const updateSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.put(SA_ENDPOINT, async (req, res, ctx) => {
updateSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
render(
<NuqsTestingAdapter searchParams={{ account: 'sa-1' }} hasMemory>
<ServiceAccountDrawer onSuccess={onSuccess} />
</NuqsTestingAdapter>,
);
const nameInput = await screen.findByDisplayValue('CI Bot');
await user.clear(nameInput);
await user.type(nameInput, 'CI Bot Updated');
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(updateSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'CI Bot Updated',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
}),
);
expect(onSuccess).toHaveBeenCalledWith({ closeDrawer: false });
});
});
it('changing roles enables Save; clicking Save sends updated roles in payload', async () => {
const updateSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.put(SA_ENDPOINT, async (req, res, ctx) => {
updateSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
renderDrawer();
await screen.findByDisplayValue('CI Bot');
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-viewer'));
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(updateSpy).toHaveBeenCalledWith(
expect.objectContaining({
roles: expect.arrayContaining(['signoz-admin', 'signoz-viewer']),
}),
);
});
});
it('"Disable Service Account" opens confirm dialog; confirming sends correct status payload', async () => {
const statusSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.put(SA_STATUS_ENDPOINT, async (req, res, ctx) => {
statusSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
renderDrawer();
await screen.findByDisplayValue('CI Bot');
await user.click(
screen.getByRole('button', { name: /Disable Service Account/i }),
);
const dialog = await screen.findByRole('dialog', {
name: /Disable service account CI Bot/i,
});
expect(dialog).toBeInTheDocument();
const confirmBtns = screen.getAllByRole('button', { name: /^Disable$/i });
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(statusSpy).toHaveBeenCalledWith({ status: 'DISABLED' });
});
await waitFor(() => {
expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument();
});
});
it('disabled account shows read-only name, no Save button, no Disable button', async () => {
server.use(
rest.get('*/api/v1/service_accounts/sa-2', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: disabledAccountResponse })),
),
rest.get('*/api/v1/service_accounts/sa-2/keys', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
);
renderDrawer({ account: 'sa-2' });
await screen.findByText('CI Bot');
expect(
screen.queryByRole('button', { name: /Save Changes/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /Disable Service Account/i }),
).not.toBeInTheDocument();
expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument();
});
it('switching to Keys tab shows "No keys" empty state', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer();
await screen.findByDisplayValue('CI Bot');
await user.click(screen.getByRole('radio', { name: /Keys/i }));
await screen.findByText(/No keys/i);
});
it('shows skeleton while loading account data', () => {
renderDrawer();
// Skeleton renders while the fetch is in-flight
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('shows error state when account fetch fails', async () => {
server.use(
rest.get(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(500), ctx.json({ message: 'Server error' })),
),
);
renderDrawer();
expect(
await screen.findByText(
/An unexpected error occurred while fetching service account details/i,
),
).toBeInTheDocument();
});
});

View File

@@ -1,33 +0,0 @@
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
export enum ServiceAccountDrawerTab {
Overview = 'overview',
Keys = 'keys',
}
export function formatLastObservedAt(
lastObservedAt: string | Date | null | undefined,
formatTimezoneAdjustedTimestamp: (ts: string, format: string) => string,
): string {
if (!lastObservedAt) {
return '—';
}
const str =
typeof lastObservedAt === 'string'
? lastObservedAt
: lastObservedAt.toISOString();
// Go zero time means the key has never been used
if (str.startsWith('0001-01-01')) {
return '—';
}
const d = new Date(str);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(str, DATE_TIME_FORMATS.DASH_DATETIME);
}
export const disabledDate = (current: Dayjs): boolean =>
!!current && current < dayjs().startOf('day');

View File

@@ -1,217 +0,0 @@
.sa-table-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 4px;
}
.sa-table {
.ant-table {
background: transparent;
font-size: 13px;
}
.ant-table-container {
border-radius: 0 !important;
border: none !important;
}
.ant-table-thead {
> tr > th,
> tr > td {
background: var(--background);
font-size: var(--paragraph-small-600-font-size);
font-weight: var(--paragraph-small-600-font-weight);
line-height: var(--paragraph-small-600-line-height);
letter-spacing: 0.44px;
text-transform: uppercase;
color: var(--foreground);
padding: var(--padding-2) var(--padding-4);
border-bottom: none !important;
border-top: none !important;
&::before {
display: none !important;
}
}
}
.ant-table-tbody {
> tr > td {
border-bottom: none !important;
padding: var(--padding-2) var(--padding-4);
background: transparent;
transition: none;
}
> tr.sa-table-row--tinted > td {
background: rgba(171, 189, 255, 0.02);
}
> tr:hover > td {
background: rgba(171, 189, 255, 0.04) !important;
}
}
.ant-table-wrapper,
.ant-table-container,
.ant-spin-nested-loading,
.ant-spin-container {
border: none !important;
box-shadow: none !important;
}
.sa-name-column {
.ant-table-column-sorters {
justify-content: flex-start;
gap: var(--spacing-2);
}
.ant-table-column-title {
flex: none;
}
}
.sa-status-cell {
[data-slot='badge'] {
padding: var(--padding-1) var(--padding-2);
align-items: center;
font-size: var(--uppercase-small-500-font-size);
font-weight: var(--uppercase-small-500-font-weight);
line-height: 100%;
letter-spacing: 0.44px;
text-transform: uppercase;
}
}
}
.sa-name-email-cell {
display: flex;
align-items: center;
gap: var(--spacing-2);
height: 22px;
overflow: hidden;
.sa-name {
font-size: var(--paragraph-base-500-font-size);
font-weight: var(--paragraph-base-500-font-weight);
color: var(--foreground);
line-height: var(--paragraph-base-500-line-height);
letter-spacing: -0.07px;
white-space: nowrap;
flex-shrink: 0;
}
.sa-email {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--l3-foreground-hover);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.07px;
flex: 1 0 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.sa-roles-cell {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.sa-dash {
font-size: var(--paragraph-base-400-font-size);
color: var(--l3-foreground-hover);
}
.sa-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--padding-12) var(--padding-4);
gap: var(--spacing-4);
color: var(--foreground);
&__icon {
color: var(--l2-foreground);
}
&__text {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
margin: 0;
line-height: var(--paragraph-base-400-line-height);
strong {
font-weight: var(--font-weight-medium);
color: var(--bg-base-white);
}
}
}
.sa-table-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--padding-2) var(--padding-4);
.ant-pagination-total-text {
margin-right: auto;
}
.sa-pagination-range {
font-size: var(--font-size-xs);
color: var(--foreground);
}
.sa-pagination-total {
font-size: var(--font-size-xs);
color: var(--foreground);
opacity: 0.5;
}
}
.sa-tooltip {
.ant-tooltip-inner {
background-color: var(--bg-slate-500);
color: var(--foreground);
font-size: var(--font-size-xs);
line-height: normal;
padding: var(--padding-2) var(--padding-3);
border-radius: 4px;
text-align: left;
}
.ant-tooltip-arrow-content {
background-color: var(--bg-slate-500);
}
}
.lightMode {
.sa-table {
.ant-table-tbody {
> tr.sa-table-row--tinted > td {
background: rgba(0, 0, 0, 0.015);
}
> tr:hover > td {
background: rgba(0, 0, 0, 0.03) !important;
}
}
}
.sa-empty-state {
&__text {
strong {
color: var(--bg-base-black);
}
}
}
}

View File

@@ -1,99 +0,0 @@
import React from 'react';
import { Table } from 'antd';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { parseAsInteger, parseAsString, useQueryState } from 'nuqs';
import {
columns,
ServiceAccountsEmptyState,
showPaginationTotal,
} from './utils';
import './ServiceAccountsTable.styles.scss';
export const PAGE_SIZE = 20;
interface ServiceAccountsTableProps {
data: ServiceAccountRow[];
loading: boolean;
onRowClick?: (row: ServiceAccountRow) => void;
}
function ServiceAccountsTable({
data,
loading,
onRowClick,
}: ServiceAccountsTableProps): JSX.Element {
const [currentPage, setPage] = useQueryState(
SA_QUERY_PARAMS.PAGE,
parseAsInteger.withDefault(1),
);
const [searchQuery] = useQueryState(
SA_QUERY_PARAMS.SEARCH,
parseAsString.withDefault(''),
);
return (
<div className="sa-table-wrapper">
{/* Todo: use new table component from periscope when ready */}
<Table<ServiceAccountRow>
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={
data.length > PAGE_SIZE
? {
current: currentPage,
pageSize: PAGE_SIZE,
total: data.length,
showTotal: showPaginationTotal,
showSizeChanger: false,
onChange: (page: number): void => void setPage(page),
className: 'sa-table-pagination',
}
: false
}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'sa-table-row--tinted' : ''
}
showSorterTooltip={false}
locale={{
emptyText: <ServiceAccountsEmptyState searchQuery={searchQuery} />,
}}
className="sa-table"
onRow={(
record,
): {
onClick?: () => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLElement>) => void;
style?: React.CSSProperties;
tabIndex?: number;
role?: string;
'aria-label'?: string;
} => {
if (!onRowClick) {
return {};
}
return {
onClick: (): void => onRowClick(record),
onKeyDown: (e: React.KeyboardEvent<HTMLElement>): void => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onRowClick(record);
}
},
style: { cursor: 'pointer' },
tabIndex: 0,
role: 'button',
'aria-label': `View service account ${record.name || record.email}`,
};
}}
/>
</div>
);
}
export default ServiceAccountsTable;

View File

@@ -1,96 +0,0 @@
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent } from 'tests/test-utils';
import ServiceAccountsTable from '../ServiceAccountsTable';
const mockActiveAccount: ServiceAccountRow = {
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
};
const mockDisabledAccount: ServiceAccountRow = {
id: 'sa-2',
name: 'Legacy Bot',
email: 'legacy@signoz.io',
roles: ['signoz-viewer', 'signoz-editor', 'billing-manager'],
status: 'DISABLED',
createdAt: '2025-06-01T00:00:00Z',
updatedAt: '2025-12-01T00:00:00Z',
};
const defaultProps = {
loading: false,
onRowClick: jest.fn(),
};
describe('ServiceAccountsTable', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders name, email, role badge, and ACTIVE status badge', () => {
render(<ServiceAccountsTable {...defaultProps} data={[mockActiveAccount]} />);
expect(screen.getByText('CI Bot')).toBeInTheDocument();
expect(screen.getByText('ci-bot@signoz.io')).toBeInTheDocument();
expect(screen.getByText('signoz-admin')).toBeInTheDocument();
expect(screen.getByText('ACTIVE')).toBeInTheDocument();
});
it('shows DISABLED badge and +2 overflow badge for multi-role accounts', () => {
render(
<ServiceAccountsTable {...defaultProps} data={[mockDisabledAccount]} />,
);
expect(screen.getByText('DISABLED')).toBeInTheDocument();
expect(screen.getByText('signoz-viewer')).toBeInTheDocument();
expect(screen.getByText('+2')).toBeInTheDocument();
});
it('calls onRowClick with the correct account when a row is clicked', async () => {
const onRowClick = jest.fn() as jest.MockedFunction<
(row: ServiceAccountRow) => void
>;
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ServiceAccountsTable
{...defaultProps}
data={[mockActiveAccount]}
onRowClick={onRowClick}
/>,
);
await user.click(
screen.getByRole('button', { name: /View service account CI Bot/i }),
);
expect(onRowClick).toHaveBeenCalledTimes(1);
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: 'sa-1', email: 'ci-bot@signoz.io' }),
);
});
it('shows "No service accounts" empty state when data is empty and no search query', () => {
render(<ServiceAccountsTable {...defaultProps} data={[]} />);
expect(screen.getByText(/No service accounts/i)).toBeInTheDocument();
});
it('shows "No results for {query}" empty state when search is active', () => {
render(
<NuqsTestingAdapter searchParams={{ search: 'ghost' }}>
<ServiceAccountsTable {...defaultProps} data={[]} />
</NuqsTestingAdapter>,
);
expect(screen.getByText(/No results for/i)).toBeInTheDocument();
expect(screen.getByText('ghost')).toBeInTheDocument();
});
});

View File

@@ -1,132 +0,0 @@
import { Badge } from '@signozhq/badge';
import { ScanSearch } from '@signozhq/icons';
import { Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
export function NameEmailCell({
name,
email,
}: {
name: string;
email: string;
}): JSX.Element {
return (
<div className="sa-name-email-cell">
{name && (
<span className="sa-name" title={name}>
{name}
</span>
)}
<Tooltip title={email} overlayClassName="sa-tooltip">
<span className="sa-email">{email}</span>
</Tooltip>
</div>
);
}
export function RolesCell({ roles }: { roles: string[] }): JSX.Element {
if (!roles || roles.length === 0) {
return <span className="sa-dash"></span>;
}
const first = roles[0];
const overflow = roles.length - 1;
const tooltipContent = roles.slice(1).join(', ');
return (
<div className="sa-roles-cell">
<Badge color="vanilla">{first}</Badge>
{overflow > 0 && (
<Tooltip
title={tooltipContent}
overlayClassName="sa-tooltip"
overlayStyle={{ maxWidth: '600px' }}
>
<Badge color="vanilla" variant="outline" className="sa-status-badge">
+{overflow}
</Badge>
</Tooltip>
)}
</div>
);
}
export function StatusBadge({ status }: { status: string }): JSX.Element {
if (status?.toUpperCase() === 'ACTIVE') {
return (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
);
}
return (
<Badge color="vanilla" variant="outline" className="sa-status-badge">
DISABLED
</Badge>
);
}
export function ServiceAccountsEmptyState({
searchQuery,
}: {
searchQuery: string;
}): JSX.Element {
return (
<div className="sa-empty-state">
<ScanSearch size={24} className="sa-empty-state__icon" />
{searchQuery ? (
<p className="sa-empty-state__text">
No results for <strong>{searchQuery}</strong>
</p>
) : (
<p className="sa-empty-state__text">
No service accounts. Start by creating one to manage keys.
</p>
)}
</div>
);
}
export const columns: ColumnsType<ServiceAccountRow> = [
{
title: 'Name / Email',
dataIndex: 'name',
key: 'name',
className: 'sa-name-column',
sorter: (a, b): number => a.email.localeCompare(b.email),
render: (_, record): JSX.Element => (
<NameEmailCell name={record.name} email={record.email} />
),
},
{
title: 'Roles',
dataIndex: 'roles',
key: 'roles',
width: 420,
render: (roles: string[]): JSX.Element => <RolesCell roles={roles} />,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 120,
align: 'right' as const,
className: 'sa-status-cell',
sorter: (a, b): number =>
(a.status?.toUpperCase() === 'ACTIVE' ? 0 : 1) -
(b.status?.toUpperCase() === 'ACTIVE' ? 0 : 1),
render: (status: string): JSX.Element => <StatusBadge status={status} />,
},
];
export const showPaginationTotal = (
_total: number,
range: number[],
): JSX.Element => (
<>
<span className="sa-pagination-range">
{range[0]} &#8212; {range[1]}
</span>
<span className="sa-pagination-total"> of {_total}</span>
</>
);

View File

@@ -1,13 +1,13 @@
import { createShortcutActions } from '../../constants/shortcutActions';
import { useCmdK } from '../../providers/cmdKProvider';
import { ROLES } from '../../types/roles';
import { ShiftOverlay } from './ShiftOverlay';
import { useShiftHoldOverlay } from './useShiftHoldOverlay';
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export function ShiftHoldOverlayController({
userRole,
}: {
userRole: ROLES;
userRole: UserRole;
}): JSX.Element | null {
const { open: isCmdKOpen } = useCmdK();
const noop = (): void => undefined;

View File

@@ -1,18 +1,18 @@
import { useMemo } from 'react';
import ReactDOM from 'react-dom';
import { ROLES } from 'types/roles';
import { formatShortcut } from './formatShortcut';
import './shiftOverlay.scss';
export type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export type CmdAction = {
id: string;
name: string;
shortcut?: string[];
keywords?: string;
section?: string;
roles?: ROLES[];
roles?: UserRole[];
perform: () => void;
};
@@ -33,7 +33,7 @@ function Shortcut({ label, keyHint }: ShortcutProps): JSX.Element {
interface ShiftOverlayProps {
visible: boolean;
actions: CmdAction[];
userRole: ROLES;
userRole: UserRole;
}
export function ShiftOverlay({

View File

@@ -11,7 +11,6 @@ import {
import logEvent from 'api/common/logEvent';
import { useThemeMode } from 'hooks/useDarkMode';
import history from 'lib/history';
import { ROLES as UserRole } from 'types/roles';
import { createShortcutActions } from '../../constants/shortcutActions';
import { useCmdK } from '../../providers/cmdKProvider';
@@ -29,6 +28,7 @@ type CmdAction = {
perform: () => void;
};
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export function CmdKPalette({
userRole,
}: {

View File

@@ -33,7 +33,6 @@ export const DATE_TIME_FORMATS = {
SPAN_POPOVER_DATE: 'D/M/YY - HH:mm:ss',
// Month name formats
MONTH_DATE: 'MMM D, YYYY',
MONTH_DATE_FULL: 'MMMM DD, YYYY',
MONTH_DATE_SHORT: 'DD MMM YYYY',
MONTH_DATETIME_SHORT: 'DD MMM YYYY HH:mm',

View File

@@ -35,5 +35,4 @@ export enum LOCALSTORAGE {
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
}

View File

@@ -86,7 +86,6 @@ const ROUTES = {
METER_EXPLORER_VIEWS: '/meter/explorer/views',
HOME_PAGE: '/',
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
} as const;
export default ROUTES;

View File

@@ -18,7 +18,8 @@ import {
TowerControl,
Workflow,
} from 'lucide-react';
import { ROLES } from 'types/roles';
export type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export type CmdAction = {
id: string;
@@ -27,7 +28,7 @@ export type CmdAction = {
keywords?: string;
section?: string;
icon?: React.ReactNode;
roles?: ROLES[];
roles?: UserRole[];
perform: () => void;
};

View File

@@ -700,17 +700,11 @@
.ant-btn {
box-shadow: none;
position: relative;
}
.tab {
border: 1px solid var(--bg-slate-400);
width: 114px;
z-index: 1;
}
.tab:hover {
z-index: 3;
}
.tab::before {
@@ -721,7 +715,6 @@
background: var(--bg-slate-400);
color: var(--text-vanilla-100);
border: 1px solid var(--bg-slate-400);
z-index: 2;
}
.selected_view::before {

View File

@@ -144,10 +144,6 @@
background: var(--bg-ink-300);
}
.expanded-clickable-row {
cursor: pointer;
}
.error-rate {
width: 120px;
}

View File

@@ -5,7 +5,7 @@ import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart/UPlotChart';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart';
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import noop from 'lodash-es/noop';

View File

@@ -123,7 +123,7 @@ export const prepareUPlotConfig = ({
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label: label,
colorMapping: widget.customLegendColors ?? {},
spanGaps: widget.spanGaps ?? true,
spanGaps: true,
lineStyle: widget.lineStyle || LineStyle.Solid,
lineInterpolation: widget.lineInterpolation || LineInterpolation.Spline,
showPoints:

View File

@@ -6,16 +6,13 @@ import { Button, Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import listUserPreferences from 'api/v1/user/preferences/list';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { PersistedAnnouncementBanner } from 'components/AnnouncementBanner';
import Header from 'components/Header/Header';
import { ENTITY_VERSION_V5 } from 'constants/app';
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';
import { getMetricsListQuery } from 'container/MetricsExplorer/Summary/utils';
import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config';
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -294,24 +291,6 @@ export default function Home(): JSX.Element {
return (
<div className="home-container">
{IS_SERVICE_ACCOUNTS_ENABLED && (
<PersistedAnnouncementBanner
type="warning"
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
message={
<>
<strong>API Keys</strong> have been deprecated and replaced by{' '}
<strong>Service Accounts</strong>. Please migrate to Service Accounts for
programmatic API access.
</>
}
action={{
label: 'Go to Service Accounts',
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
}}
/>
)}
<div className="sticky-header">
<Header
leftComponent={

View File

@@ -1,5 +1,5 @@
import { Switch, Typography } from 'antd';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import LogsDownloadOptionsMenu from 'components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -21,6 +21,8 @@ function LogsActionsContainer({
isLoading,
isError,
isSuccess,
minTime,
maxTime,
}: {
listQuery: any;
selectedPanelType: PANEL_TYPES;
@@ -32,6 +34,8 @@ function LogsActionsContainer({
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
minTime: number;
maxTime: number;
}): JSX.Element {
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
@@ -92,7 +96,13 @@ function LogsActionsContainer({
/>
</div>
<div className="download-options-container">
<DownloadOptionsMenu dataSource={DataSource.LOGS} />
<LogsDownloadOptionsMenu
startTime={minTime}
endTime={maxTime}
filter={listQuery?.filter?.expression || ''}
columns={config.addColumn?.value || []}
orderBy={orderBy}
/>
</div>
<div className="format-options-container">
<LogsFormatOptionsMenu

View File

@@ -444,6 +444,8 @@ function LogsExplorerViewsContainer({
isLoading={isLoading}
isError={isError}
isSuccess={isSuccess}
minTime={minTime}
maxTime={maxTime}
/>
)}

View File

@@ -168,7 +168,7 @@ describe('LogsExplorerViews -', () => {
lodsQueryServerRequest();
const { queryByTestId } = renderer();
const periscopeDownloadButtonTestId = 'periscope-btn-download-logs';
const periscopeDownloadButtonTestId = 'periscope-btn-download-options';
const periscopeFormatButtonTestId = 'periscope-btn-format-options';
// Test that the periscope button is present

View File

@@ -12,7 +12,6 @@ import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
import useUrlQuery from 'hooks/useUrlQuery';
import { useAppContext } from 'providers/App/App';
import { toISOString } from 'utils/app';
import { FilterMode, MemberStatus, toMemberStatus } from './utils';
@@ -47,8 +46,8 @@ function MembersSettings(): JSX.Element {
email: user.email,
role: user.role,
status: toMemberStatus(user.status ?? ''),
joinedOn: toISOString(user.createdAt),
updatedAt: toISOString(user?.updatedAt),
joinedOn: user.createdAt ? String(user.createdAt) : null,
updatedAt: user.updatedAt ? String(user.updatedAt) : null,
})),
[usersData],
);
@@ -90,9 +89,6 @@ function MembersSettings(): JSX.Element {
if (currentPage > maxPage) {
setPage(maxPage);
}
if (currentPage < 1) {
setPage(1);
}
}, [filteredMembers.length, currentPage, setPage]);
const pendingCount = allMembers.filter(
@@ -180,7 +176,6 @@ function MembersSettings(): JSX.Element {
<div className="members-settings__search">
<Input
type="search"
placeholder="Search by name, email, or role..."
value={searchQuery}
onChange={(e): void => {
@@ -189,7 +184,6 @@ function MembersSettings(): JSX.Element {
}}
className="members-search-input"
color="secondary"
name="members-search"
/>
</div>

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