Compare commits

..

21 Commits

Author SHA1 Message Date
Piyush Singariya
b30bfa6371 fix: better review for test file 2026-04-02 12:53:44 +05:30
Piyush Singariya
e7f4a04b36 Merge branch 'main' into fix/array-json 2026-04-01 15:44:01 +05:30
Piyush Singariya
0687634da3 fix: stringified integer value input 2026-04-01 15:25:35 +05:30
Piyush Singariya
7e7732243e fix: dynamic array tests 2026-04-01 12:54:26 +05:30
Piyush Singariya
2f952e402f feat: change filtering of dynamic arrays 2026-04-01 12:09:32 +05:30
Piyush Singariya
a12febca4a fix: array json element comparison 2026-04-01 10:34:55 +05:30
Piyush Singariya
cb71c9c3f7 Merge branch 'main' into fix/array-json 2026-03-31 15:42:09 +05:30
Piyush Singariya
1cd4ce6509 Merge branch 'main' into fix/array-json 2026-03-31 14:55:36 +05:30
Piyush Singariya
9299c8ab18 fix: indexed unit tests 2026-03-30 15:47:47 +05:30
Piyush Singariya
24749de269 fix: comment 2026-03-30 15:16:28 +05:30
Piyush Singariya
39098ec3f4 fix: unit tests 2026-03-30 15:12:17 +05:30
Piyush Singariya
fe554f5c94 fix: remove not used paths from testdata 2026-03-30 14:24:48 +05:30
Piyush Singariya
8a60a041a6 fix: unit tests 2026-03-30 14:14:49 +05:30
Piyush Singariya
541f19c34a fix: array type filtering from dynamic arrays 2026-03-30 12:59:31 +05:30
Piyush Singariya
010db03d6e fix: indexed tests passing 2026-03-30 12:24:26 +05:30
Piyush Singariya
5408acbd8c fix: primitive conditions working 2026-03-30 12:01:35 +05:30
Piyush Singariya
0de6c85f81 feat: align negative operators to include other logs 2026-03-28 10:30:11 +05:30
Piyush Singariya
69ec24fa05 test: fix unit tests 2026-03-27 15:12:49 +05:30
Piyush Singariya
539d732b65 fix: contextual path index usage 2026-03-27 14:44:51 +05:30
Piyush Singariya
843d5fb199 Merge branch 'main' into feat/json-index 2026-03-27 14:17:52 +05:30
Piyush Singariya
fabdfb8cc1 feat: enable JSON Path index 2026-03-27 14:07:37 +05:30
265 changed files with 9783 additions and 80575 deletions

View File

@@ -2313,6 +2313,15 @@ components:
- status
- error
type: object
RulestatehistorytypesAlertState:
enum:
- inactive
- pending
- recovering
- firing
- nodata
- disabled
type: string
RulestatehistorytypesGettableRuleStateHistory:
properties:
fingerprint:
@@ -2324,15 +2333,15 @@ components:
nullable: true
type: array
overallState:
$ref: '#/components/schemas/RuletypesAlertState'
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
overallStateChanged:
type: boolean
ruleId:
ruleID:
type: string
ruleName:
type: string
state:
$ref: '#/components/schemas/RuletypesAlertState'
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
stateChanged:
type: boolean
unixMilli:
@@ -2342,7 +2351,7 @@ components:
format: double
type: number
required:
- ruleId
- ruleID
- ruleName
- overallState
- overallStateChanged
@@ -2432,21 +2441,12 @@ components:
format: int64
type: integer
state:
$ref: '#/components/schemas/RuletypesAlertState'
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
required:
- state
- start
- end
type: object
RuletypesAlertState:
enum:
- inactive
- pending
- recovering
- firing
- nodata
- disabled
type: string
ServiceaccounttypesGettableFactorAPIKey:
properties:
createdAt:
@@ -8469,7 +8469,7 @@ paths:
- in: query
name: state
schema:
$ref: '#/components/schemas/RuletypesAlertState'
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
- in: query
name: filterExpression
schema:

View File

@@ -32,7 +32,7 @@ func (s Seasonality) IsValid() bool {
}
type AnomaliesRequest struct {
Params *qbtypes.QueryRangeRequest
Params qbtypes.QueryRangeRequest
Seasonality Seasonality
}
@@ -81,7 +81,7 @@ type anomalyQueryParams struct {
Past3SeasonQuery qbtypes.QueryRangeRequest
}
func prepareAnomalyQueryParams(req *qbtypes.QueryRangeRequest, seasonality Seasonality) *anomalyQueryParams {
func prepareAnomalyQueryParams(req qbtypes.QueryRangeRequest, seasonality Seasonality) *anomalyQueryParams {
start := req.Start
end := req.End

View File

@@ -76,12 +76,12 @@ func (provider *provider) Start(ctx context.Context) error {
}
func (provider *provider) Audit(ctx context.Context, event audittypes.AuditEvent) {
if event.PrincipalAttributes.PrincipalOrgID.IsZero() {
if event.PrincipalOrgID.IsZero() {
provider.settings.Logger().WarnContext(ctx, "audit event dropped as org_id is zero")
return
}
if _, err := provider.licensing.GetActive(ctx, event.PrincipalAttributes.PrincipalOrgID); err != nil {
if _, err := provider.licensing.GetActive(ctx, event.PrincipalOrgID); err != nil {
return
}

View File

@@ -65,7 +65,7 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
}
if anomalyQuery, ok := queryRangeRequest.IsAnomalyRequest(); ok {
anomalies, err := h.handleAnomalyQuery(ctx, orgID, anomalyQuery, &queryRangeRequest)
anomalies, err := h.handleAnomalyQuery(ctx, orgID, anomalyQuery, queryRangeRequest)
if err != nil {
render.Error(rw, errors.NewInternalf(errors.CodeInternal, "failed to get anomalies: %v", err))
return
@@ -149,7 +149,7 @@ func (h *handler) createAnomalyProvider(seasonality anomalyV2.Seasonality) anoma
}
}
func (h *handler) handleAnomalyQuery(ctx context.Context, orgID valuer.UUID, anomalyQuery *qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], queryRangeRequest *qbtypes.QueryRangeRequest) (*anomalyV2.AnomaliesResponse, error) {
func (h *handler) handleAnomalyQuery(ctx context.Context, orgID valuer.UUID, anomalyQuery *qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], queryRangeRequest qbtypes.QueryRangeRequest) (*anomalyV2.AnomaliesResponse, error) {
seasonality := extractSeasonality(anomalyQuery)
provider := h.createAnomalyProvider(seasonality)

View File

@@ -49,6 +49,7 @@ import (
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/utils"
)
@@ -98,6 +99,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
)
rm, err := makeRulesManager(
reader,
signoz.Cache,
signoz.Alertmanager,
signoz.SQLStore,
@@ -227,7 +229,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
r.Use(middleware.NewComment().Wrap)
apiHandler.RegisterRoutes(r, am)
@@ -343,7 +345,7 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}
func makeRulesManager(cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, ruleStateHistoryModule rulestatehistory.Module, querier querier.Querier, providerSettings factory.ProviderSettings, queryParser queryparser.QueryParser) (*baserules.Manager, error) {
func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, ruleStateHistoryModule rulestatehistory.Module, querier querier.Querier, providerSettings factory.ProviderSettings, queryParser queryparser.QueryParser) (*baserules.Manager, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
// create manager opts
@@ -352,6 +354,7 @@ func makeRulesManager(cache cache.Cache, alertmanager alertmanager.Alertmanager,
MetadataStore: metadataStore,
Prometheus: prometheus,
Context: context.Background(),
Reader: ch,
Querier: querier,
Logger: providerSettings.Logger,
Cache: cache,
@@ -362,7 +365,7 @@ func makeRulesManager(cache cache.Cache, alertmanager alertmanager.Alertmanager,
OrgGetter: orgGetter,
RuleStore: ruleStore,
MaintenanceStore: maintenanceStore,
SQLStore: sqlstore,
SqlStore: sqlstore,
QueryParser: queryParser,
RuleStateHistoryModule: ruleStateHistoryModule,
}

View File

@@ -5,34 +5,58 @@ import (
"encoding/json"
"fmt"
"log/slog"
"math"
"strings"
"sync"
"time"
"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/querier"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"github.com/SigNoz/signoz/pkg/query-service/common"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
querierV2 "github.com/SigNoz/signoz/pkg/query-service/app/querier/v2"
"github.com/SigNoz/signoz/pkg/query-service/app/queryBuilder"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/query-service/utils/times"
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
"github.com/SigNoz/signoz/pkg/units"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/ee/anomaly"
querierV5 "github.com/SigNoz/signoz/pkg/querier"
anomalyV2 "github.com/SigNoz/signoz/ee/anomaly"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
const (
RuleTypeAnomaly = "anomaly_rule"
)
type AnomalyRule struct {
*baserules.BaseRule
mtx sync.Mutex
// querier is used for alerts migrated after the introduction of new query builder
querier querier.Querier
reader interfaces.Reader
provider anomaly.Provider
// querierV2 is used for alerts created after the introduction of new metrics query builder
querierV2 interfaces.Querier
// querierV5 is used for alerts migrated after the introduction of new query builder
querierV5 querierV5.Querier
provider anomaly.Provider
providerV2 anomalyV2.Provider
version string
logger *slog.Logger
@@ -46,16 +70,18 @@ func NewAnomalyRule(
id string,
orgID valuer.UUID,
p *ruletypes.PostableRule,
querier querier.Querier,
reader interfaces.Reader,
querierV5 querierV5.Querier,
logger *slog.Logger,
cache cache.Cache,
opts ...baserules.RuleOption,
) (*AnomalyRule, error) {
logger.Info("creating new AnomalyRule", slog.String("rule.id", id))
logger.Info("creating new AnomalyRule", "rule_id", id)
opts = append(opts, baserules.WithLogger(logger))
baseRule, err := baserules.NewBaseRule(id, orgID, p, opts...)
baseRule, err := baserules.NewBaseRule(id, orgID, p, reader, opts...)
if err != nil {
return nil, err
}
@@ -75,38 +101,93 @@ func NewAnomalyRule(
t.seasonality = anomaly.SeasonalityDaily
}
logger.Info("using seasonality", slog.String("rule.id", id), slog.String("rule.seasonality", t.seasonality.StringValue()))
logger.Info("using seasonality", "seasonality", t.seasonality.String())
querierOptsV2 := querierV2.QuerierOptions{
Reader: reader,
Cache: cache,
KeyGenerator: queryBuilder.NewKeyGenerator(),
}
t.querierV2 = querierV2.NewQuerier(querierOptsV2)
t.reader = reader
if t.seasonality == anomaly.SeasonalityHourly {
t.provider = anomaly.NewHourlyProvider(
anomaly.WithQuerier[*anomaly.HourlyProvider](querier),
anomaly.WithLogger[*anomaly.HourlyProvider](logger),
anomaly.WithCache[*anomaly.HourlyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.HourlyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.HourlyProvider](reader),
)
} else if t.seasonality == anomaly.SeasonalityDaily {
t.provider = anomaly.NewDailyProvider(
anomaly.WithQuerier[*anomaly.DailyProvider](querier),
anomaly.WithLogger[*anomaly.DailyProvider](logger),
anomaly.WithCache[*anomaly.DailyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.DailyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.DailyProvider](reader),
)
} else if t.seasonality == anomaly.SeasonalityWeekly {
t.provider = anomaly.NewWeeklyProvider(
anomaly.WithQuerier[*anomaly.WeeklyProvider](querier),
anomaly.WithLogger[*anomaly.WeeklyProvider](logger),
anomaly.WithCache[*anomaly.WeeklyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.WeeklyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.WeeklyProvider](reader),
)
}
t.querier = querier
if t.seasonality == anomaly.SeasonalityHourly {
t.providerV2 = anomalyV2.NewHourlyProvider(
anomalyV2.WithQuerier[*anomalyV2.HourlyProvider](querierV5),
anomalyV2.WithLogger[*anomalyV2.HourlyProvider](logger),
)
} else if t.seasonality == anomaly.SeasonalityDaily {
t.providerV2 = anomalyV2.NewDailyProvider(
anomalyV2.WithQuerier[*anomalyV2.DailyProvider](querierV5),
anomalyV2.WithLogger[*anomalyV2.DailyProvider](logger),
)
} else if t.seasonality == anomaly.SeasonalityWeekly {
t.providerV2 = anomalyV2.NewWeeklyProvider(
anomalyV2.WithQuerier[*anomalyV2.WeeklyProvider](querierV5),
anomalyV2.WithLogger[*anomalyV2.WeeklyProvider](logger),
)
}
t.querierV5 = querierV5
t.version = p.Version
t.logger = logger
return &t, nil
}
func (r *AnomalyRule) Type() ruletypes.RuleType {
return ruletypes.RuleTypeAnomaly
return RuleTypeAnomaly
}
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v3.QueryRangeParamsV3, error) {
r.logger.InfoContext(ctx, "prepare query range request", slog.String("rule.id", r.ID()), slog.Int64("ts", ts.UnixMilli()), slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()), slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()))
r.logger.InfoContext(
ctx, "prepare query range request v4", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds(),
)
st, en := r.Timestamps(ts)
start := st.UnixMilli()
end := en.UnixMilli()
compositeQuery := r.Condition().CompositeQuery
if compositeQuery.PanelType != v3.PanelTypeGraph {
compositeQuery.PanelType = v3.PanelTypeGraph
}
// default mode
return &v3.QueryRangeParamsV3{
Start: start,
End: end,
Step: int64(math.Max(float64(common.MinAllowedStepInterval(start, end)), 60)),
CompositeQuery: compositeQuery,
Variables: make(map[string]interface{}, 0),
NoCache: false,
}, nil
}
func (r *AnomalyRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (*qbtypes.QueryRangeRequest, error) {
r.logger.InfoContext(ctx, "prepare query range request v5", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds())
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
@@ -122,14 +203,25 @@ func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbty
}
req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(r.Condition().CompositeQuery.Queries))
copy(req.CompositeQuery.Queries, r.Condition().CompositeQuery.Queries)
return req
return req, nil
}
func (r *AnomalyRule) GetSelectedQuery() string {
return r.Condition().GetSelectedQueryName()
}
func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
params := r.prepareQueryRange(ctx, ts)
params, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return nil, err
}
err = r.PopulateTemporality(ctx, orgID, params)
if err != nil {
return nil, fmt.Errorf("internal error while setting temporality")
}
anomalies, err := r.provider.GetAnomalies(ctx, orgID, &anomaly.AnomaliesRequest{
anomalies, err := r.provider.GetAnomalies(ctx, orgID, &anomaly.GetAnomaliesRequest{
Params: params,
Seasonality: r.seasonality,
})
@@ -137,43 +229,87 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
return nil, err
}
var queryResult *qbtypes.TimeSeriesData
var queryResult *v3.Result
for _, result := range anomalies.Results {
if result.QueryName == r.SelectedQuery(ctx) {
if result.QueryName == r.GetSelectedQuery() {
queryResult = result
break
}
}
if queryResult == nil {
r.logger.WarnContext(ctx, "nil qb result", slog.String("rule.id", r.ID()), slog.Int64("ts", ts.UnixMilli()))
return ruletypes.Vector{}, nil
}
hasData := len(queryResult.Aggregations) > 0 &&
queryResult.Aggregations[0] != nil &&
len(queryResult.Aggregations[0].AnomalyScores) > 0
hasData := len(queryResult.AnomalyScores) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
} else if !hasData {
r.logger.WarnContext(ctx, "no anomaly result", slog.String("rule.id", r.ID()))
return ruletypes.Vector{}, nil
}
var resultVector ruletypes.Vector
scoresJSON, _ := json.Marshal(queryResult.Aggregations[0].AnomalyScores)
// TODO(srikanthccv): this could be noisy but we do this to answer false alert requests
r.logger.InfoContext(ctx, "anomaly scores", slog.String("rule.id", r.ID()), slog.String("anomaly.scores", string(scoresJSON)))
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
for _, series := range queryResult.AnomalyScores {
if !r.Condition().ShouldEval(series) {
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
continue
}
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
if err != nil {
return nil, err
}
resultVector = append(resultVector, results...)
}
return resultVector, nil
}
func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
params, err := r.prepareQueryRangeV5(ctx, ts)
if err != nil {
return nil, err
}
anomalies, err := r.providerV2.GetAnomalies(ctx, orgID, &anomalyV2.AnomaliesRequest{
Params: *params,
Seasonality: anomalyV2.Seasonality{String: valuer.NewString(r.seasonality.String())},
})
if err != nil {
return nil, err
}
var qbResult *qbtypes.TimeSeriesData
for _, result := range anomalies.Results {
if result.QueryName == r.GetSelectedQuery() {
qbResult = result
break
}
}
if qbResult == nil {
r.logger.WarnContext(ctx, "nil qb result", "ts", ts.UnixMilli())
}
queryResult := transition.ConvertV5TimeSeriesDataToV4Result(qbResult)
hasData := len(queryResult.AnomalyScores) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
}
var resultVector ruletypes.Vector
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
// Filter out new series if newGroupEvalDelay is configured
seriesToProcess := queryResult.Aggregations[0].AnomalyScores
seriesToProcess := queryResult.AnomalyScores
if r.ShouldSkipNewGroups() {
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", slog.String("rule.id", r.ID()), errors.Attr(filterErr))
r.logger.ErrorContext(ctx, "Error filtering new series, ", errors.Attr(filterErr), "rule_name", r.Name())
} else {
seriesToProcess = filteredSeries
}
@@ -181,10 +317,10 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
for _, series := range seriesToProcess {
if !r.Condition().ShouldEval(series) {
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", slog.String("rule.id", r.ID()), slog.Int("series.num_points", len(series.Values)), slog.Int("series.required_points", r.Condition().RequiredNumPoints))
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
continue
}
results, err := r.Threshold.Eval(series, r.Unit(), ruletypes.EvalData{
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
@@ -205,9 +341,13 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
var res ruletypes.Vector
var err error
r.logger.InfoContext(ctx, "running query", slog.String("rule.id", r.ID()))
res, err = r.buildAndRunQuery(ctx, r.OrgID(), ts)
if r.version == "v5" {
r.logger.InfoContext(ctx, "running v5 query")
res, err = r.buildAndRunQueryV5(ctx, r.OrgID(), ts)
} else {
r.logger.InfoContext(ctx, "running v4 query")
res, err = r.buildAndRunQuery(ctx, r.OrgID(), ts)
}
if err != nil {
return 0, err
}
@@ -231,7 +371,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
value := valueFormatter.Format(smpl.V, r.Unit())
threshold := valueFormatter.Format(smpl.Target, smpl.TargetUnit)
r.logger.DebugContext(ctx, "alert template data for rule", slog.String("rule.id", r.ID()), slog.String("formatter.name", valueFormatter.Name()), slog.String("alert.value", value), slog.String("alert.threshold", threshold))
r.logger.DebugContext(ctx, "Alert template data for rule", "rule_name", r.Name(), "formatter", valueFormatter.Name(), "value", value, "threshold", threshold)
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
// Inject some convenience variables that are easier to remember for users
@@ -246,34 +386,35 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
defs+text,
"__alert_"+r.Name(),
tmplData,
times.Time(timestamp.FromTime(ts)),
nil,
)
result, err := tmpl.Expand()
if err != nil {
result = fmt.Sprintf("<error expanding template: %s>", err)
r.logger.ErrorContext(ctx, "expanding alert template failed", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.template_data", tmplData))
r.logger.ErrorContext(ctx, "Expanding alert template failed", errors.Attr(err), "data", tmplData, "rule_name", r.Name())
}
return result
}
lb := ruletypes.NewBuilder(smpl.Metric...).Del(ruletypes.MetricNameLabel).Del(ruletypes.TemporalityLabel)
resultLabels := ruletypes.NewBuilder(smpl.Metric...).Del(ruletypes.MetricNameLabel).Del(ruletypes.TemporalityLabel).Labels()
lb := labels.NewBuilder(smpl.Metric).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel)
resultLabels := labels.NewBuilder(smpl.Metric).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel).Labels()
for name, value := range r.Labels().Map() {
lb.Set(name, expand(value))
}
lb.Set(ruletypes.AlertNameLabel, r.Name())
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
lb.Set(labels.AlertNameLabel, r.Name())
lb.Set(labels.AlertRuleIdLabel, r.ID())
lb.Set(labels.RuleSourceLabel, r.GeneratorURL())
annotations := make(ruletypes.Labels, 0, len(r.Annotations().Map()))
annotations := make(labels.Labels, 0, len(r.Annotations().Map()))
for name, value := range r.Annotations().Map() {
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
annotations = append(annotations, labels.Label{Name: name, Value: expand(value)})
}
if smpl.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")
lb.Set(labels.AlertNameLabel, "[No data] "+r.Name())
lb.Set(labels.NoDataLabel, "true")
}
lbs := lb.Labels()
@@ -281,17 +422,17 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
resultFPs[h] = struct{}{}
if _, ok := alerts[h]; ok {
r.logger.ErrorContext(ctx, "the alert query returns duplicate records", slog.String("rule.id", r.ID()), slog.Any("alert", alerts[h]))
err = errors.NewInternalf(errors.CodeInternal, "duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
r.logger.ErrorContext(ctx, "the alert query returns duplicate records", "rule_id", r.ID(), "alert", alerts[h])
err = fmt.Errorf("duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
return 0, err
}
alerts[h] = &ruletypes.Alert{
Labels: lbs,
QueryResultLabels: resultLabels,
QueryResultLables: resultLabels,
Annotations: annotations,
ActiveAt: ts,
State: ruletypes.StatePending,
State: model.StatePending,
Value: smpl.V,
GeneratorURL: r.GeneratorURL(),
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
@@ -300,12 +441,12 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
}
r.logger.InfoContext(ctx, "number of alerts found", slog.String("rule.id", r.ID()), slog.Int("alert.count", len(alerts)))
r.logger.InfoContext(ctx, "number of alerts found", "rule_name", r.Name(), "alerts_count", len(alerts))
// alerts[h] is ready, add or update active list now
for h, a := range alerts {
// Check whether we already have alerting state for the identifying label set.
// Update the last value and annotations if so, create a new alert entry otherwise.
if alert, ok := r.Active[h]; ok && alert.State != ruletypes.StateInactive {
if alert, ok := r.Active[h]; ok && alert.State != model.StateInactive {
alert.Value = a.Value
alert.Annotations = a.Annotations
@@ -321,76 +462,76 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
r.Active[h] = a
}
itemsToAdd := []rulestatehistorytypes.RuleStateHistory{}
itemsToAdd := []model.RuleStateHistory{}
// Check if any pending alerts should be removed or fire now. Write out alert timeseries.
for fp, a := range r.Active {
labelsJSON, err := json.Marshal(a.QueryResultLabels)
labelsJSON, err := json.Marshal(a.QueryResultLables)
if err != nil {
r.logger.ErrorContext(ctx, "error marshaling labels", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.labels", a.Labels))
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err), "labels", a.Labels)
}
if _, ok := resultFPs[fp]; !ok {
// If the alert was previously firing, keep it around for a given
// retention time so it is reported as resolved to the AlertManager.
if a.State == ruletypes.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
if a.State == model.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
delete(r.Active, fp)
}
if a.State != ruletypes.StateInactive {
a.State = ruletypes.StateInactive
if a.State != model.StateInactive {
a.State = model.StateInactive
a.ResolvedAt = ts
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: ruletypes.StateInactive,
State: model.StateInactive,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Value: a.Value,
})
}
continue
}
if a.State == ruletypes.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration().Duration() {
a.State = ruletypes.StateFiring
if a.State == model.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration().Duration() {
a.State = model.StateFiring
a.FiredAt = ts
state := ruletypes.StateFiring
state := model.StateFiring
if a.Missing {
state = ruletypes.StateNoData
state = model.StateNoData
}
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: state,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Value: a.Value,
})
}
// We need to change firing alert to recovering if the returned sample meets recovery threshold
changeFiringToRecovering := a.State == ruletypes.StateFiring && a.IsRecovering
changeFiringToRecovering := a.State == model.StateFiring && a.IsRecovering
// We need to change recovering alerts to firing if the returned sample meets target threshold
changeRecoveringToFiring := a.State == ruletypes.StateRecovering && !a.IsRecovering && !a.Missing
changeRecoveringToFiring := a.State == model.StateRecovering && !a.IsRecovering && !a.Missing
// in any of the above case we need to update the status of alert
if changeFiringToRecovering || changeRecoveringToFiring {
state := ruletypes.StateRecovering
state := model.StateRecovering
if changeRecoveringToFiring {
state = ruletypes.StateFiring
state = model.StateFiring
}
a.State = state
r.logger.DebugContext(ctx, "converting alert state", slog.String("rule.id", r.ID()), slog.Any("alert.state", state))
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
r.logger.DebugContext(ctx, "converting alert state", "name", r.Name(), "state", state)
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: state,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Value: a.Value,
})
}

View File

@@ -2,19 +2,21 @@ package rules
import (
"context"
"log/slog"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/ee/query-service/anomaly"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/ee/anomaly"
)
// mockAnomalyProvider is a mock implementation of anomaly.Provider for testing.
@@ -22,13 +24,13 @@ import (
// time periods (current, past period, current season, past season, past 2 seasons,
// past 3 seasons), making it cumbersome to create mock data.
type mockAnomalyProvider struct {
responses []*anomaly.AnomaliesResponse
responses []*anomaly.GetAnomaliesResponse
callCount int
}
func (m *mockAnomalyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *anomaly.AnomaliesRequest) (*anomaly.AnomaliesResponse, error) {
func (m *mockAnomalyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *anomaly.GetAnomaliesRequest) (*anomaly.GetAnomaliesResponse, error) {
if m.callCount >= len(m.responses) {
return &anomaly.AnomaliesResponse{Results: []*qbtypes.TimeSeriesData{}}, nil
return &anomaly.GetAnomaliesResponse{Results: []*v3.Result{}}, nil
}
resp := m.responses[m.callCount]
m.callCount++
@@ -47,46 +49,45 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Test anomaly no data",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeAnomaly,
RuleType: RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: evalWindow,
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
CompareOp: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
Expression: "A",
DataSource: v3.DataSourceMetrics,
Temporality: v3.Unspecified,
},
}},
},
},
SelectedQuery: "A",
Seasonality: "daily",
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
Name: "Test anomaly no data",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
Name: "Test anomaly no data",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
}},
},
},
}
responseNoData := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
responseNoData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
{
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{},
}},
QueryName: "A",
AnomalyScores: []*v3.Series{},
},
},
}
@@ -114,17 +115,23 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
t.Run(c.description, func(t *testing.T) {
postableRule.RuleCondition.AlertOnAbsent = c.alertOnAbsent
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, nil)
options := clickhouseReader.NewOptions("primaryNamespace")
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, nil, "", time.Second, nil, nil, options)
rule, err := NewAnomalyRule(
"test-anomaly-rule",
valuer.GenerateUUID(),
&postableRule,
reader,
nil,
logger,
nil,
)
require.NoError(t, err)
rule.provider = &mockAnomalyProvider{
responses: []*anomaly.AnomaliesResponse{responseNoData},
responses: []*anomaly.GetAnomaliesResponse{responseNoData},
}
alertsFound, err := rule.Eval(context.Background(), evalTime)
@@ -149,47 +156,46 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Test anomaly no data with AbsentFor",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeAnomaly,
RuleType: RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: evalWindow,
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
CompareOp: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
Expression: "A",
DataSource: v3.DataSourceMetrics,
Temporality: v3.Unspecified,
},
}},
},
},
SelectedQuery: "A",
Seasonality: "daily",
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
Name: "Test anomaly no data with AbsentFor",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
Name: "Test anomaly no data with AbsentFor",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
}},
},
},
}
responseNoData := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
responseNoData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
{
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{},
}},
QueryName: "A",
AnomalyScores: []*v3.Series{},
},
},
}
@@ -223,35 +229,32 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
t1 := baseTime.Add(5 * time.Minute)
t2 := t1.Add(c.timeBetweenEvals)
responseWithData := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
responseWithData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
{
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{
{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{Name: "Test"},
Value: "labels",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: baseTime.UnixMilli(), Value: 1.0},
{Timestamp: baseTime.Add(time.Minute).UnixMilli(), Value: 1.5},
},
AnomalyScores: []*v3.Series{
{
Labels: map[string]string{"test": "label"},
Points: []v3.Point{
{Timestamp: baseTime.UnixMilli(), Value: 1.0},
{Timestamp: baseTime.Add(time.Minute).UnixMilli(), Value: 1.5},
},
},
}},
},
},
},
}
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, nil, logger)
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, nil)
options := clickhouseReader.NewOptions("primaryNamespace")
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, nil, "", time.Second, nil, nil, options)
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, reader, nil, logger, nil)
require.NoError(t, err)
rule.provider = &mockAnomalyProvider{
responses: []*anomaly.AnomaliesResponse{responseWithData, responseNoData},
responses: []*anomaly.GetAnomaliesResponse{responseWithData, responseNoData},
}
alertsFound1, err := rule.Eval(context.Background(), t1)

View File

@@ -11,7 +11,9 @@ import (
"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"
)
@@ -21,7 +23,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules := make([]baserules.Rule, 0)
var task baserules.Task
ruleID := baserules.RuleIDFromTaskName(opts.TaskName)
ruleId := baserules.RuleIdFromTaskName(opts.TaskName)
evaluation, err := opts.Rule.Evaluation.GetEvaluation()
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err)
@@ -30,9 +32,10 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
if opts.Rule.RuleType == ruletypes.RuleTypeThreshold {
// create a threshold rule
tr, err := baserules.NewThresholdRule(
ruleID,
ruleId,
opts.OrgID,
opts.Rule,
opts.Reader,
opts.Querier,
opts.Logger,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
@@ -55,10 +58,11 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
// create promql rule
pr, err := baserules.NewPromRule(
ruleID,
ruleId,
opts.OrgID,
opts.Rule,
opts.Logger,
opts.Reader,
opts.ManagerOpts.Prometheus,
baserules.WithSQLStore(opts.SQLStore),
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
@@ -78,11 +82,13 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
ar, err := NewAnomalyRule(
ruleID,
ruleId,
opts.OrgID,
opts.Rule,
opts.Reader,
opts.Querier,
opts.Logger,
opts.Cache,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
baserules.WithSQLStore(opts.SQLStore),
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
@@ -99,7 +105,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
}
return task, nil
@@ -107,12 +113,12 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
// TestNotification prepares a dummy rule for given rule parameters and
// sends a test notification. returns alert count and error (if any)
func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.ApiError) {
ctx := context.Background()
if opts.Rule == nil {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "rule is required")
return 0, basemodel.BadRequest(fmt.Errorf("rule is required"))
}
parsedRule := opts.Rule
@@ -132,14 +138,15 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
if parsedRule.RuleType == ruletypes.RuleTypeThreshold {
// add special labels for test alerts
parsedRule.Labels[ruletypes.RuleSourceLabel] = ""
parsedRule.Labels[ruletypes.AlertRuleIDLabel] = ""
parsedRule.Labels[labels.RuleSourceLabel] = ""
parsedRule.Labels[labels.AlertRuleIdLabel] = ""
// create a threshold rule
rule, err = baserules.NewThresholdRule(
alertname,
opts.OrgID,
parsedRule,
opts.Reader,
opts.Querier,
opts.Logger,
baserules.WithSendAlways(),
@@ -151,7 +158,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
if err != nil {
slog.Error("failed to prepare a new threshold rule for test", "name", alertname, errors.Attr(err))
return 0, err
return 0, basemodel.BadRequest(err)
}
} else if parsedRule.RuleType == ruletypes.RuleTypeProm {
@@ -162,6 +169,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
opts.OrgID,
parsedRule,
opts.Logger,
opts.Reader,
opts.ManagerOpts.Prometheus,
baserules.WithSendAlways(),
baserules.WithSendUnmatched(),
@@ -172,7 +180,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
if err != nil {
slog.Error("failed to prepare a new promql rule for test", "name", alertname, errors.Attr(err))
return 0, err
return 0, basemodel.BadRequest(err)
}
} else if parsedRule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
@@ -180,8 +188,10 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
alertname,
opts.OrgID,
parsedRule,
opts.Reader,
opts.Querier,
opts.Logger,
opts.Cache,
baserules.WithSendAlways(),
baserules.WithSendUnmatched(),
baserules.WithSQLStore(opts.SQLStore),
@@ -190,10 +200,10 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
)
if err != nil {
slog.Error("failed to prepare a new anomaly rule for test", "name", alertname, errors.Attr(err))
return 0, err
return 0, basemodel.BadRequest(err)
}
} else {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to derive ruletype with given information")
return 0, basemodel.BadRequest(fmt.Errorf("failed to derive ruletype with given information"))
}
// set timestamp to current utc time
@@ -202,7 +212,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
alertsFound, err := rule.Eval(ctx, ts)
if err != nil {
slog.Error("evaluating rule failed", "rule", rule.Name(), errors.Attr(err))
return 0, err
return 0, basemodel.InternalError(fmt.Errorf("rule evaluation failed"))
}
rule.SendAlerts(ctx, ts, 0, time.Minute, opts.NotifyFunc)

View File

@@ -114,8 +114,11 @@ func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
},
})
count, err := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
require.Nil(t, err)
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
if apiErr != nil {
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
}
require.Nil(t, apiErr)
assert.Equal(t, tc.ExpectAlerts, count)
if tc.ExpectAlerts > 0 {
@@ -265,8 +268,11 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
},
})
count, err := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
require.Nil(t, err)
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
if apiErr != nil {
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
}
require.Nil(t, apiErr)
assert.Equal(t, tc.ExpectAlerts, count)
if tc.ExpectAlerts > 0 {

View File

@@ -54,7 +54,7 @@
"@signozhq/checkbox": "0.0.2",
"@signozhq/combobox": "0.0.2",
"@signozhq/command": "0.0.0",
"@signozhq/design-tokens": "2.1.4",
"@signozhq/design-tokens": "2.1.1",
"@signozhq/dialog": "^0.0.2",
"@signozhq/drawer": "0.0.4",
"@signozhq/icons": "0.1.0",

View File

@@ -2710,6 +2710,14 @@ export interface RenderErrorResponseDTO {
status: string;
}
export enum RulestatehistorytypesAlertStateDTO {
inactive = 'inactive',
pending = 'pending',
recovering = 'recovering',
firing = 'firing',
nodata = 'nodata',
disabled = 'disabled',
}
export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
/**
* @type integer
@@ -2721,7 +2729,7 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
* @nullable true
*/
labels: Querybuildertypesv5LabelDTO[] | null;
overallState: RuletypesAlertStateDTO;
overallState: RulestatehistorytypesAlertStateDTO;
/**
* @type boolean
*/
@@ -2729,12 +2737,12 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
/**
* @type string
*/
ruleId: string;
ruleID: string;
/**
* @type string
*/
ruleName: string;
state: RuletypesAlertStateDTO;
state: RulestatehistorytypesAlertStateDTO;
/**
* @type boolean
*/
@@ -2832,17 +2840,9 @@ export interface RulestatehistorytypesGettableRuleStateWindowDTO {
* @format int64
*/
start: number;
state: RuletypesAlertStateDTO;
state: RulestatehistorytypesAlertStateDTO;
}
export enum RuletypesAlertStateDTO {
inactive = 'inactive',
pending = 'pending',
recovering = 'recovering',
firing = 'firing',
nodata = 'nodata',
disabled = 'disabled',
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -4613,7 +4613,7 @@ export type GetRuleHistoryTimelineParams = {
/**
* @description undefined
*/
state?: RuletypesAlertStateDTO;
state?: RulestatehistorytypesAlertStateDTO;
/**
* @type string
* @description undefined

View File

@@ -1,35 +1,21 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { UnderscoreToDotMap } from 'api/utils';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { K8sBaseFilters } from '../Base/K8sBaseList';
import { UnderscoreToDotMap } from '../utils';
export const podsMetaMap = [
{ dot: 'k8s.cronjob.name', under: 'k8s_cronjob_name' },
{ dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' },
{ dot: 'k8s.deployment.name', under: 'k8s_deployment_name' },
{ dot: 'k8s.job.name', under: 'k8s_job_name' },
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
{ dot: 'k8s.pod.name', under: 'k8s_pod_name' },
{ dot: 'k8s.pod.uid', under: 'k8s_pod_uid' },
{ dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' },
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
] as const;
export function mapPodsMeta(raw: Record<string, unknown>): K8sPodsData['meta'] {
// clone everything
const out: Record<string, unknown> = { ...raw };
// overlay only the dot→under mappings
podsMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sPodsData['meta'];
export interface K8sPodsListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface TimeSeriesValue {
@@ -85,9 +71,35 @@ export interface K8sPodsListResponse {
};
}
// TODO: Remove this method once we move this to OpenAPI
export const podsMetaMap = [
{ dot: 'k8s.cronjob.name', under: 'k8s_cronjob_name' },
{ dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' },
{ dot: 'k8s.deployment.name', under: 'k8s_deployment_name' },
{ dot: 'k8s.job.name', under: 'k8s_job_name' },
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
{ dot: 'k8s.pod.name', under: 'k8s_pod_name' },
{ dot: 'k8s.pod.uid', under: 'k8s_pod_uid' },
{ dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' },
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
] as const;
export function mapPodsMeta(raw: Record<string, unknown>): K8sPodsData['meta'] {
// clone everything
const out: Record<string, unknown> = { ...raw };
// overlay only the dot→under mappings
podsMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sPodsData['meta'];
}
// getK8sPodsList
export const getK8sPodsList = async (
props: K8sBaseFilters,
props: K8sPodsListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
@@ -99,7 +111,7 @@ export const getK8sPodsList = async (
...props,
filters: {
...props.filters,
items: props.filters?.items.reduce<typeof props.filters.items>(
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;

View File

@@ -1,10 +1,22 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { UnderscoreToDotMap } from 'api/utils';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { K8sBaseFilters } from '../Base/K8sBaseList';
import { UnderscoreToDotMap } from '../utils';
export interface K8sVolumesListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface K8sVolumesData {
persistentVolumeClaimName: string;
@@ -56,8 +68,10 @@ export const volumesMetaMap: Array<{
export function mapVolumesMeta(
rawMeta: Record<string, unknown>,
): K8sVolumesData['meta'] {
// start with everything that was already there
const out: Record<string, unknown> = { ...rawMeta };
// for each dot→under rule, if the raw has the dot, overwrite the underscore
volumesMetaMap.forEach(({ dot, under }) => {
if (dot in rawMeta) {
const val = rawMeta[dot];
@@ -69,47 +83,42 @@ export function mapVolumesMeta(
}
export const getK8sVolumesList = async (
props: K8sBaseFilters,
props: K8sVolumesListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sVolumesListResponse> | ErrorResponse> => {
try {
const { orderBy, ...rest } = props;
const basePayload = {
...rest,
filters: props.filters ?? { items: [], op: 'and' },
...(orderBy != null ? { orderBy } : {}),
};
// Prepare filters
const requestProps =
dotMetricsEnabled && Array.isArray(basePayload.filters?.items)
dotMetricsEnabled && Array.isArray(props.filters?.items)
? {
...basePayload,
...props,
filters: {
...basePayload.filters,
items: basePayload.filters.items.reduce<
typeof basePayload.filters.items
>((acc, item) => {
if (item.value === undefined) {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({ ...item, key: { ...item.key, key: mappedKey } });
} else {
acc.push(item);
}
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({ ...item, key: { ...item.key, key: mappedKey } });
} else {
acc.push(item);
}
return acc;
}, [] as typeof basePayload.filters.items),
},
[] as typeof props.filters.items,
),
},
}
: basePayload;
: props;
const response = await axios.post('/pvcs/list', requestProps, {
signal,

View File

@@ -0,0 +1,97 @@
.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

@@ -0,0 +1,89 @@
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

@@ -0,0 +1,84 @@
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

@@ -0,0 +1,34 @@
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

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

View File

@@ -1,52 +0,0 @@
import { useEffect } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { useGlobalTimeStore } from 'store/globalTime/globalTimeStore';
import { createCustomTimeRange } from 'store/globalTime/utils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
/**
* Adapter component that syncs Redux global time state to Zustand store.
* This component should be rendered once at the app level.
*
* It reads from the Redux globalTime reducer and updates the Zustand store
* to provide a migration path from Redux to Zustand.
*/
export function GlobalTimeStoreAdapter(): null {
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const setSelectedTime = useGlobalTimeStore((s) => s.setSelectedTime);
useEffect(() => {
// Convert the selectedTime to the new format
// If it's 'custom', store the min/max times in the custom format
const selectedTime =
globalTime.selectedTime === 'custom'
? createCustomTimeRange(globalTime.minTime, globalTime.maxTime)
: (globalTime.selectedTime as Time);
// Find refresh interval from Redux state
const refreshOption = refreshIntervalOptions.find(
(option) => option.key === globalTime.selectedAutoRefreshInterval,
);
const refreshInterval =
!globalTime.isAutoRefreshDisabled && refreshOption ? refreshOption.value : 0;
setSelectedTime(selectedTime, refreshInterval);
}, [
globalTime.selectedTime,
globalTime.isAutoRefreshDisabled,
globalTime.selectedAutoRefreshInterval,
globalTime.minTime,
globalTime.maxTime,
setSelectedTime,
]);
return null;
}

View File

@@ -1,227 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { act, render, renderHook } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
import { useGlobalTimeStore } from 'store/globalTime/globalTimeStore';
import { createCustomTimeRange } from 'store/globalTime/utils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { GlobalTimeStoreAdapter } from '../GlobalTimeStoreAdapter';
const mockStore = configureStore<Partial<AppState>>([]);
const randomTime = 1700000000000000000;
describe('GlobalTimeStoreAdapter', () => {
let store: MockStoreEnhanced<Partial<AppState>>;
const createGlobalTimeState = (
overrides: Partial<GlobalReducer> = {},
): GlobalReducer => ({
minTime: randomTime,
maxTime: randomTime,
loading: false,
selectedTime: '15m',
isAutoRefreshDisabled: true,
selectedAutoRefreshInterval: 'off',
...overrides,
});
beforeEach(() => {
// Reset Zustand store before each test
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
it('should render null because it just an adapter', () => {
store = mockStore({
globalTime: createGlobalTimeState(),
});
const { container } = render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
expect(container.firstChild).toBeNull();
});
it('should sync relative time from Redux to Zustand store', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: true,
selectedAutoRefreshInterval: 'off',
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe('15m');
expect(result.current.refreshInterval).toBe(0);
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should sync custom time from Redux to Zustand store', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: 'custom',
minTime: randomTime,
maxTime: randomTime,
isAutoRefreshDisabled: true,
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(
createCustomTimeRange(randomTime, randomTime),
);
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should sync refresh interval when auto refresh is enabled', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '5s',
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe('15m');
expect(result.current.refreshInterval).toBe(5000); // 5s = 5000ms
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should set refreshInterval to 0 when auto refresh is disabled', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: true,
selectedAutoRefreshInterval: '5s', // Even with interval set, should be 0 when disabled
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should update Zustand store when Redux state changes', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: true,
}),
});
const { rerender } = render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
// Verify initial state
let zustandState = renderHook(() => useGlobalTimeStore());
expect(zustandState.result.current.selectedTime).toBe('15m');
// Update Redux store
const newStore = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '1h',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '30s',
}),
});
rerender(
<Provider store={newStore}>
<GlobalTimeStoreAdapter />
</Provider>,
);
// Verify updated state
zustandState = renderHook(() => useGlobalTimeStore());
expect(zustandState.result.current.selectedTime).toBe('1h');
expect(zustandState.result.current.refreshInterval).toBe(30000); // 30s = 30000ms
expect(zustandState.result.current.isRefreshEnabled).toBe(true);
});
it('should handle various refresh interval options', () => {
const testCases = [
{ key: '5s', expectedValue: 5000 },
{ key: '10s', expectedValue: 10000 },
{ key: '30s', expectedValue: 30000 },
{ key: '1m', expectedValue: 60000 },
{ key: '5m', expectedValue: 300000 },
];
testCases.forEach(({ key, expectedValue }) => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: key,
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(expectedValue);
});
});
it('should handle unknown refresh interval by setting 0', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: 'unknown-interval',
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
expect(result.current.isRefreshEnabled).toBe(false);
});
});

View File

@@ -165,17 +165,7 @@ function KeysTab({
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.{' '}
<a
href="https://signoz.io/docs/manage/administrator-guide/iam/service-accounts/#step-3-generate-an-api-key"
target="_blank"
rel="noopener noreferrer"
className="keys-tab__learn-more"
>
Learn more
</a>
</p>
<p className="keys-tab__empty-text">No keys. Start by creating one.</p>
<Button
type="button"
className="keys-tab__learn-more"

View File

@@ -1,11 +1,4 @@
export const REACT_QUERY_KEY = {
/**
* For any query that should support AutoRefresh and min/max time is from DateTimeSelectionV2
* You can prefix the query with this KEY, it will allow the queries to be automatically refreshed
* when the user clicks in the refresh button, or alert the user when the data is being refreshed.
*/
AUTO_REFRESH_QUERY: 'AUTO_REFRESH_QUERY',
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',

View File

@@ -248,35 +248,5 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
roles: ['ADMIN', 'EDITOR'],
perform: (): void => navigate(ROUTES.BILLING),
},
{
id: 'my-settings-service-accounts',
name: 'Go to Service Accounts',
shortcut: [GlobalShortcutsName.NavigateToSettingsServiceAccounts],
keywords: 'settings service accounts',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN'],
perform: (): void => navigate(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
},
{
id: 'my-settings-roles',
name: 'Go to Roles',
shortcut: [GlobalShortcutsName.NavigateToSettingsRoles],
keywords: 'settings roles',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN'],
perform: (): void => navigate(ROUTES.ROLES_SETTINGS),
},
{
id: 'my-settings-members',
name: 'Go to Members',
shortcut: [GlobalShortcutsName.NavigateToSettingsMembers],
keywords: 'settings members',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN'],
perform: (): void => navigate(ROUTES.MEMBERS_SETTINGS),
},
];
}

View File

@@ -27,9 +27,6 @@ export const GlobalShortcuts = {
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
};
export const GlobalShortcutsName = {
@@ -50,9 +47,6 @@ export const GlobalShortcutsName = {
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
NavigateToLogs: 'shift+l',
NavigateToLogsPipelines: 'shift+l+p',
NavigateToLogsViews: 'shift+l+v',
@@ -80,7 +74,4 @@ export const GlobalShortcutsDescription = {
'Navigate to Notification Channels Settings',
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
NavigateToLogsViews: 'Navigate to Logs Views',
NavigateToSettingsServiceAccounts: 'Navigate to Service Accounts Settings',
NavigateToSettingsRoles: 'Navigate to Roles Settings',
NavigateToSettingsMembers: 'Navigate to Members Settings',
};

View File

@@ -3,12 +3,12 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Compass, Dot, House, Plus, Wrench } from '@signozhq/icons';
import { PersistedAnnouncementBanner } from '@signozhq/ui';
import { Button, Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetMetricsOnboardingStatus } from 'api/generated/services/metrics';
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';
@@ -265,19 +265,20 @@ export default function Home(): JSX.Element {
return (
<div className="home-container">
<PersistedAnnouncementBanner
type="info"
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),
}}
>
<>
<strong>API keys</strong> have been deprecated in favour of{' '}
<strong>Service accounts</strong>. The existing API Keys have been migrated
to service accounts.
</>
</PersistedAnnouncementBanner>
/>
<div className="sticky-header">
<Header

View File

@@ -1,171 +0,0 @@
.clickableRow {
cursor: pointer;
}
.expandedClickableRow {
cursor: pointer;
}
.expandedTableContainer {
border: 1px solid var(--l1-border);
overflow-x: auto;
padding-left: 48px;
:global(.ant-table-tbody > tr:hover > td) {
background: none !important;
}
}
.expandedTableFooter {
display: flex;
justify-content: flex-start;
gap: 8px;
padding: 8px;
padding-left: 42px;
margin-top: 8px;
}
.viewAllButton {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.k8sListTable {
:global(.ant-table) {
:global(.ant-table-thead > tr > th) {
padding: 12px;
font-weight: 500;
font-size: 12px;
line-height: 18px;
border-bottom: none;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.44px;
text-transform: uppercase;
background: var(--bg-ink-500) !important;
&::before {
background: transparent !important;
}
}
:global(.ant-table-cell) {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--l1-foreground);
background: var(--bg-ink-500);
border-bottom: none;
&:before,
&:after {
display: none;
}
}
:global(.hostname-column-value) {
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-style: normal;
font-weight: 600;
line-height: 20px;
letter-spacing: -0.07px;
}
:global(.progress-container) {
:global(.ant-progress-bg) {
height: 8px !important;
border-radius: 4px;
}
}
:global(.ant-table-tbody > tr:hover > td) {
background: var(--bg-ink-500);
}
:global(.ant-table-tbody > tr:not(.ant-table-expanded-row):hover > td) {
background: var(--l2-background);
}
:global(.ant-table-tbody > tr > td) {
border-bottom: none;
}
:global(.ant-empty-normal) {
visibility: hidden;
}
:global(.ant-table-cell) {
min-width: 180px !important;
max-width: 180px !important;
}
:global(.ant-table-cell:first-of-type):not(:global(.ant-table-row-expand-icon-cell)) {
min-width: 250px !important;
max-width: 250px !important;
}
:global(.ant-table-row-expand-icon-cell) {
min-width: 40px !important;
max-width: 40px !important;
}
}
:global(.ant-pagination) {
width: 100%;
position: fixed;
bottom: 0;
right: 0;
padding: 16px;
background-color: var(--bg-ink-500);
margin: 0 !important;
padding-right: 72px;
:global(.ant-pagination-item) {
border-radius: 4px;
}
:global(.ant-pagination-item-active) {
background: var(--l2-background);
border-color: var(--l2-border);
a {
color: var(--l1-foreground) !important;
}
}
}
}
.noFilteredHostsMessageContainer {
height: 30vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.noFilteredHostsMessageContent {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
width: fit-content;
padding: 24px;
}
.noFilteredHostsMessage {
margin-top: var(--spacing-4);
}
.emptyStateSvg {
width: 32px;
max-width: 100%;
}

View File

@@ -1,650 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { LoadingOutlined } from '@ant-design/icons';
import {
Button,
Spin,
Table,
TableColumnType as ColumnType,
TablePaginationConfig,
TableProps,
Typography,
} from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import logEvent from 'api/common/logEvent';
import { InfraMonitoringEvents } from 'constants/events';
import { ChevronDown, ChevronRight, CornerDownRight } from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
import { K8sCategory } from '../constants';
import {
useInfraMonitoringCurrentPage,
useInfraMonitoringFilters,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringQueryFilters,
useInfraMonitoringSelectedItem,
} from '../hooks';
import LoadingContainer from '../LoadingContainer';
import { OrderBySchemaType } from '../schemas';
import { usePageSize } from '../utils';
import K8sHeader from './K8sHeader';
import {
IEntityColumn,
useInfraMonitoringTableColumnsForPage,
useInfraMonitoringTableColumnsStore,
} from './useInfraMonitoringTableColumnsStore';
import styles from './K8sBaseList.module.scss';
export type K8sBaseFilters = {
filters?: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
start: number;
end: number;
orderBy?: OrderBySchemaType;
};
export type K8sRenderedRowData = {
/**
* The unique ID for the row
*/
key: string;
/**
* The ID to the selectedItem
*/
itemKey: string;
groupedByMeta: Record<string, string>;
[key: string]: unknown;
};
export type K8sBaseListProps<T = unknown> = {
controlListPrefix?: React.ReactNode;
entity: K8sCategory;
tableColumnsDefinitions: IEntityColumn[];
tableColumns: ColumnType<K8sRenderedRowData>[];
fetchListData: (
filters: K8sBaseFilters,
signal?: AbortSignal,
) => Promise<{
data: T[];
total: number;
error?: string | null;
}>;
renderRowData: (
record: T,
groupBy: BaseAutocompleteData[],
) => K8sRenderedRowData;
eventCategory: InfraMonitoringEvents;
};
export type K8sExpandedRowProps<T> = {
record: K8sRenderedRowData;
entity: K8sCategory;
tableColumns: ColumnType<K8sRenderedRowData>[];
fetchListData: K8sBaseListProps<T>['fetchListData'];
renderRowData: K8sBaseListProps<T>['renderRowData'];
};
function K8sExpandedRow<T>({
record,
entity,
tableColumns,
fetchListData,
renderRowData,
}: K8sExpandedRowProps<T>): JSX.Element {
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [, setCurrentPage] = useInfraMonitoringCurrentPage();
const [, setFilters] = useInfraMonitoringFilters();
const [, setSelectedItem] = useInfraMonitoringSelectedItem();
const queryFilters = useInfraMonitoringQueryFilters();
const [
columnsDefinitions,
columnsHidden,
] = useInfraMonitoringTableColumnsForPage(entity);
const hiddenColumnIdsForNested = useMemo(
() =>
columnsDefinitions
.filter((col) => col.behavior === 'hidden-on-collapse')
.map((col) => col.id),
[columnsDefinitions],
);
const nestedColumns = useMemo(
() =>
tableColumns.filter(
(c) =>
!columnsHidden.includes(c.key?.toString() || '') &&
!hiddenColumnIdsForNested.includes(c.key?.toString() || ''),
),
[tableColumns, columnsHidden, hiddenColumnIdsForNested],
);
const createFiltersForRecord = useCallback((): IBuilderQuery['filters'] => {
const baseFilters: IBuilderQuery['filters'] = {
items: [...queryFilters.items],
op: 'and',
};
const { groupedByMeta } = record;
for (const key of Object.keys(groupedByMeta)) {
baseFilters.items.push({
key: {
key,
type: null,
},
op: '=',
value: groupedByMeta[key],
id: key,
});
}
return baseFilters;
}, [queryFilters.items, record]);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(selectedTime, [
'k8sExpandedRow',
record.key,
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
]);
}, [selectedTime, record.key, queryFilters, orderBy]);
const { data, isFetching, isLoading, isError } = useQuery({
queryKey,
queryFn: ({ signal }) => {
const { minTime, maxTime } = getMinMaxTime();
return fetchListData(
{
limit: 10,
offset: 0,
filters: createFiltersForRecord(),
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
orderBy: orderBy || undefined,
groupBy: undefined,
},
signal,
);
},
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
const formattedData = useMemo(() => {
if (!data?.data) {
return undefined;
}
const rows = data.data.map((item) => renderRowData(item, groupBy));
// Without handling duplicated keys, the table became unpredictable/unstable
const keyCount = new Map<string, number>();
return rows.map(
(row): K8sRenderedRowData => {
const count = keyCount.get(row.key) || 0;
keyCount.set(row.key, count + 1);
if (count > 0) {
return { ...row, key: `${row.key}-${count}` };
}
return row;
},
);
}, [data?.data, renderRowData, groupBy]);
const openRecordInNewTab = (rowRecord: K8sRenderedRowData): void => {
const newParams = new URLSearchParams(document.location.search);
newParams.set('selectedItem', rowRecord.itemKey);
openInNewTab(
buildAbsolutePath({
relativePath: '',
urlQueryString: newParams.toString(),
}),
);
};
const handleViewAllClick = (): void => {
const filters = createFiltersForRecord();
setFilters(JSON.stringify(filters));
setCurrentPage(1);
setGroupBy([]);
setOrderBy(null);
};
return (
<div
className={styles.expandedTableContainer}
data-testid="expanded-table-container"
>
{isError && (
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
)}
{isFetching || isLoading ? (
<LoadingContainer />
) : (
<div data-testid="expanded-table">
<Table
columns={nestedColumns}
dataSource={formattedData}
pagination={false}
scroll={{ x: true }}
tableLayout="fixed"
showHeader={false}
loading={{
spinning: isFetching || isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
onRow={(
rowRecord: K8sRenderedRowData,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => {
if (isModifierKeyPressed(event)) {
openRecordInNewTab(rowRecord);
return;
}
setSelectedItem(rowRecord.itemKey);
},
className: styles.expandedClickableRow,
})}
/>
{data?.total && data?.total > 10 && (
<div className={styles.expandedTableFooter}>
<Button
type="default"
size="small"
className={styles.viewAllButton}
onClick={handleViewAllClick}
>
<CornerDownRight size={14} />
View All
</Button>
</div>
)}
</div>
)}
</div>
);
}
export function K8sBaseList<T>({
controlListPrefix,
entity,
tableColumnsDefinitions,
tableColumns,
fetchListData,
renderRowData,
eventCategory,
}: K8sBaseListProps<T>): JSX.Element {
const queryFilters = useInfraMonitoringQueryFilters();
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
const [groupBy] = useInfraMonitoringGroupBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [initialOrderBy] = useState(orderBy);
const [selectedItem, setSelectedItem] = useQueryState(
'selectedItem',
parseAsString,
);
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
useEffect(() => {
setExpandedRowKeys([]);
}, [groupBy, currentPage]);
const { pageSize, setPageSize } = usePageSize(entity);
const initializeTableColumns = useInfraMonitoringTableColumnsStore(
(state) => state.initializePageColumns,
);
useEffect(() => {
initializeTableColumns(entity, tableColumnsDefinitions);
}, [initializeTableColumns, entity, tableColumnsDefinitions]);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(
selectedTime,
'k8sBaseList',
entity,
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
);
}, [
selectedTime,
entity,
pageSize,
currentPage,
queryFilters,
orderBy,
groupBy,
]);
const { data, isLoading, isError } = useQuery({
queryKey,
queryFn: ({ signal }) => {
const { minTime, maxTime } = getMinMaxTime();
return fetchListData(
{
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: queryFilters,
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
orderBy: orderBy || undefined,
groupBy: groupBy?.length > 0 ? groupBy : undefined,
},
signal,
);
},
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
const pageData = data?.data;
const totalCount = data?.total || 0;
const formattedItemsData = useMemo(() => {
if (!pageData) {
return undefined;
}
const rows = pageData.map((item) => renderRowData(item, groupBy));
// Without handling duplicated keys, the table became unpredictable/unstable
const keyCount = new Map<string, number>();
return rows.map(
// eslint-disable-next-line sonarjs/no-identical-functions
(row): K8sRenderedRowData => {
const count = keyCount.get(row.key) || 0;
keyCount.set(row.key, count + 1);
if (count > 0) {
return { ...row, key: `${row.key}-${count}` };
}
return row;
},
);
}, [pageData, renderRowData, groupBy]);
const handleTableChange: TableProps<K8sRenderedRowData>['onChange'] = useCallback(
(
pagination: TablePaginationConfig,
_filters: Record<string, (string | number | boolean)[] | null>,
sorter:
| SorterResult<K8sRenderedRowData>
| SorterResult<K8sRenderedRowData>[],
): void => {
if (pagination.current) {
setCurrentPage(pagination.current);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
}
if ('field' in sorter && sorter.order) {
setOrderBy({
columnName: sorter.field as string,
order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc',
});
} else {
setOrderBy(null);
}
},
[eventCategory, setCurrentPage, setOrderBy],
);
useEffect(() => {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
total: totalCount,
});
}, [eventCategory, totalCount]);
const handleGroupByRowClick = (record: K8sRenderedRowData): void => {
if (expandedRowKeys.includes(record.key)) {
setExpandedRowKeys(expandedRowKeys.filter((key) => key !== record.key));
} else {
setExpandedRowKeys([record.key]);
}
};
const openItemInNewTab = (record: K8sRenderedRowData): void => {
const newParams = new URLSearchParams(document.location.search);
newParams.set('selectedItem', record.itemKey);
openInNewTab(
buildAbsolutePath({
relativePath: '',
urlQueryString: newParams.toString(),
}),
);
};
const handleRowClick = (
record: K8sRenderedRowData,
event: React.MouseEvent,
): void => {
if (event && isModifierKeyPressed(event)) {
openItemInNewTab(record);
return;
}
if (groupBy.length === 0) {
setSelectedItem(record.itemKey);
} else {
handleGroupByRowClick(record);
}
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
};
const [
columnsDefinitions,
columnsHidden,
] = useInfraMonitoringTableColumnsForPage(entity);
const hiddenColumnIdsOnList = useMemo(
() =>
columnsDefinitions
.filter(
(col) =>
(groupBy?.length > 0 && col.behavior === 'hidden-on-expand') ||
(!groupBy?.length && col.behavior === 'hidden-on-collapse'),
)
.map((col) => col.id),
[columnsDefinitions, groupBy?.length],
);
const mapDefaultSort = useCallback(
(
tableColumn: ColumnType<K8sRenderedRowData>,
): ColumnType<K8sRenderedRowData> => {
if (tableColumn.key === initialOrderBy?.columnName) {
return {
...tableColumn,
defaultSortOrder: initialOrderBy?.order === 'asc' ? 'ascend' : 'descend',
};
}
return tableColumn;
},
[initialOrderBy?.columnName, initialOrderBy?.order],
);
const columns = useMemo(
() =>
tableColumns
.filter(
(c) =>
!hiddenColumnIdsOnList.includes(c.key?.toString() || '') &&
!columnsHidden.includes(c.key?.toString() || ''),
)
.map(mapDefaultSort),
[columnsHidden, hiddenColumnIdsOnList, mapDefaultSort, tableColumns],
);
const isGroupedByAttribute = groupBy.length > 0;
const expandedRowRender = (record: K8sRenderedRowData): JSX.Element => (
<K8sExpandedRow<T>
record={record}
entity={entity}
tableColumns={tableColumns}
fetchListData={fetchListData}
renderRowData={renderRowData}
/>
);
const expandRowIconRenderer = ({
expanded,
onExpand,
record,
}: {
expanded: boolean;
onExpand: (
record: K8sRenderedRowData,
e: React.MouseEvent<HTMLButtonElement>,
) => void;
record: K8sRenderedRowData;
}): JSX.Element | null => {
if (!isGroupedByAttribute) {
return null;
}
return expanded ? (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronDown size={14} />
</Button>
) : (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronRight size={14} />
</Button>
);
};
const onPaginationChange = (page: number, pageSize: number): void => {
setCurrentPage(page);
setPageSize(pageSize);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
};
const showTableLoadingState = isLoading;
return (
<>
<K8sHeader
controlListPrefix={controlListPrefix}
entity={entity}
showAutoRefresh={!selectedItem}
/>
{isError && (
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
)}
<Table
className={styles.k8sListTable}
dataSource={showTableLoadingState ? [] : formattedItemsData}
columns={columns}
pagination={{
current: currentPage,
pageSize,
total: totalCount,
showSizeChanger: true,
hideOnSinglePage: false,
onChange: onPaginationChange,
}}
loading={{
spinning: showTableLoadingState,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText: showTableLoadingState ? null : (
<div className={styles.noFilteredHostsMessageContainer}>
<div className={styles.noFilteredHostsMessageContent}>
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className={styles.emptyStateSvg}
/>
<Typography.Text className={styles.noFilteredHostsMessage}>
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
),
}}
scroll={{ x: true }}
tableLayout="fixed"
onChange={handleTableChange}
onRow={(
record,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => handleRowClick(record, event),
className: styles.clickableRow,
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,
expandIcon: expandRowIconRenderer,
expandedRowKeys,
}}
/>
</>
);
}

View File

@@ -1,36 +0,0 @@
.drawer {
--dialog-description-padding: 0px 0px var(--spacing-8) 0px;
}
.columnItem {
display: flex;
align-items: center;
}
.columnsTitle {
color: var(--l2-foreground);
font-size: var(--periscope-font-size-small, 11px);
font-weight: var(--periscope-font-weight-medium, 500);
text-transform: uppercase;
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--l2-border);
&:not(:first-of-type) {
border-top: 1px solid var(--l2-border);
}
}
.columnsList {
display: flex;
flex-direction: column;
}
.columnItem {
justify-content: flex-start !important;
width: 100%;
}
.horizontalDivider {
border-top: 1px solid var(--l1-border);
}

View File

@@ -1,102 +0,0 @@
import { Button, DrawerWrapper } from '@signozhq/ui';
import { K8sCategory } from '../constants';
import {
useInfraMonitoringTableColumnsForPage,
useInfraMonitoringTableColumnsStore,
} from './useInfraMonitoringTableColumnsStore';
import styles from './K8sFiltersSidePanel.module.scss';
function K8sFiltersSidePanel({
open,
onClose,
entity,
}: {
open: boolean;
onClose: () => void;
entity: K8sCategory;
}): JSX.Element {
const addColumn = useInfraMonitoringTableColumnsStore(
(state) => state.addColumn,
);
const removeColumn = useInfraMonitoringTableColumnsStore(
(state) => state.removeColumn,
);
const [columns, columnsHidden] = useInfraMonitoringTableColumnsForPage(entity);
const drawerContent = (
<>
<div className={styles.columnsTitle}>Added Columns (Click to remove)</div>
<div className={styles.columnsList}>
{columns
.filter(
(column) =>
!columnsHidden.includes(column.id) &&
column.behavior !== 'hidden-on-collapse',
)
.map((column) => (
<div className={styles.columnItem} key={column.value}>
{/*<GripVertical size={16} /> TODO: Add support back when update the table component */}
<Button
variant="ghost"
color="none"
className={styles.columnItem}
disabled={!column.canBeHidden}
data-testid={`remove-column-${column.id}`}
onClick={(): void => removeColumn(entity, column.id)}
>
{column.label}
</Button>
</div>
))}
</div>
<div className={styles.horizontalDivider} />
<div className={styles.columnsTitle}>Other Columns (Click to add)</div>
<div className={styles.columnsList}>
{columns
.filter((column) => columnsHidden.includes(column.id))
.map((column) => (
<div className={styles.columnItem} key={column.value}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
data-can-be-added="true"
data-testid={`add-column-${column.id}`}
onClick={(): void => addColumn(entity, column.id)}
tabIndex={0}
>
{column.label}
</Button>
</div>
))}
</div>
</>
);
return (
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="Columns"
direction="right"
showCloseButton
showOverlay={false}
className={styles.drawer}
>
{drawerContent}
</DrawerWrapper>
);
}
export default K8sFiltersSidePanel;

View File

@@ -1,80 +0,0 @@
.k8sListControls {
padding: var(--spacing-4);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-4);
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l2-background) !important;
input {
font-size: 12px;
}
:global(.ant-tag .ant-typography) {
font-size: 12px;
}
}
}
.k8sListControlsLeft {
flex: 1;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-4);
.k8sQbSearchContainer {
flex: 1;
min-width: 240px;
max-width: 60%;
}
}
.k8sAttributeSearchContainer {
flex: 1;
min-width: 240px;
max-width: 40%;
display: flex;
align-items: center;
}
.groupByLabel {
min-width: max-content;
font-size: var(--periscope-font-size-base, 13px);
font-weight: var(--periscope-font-weight-regular, 400);
line-height: 18px;
letter-spacing: -0.07px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
border-right: none;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
display: flex;
height: 32px;
padding: var(--spacing-3) var(--spacing-3) var(--spacing-3) var(--spacing-4);
justify-content: center;
align-items: center;
gap: var(--spacing-2);
}
.groupBySelect {
:global(.ant-select-selector) {
border-left: none;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
}
.k8sListControlsRight {
min-width: 240px;
display: flex;
align-items: center;
gap: var(--spacing-2);
}

View File

@@ -1,226 +0,0 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Button } from '@signozhq/button';
import { Select } from 'antd';
import logEvent from 'api/common/logEvent';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { initialQueriesMap } from 'constants/queryBuilder';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { SlidersHorizontal } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { GetK8sEntityToAggregateAttribute, K8sCategory } from '../constants';
import {
useInfraMonitoringCurrentPage,
useInfraMonitoringFiltersK8s,
useInfraMonitoringGroupBy,
} from '../hooks';
import K8sFiltersSidePanel from './K8sFiltersSidePanel';
import styles from './K8sHeader.module.scss';
interface K8sHeaderProps {
controlListPrefix?: React.ReactNode;
entity: K8sCategory;
showAutoRefresh: boolean;
}
function K8sHeader({
controlListPrefix,
entity,
showAutoRefresh,
}: K8sHeaderProps): JSX.Element {
const [isFiltersSidePanelOpen, setIsFiltersSidePanelOpen] = useState(false);
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const currentQuery = initialQueriesMap[DataSource.METRICS];
const updatedCurrentQuery = useMemo(() => {
let { filters } = currentQuery.builder.queryData[0];
if (urlFilters) {
filters = urlFilters;
}
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
aggregateOperator: 'noop',
aggregateAttribute: {
...currentQuery.builder.queryData[0].aggregateAttribute,
},
filters,
},
],
},
};
}, [currentQuery, urlFilters]);
const query = useMemo(
() => updatedCurrentQuery?.builder?.queryData[0] || null,
[updatedCurrentQuery],
);
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const [, setCurrentPage] = useInfraMonitoringCurrentPage();
const handleChangeTagFilters = useCallback(
(value: IBuilderQuery['filters']) => {
setUrlFilters(value || null);
handleChangeQueryData('filters', value);
setCurrentPage(1);
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
}
},
[handleChangeQueryData, setCurrentPage, setUrlFilters],
);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const {
data: groupByFiltersData,
isLoading: isLoadingGroupByFilters,
} = useGetAggregateKeys(
{
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateAttribute: GetK8sEntityToAggregateAttribute(
entity,
dotMetricsEnabled,
),
aggregateOperator: 'noop',
searchText: '',
tagType: '',
},
{
queryKey: [currentQuery.builder.queryData[0].dataSource, 'noop'],
},
true,
entity,
);
const groupByOptions = useMemo(
() =>
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
[groupByFiltersData],
);
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
const newGroupBy = [];
for (let index = 0; index < value.length; index++) {
const element = (value[index] as unknown) as string;
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(k) => k.key === element,
);
if (key) {
newGroupBy.push(key);
}
}
// Reset pagination on switching to groupBy
setCurrentPage(1);
setGroupBy(newGroupBy);
logEvent(InfraMonitoringEvents.GroupByChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
},
[groupByFiltersData, setCurrentPage, setGroupBy],
);
const onClickOutside = useCallback(() => {
setIsFiltersSidePanelOpen(false);
}, []);
return (
<div className={styles.k8sListControls}>
<div className={styles.k8sListControlsLeft}>
{controlListPrefix}
<div className={styles.k8sQbSearchContainer}>
<QueryBuilderSearch
query={query as IBuilderQuery}
onChange={handleChangeTagFilters}
isInfraMonitoring
disableNavigationShortcuts
entity={entity}
/>
</div>
<div className={styles.k8sAttributeSearchContainer}>
<div className={styles.groupByLabel}> Group by </div>
<Select
className={styles.groupBySelect}
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}
allowClear
maxTagCount="responsive"
placeholder="Search for attribute"
style={{ width: '100%' }}
options={groupByOptions}
onChange={handleGroupByChange}
/>
</div>
</div>
<div className={styles.k8sListControlsRight}>
<DateTimeSelectionV2
showAutoRefresh={showAutoRefresh}
showRefreshText={false}
hideShareModal
/>
<Button
type="button"
variant="ghost"
size="icon"
color="none"
disabled={groupBy?.length > 0}
data-testid="k8s-list-filters-button"
onClick={(): void => setIsFiltersSidePanelOpen(true)}
>
<SlidersHorizontal size={14} />
</Button>
</div>
<K8sFiltersSidePanel
open={isFiltersSidePanelOpen}
entity={entity}
onClose={onClickOutside}
/>
</div>
);
}
export default K8sHeader;

View File

@@ -1,113 +0,0 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface IEntityColumn {
label: string;
value: string;
id: string;
defaultVisibility: boolean;
canBeHidden: boolean;
behavior: 'hidden-on-expand' | 'hidden-on-collapse' | 'always-visible';
}
export interface IInfraMonitoringTableColumnsStore {
columns: Record<string, IEntityColumn[]>;
columnsHidden: Record<string, string[]>;
addColumn: (page: string, columnId: string) => void;
removeColumn: (page: string, columnId: string) => void;
initializePageColumns: (page: string, columns: IEntityColumn[]) => void;
}
export const useInfraMonitoringTableColumnsStore = create<IInfraMonitoringTableColumnsStore>()(
persist(
(set, get) => ({
columns: {},
columnsHidden: {},
addColumn: (page, columnId): void => {
const state = get();
const columnDefinition = state.columns[page]?.find(
(c) => c.id === columnId,
);
if (!columnDefinition) {
return;
}
if (!columnDefinition.canBeHidden) {
return;
}
const columnsHidden = state.columnsHidden[page];
if (columnsHidden.includes(columnId)) {
set({
columnsHidden: {
...state.columnsHidden,
[page]: columnsHidden.filter((id) => id !== columnId),
},
});
}
},
removeColumn: (page, columnId): void => {
const state = get();
const columnDefinition = state.columns[page]?.find(
(c) => c.id === columnId,
);
if (!columnDefinition) {
return;
}
if (!columnDefinition.canBeHidden) {
return;
}
const columnsHidden = state.columnsHidden[page];
if (!columnsHidden.includes(columnId)) {
set({
columnsHidden: {
...state.columnsHidden,
[page]: [...columnsHidden, columnId],
},
});
}
},
initializePageColumns: (page, columns): void => {
const state = get();
set({
columns: {
...state.columns,
[page]: columns,
},
});
if (state.columnsHidden[page] === undefined) {
set({
columnsHidden: {
...state.columnsHidden,
[page]: columns
.filter((c) => c.defaultVisibility === false)
.map((c) => c.id),
},
});
}
},
}),
{
name: '@signoz/infra-monitoring-columns',
},
),
);
export const useInfraMonitoringTableColumnsForPage = (
page: string,
): [columns: IEntityColumn[], columnsHidden: string[]] => {
const state = useInfraMonitoringTableColumnsStore((s) => s.columns);
const columnsHidden = useInfraMonitoringTableColumnsStore(
(s) => s.columnsHidden,
);
return [state[page] ?? [], columnsHidden[page] ?? []];
};

View File

@@ -1,18 +0,0 @@
.itemDataGroup {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemDataGroupTagItem {
display: block !important;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,93 +0,0 @@
import { Badge } from '@signozhq/ui';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import styles from './utils.module.scss';
const dotToUnder: Record<string, string> = {
'k8s.node.name': 'k8s_node_name',
'k8s.cluster.name': 'k8s_cluster_name',
'k8s.node.uid': 'k8s_node_uid',
'k8s.cronjob.name': 'k8s_cronjob_name',
'k8s.daemonset.name': 'k8s_daemonset_name',
'k8s.deployment.name': 'k8s_deployment_name',
'k8s.job.name': 'k8s_job_name',
'k8s.namespace.name': 'k8s_namespace_name',
'k8s.pod.name': 'k8s_pod_name',
'k8s.pod.uid': 'k8s_pod_uid',
'k8s.statefulset.name': 'k8s_statefulset_name',
'k8s.persistentvolumeclaim.name': 'k8s_persistentvolumeclaim_name',
};
export function getGroupedByMeta<T extends { meta: Record<string, string> }>(
itemData: T,
groupBy: BaseAutocompleteData[],
): Record<string, string> {
const result: Record<string, string> = {};
groupBy.forEach((group) => {
const rawKey = group.key as string;
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof itemData.meta;
result[rawKey] = (itemData.meta[metaKey] || itemData.meta[rawKey]) ?? '';
});
return result;
}
export function getRowKey<T extends { meta: Record<string, string> }>(
itemData: T,
getItemIdentifier: () => string,
groupBy: BaseAutocompleteData[],
): string {
const nodeIdentifier = getItemIdentifier();
if (groupBy.length === 0) {
return nodeIdentifier || JSON.stringify(itemData.meta);
}
const groupedMeta = getGroupedByMeta(itemData, groupBy);
const groupKey = Object.values(groupedMeta).join('-');
if (groupKey && nodeIdentifier) {
return `${groupKey}-${nodeIdentifier}`;
}
if (groupKey) {
return groupKey;
}
if (nodeIdentifier) {
return nodeIdentifier;
}
return JSON.stringify(itemData.meta);
}
export function getGroupByEl<T extends { meta: Record<string, string> }>(
itemData: T,
groupBy: IBuilderQuery['groupBy'],
): React.ReactNode {
const groupByValues: string[] = [];
groupBy.forEach((group) => {
const rawKey = group.key as string;
// Choose mapped key if present, otherwise use rawKey
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof itemData.meta;
const value = itemData.meta[metaKey] || itemData.meta[rawKey] || '<no-value>';
groupByValues.push(value);
});
return (
<div className={styles.itemDataGroup}>
{groupByValues.map((value) => (
<Badge
key={value}
color="secondary"
className={styles.itemDataGroupTagItem}
>
{value === '' ? '<no-value>' : value}
</Badge>
))}
</div>
);
}

View File

@@ -29,7 +29,6 @@ export const QUERY_KEYS = {
K8S_STATEFUL_SET_NAME: 'k8s.statefulset.name',
K8S_JOB_NAME: 'k8s.job.name',
K8S_DAEMON_SET_NAME: 'k8s.daemonset.name',
K8S_PERSISTENT_VOLUME_CLAIM_NAME: 'k8s.persistentvolumeclaim.name',
};
/**

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from 'react';
import { useState } from 'react';
import { VerticalAlignTopOutlined } from '@ant-design/icons';
import * as Sentry from '@sentry/react';
import { Button, CollapseProps } from 'antd';
import type { CollapseProps } from 'antd';
import { Collapse, Tooltip, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
@@ -16,7 +16,6 @@ import {
Computer,
Container,
FilePenLine,
Filter,
Group,
HardDrive,
Workflow,
@@ -68,9 +67,9 @@ export default function InfraMonitoringK8s(): JSX.Element {
const { currentQuery } = useQueryBuilder();
const handleFilterVisibilityChange = useCallback((): void => {
setShowFilters((show) => !show);
}, []);
const handleFilterVisibilityChange = (): void => {
setShowFilters(!showFilters);
};
const { handleChangeQueryData } = useQueryOperations({
index: 0,
@@ -322,25 +321,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
}
};
const showFiltersComp = useMemo(() => {
return (
<>
{!showFilters && (
<div className="quick-filters-toggle-container">
<Button
className="periscope-btn ghost"
type="text"
size="small"
onClick={handleFilterVisibilityChange}
>
<Filter size={14} />
</Button>
</div>
)}
</>
);
}, [handleFilterVisibilityChange, showFilters]);
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="infra-monitoring-container">
@@ -375,7 +355,11 @@ export default function InfraMonitoringK8s(): JSX.Element {
}`}
>
{selectedCategory === K8sCategories.PODS && (
<K8sPodLists controlListPrefix={showFiltersComp} />
<K8sPodLists
isFiltersVisible={showFilters}
handleFilterVisibilityChange={handleFilterVisibilityChange}
quickFiltersLastUpdated={quickFiltersLastUpdated}
/>
)}
{selectedCategory === K8sCategories.NODES && (
@@ -435,7 +419,11 @@ export default function InfraMonitoringK8s(): JSX.Element {
)}
{selectedCategory === K8sCategories.VOLUMES && (
<K8sVolumesList controlListPrefix={showFiltersComp} />
<K8sVolumesList
isFiltersVisible={showFilters}
handleFilterVisibilityChange={handleFilterVisibilityChange}
quickFiltersLastUpdated={quickFiltersLastUpdated}
/>
)}
</div>
</div>

View File

@@ -1,116 +1,753 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { LoadingOutlined } from '@ant-design/icons';
import {
Button,
Spin,
Table,
TableColumnType as ColumnType,
TablePaginationConfig,
TableProps,
Typography,
} from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import get from 'api/browser/localstorage/get';
import set from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import { K8sPodsListPayload } from 'api/infraMonitoring/getK8sPodsList';
import classNames from 'classnames';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { useGetK8sPodsList } from 'hooks/infraMonitoring/useGetK8sPodsList';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { ChevronDown, ChevronRight, CornerDownRight } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseFilters, K8sBaseList } from '../Base/K8sBaseList';
import { K8sCategory } from '../constants';
import { getK8sPodsList, K8sPodsData } from './api';
import {
getPodMetricsQueryPayload,
k8sPodDetailsMetadataConfig,
k8sPodGetEntityName,
k8sPodGetSelectedItemFilters,
k8sPodInitialEventsFilter,
k8sPodInitialFilters,
k8sPodInitialLogTracesFilter,
podWidgetInfo,
} from './constants';
GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS,
K8sCategory,
} from '../constants';
import {
k8sPodColumns,
k8sPodColumnsConfig,
k8sPodRenderRowData,
} from './table';
useInfraMonitoringCurrentPage,
useInfraMonitoringEventsFilters,
useInfraMonitoringGroupBy,
useInfraMonitoringLogFilters,
useInfraMonitoringOrderBy,
useInfraMonitoringPodUID,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from '../hooks';
import K8sHeader from '../K8sHeader';
import LoadingContainer from '../LoadingContainer';
import {
defaultAddedColumns,
defaultAvailableColumns,
formatDataForTable,
getK8sPodsListColumns,
getK8sPodsListQuery,
IEntityColumn,
K8sPodsRowData,
usePageSize,
} from '../utils';
import PodDetails from './PodDetails/PodDetails';
import '../InfraMonitoringK8s.styles.scss';
function K8sPodsList({
controlListPrefix,
isFiltersVisible,
handleFilterVisibilityChange,
quickFiltersLastUpdated,
}: {
controlListPrefix?: React.ReactNode;
isFiltersVisible: boolean;
handleFilterVisibilityChange: () => void;
quickFiltersLastUpdated: number;
}): JSX.Element {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [defaultOrderBy] = useState(orderBy);
const [selectedPodUID, setSelectedPodUID] = useInfraMonitoringPodUID();
const [, setView] = useInfraMonitoringView();
const [, setTracesFilters] = useInfraMonitoringTracesFilters();
const [, setEventsFilters] = useInfraMonitoringEventsFilters();
const [, setLogFilters] = useInfraMonitoringLogFilters();
const [filtersInitialised, setFiltersInitialised] = useState(false);
const [addedColumns, setAddedColumns] = useState<IEntityColumn[]>([]);
const [availableColumns, setAvailableColumns] = useState<IEntityColumn[]>(
defaultAvailableColumns,
);
const [selectedRowData, setSelectedRowData] = useState<K8sPodsRowData | null>(
null,
);
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
const [groupByOptions, setGroupByOptions] = useState<
{ value: string; label: string }[]
>([]);
const { currentQuery } = useQueryBuilder();
const queryFilters = useMemo(
() =>
currentQuery?.builder?.queryData[0]?.filters || {
items: [],
op: 'and',
},
[currentQuery?.builder?.queryData],
);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
filters.orderBy ||= {
columnName: 'cpu',
order: 'desc',
};
const response = await getK8sPodsList(
filters,
signal,
undefined,
const {
data: groupByFiltersData,
isLoading: isLoadingGroupByFilters,
} = useGetAggregateKeys(
{
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateAttribute: GetK8sEntityToAggregateAttribute(
K8sCategory.PODS,
dotMetricsEnabled,
);
return {
data: response.payload?.data.records || [],
total: response.payload?.data.total || 0,
error: response.error,
};
),
aggregateOperator: 'noop',
searchText: '',
tagType: '',
},
[dotMetricsEnabled],
{
queryKey: [currentQuery.builder.queryData[0].dataSource, 'noop'],
},
true, // isInfraMonitoring
K8sCategory.PODS, // infraMonitoringEntity
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{ data: K8sPodsData | null; error?: string | null }> => {
const response = await getK8sPodsList(
{
filters: filters.filters,
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
// Reset pagination every time quick filters are changed
useEffect(() => {
if (quickFiltersLastUpdated !== -1) {
setCurrentPage(1);
}
}, [quickFiltersLastUpdated, setCurrentPage]);
useEffect(() => {
const addedColumns = JSON.parse(get('k8sPodsAddedColumns') ?? '[]');
if (addedColumns && addedColumns.length > 0) {
const availableColumns = defaultAvailableColumns.filter(
(column) => !addedColumns.includes(column.id),
);
const newAddedColumns = defaultAvailableColumns.filter((column) =>
addedColumns.includes(column.id),
);
setAvailableColumns(availableColumns);
setAddedColumns(newAddedColumns);
}
}, []);
const { pageSize, setPageSize } = usePageSize(K8sCategory.PODS);
const query = useMemo(() => {
const baseQuery = getK8sPodsListQuery();
const queryPayload = {
...baseQuery,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: queryFilters,
start: Math.floor(minTime / 1000000),
end: Math.floor(maxTime / 1000000),
orderBy: orderBy || baseQuery.orderBy,
};
if (groupBy.length > 0) {
queryPayload.groupBy = groupBy;
}
return queryPayload;
}, [pageSize, currentPage, queryFilters, minTime, maxTime, orderBy, groupBy]);
const queryKey = useMemo(() => {
if (selectedPodUID) {
return [
'podList',
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
];
}
return [
'podList',
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
String(minTime),
String(maxTime),
];
}, [
selectedPodUID,
pageSize,
currentPage,
queryFilters,
orderBy,
groupBy,
minTime,
maxTime,
]);
const { data, isFetching, isLoading, isError } = useGetK8sPodsList(
query as K8sPodsListPayload,
{
queryKey,
enabled: !!query,
keepPreviousData: true,
},
undefined,
dotMetricsEnabled,
);
const createFiltersForSelectedRowData = (
selectedRowData: K8sPodsRowData,
): IBuilderQuery['filters'] => {
const baseFilters: IBuilderQuery['filters'] = {
items: [...queryFilters.items],
op: 'and',
};
if (!selectedRowData) {
return baseFilters;
}
const { groupedByMeta } = selectedRowData;
for (const key of Object.keys(groupedByMeta)) {
baseFilters.items.push({
key: {
key,
type: null,
},
signal,
undefined,
dotMetricsEnabled,
);
op: '=',
value: groupedByMeta[key],
id: key,
});
}
const records = response.payload?.data.records || [];
return baseFilters;
};
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
const fetchGroupedByRowDataQuery = useMemo(() => {
if (!selectedRowData) {
return null;
}
const baseQuery = getK8sPodsListQuery();
const filters = createFiltersForSelectedRowData(selectedRowData);
return {
...baseQuery,
limit: 10,
offset: 0,
filters,
start: Math.floor(minTime / 1000000),
end: Math.floor(maxTime / 1000000),
orderBy: orderBy || baseQuery.orderBy,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minTime, maxTime, orderBy, selectedRowData]);
const groupedByRowDataQueryKey = useMemo(() => {
// be careful with what you serialize from selectedRowData
// since it's react node, it could contain circular references
const selectedRowDataKey = JSON.stringify(selectedRowData?.groupedByMeta);
if (selectedPodUID) {
return [
'podList',
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
selectedRowDataKey,
];
}
return [
'podList',
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
selectedRowDataKey,
String(minTime),
String(maxTime),
];
}, [queryFilters, orderBy, selectedPodUID, minTime, maxTime, selectedRowData]);
const {
data: groupedByRowData,
isFetching: isFetchingGroupedByRowData,
isLoading: isLoadingGroupedByRowData,
isError: isErrorGroupedByRowData,
refetch: fetchGroupedByRowData,
} = useGetK8sPodsList(
fetchGroupedByRowDataQuery as K8sPodsListPayload,
{
queryKey: groupedByRowDataQueryKey,
enabled: !!fetchGroupedByRowDataQuery && !!selectedRowData,
},
[dotMetricsEnabled],
undefined,
dotMetricsEnabled,
);
const podsData = useMemo(() => data?.payload?.data?.records || [], [data]);
const totalCount = data?.payload?.data?.total || 0;
const nestedPodsData = useMemo(() => {
if (!selectedRowData || !groupedByRowData?.payload?.data.records) {
return [];
}
return groupedByRowData?.payload?.data?.records || [];
}, [groupedByRowData, selectedRowData]);
const formattedPodsData = useMemo(
() => formatDataForTable(podsData, groupBy),
[podsData, groupBy],
);
const formattedGroupedByPodsData = useMemo(
() =>
formatDataForTable(groupedByRowData?.payload?.data?.records || [], groupBy),
[groupedByRowData, groupBy],
);
const columns = useMemo(
() => getK8sPodsListColumns(addedColumns, groupBy, defaultOrderBy),
[addedColumns, groupBy, defaultOrderBy],
);
const handleTableChange: TableProps<K8sPodsRowData>['onChange'] = useCallback(
(
pagination: TablePaginationConfig,
_filters: Record<string, (string | number | boolean)[] | null>,
sorter: SorterResult<K8sPodsRowData> | SorterResult<K8sPodsRowData>[],
): void => {
if (pagination.current) {
setCurrentPage(pagination.current);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
}
if ('field' in sorter && sorter.order) {
setOrderBy({
columnName: sorter.field as string,
order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc',
});
} else {
setOrderBy(null);
}
},
[setCurrentPage, setOrderBy],
);
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const handleFiltersChange = useCallback(
(value: IBuilderQuery['filters']): void => {
handleChangeQueryData('filters', value);
if (filtersInitialised) {
setCurrentPage(1);
} else {
setFiltersInitialised(true);
}
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
const newGroupBy = [];
for (let index = 0; index < value.length; index++) {
const element = (value[index] as unknown) as string;
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(k) => k.key === element,
);
if (key) {
newGroupBy.push(key);
}
}
// Reset pagination on switching to groupBy
setCurrentPage(1);
setGroupBy(newGroupBy);
setExpandedRowKeys([]);
logEvent(InfraMonitoringEvents.GroupByChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
},
[groupByFiltersData, setCurrentPage, setGroupBy],
);
useEffect(() => {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
total: data?.payload?.data?.total,
});
}, [data?.payload?.data?.total]);
const selectedPodData = useMemo(() => {
if (!selectedPodUID) {
return null;
}
if (groupBy.length > 0) {
// If grouped by, return the pod from the formatted grouped by pods data
return nestedPodsData.find((pod) => pod.podUID === selectedPodUID) || null;
}
// If not grouped by, return the node from the nodes data
return podsData.find((pod) => pod.podUID === selectedPodUID) || null;
}, [selectedPodUID, groupBy.length, podsData, nestedPodsData]);
const handleGroupByRowClick = (record: K8sPodsRowData): void => {
setSelectedRowData(record);
if (expandedRowKeys.includes(record.key)) {
setExpandedRowKeys(expandedRowKeys.filter((key) => key !== record.key));
} else {
setExpandedRowKeys([record.key]);
}
};
useEffect(() => {
if (selectedRowData) {
fetchGroupedByRowData();
}
}, [selectedRowData, fetchGroupedByRowData]);
const openPodInNewTab = (record: K8sPodsRowData): void => {
const newParams = new URLSearchParams(document.location.search);
newParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.POD_UID, record.podUID);
openInNewTab(
buildAbsolutePath({
relativePath: '',
urlQueryString: newParams.toString(),
}),
);
};
const handleRowClick = (
record: K8sPodsRowData,
event: React.MouseEvent,
): void => {
if (event && isModifierKeyPressed(event)) {
openPodInNewTab(record);
return;
}
if (groupBy.length === 0) {
setSelectedPodUID(record.podUID);
setSelectedRowData(null);
} else {
handleGroupByRowClick(record);
}
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
};
const handleClosePodDetail = (): void => {
setSelectedPodUID(null);
setView(null);
setTracesFilters(null);
setEventsFilters(null);
setLogFilters(null);
};
const handleAddColumn = useCallback(
(column: IEntityColumn): void => {
setAddedColumns((prev) => [...prev, column]);
setAvailableColumns((prev) => prev.filter((c) => c.value !== column.value));
},
[setAddedColumns, setAvailableColumns],
);
// Update local storage when added columns updated
useEffect(() => {
const addedColumnIDs = addedColumns.map((column) => column.id);
set('k8sPodsAddedColumns', JSON.stringify(addedColumnIDs));
}, [addedColumns]);
useEffect(() => {
if (groupByFiltersData?.payload) {
setGroupByOptions(
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
);
}
}, [groupByFiltersData]);
const handleRemoveColumn = useCallback(
(column: IEntityColumn): void => {
setAddedColumns((prev) => prev.filter((c) => c.value !== column.value));
setAvailableColumns((prev) => [...prev, column]);
},
[setAddedColumns, setAvailableColumns],
);
const nestedColumns = useMemo(
() => getK8sPodsListColumns(addedColumns, [], defaultOrderBy),
[addedColumns, defaultOrderBy],
);
const isGroupedByAttribute = groupBy.length > 0;
const handleExpandedRowViewAllClick = (): void => {
if (!selectedRowData) {
return;
}
const filters = createFiltersForSelectedRowData(selectedRowData);
handleFiltersChange(filters);
setCurrentPage(1);
setSelectedRowData(null);
setGroupBy([]);
setOrderBy(null);
};
const expandedRowRender = (): JSX.Element => (
<div className="expanded-table-container">
{isErrorGroupedByRowData && (
<Typography>{groupedByRowData?.error || 'Something went wrong'}</Typography>
)}
{isFetchingGroupedByRowData || isLoadingGroupedByRowData ? (
<LoadingContainer />
) : (
<div className="expanded-table">
<Table
columns={nestedColumns as ColumnType<K8sPodsRowData>[]}
dataSource={formattedGroupedByPodsData}
pagination={false}
scroll={{ x: true }}
tableLayout="fixed"
showHeader={false}
loading={{
spinning: isFetchingGroupedByRowData || isLoadingGroupedByRowData,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
onRow={(
record: K8sPodsRowData,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => {
if (isModifierKeyPressed(event)) {
openPodInNewTab(record);
return;
}
setSelectedPodUID(record.podUID);
},
className: 'expanded-clickable-row',
})}
/>
{groupedByRowData?.payload?.data?.total &&
groupedByRowData?.payload?.data?.total > 10 && (
<div className="expanded-table-footer">
<Button
type="default"
size="small"
className="periscope-btn secondary"
onClick={handleExpandedRowViewAllClick}
>
<CornerDownRight size={14} />
View All
</Button>
</div>
)}
</div>
)}
</div>
);
const expandRowIconRenderer = ({
expanded,
onExpand,
record,
}: {
expanded: boolean;
onExpand: (
record: K8sPodsRowData,
e: React.MouseEvent<HTMLButtonElement>,
) => void;
record: K8sPodsRowData;
}): JSX.Element | null => {
if (!isGroupedByAttribute) {
return null;
}
return expanded ? (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronDown size={14} />
</Button>
) : (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronRight size={14} />
</Button>
);
};
const onPaginationChange = (page: number, pageSize: number): void => {
setCurrentPage(page);
setPageSize(pageSize);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
};
const showTableLoadingState =
(isFetching || isLoading) && formattedPodsData.length === 0;
return (
<>
<K8sBaseList<K8sPodsData>
controlListPrefix={controlListPrefix}
<div className="k8s-list">
<K8sHeader
selectedGroupBy={groupBy}
groupByOptions={groupByOptions}
isLoadingGroupByFilters={isLoadingGroupByFilters}
isFiltersVisible={isFiltersVisible}
handleFilterVisibilityChange={handleFilterVisibilityChange}
defaultAddedColumns={defaultAddedColumns}
addedColumns={addedColumns}
availableColumns={availableColumns}
handleFiltersChange={handleFiltersChange}
handleGroupByChange={handleGroupByChange}
onAddColumn={handleAddColumn}
onRemoveColumn={handleRemoveColumn}
entity={K8sCategory.PODS}
tableColumnsDefinitions={k8sPodColumns}
tableColumns={k8sPodColumnsConfig}
fetchListData={fetchListData}
renderRowData={k8sPodRenderRowData}
eventCategory={InfraMonitoringEvents.Pod}
showAutoRefresh={!selectedPodData}
/>
{isError && <Typography>{data?.error || 'Something went wrong'}</Typography>}
<Table
className={classNames('k8s-list-table', {
'expanded-k8s-list-table': isGroupedByAttribute,
})}
dataSource={showTableLoadingState ? [] : formattedPodsData}
columns={columns}
pagination={{
current: currentPage,
pageSize,
total: totalCount,
showSizeChanger: true,
hideOnSinglePage: false,
onChange: onPaginationChange,
}}
loading={{
spinning: showTableLoadingState,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText: showTableLoadingState ? null : (
<div className="no-filtered-hosts-message-container">
<div className="no-filtered-hosts-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-filtered-hosts-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
),
}}
scroll={{ x: true }}
tableLayout="fixed"
onChange={handleTableChange}
onRow={(
record: K8sPodsRowData,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => handleRowClick(record, event),
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,
expandIcon: expandRowIconRenderer,
expandedRowKeys,
}}
/>
<K8sBaseDetails<K8sPodsData>
category={K8sCategory.PODS}
eventCategory={InfraMonitoringEvents.Pod}
getSelectedItemFilters={k8sPodGetSelectedItemFilters}
fetchEntityData={fetchEntityData}
getEntityName={k8sPodGetEntityName}
getInitialLogTracesFilters={k8sPodInitialLogTracesFilter}
getInitialEventsFilters={k8sPodInitialEventsFilter}
primaryFilterKeys={k8sPodInitialFilters}
metadataConfig={k8sPodDetailsMetadataConfig}
entityWidgetInfo={podWidgetInfo}
getEntityQueryPayload={getPodMetricsQueryPayload}
queryKeyPrefix="pod"
/>
</>
{selectedPodData && (
<PodDetails
pod={selectedPodData}
isModalTimeSelection
onClose={handleClosePodDetail}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,7 @@
import { K8sPodsData } from 'api/infraMonitoring/getK8sPodsList';
export type PodDetailProps = {
pod: K8sPodsData | null;
isModalTimeSelection: boolean;
onClose: () => void;
};

View File

@@ -1,10 +1,12 @@
/* eslint-disable sonarjs/no-identical-functions */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button, Divider, Drawer, Radio, Tooltip, Typography } from 'antd';
import type { RadioChangeEvent } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { K8sPodsData } from 'api/infraMonitoring/getK8sPodsList';
import { VIEW_TYPES, VIEWS } from 'components/HostMetricsDetail/constants';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
@@ -13,13 +15,21 @@ import {
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { filterDuplicateFilters } from 'container/InfraMonitoringK8s/commonUtils';
import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
import { QUERY_KEYS } from 'container/InfraMonitoringK8s/EntityDetailsUtils/utils';
import {
useInfraMonitoringEventsFilters,
useInfraMonitoringLogFilters,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from 'container/InfraMonitoringK8s/hooks';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import {
BarChart2,
@@ -29,133 +39,48 @@ import {
ScrollText,
X,
} from 'lucide-react';
import { AppState } from 'store/reducers';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import {
LogsAggregatorOperator,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuidv4 } from 'uuid';
import {
isCustomTimeRange,
useGlobalTimeStore,
} from '../../../store/globalTime';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from '../../../store/globalTime/utils';
import { DEFAULT_TIME_RANGE } from '../../TopNav/DateTimeSelectionV2/constants';
import { filterDuplicateFilters } from '../commonUtils';
import { K8sCategory } from '../constants';
import EntityEvents from '../EntityDetailsUtils/EntityEvents';
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
import { QUERY_KEYS } from '../EntityDetailsUtils/utils';
import {
useInfraMonitoringEventsFilters,
useInfraMonitoringLogFilters,
useInfraMonitoringSelectedItem,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from '../hooks';
import LoadingContainer from '../LoadingContainer';
import PodEvents from '../../EntityDetailsUtils/EntityEvents';
import PodLogs from '../../EntityDetailsUtils/EntityLogs';
import PodMetrics from '../../EntityDetailsUtils/EntityMetrics';
import PodTraces from '../../EntityDetailsUtils/EntityTraces';
import { getPodMetricsQueryPayload, podWidgetInfo } from './constants';
import { PodDetailProps } from './PodDetail.interfaces';
import '../EntityDetailsUtils/entityDetails.styles.scss';
import '../../EntityDetailsUtils/entityDetails.styles.scss';
const TimeRangeOffset = 1000000000;
export interface K8sDetailsMetadataConfig<T> {
label: string;
getValue: (entity: T) => string;
}
function PodDetails({
pod,
onClose,
isModalTimeSelection,
}: PodDetailProps): JSX.Element {
const { maxTime, minTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
export interface K8sDetailsFilters {
filters: TagFilter;
start: number;
end: number;
}
const startMs = useMemo(() => Math.floor(Number(minTime) / TimeRangeOffset), [
minTime,
]);
const endMs = useMemo(() => Math.floor(Number(maxTime) / TimeRangeOffset), [
maxTime,
]);
export interface K8sBaseDetailsProps<T> {
category: K8sCategory;
eventCategory: string;
// Data fetching configuration
getSelectedItemFilters: (selectedItem: string) => TagFilter;
fetchEntityData: (
filters: K8sDetailsFilters,
signal?: AbortSignal,
) => Promise<{ data: T | null; error?: string | null }>;
// Entity configuration
getEntityName: (entity: T) => string;
getInitialLogTracesFilters: (entity: T) => TagFilterItem[];
getInitialEventsFilters: (entity: T) => TagFilterItem[];
primaryFilterKeys: string[];
metadataConfig: K8sDetailsMetadataConfig<T>[];
entityWidgetInfo: {
title: string;
yAxisUnit: string;
}[];
getEntityQueryPayload: (
entity: T,
start: number,
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
queryKeyPrefix: string;
/** When true, only metrics are shown and the Metrics/Logs/Traces/Events tab bar is hidden. */
hideDetailViewTabs?: boolean;
}
export function createFilterItem(
key: string,
value: string,
dataType: DataTypes = DataTypes.String,
): TagFilterItem {
return {
id: uuidv4(),
key: {
key,
dataType,
type: 'resource',
id: `${key}--string--resource--false`,
},
op: '=',
value,
};
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function K8sBaseDetails<T>({
category,
eventCategory,
getSelectedItemFilters,
fetchEntityData,
getEntityName,
getInitialLogTracesFilters,
getInitialEventsFilters,
primaryFilterKeys,
metadataConfig,
entityWidgetInfo,
getEntityQueryPayload,
queryKeyPrefix,
hideDetailViewTabs = false,
}: K8sBaseDetailsProps<T>): JSX.Element {
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const { startMs, endMs } = useMemo(() => {
const { minTime: startNs, maxTime: endNs } = getMinMaxTime(selectedTime);
return {
startMs: Math.floor(startNs / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(endNs / NANO_SECOND_MULTIPLIER),
};
}, [getMinMaxTime, selectedTime]);
const urlQuery = useUrlQuery();
const [modalTimeRange, setModalTimeRange] = useState(() => ({
startTime: startMs,
@@ -163,17 +88,14 @@ function K8sBaseDetails<T>({
}));
const lastSelectedInterval = useRef<Time | null>(null);
const [selectedInterval, setSelectedInterval] = useState<Time>(
lastSelectedInterval.current
? lastSelectedInterval.current
: isCustomTimeRange(selectedTime)
? DEFAULT_TIME_RANGE
: selectedTime,
: (selectedTime as Time),
);
const [selectedView, setSelectedView] = useInfraMonitoringView();
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
const [logFiltersParam, setLogFiltersParam] = useInfraMonitoringLogFilters();
const [
tracesFiltersParam,
@@ -185,89 +107,79 @@ function K8sBaseDetails<T>({
] = useInfraMonitoringEventsFilters();
const isDarkMode = useIsDarkMode();
const [selectedItem, setSelectedItem] = useInfraMonitoringSelectedItem();
const urlQuery = useUrlQuery();
useEffect(() => {
if (
hideDetailViewTabs &&
selectedItem &&
selectedView !== VIEW_TYPES.METRICS
) {
setSelectedView(VIEW_TYPES.METRICS);
}
}, [hideDetailViewTabs, selectedItem, selectedView, setSelectedView]);
const entityQueryKey = useMemo(
() =>
getAutoRefreshQueryKey(
selectedTime,
`${queryKeyPrefix}EntityDetails`,
selectedItem,
),
[queryKeyPrefix, selectedItem, selectedTime],
);
const {
data: entityResponse,
isLoading: isEntityLoading,
isError: isEntityError,
} = useQuery({
queryKey: entityQueryKey,
queryFn: ({ signal }) => {
if (!selectedItem) {
return { data: null };
}
const filters = getSelectedItemFilters(selectedItem);
const { minTime, maxTime } = getMinMaxTime();
return fetchEntityData(
{
filters,
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
},
signal,
);
},
enabled: !!selectedItem,
});
const entity = entityResponse?.data ?? null;
const initialFilters = useMemo(() => {
const filters =
effectiveView === VIEW_TYPES.LOGS ? logFiltersParam : tracesFiltersParam;
selectedView === VIEW_TYPES.LOGS ? logFiltersParam : tracesFiltersParam;
if (filters) {
return filters;
}
if (!entity) {
return { op: 'AND', items: [] };
}
return {
op: 'AND',
items: getInitialLogTracesFilters(entity),
items: [
{
id: uuidv4(),
key: {
key: QUERY_KEYS.K8S_POD_NAME,
dataType: DataTypes.String,
type: 'resource',
id: 'k8s_pod_name--string--resource--false',
},
op: '=',
value: pod?.meta.k8s_pod_name || '',
},
{
id: uuidv4(),
key: {
key: QUERY_KEYS.K8S_NAMESPACE_NAME,
dataType: DataTypes.String,
type: 'resource',
id: 'k8s_pod_name--string--resource--false',
},
op: '=',
value: pod?.meta.k8s_namespace_name || '',
},
],
};
}, [
entity,
effectiveView,
pod?.meta.k8s_namespace_name,
pod?.meta.k8s_pod_name,
selectedView,
logFiltersParam,
tracesFiltersParam,
getInitialLogTracesFilters,
]);
const initialEventsFilters = useMemo(() => {
if (eventsFiltersParam) {
return eventsFiltersParam;
}
if (!entity) {
return { op: 'AND', items: [] };
}
return {
op: 'AND',
items: getInitialEventsFilters(entity),
items: [
{
id: uuidv4(),
key: {
key: QUERY_KEYS.K8S_OBJECT_KIND,
dataType: DataTypes.String,
type: 'resource',
id: 'k8s.object.kind--string--resource--false',
},
op: '=',
value: 'Pod',
},
{
id: uuidv4(),
key: {
key: QUERY_KEYS.K8S_OBJECT_NAME,
dataType: DataTypes.String,
type: 'resource',
id: 'k8s.object.name--string--resource--false',
},
op: '=',
value: pod?.meta.k8s_pod_name || '',
},
],
};
}, [entity, eventsFiltersParam, getInitialEventsFilters]);
}, [pod?.meta.k8s_pod_name, eventsFiltersParam]);
const [logsAndTracesFilters, setLogsAndTracesFilters] = useState<
IBuilderQuery['filters']
@@ -278,14 +190,14 @@ function K8sBaseDetails<T>({
);
useEffect(() => {
if (entity) {
if (pod) {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
category: InfraMonitoringEvents.Pod,
});
}
}, [entity, eventCategory]);
}, [pod]);
useEffect(() => {
setLogsAndTracesFilters(initialFilters);
@@ -294,16 +206,17 @@ function K8sBaseDetails<T>({
useEffect(() => {
const currentSelectedInterval = lastSelectedInterval.current || selectedTime;
if (!isCustomTimeRange(currentSelectedInterval)) {
setSelectedInterval(currentSelectedInterval);
const { minTime, maxTime } = getMinMaxTime();
setSelectedInterval(currentSelectedInterval as Time);
if (currentSelectedInterval !== 'custom') {
const { maxTime, minTime } = GetMinMax(currentSelectedInterval);
setModalTimeRange({
startTime: Math.floor(minTime / TimeRangeOffset),
endTime: Math.floor(maxTime / TimeRangeOffset),
});
}
}, [getMinMaxTime, selectedTime]);
}, [selectedTime, minTime, maxTime]);
const handleTabChange = (e: RadioChangeEvent): void => {
setSelectedView(e.target.value);
@@ -313,7 +226,7 @@ function K8sBaseDetails<T>({
logEvent(InfraMonitoringEvents.TabChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
category: InfraMonitoringEvents.Pod,
view: e.target.value,
});
};
@@ -340,34 +253,38 @@ function K8sBaseDetails<T>({
logEvent(InfraMonitoringEvents.TimeUpdated, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
category: InfraMonitoringEvents.Pod,
interval,
view: effectiveView,
view: selectedView,
});
},
[eventCategory, effectiveView],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleChangeLogFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setLogsAndTracesFilters((prevFilters) => {
const primaryFilters = prevFilters?.items?.filter((item) =>
primaryFilterKeys.includes(item.key?.key ?? ''),
[
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
].includes(item.key?.key ?? ''),
);
const paginationFilter = value?.items?.find(
(item) => item.key?.key === 'id',
);
const newFilters = value?.items?.filter(
(item) =>
item.key?.key !== 'id' &&
!primaryFilterKeys.includes(item.key?.key ?? ''),
item.key?.key !== 'id' && item.key?.key !== QUERY_KEYS.K8S_CLUSTER_NAME,
);
if (newFilters && newFilters?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
category: InfraMonitoringEvents.Pod,
view: selectedView,
});
}
@@ -389,27 +306,26 @@ function K8sBaseDetails<T>({
return updatedFilters;
});
},
[
setLogFiltersParam,
setSelectedView,
primaryFilterKeys,
eventCategory,
selectedView,
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleChangeTracesFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setLogsAndTracesFilters((prevFilters) => {
const primaryFilters = prevFilters?.items?.filter((item) =>
primaryFilterKeys.includes(item.key?.key ?? ''),
[
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
].includes(item.key?.key ?? ''),
);
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
category: InfraMonitoringEvents.Pod,
view: selectedView,
});
}
@@ -420,7 +336,7 @@ function K8sBaseDetails<T>({
[
...(primaryFilters || []),
...(value?.items?.filter(
(item) => !primaryFilterKeys.includes(item.key?.key ?? ''),
(item) => item.key?.key !== QUERY_KEYS.K8S_POD_NAME,
) || []),
].filter((item): item is TagFilterItem => item !== undefined),
),
@@ -432,22 +348,17 @@ function K8sBaseDetails<T>({
return updatedFilters;
});
},
[
setTracesFiltersParam,
setSelectedView,
primaryFilterKeys,
eventCategory,
selectedView,
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleChangeEventsFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setEventsFilters((prevFilters) => {
const kindFilter = prevFilters?.items?.find(
const podKindFilter = prevFilters?.items?.find(
(item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_KIND,
);
const nameFilter = prevFilters?.items?.find(
const podNameFilter = prevFilters?.items?.find(
(item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_NAME,
);
@@ -455,24 +366,22 @@ function K8sBaseDetails<T>({
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
category: InfraMonitoringEvents.Pod,
view: selectedView,
});
}
const updatedFilters = {
op: 'AND',
items: filterDuplicateFilters(
[
kindFilter,
nameFilter,
...(value?.items?.filter(
(item) =>
item.key?.key !== QUERY_KEYS.K8S_OBJECT_KIND &&
item.key?.key !== QUERY_KEYS.K8S_OBJECT_NAME,
) || []),
].filter((item): item is TagFilterItem => item !== undefined),
),
items: [
podKindFilter,
podNameFilter,
...(value?.items?.filter(
(item) =>
item.key?.key !== QUERY_KEYS.K8S_OBJECT_KIND &&
item.key?.key !== QUERY_KEYS.K8S_OBJECT_NAME,
) || []),
].filter((item): item is TagFilterItem => item !== undefined),
};
setEventsFiltersParam(updatedFilters);
@@ -481,7 +390,8 @@ function K8sBaseDetails<T>({
return updatedFilters;
});
},
[eventCategory, selectedView, setEventsFiltersParam, setSelectedView],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleExplorePagesRedirect = (): void => {
@@ -496,7 +406,7 @@ function K8sBaseDetails<T>({
logEvent(InfraMonitoringEvents.ExploreClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
category: InfraMonitoringEvents.Pod,
view: selectedView,
});
@@ -566,28 +476,24 @@ function K8sBaseDetails<T>({
endTime: Math.floor(maxTime / TimeRangeOffset),
});
}
setSelectedItem(null);
setSelectedView(null);
setTracesFiltersParam(null);
setEventsFiltersParam(null);
setLogFiltersParam(null);
setSelectedView(VIEW_TYPES.METRICS);
onClose();
};
const entityName = entity ? getEntityName(entity) : '';
return (
<Drawer
width="70%"
title={
<>
<Divider type="vertical" />
<Typography.Text className="title">{entityName}</Typography.Text>
<Typography.Text className="title">
{pod?.meta.k8s_pod_name}
</Typography.Text>
</>
}
placement="right"
onClose={handleClose}
open={!!selectedItem}
open={!!pod}
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
@@ -596,157 +502,173 @@ function K8sBaseDetails<T>({
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
{isEntityLoading && <LoadingContainer />}
{isEntityError && (
<Typography.Text type="danger">
{entityResponse?.error || 'Failed to load entity details'}
</Typography.Text>
)}
{entity && !isEntityLoading && (
{pod && (
<>
<div className="entity-detail-drawer__entity">
<div className="entity-details-grid">
<div className="labels-row">
{metadataConfig.map((config) => (
<Typography.Text
key={config.label}
type="secondary"
className="entity-details-metadata-label"
>
{config.label}
</Typography.Text>
))}
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
NAMESPACE
</Typography.Text>
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
Cluster Name
</Typography.Text>
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
Node
</Typography.Text>
</div>
<div className="values-row">
{metadataConfig.map((config) => {
const value = config.getValue(entity);
return (
<Typography.Text
key={config.label}
className="entity-details-metadata-value"
>
<Tooltip title={value}>{value}</Tooltip>
</Typography.Text>
);
})}
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={pod.meta.k8s_namespace_name}>
{pod.meta.k8s_namespace_name}
</Tooltip>
</Typography.Text>
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={pod.meta.k8s_cluster_name}>
{pod.meta.k8s_cluster_name}
</Tooltip>
</Typography.Text>
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={pod.meta.k8s_node_name}>
{pod.meta.k8s_node_name}
</Tooltip>
</Typography.Text>
</div>
</div>
</div>
{!hideDetailViewTabs && (
<div className="views-tabs-container">
<Radio.Group
className="views-tabs"
onChange={handleTabChange}
value={selectedView}
<div className="views-tabs-container">
<Radio.Group
className="views-tabs"
onChange={handleTabChange}
value={selectedView}
>
<Radio.Button
className={
selectedView === VIEW_TYPES.METRICS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.METRICS}
>
<Radio.Button
className={
selectedView === VIEW_TYPES.METRICS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.METRICS}
>
<div className="view-title">
<BarChart2 size={14} />
Metrics
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.LOGS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.LOGS}
>
<div className="view-title">
<ScrollText size={14} />
Logs
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.TRACES ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.TRACES}
>
<div className="view-title">
<DraftingCompass size={14} />
Traces
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.EVENTS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.EVENTS}
>
<div className="view-title">
<ChevronsLeftRight size={14} />
Events
</div>
</Radio.Button>
</Radio.Group>
<div className="view-title">
<BarChart2 size={14} />
Metrics
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.LOGS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.LOGS}
>
<div className="view-title">
<ScrollText size={14} />
Logs
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.TRACES ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.TRACES}
>
<div className="view-title">
<DraftingCompass size={14} />
Traces
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.EVENTS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.EVENTS}
>
<div className="view-title">
<ChevronsLeftRight size={14} />
Events
</div>
</Radio.Button>
</Radio.Group>
{(selectedView === VIEW_TYPES.LOGS ||
selectedView === VIEW_TYPES.TRACES) && (
<Button
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
/>
)}
</div>
)}
{(selectedView === VIEW_TYPES.LOGS ||
selectedView === VIEW_TYPES.TRACES) && (
<Button
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
/>
)}
</div>
{effectiveView === VIEW_TYPES.METRICS && (
<EntityMetrics<T>
entity={entity}
{selectedView === VIEW_TYPES.METRICS && (
<PodMetrics<K8sPodsData>
entity={pod}
selectedInterval={selectedInterval}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
isModalTimeSelection
entityWidgetInfo={entityWidgetInfo}
getEntityQueryPayload={getEntityQueryPayload}
category={category}
queryKey={`${queryKeyPrefix}Metrics`}
isModalTimeSelection={isModalTimeSelection}
entityWidgetInfo={podWidgetInfo}
getEntityQueryPayload={getPodMetricsQueryPayload}
category={K8sCategory.PODS}
queryKey="podMetrics"
/>
)}
{effectiveView === VIEW_TYPES.LOGS && (
<EntityLogs
{selectedView === VIEW_TYPES.LOGS && (
<PodLogs
timeRange={modalTimeRange}
isModalTimeSelection
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
handleChangeLogFilters={handleChangeLogFilters}
logFilters={logsAndTracesFilters}
selectedInterval={selectedInterval}
queryKeyFilters={primaryFilterKeys}
queryKey={`${queryKeyPrefix}Logs`}
category={category}
queryKeyFilters={[
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
]}
queryKey="podLogs"
category={K8sCategory.PODS}
/>
)}
{effectiveView === VIEW_TYPES.TRACES && (
<EntityTraces
{selectedView === VIEW_TYPES.TRACES && (
<PodTraces
timeRange={modalTimeRange}
isModalTimeSelection
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
handleChangeTracesFilters={handleChangeTracesFilters}
tracesFilters={logsAndTracesFilters}
selectedInterval={selectedInterval}
queryKey={`${queryKeyPrefix}Traces`}
category={eventCategory}
queryKeyFilters={primaryFilterKeys}
queryKey="podTraces"
category={InfraMonitoringEvents.Pod}
queryKeyFilters={[
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
]}
/>
)}
{effectiveView === VIEW_TYPES.EVENTS && (
<EntityEvents
{selectedView === VIEW_TYPES.EVENTS && (
<PodEvents
timeRange={modalTimeRange}
isModalTimeSelection
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
handleChangeEventFilters={handleChangeEventsFilters}
filters={eventsFilters}
selectedInterval={selectedInterval}
category={category}
queryKey={`${queryKeyPrefix}Events`}
category={K8sCategory.PODS}
queryKey="podEvents"
/>
)}
</>
@@ -755,4 +677,4 @@ function K8sBaseDetails<T>({
);
}
export default K8sBaseDetails;
export default PodDetails;

View File

@@ -1,69 +1,11 @@
import { K8sPodsData } from 'api/infraMonitoring/getK8sPodsList';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import {
createFilterItem,
K8sDetailsMetadataConfig,
} from '../Base/K8sBaseDetails';
import { QUERY_KEYS } from '../EntityDetailsUtils/utils';
import { K8sPodsData } from './api';
export const k8sPodGetSelectedItemFilters = (
selectedItemId: string,
): TagFilter => {
return {
op: 'AND',
items: [
{
id: 'k8s_pod_uid',
key: {
key: 'k8s_pod_uid',
type: null,
},
op: '=',
value: selectedItemId,
},
],
};
};
export const k8sPodDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sPodsData>[] = [
{ label: 'NAMESPACE', getValue: (p): string => p.meta.k8s_namespace_name },
{
label: 'Cluster Name',
getValue: (p): string => p.meta.k8s_cluster_name,
},
{ label: 'Node', getValue: (p): string => p.meta.k8s_node_name },
];
export const k8sPodInitialFilters = [
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sPodInitialEventsFilter = (
pod: K8sPodsData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_OBJECT_KIND, 'Pod'),
createFilterItem(QUERY_KEYS.K8S_OBJECT_NAME, pod.meta.k8s_pod_name),
];
export const k8sPodInitialLogTracesFilter = (
pod: K8sPodsData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_POD_NAME, pod.meta.k8s_pod_name),
createFilterItem(QUERY_KEYS.K8S_NAMESPACE_NAME, pod.meta.k8s_namespace_name),
];
export const k8sPodGetEntityName = (pod: K8sPodsData): string =>
pod.meta.k8s_pod_name;
export const podWidgetInfo = [
{
title: 'CPU Usage (cores)',

View File

@@ -0,0 +1,3 @@
import PodDetails from './PodDetails';
export default PodDetails;

View File

@@ -1,11 +0,0 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}
.progressBar {
display: flex;
justify-content: start;
}

View File

@@ -1,328 +0,0 @@
import React from 'react';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { K8sRenderedRowData } from '../Base/K8sBaseList';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from '../commonUtils';
import { K8sCategory } from '../constants';
import { K8sPodsData } from './api';
import styles from './table.module.scss';
export interface K8sPodsRowData {
key: string;
podName: React.ReactNode;
podUID: string;
cpu_request: React.ReactNode;
cpu_limit: React.ReactNode;
cpu: React.ReactNode;
memory_request: React.ReactNode;
memory_limit: React.ReactNode;
memory: React.ReactNode;
restarts: React.ReactNode;
groupedByMeta?: any;
}
export const k8sPodColumns: IEntityColumn[] = [
{
label: 'Pod Group',
value: 'podGroup',
id: 'podGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Pod name',
value: 'podName',
id: 'podName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Namespace name',
value: 'namespace',
id: 'namespace',
canBeHidden: true,
defaultVisibility: false,
behavior: 'always-visible',
},
{
label: 'Node name',
value: 'node',
id: 'node',
canBeHidden: true,
defaultVisibility: false,
behavior: 'always-visible',
},
{
label: 'Cluster name',
value: 'cluster',
id: 'cluster',
canBeHidden: true,
defaultVisibility: false,
behavior: 'always-visible',
},
// TODO - Re-enable the column once backend issue is fixed
// {
// label: 'Restarts',
// value: 'restarts',
// id: 'restarts',
// canRemove: false,
// },
];
export const k8sPodColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> POD GROUP
</div>
),
dataIndex: 'podGroup',
key: 'podGroup',
ellipsis: true,
width: 180,
sorter: false,
},
{
title: <div>Pod Name</div>,
dataIndex: 'podName',
key: 'podName',
width: 180,
ellipsis: true,
sorter: false,
},
{
title: <div>CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 180,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>Namespace</div>,
dataIndex: 'namespace',
key: 'namespace',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
},
{
title: <div>Node</div>,
dataIndex: 'node',
key: 'node',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
},
{
title: <div>Cluster</div>,
dataIndex: 'cluster',
key: 'cluster',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
},
// TODO - Re-enable the column once backend issue is fixed
// {
// title: (
// <div className="column-header">
// <Tooltip title="Container Restarts">Restarts</Tooltip>
// </div>
// ),
// dataIndex: 'restarts',
// key: 'restarts',
// width: 40,
// ellipsis: true,
// sorter: true,
// align: 'left',
// className: `column ${columnProgressBarClassName}`,
// },
];
export const k8sPodRenderRowData = (
pod: K8sPodsData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
pod,
() => pod.podUID || pod.meta.k8s_pod_uid || pod.meta.k8s_pod_name,
groupBy,
),
itemKey: pod.podUID,
podName: (
<Tooltip title={pod.meta.k8s_pod_name || ''}>
{pod.meta.k8s_pod_name || ''}
</Tooltip>
),
podUID: pod.podUID || '',
cpu_request: (
<ValidateColumnValueWrapper
value={pod.podCPURequest}
entity={K8sCategory.PODS}
attribute="CPU Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podCPURequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper
value={pod.podCPULimit}
entity={K8sCategory.PODS}
attribute="CPU Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podCPULimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={pod.podCPU}>
{pod.podCPU}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper
value={pod.podMemoryRequest}
entity={K8sCategory.PODS}
attribute="Memory Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podMemoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper
value={pod.podMemoryLimit}
entity={K8sCategory.PODS}
attribute="Memory Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podMemoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={pod.podMemory}>
{formatBytes(pod.podMemory)}
</ValidateColumnValueWrapper>
),
restarts: (
<ValidateColumnValueWrapper value={pod.restartCount}>
{pod.restartCount}
</ValidateColumnValueWrapper>
),
namespace: pod.meta.k8s_namespace_name,
node: pod.meta.k8s_node_name,
cluster: pod.meta.k8s_cluster_name,
meta: pod.meta,
podGroup: getGroupByEl(pod, groupBy),
...pod.meta,
groupedByMeta: getGroupedByMeta(pod, groupBy),
});

View File

@@ -0,0 +1,45 @@
.infra-monitoring-container {
.volumes-list-table {
.expanded-table-container {
padding-left: 60px;
}
.ant-table-row-expand-icon-cell {
min-width: 30px !important;
max-width: 30px !important;
}
}
}
.ant-table-cell {
&:has(.pvc-name-header) {
min-width: 250px !important;
max-width: 250px !important;
}
}
.ant-table-cell {
&:has(.namespace-name-header) {
min-width: 220px !important;
max-width: 220px !important;
}
}
.ant-table-cell {
&:has(.small-col) {
min-width: 120px !important;
max-width: 120px !important;
}
}
.expanded-volumes-list-table {
.ant-table-cell {
min-width: 223px !important;
max-width: 223px !important;
}
.ant-table-row-expand-icon-cell {
min-width: 30px !important;
max-width: 30px !important;
}
}

View File

@@ -1,117 +1,674 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { LoadingOutlined } from '@ant-design/icons';
import {
Button,
Spin,
Table,
TableColumnType as ColumnType,
TablePaginationConfig,
TableProps,
Typography,
} from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import logEvent from 'api/common/logEvent';
import { K8sVolumesListPayload } from 'api/infraMonitoring/getK8sVolumesList';
import classNames from 'classnames';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { useGetK8sVolumesList } from 'hooks/infraMonitoring/useGetK8sVolumesList';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseFilters, K8sBaseList } from '../Base/K8sBaseList';
import { K8sCategory } from '../constants';
import { getK8sVolumesList, K8sVolumesData } from './api';
import {
getVolumeMetricsQueryPayload,
k8sVolumeDetailsMetadataConfig,
k8sVolumeGetEntityName,
k8sVolumeGetSelectedItemFilters,
k8sVolumeInitialEventsFilter,
k8sVolumeInitialFilters,
k8sVolumeInitialLogTracesFilter,
volumeWidgetInfo,
} from './constants';
GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS,
K8sCategory,
} from '../constants';
import {
k8sVolumesColumns,
k8sVolumesColumnsConfig,
k8sVolumesRenderRowData,
} from './table';
useInfraMonitoringCurrentPage,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringVolumeUID,
} from '../hooks';
import K8sHeader from '../K8sHeader';
import LoadingContainer from '../LoadingContainer';
import { usePageSize } from '../utils';
import {
defaultAddedColumns,
formatDataForTable,
getK8sVolumesListColumns,
getK8sVolumesListQuery,
getVolumeListGroupedByRowDataQueryKey,
getVolumesListQueryKey,
K8sVolumesRowData,
} from './utils';
import VolumeDetails from './VolumeDetails';
import '../InfraMonitoringK8s.styles.scss';
import './K8sVolumesList.styles.scss';
function K8sVolumesList({
controlListPrefix,
isFiltersVisible,
handleFilterVisibilityChange,
quickFiltersLastUpdated,
}: {
controlListPrefix?: React.ReactNode;
isFiltersVisible: boolean;
handleFilterVisibilityChange: () => void;
quickFiltersLastUpdated: number;
}): JSX.Element {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
const [filtersInitialised, setFiltersInitialised] = useState(false);
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [
selectedVolumeUID,
setselectedVolumeUID,
] = useInfraMonitoringVolumeUID();
const { pageSize, setPageSize } = usePageSize(K8sCategory.VOLUMES);
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const [
selectedRowData,
setSelectedRowData,
] = useState<K8sVolumesRowData | null>(null);
const [groupByOptions, setGroupByOptions] = useState<
{ value: string; label: string }[]
>([]);
const { currentQuery } = useQueryBuilder();
const queryFilters = useMemo(
() =>
currentQuery?.builder?.queryData[0]?.filters || {
items: [],
op: 'and',
},
[currentQuery?.builder?.queryData],
);
// Reset pagination every time quick filters are changed
useEffect(() => {
if (quickFiltersLastUpdated !== -1) {
setCurrentPage(1);
}
}, [quickFiltersLastUpdated, setCurrentPage]);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
filters.orderBy ||= {
columnName: 'usage',
order: 'desc',
};
const createFiltersForSelectedRowData = (
selectedRowData: K8sVolumesRowData,
groupBy: IBuilderQuery['groupBy'],
): IBuilderQuery['filters'] => {
const baseFilters: IBuilderQuery['filters'] = {
items: [...queryFilters.items],
op: 'and',
};
const response = await getK8sVolumesList(
filters,
signal,
undefined,
dotMetricsEnabled,
);
if (!selectedRowData) {
return baseFilters;
}
return {
data: response.payload?.data.records || [],
total: response.payload?.data.total || 0,
error: response.error,
};
},
[dotMetricsEnabled],
);
const { groupedByMeta } = selectedRowData;
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{ data: K8sVolumesData | null; error?: string | null }> => {
const response = await getK8sVolumesList(
{
filters: filters.filters,
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
for (const key of groupBy) {
baseFilters.items.push({
key: {
key: key.key,
type: null,
},
signal,
undefined,
dotMetricsEnabled,
);
op: '=',
value: groupedByMeta[key.key],
id: key.key,
});
}
const records = response.payload?.data.records || [];
return baseFilters;
};
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
},
[dotMetricsEnabled],
const fetchGroupedByRowDataQuery = useMemo(() => {
if (!selectedRowData) {
return null;
}
const baseQuery = getK8sVolumesListQuery();
const filters = createFiltersForSelectedRowData(selectedRowData, groupBy);
return {
...baseQuery,
limit: 10,
offset: 0,
filters,
start: Math.floor(minTime / 1000000),
end: Math.floor(maxTime / 1000000),
orderBy: orderBy || baseQuery.orderBy,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minTime, maxTime, orderBy, selectedRowData, groupBy]);
const groupedByRowDataQueryKey = useMemo(
() =>
getVolumeListGroupedByRowDataQueryKey(
selectedRowData?.groupedByMeta,
queryFilters,
orderBy,
groupBy,
minTime,
maxTime,
),
[
selectedRowData?.groupedByMeta,
queryFilters,
orderBy,
groupBy,
minTime,
maxTime,
],
);
const {
data: groupedByRowData,
isFetching: isFetchingGroupedByRowData,
isLoading: isLoadingGroupedByRowData,
isError: isErrorGroupedByRowData,
refetch: fetchGroupedByRowData,
} = useGetK8sVolumesList(
fetchGroupedByRowDataQuery as K8sVolumesListPayload,
{
queryKey: groupedByRowDataQueryKey,
enabled: !!fetchGroupedByRowDataQuery && !!selectedRowData,
},
undefined,
dotMetricsEnabled,
);
const {
data: groupByFiltersData,
isLoading: isLoadingGroupByFilters,
} = useGetAggregateKeys(
{
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateAttribute: GetK8sEntityToAggregateAttribute(
K8sCategory.VOLUMES,
dotMetricsEnabled,
),
aggregateOperator: 'noop',
searchText: '',
tagType: '',
},
{
queryKey: [currentQuery.builder.queryData[0].dataSource, 'noop'],
},
true,
K8sCategory.VOLUMES,
);
const query = useMemo(() => {
const baseQuery = getK8sVolumesListQuery();
const queryPayload = {
...baseQuery,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: queryFilters,
start: Math.floor(minTime / 1000000),
end: Math.floor(maxTime / 1000000),
orderBy: orderBy || baseQuery.orderBy,
};
if (groupBy.length > 0) {
queryPayload.groupBy = groupBy;
}
return queryPayload;
}, [pageSize, currentPage, queryFilters, minTime, maxTime, orderBy, groupBy]);
const volumesListQueryKey = useMemo(() => {
return getVolumesListQueryKey(
selectedVolumeUID,
pageSize,
currentPage,
queryFilters,
orderBy,
groupBy,
minTime,
maxTime,
);
}, [
selectedVolumeUID,
pageSize,
currentPage,
queryFilters,
groupBy,
orderBy,
minTime,
maxTime,
]);
const formattedGroupedByVolumesData = useMemo(
() =>
formatDataForTable(groupedByRowData?.payload?.data?.records || [], groupBy),
[groupedByRowData, groupBy],
);
const nestedVolumesData = useMemo(() => {
if (!selectedRowData || !groupedByRowData?.payload?.data.records) {
return [];
}
return groupedByRowData?.payload?.data?.records || [];
}, [groupedByRowData, selectedRowData]);
const { data, isFetching, isLoading, isError } = useGetK8sVolumesList(
query as K8sVolumesListPayload,
{
queryKey: volumesListQueryKey,
enabled: !!query,
},
undefined,
dotMetricsEnabled,
);
const volumesData = useMemo(() => data?.payload?.data?.records || [], [data]);
const totalCount = data?.payload?.data?.total || 0;
const formattedVolumesData = useMemo(
() => formatDataForTable(volumesData, groupBy),
[volumesData, groupBy],
);
const columns = useMemo(() => getK8sVolumesListColumns(groupBy), [groupBy]);
const handleGroupByRowClick = (record: K8sVolumesRowData): void => {
setSelectedRowData(record);
if (expandedRowKeys.includes(record.key)) {
setExpandedRowKeys(expandedRowKeys.filter((key) => key !== record.key));
} else {
setExpandedRowKeys([record.key]);
}
};
useEffect(() => {
if (selectedRowData) {
fetchGroupedByRowData();
}
}, [selectedRowData, fetchGroupedByRowData]);
const handleTableChange: TableProps<K8sVolumesRowData>['onChange'] = useCallback(
(
pagination: TablePaginationConfig,
_filters: Record<string, (string | number | boolean)[] | null>,
sorter: SorterResult<K8sVolumesRowData> | SorterResult<K8sVolumesRowData>[],
): void => {
if (pagination.current) {
setCurrentPage(pagination.current);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Volumes,
});
}
if ('field' in sorter && sorter.order) {
setOrderBy({
columnName: sorter.field as string,
order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc',
});
} else {
setOrderBy(null);
}
},
[setCurrentPage, setOrderBy],
);
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const handleFiltersChange = useCallback(
(value: IBuilderQuery['filters']): void => {
handleChangeQueryData('filters', value);
if (filtersInitialised) {
setCurrentPage(1);
} else {
setFiltersInitialised(true);
}
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Volumes,
});
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
useEffect(() => {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Volumes,
total: data?.payload?.data?.total,
});
}, [data?.payload?.data?.total]);
const selectedVolumeData = useMemo(() => {
if (!selectedVolumeUID) {
return null;
}
if (groupBy.length > 0) {
return (
nestedVolumesData.find(
(volume) => volume.persistentVolumeClaimName === selectedVolumeUID,
) || null
);
}
return (
volumesData.find(
(volume) => volume.persistentVolumeClaimName === selectedVolumeUID,
) || null
);
}, [selectedVolumeUID, volumesData, groupBy.length, nestedVolumesData]);
const openVolumeInNewTab = (record: K8sVolumesRowData): void => {
const newParams = new URLSearchParams(document.location.search);
newParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.VOLUME_UID, record.volumeUID);
openInNewTab(
buildAbsolutePath({
relativePath: '',
urlQueryString: newParams.toString(),
}),
);
};
const handleRowClick = (
record: K8sVolumesRowData,
event: React.MouseEvent,
): void => {
if (event && isModifierKeyPressed(event)) {
openVolumeInNewTab(record);
return;
}
if (groupBy.length === 0) {
setSelectedRowData(null);
setselectedVolumeUID(record.volumeUID);
} else {
handleGroupByRowClick(record);
}
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Volumes,
});
};
const nestedColumns = useMemo(() => getK8sVolumesListColumns([]), []);
const isGroupedByAttribute = groupBy.length > 0;
const handleExpandedRowViewAllClick = (): void => {
if (!selectedRowData) {
return;
}
const filters = createFiltersForSelectedRowData(selectedRowData, groupBy);
handleFiltersChange(filters);
setCurrentPage(1);
setSelectedRowData(null);
setGroupBy([]);
setOrderBy(null);
};
const expandedRowRender = (): JSX.Element => (
<div className="expanded-table-container">
{isErrorGroupedByRowData && (
<Typography>{groupedByRowData?.error || 'Something went wrong'}</Typography>
)}
{isFetchingGroupedByRowData || isLoadingGroupedByRowData ? (
<LoadingContainer />
) : (
<div className="expanded-table">
<Table
columns={nestedColumns as ColumnType<K8sVolumesRowData>[]}
dataSource={formattedGroupedByVolumesData}
pagination={false}
scroll={{ x: true }}
tableLayout="fixed"
size="small"
loading={{
spinning: isFetchingGroupedByRowData || isLoadingGroupedByRowData,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
showHeader={false}
onRow={(
record,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => {
if (event && isModifierKeyPressed(event)) {
openVolumeInNewTab(record);
return;
}
setselectedVolumeUID(record.volumeUID);
},
className: 'expanded-clickable-row',
})}
/>
{groupedByRowData?.payload?.data?.total &&
groupedByRowData?.payload?.data?.total > 10 ? (
<div className="expanded-table-footer">
<Button
type="default"
size="small"
className="periscope-btn secondary"
onClick={handleExpandedRowViewAllClick}
>
View All
</Button>
</div>
) : null}
</div>
)}
</div>
);
const expandRowIconRenderer = ({
expanded,
onExpand,
record,
}: {
expanded: boolean;
onExpand: (
record: K8sVolumesRowData,
e: React.MouseEvent<HTMLButtonElement>,
) => void;
record: K8sVolumesRowData;
}): JSX.Element | null => {
if (!isGroupedByAttribute) {
return null;
}
return expanded ? (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronDown size={14} />
</Button>
) : (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronRight size={14} />
</Button>
);
};
const handleCloseVolumeDetail = (): void => {
setselectedVolumeUID(null);
};
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
const groupBy = [];
for (let index = 0; index < value.length; index++) {
const element = (value[index] as unknown) as string;
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(key) => key.key === element,
);
if (key) {
groupBy.push(key);
}
}
setCurrentPage(1);
setGroupBy(groupBy);
setExpandedRowKeys([]);
logEvent(InfraMonitoringEvents.GroupByChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Volumes,
});
},
[groupByFiltersData?.payload?.attributeKeys, setCurrentPage, setGroupBy],
);
useEffect(() => {
if (groupByFiltersData?.payload) {
setGroupByOptions(
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
);
}
}, [groupByFiltersData]);
const onPaginationChange = (page: number, pageSize: number): void => {
setCurrentPage(page);
setPageSize(pageSize);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Volumes,
});
};
const showTableLoadingState =
(isFetching || isLoading) && formattedVolumesData.length === 0;
return (
<>
<K8sBaseList<K8sVolumesData>
controlListPrefix={controlListPrefix}
<div className="k8s-list">
<K8sHeader
isFiltersVisible={isFiltersVisible}
handleFilterVisibilityChange={handleFilterVisibilityChange}
defaultAddedColumns={defaultAddedColumns}
handleFiltersChange={handleFiltersChange}
groupByOptions={groupByOptions}
isLoadingGroupByFilters={isLoadingGroupByFilters}
handleGroupByChange={handleGroupByChange}
selectedGroupBy={groupBy}
entity={K8sCategory.VOLUMES}
tableColumnsDefinitions={k8sVolumesColumns}
tableColumns={k8sVolumesColumnsConfig}
fetchListData={fetchListData}
renderRowData={k8sVolumesRenderRowData}
eventCategory={InfraMonitoringEvents.Volumes}
showAutoRefresh={!selectedVolumeData}
/>
{isError && <Typography>{data?.error || 'Something went wrong'}</Typography>}
<Table
className={classNames('k8s-list-table', 'volumes-list-table', {
'expanded-volumes-list-table': isGroupedByAttribute,
})}
dataSource={showTableLoadingState ? [] : formattedVolumesData}
columns={columns}
pagination={{
current: currentPage,
pageSize,
total: totalCount,
showSizeChanger: true,
hideOnSinglePage: false,
onChange: onPaginationChange,
}}
scroll={{ x: true }}
loading={{
spinning: showTableLoadingState,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText: showTableLoadingState ? null : (
<div className="no-filtered-hosts-message-container">
<div className="no-filtered-hosts-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-filtered-hosts-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
),
}}
tableLayout="fixed"
onChange={handleTableChange}
onRow={(
record,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => handleRowClick(record, event),
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,
expandIcon: expandRowIconRenderer,
expandedRowKeys,
}}
/>
<K8sBaseDetails<K8sVolumesData>
category={K8sCategory.VOLUMES}
eventCategory={InfraMonitoringEvents.Volume}
getSelectedItemFilters={k8sVolumeGetSelectedItemFilters}
fetchEntityData={fetchEntityData}
getEntityName={k8sVolumeGetEntityName}
getInitialLogTracesFilters={k8sVolumeInitialLogTracesFilter}
getInitialEventsFilters={k8sVolumeInitialEventsFilter}
primaryFilterKeys={k8sVolumeInitialFilters}
metadataConfig={k8sVolumeDetailsMetadataConfig}
entityWidgetInfo={volumeWidgetInfo}
getEntityQueryPayload={getVolumeMetricsQueryPayload}
queryKeyPrefix="volume"
hideDetailViewTabs
<VolumeDetails
volume={selectedVolumeData}
isModalTimeSelection
onClose={handleCloseVolumeDetail}
/>
</>
</div>
);
}

View File

@@ -0,0 +1,208 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Divider, Drawer, Tooltip, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { InfraMonitoringEvents } from 'constants/events';
import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import GetMinMax from 'lib/getMinMax';
import { X } from 'lucide-react';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import VolumeMetrics from '../../EntityDetailsUtils/EntityMetrics';
import { getVolumeQueryPayload, volumeWidgetInfo } from './constants';
import { VolumeDetailsProps } from './VoumeDetails.interfaces';
import '../../EntityDetailsUtils/entityDetails.styles.scss';
function VolumeDetails({
volume,
onClose,
isModalTimeSelection,
}: VolumeDetailsProps): JSX.Element {
const { maxTime, minTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const startMs = useMemo(() => Math.floor(Number(minTime) / 1000000000), [
minTime,
]);
const endMs = useMemo(() => Math.floor(Number(maxTime) / 1000000000), [
maxTime,
]);
const [modalTimeRange, setModalTimeRange] = useState(() => ({
startTime: startMs,
endTime: endMs,
}));
const lastSelectedInterval = useRef<Time | null>(null);
const [selectedInterval, setSelectedInterval] = useState<Time>(
lastSelectedInterval.current
? lastSelectedInterval.current
: (selectedTime as Time),
);
const isDarkMode = useIsDarkMode();
useEffect(() => {
if (volume) {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.Volume,
});
}
}, [volume]);
useEffect(() => {
const currentSelectedInterval = lastSelectedInterval.current || selectedTime;
setSelectedInterval(currentSelectedInterval as Time);
if (currentSelectedInterval !== 'custom') {
const { maxTime, minTime } = GetMinMax(currentSelectedInterval);
setModalTimeRange({
startTime: Math.floor(minTime / 1000000000),
endTime: Math.floor(maxTime / 1000000000),
});
}
}, [selectedTime, minTime, maxTime]);
const handleTimeChange = useCallback(
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
lastSelectedInterval.current = interval as Time;
setSelectedInterval(interval as Time);
if (interval === 'custom' && dateTimeRange) {
setModalTimeRange({
startTime: Math.floor(dateTimeRange[0] / 1000),
endTime: Math.floor(dateTimeRange[1] / 1000),
});
} else {
const { maxTime, minTime } = GetMinMax(interval);
setModalTimeRange({
startTime: Math.floor(minTime / 1000000000),
endTime: Math.floor(maxTime / 1000000000),
});
}
logEvent(InfraMonitoringEvents.TimeUpdated, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.Volume,
interval,
});
},
[],
);
const handleClose = (): void => {
lastSelectedInterval.current = null;
setSelectedInterval(selectedTime as Time);
if (selectedTime !== 'custom') {
const { maxTime, minTime } = GetMinMax(selectedTime);
setModalTimeRange({
startTime: Math.floor(minTime / 1000000000),
endTime: Math.floor(maxTime / 1000000000),
});
}
onClose();
};
return (
<Drawer
width="70%"
title={
<>
<Divider type="vertical" />
<Typography.Text className="title">
{volume?.persistentVolumeClaimName}
</Typography.Text>
</>
}
placement="right"
onClose={handleClose}
open={!!volume}
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
}}
className="entity-detail-drawer"
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
{volume && (
<>
<div className="entity-detail-drawer__entity">
<div className="entity-details-grid">
<div className="labels-row">
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
PVC Name
</Typography.Text>
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
Cluster Name
</Typography.Text>
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
Namespace Name
</Typography.Text>
</div>
<div className="values-row">
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={volume.persistentVolumeClaimName}>
{volume.persistentVolumeClaimName}
</Tooltip>
</Typography.Text>
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={volume.meta.k8s_cluster_name}>
{volume.meta.k8s_cluster_name}
</Tooltip>
</Typography.Text>
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={volume.meta.k8s_namespace_name}>
{volume.meta.k8s_namespace_name}
</Tooltip>
</Typography.Text>
</div>
</div>
</div>
<VolumeMetrics
timeRange={modalTimeRange}
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
selectedInterval={selectedInterval}
entity={volume}
entityWidgetInfo={volumeWidgetInfo}
getEntityQueryPayload={getVolumeQueryPayload}
queryKey="volumeMetrics"
category={K8sCategory.VOLUMES}
/>
</>
)}
</Drawer>
);
}
export default VolumeDetails;

View File

@@ -0,0 +1,7 @@
import { K8sVolumesData } from 'api/infraMonitoring/getK8sVolumesList';
export type VolumeDetailsProps = {
volume: K8sVolumesData | null;
isModalTimeSelection: boolean;
onClose: () => void;
};

View File

@@ -1,75 +1,11 @@
import { K8sVolumesData } from 'api/infraMonitoring/getK8sVolumesList';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import {
createFilterItem,
K8sDetailsMetadataConfig,
} from '../Base/K8sBaseDetails';
import { QUERY_KEYS } from '../EntityDetailsUtils/utils';
import { K8sVolumesData } from './api';
export const k8sVolumeGetSelectedItemFilters = (
selectedItemId: string,
): TagFilter => ({
op: 'AND',
items: [
{
id: 'k8s_persistentvolumeclaim_name',
key: {
key: 'k8s_persistentvolumeclaim_name',
type: null,
},
op: '=',
value: selectedItemId,
},
],
});
export const k8sVolumeDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sVolumesData>[] = [
{
label: 'PVC Name',
getValue: (p): string => p.persistentVolumeClaimName,
},
{
label: 'Cluster Name',
getValue: (p): string => p.meta.k8s_cluster_name,
},
{
label: 'Namespace Name',
getValue: (p): string => p.meta.k8s_namespace_name,
},
];
export const k8sVolumeInitialFilters = [
QUERY_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sVolumeInitialEventsFilter = (
item: K8sVolumesData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_OBJECT_KIND, 'PersistentVolumeClaim'),
createFilterItem(QUERY_KEYS.K8S_OBJECT_NAME, item.persistentVolumeClaimName),
];
export const k8sVolumeInitialLogTracesFilter = (
item: K8sVolumesData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(
QUERY_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
item.meta.k8s_persistentvolumeclaim_name,
),
createFilterItem(QUERY_KEYS.K8S_NAMESPACE_NAME, item.meta.k8s_namespace_name),
];
export const k8sVolumeGetEntityName = (item: K8sVolumesData): string =>
item.persistentVolumeClaimName;
export const volumeWidgetInfo = [
{
title: 'Volume available',
@@ -93,7 +29,7 @@ export const volumeWidgetInfo = [
},
];
export const getVolumeMetricsQueryPayload = (
export const getVolumeQueryPayload = (
volume: K8sVolumesData,
start: number,
end: number,

View File

@@ -0,0 +1,3 @@
import VolumeDetails from './VolumeDetails';
export default VolumeDetails;

View File

@@ -1,6 +0,0 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}

View File

@@ -1,164 +0,0 @@
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { K8sRenderedRowData } from '../Base/K8sBaseList';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import { formatBytes, ValidateColumnValueWrapper } from '../commonUtils';
import { K8sVolumesData } from './api';
import styles from './table.module.scss';
export const k8sVolumesColumns: IEntityColumn[] = [
{
label: 'Volume Group',
value: 'volumeGroup',
id: 'volumeGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'PVC Name',
value: 'pvcName',
id: 'pvcName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Volume Capacity',
value: 'capacity',
id: 'capacity',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Volume Utilization',
value: 'usage',
id: 'usage',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Volume Available',
value: 'available',
id: 'available',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const k8sVolumesColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> VOLUME GROUP
</div>
),
dataIndex: 'volumeGroup',
key: 'volumeGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
title: <div>PVC Name</div>,
dataIndex: 'pvcName',
key: 'pvcName',
ellipsis: true,
width: 120,
sorter: false,
align: 'left',
},
{
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 120,
sorter: false,
align: 'left',
},
{
title: <div>Volume Capacity</div>,
dataIndex: 'capacity',
key: 'capacity',
ellipsis: true,
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Volume Utilization</div>,
dataIndex: 'usage',
key: 'usage',
width: 100,
sorter: true,
align: 'left',
},
{
title: <div>Volume Available</div>,
dataIndex: 'available',
key: 'available',
width: 80,
sorter: true,
align: 'left',
},
];
export const k8sVolumesRenderRowData = (
volume: K8sVolumesData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
volume,
() =>
volume.persistentVolumeClaimName ||
volume.meta.k8s_persistentvolumeclaim_name ||
'',
groupBy,
),
itemKey: volume.persistentVolumeClaimName,
pvcName: (
<Tooltip title={volume.persistentVolumeClaimName}>
{volume.persistentVolumeClaimName || ''}
</Tooltip>
),
namespaceName: (
<Tooltip title={volume.meta.k8s_namespace_name}>
{volume.meta.k8s_namespace_name || ''}
</Tooltip>
),
available: (
<ValidateColumnValueWrapper value={volume.volumeAvailable}>
{formatBytes(volume.volumeAvailable)}
</ValidateColumnValueWrapper>
),
capacity: (
<ValidateColumnValueWrapper value={volume.volumeCapacity}>
{formatBytes(volume.volumeCapacity)}
</ValidateColumnValueWrapper>
),
usage: (
<ValidateColumnValueWrapper value={volume.volumeUsage}>
{formatBytes(volume.volumeUsage)}
</ValidateColumnValueWrapper>
),
volumeGroup: getGroupByEl(volume, groupBy),
...volume.meta,
groupedByMeta: getGroupedByMeta(volume, groupBy),
});

View File

@@ -0,0 +1,285 @@
import { Color } from '@signozhq/design-tokens';
import { TableColumnType as ColumnType, Tag, Tooltip } from 'antd';
import {
K8sVolumesData,
K8sVolumesListPayload,
} from 'api/infraMonitoring/getK8sVolumesList';
import { Group } from 'lucide-react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { formatBytes, ValidateColumnValueWrapper } from '../commonUtils';
import { IEntityColumn } from '../utils';
export const defaultAddedColumns: IEntityColumn[] = [
{
label: 'PVC Name',
value: 'pvcName',
id: 'pvcName',
canRemove: false,
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
canRemove: false,
},
{
label: 'Volume Capacity',
value: 'capacity',
id: 'capacity',
canRemove: false,
},
{
label: 'Volume Utilization',
value: 'usage',
id: 'usage',
canRemove: false,
},
{
label: 'Volume Available',
value: 'available',
id: 'available',
canRemove: false,
},
];
export interface K8sVolumesRowData {
key: string;
volumeUID: string;
pvcName: React.ReactNode;
namespaceName: React.ReactNode;
capacity: React.ReactNode;
usage: React.ReactNode;
available: React.ReactNode;
groupedByMeta?: any;
}
const volumeGroupColumnConfig = {
title: (
<div className="column-header entity-group-header">
<Group size={14} /> VOLUME GROUP
</div>
),
dataIndex: 'volumeGroup',
key: 'volumeGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
className: 'column entity-group-header',
};
export const getK8sVolumesListQuery = (): K8sVolumesListPayload => ({
filters: {
items: [],
op: 'and',
},
orderBy: { columnName: 'usage', order: 'desc' },
});
export const getVolumeListGroupedByRowDataQueryKey = (
groupedByMeta: K8sVolumesData['meta'] | undefined,
queryFilters: IBuilderQuery['filters'],
orderBy: { columnName: string; order: 'asc' | 'desc' } | null,
groupBy: IBuilderQuery['groupBy'],
minTime: number,
maxTime: number,
): (string | undefined)[] => {
// When we have grouped by metadata defined
// We need to leave out the min/max time
// Otherwise it will cause a loop
const groupedByMetaStr = JSON.stringify(groupedByMeta || undefined) ?? '';
if (groupedByMetaStr) {
return [
'volumeList',
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
groupedByMetaStr,
];
}
return [
'volumeList',
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
groupedByMetaStr,
String(minTime),
String(maxTime),
];
};
export const getVolumesListQueryKey = (
selectedVolumeUID: string | null,
pageSize: number,
currentPage: number,
queryFilters: IBuilderQuery['filters'],
orderBy: { columnName: string; order: 'asc' | 'desc' } | null,
groupBy: IBuilderQuery['groupBy'],
minTime: number,
maxTime: number,
): (string | undefined)[] => {
// When selected volume is defined
// We need to leave out the min/max time
// Otherwise it will cause a loop
if (selectedVolumeUID) {
return [
'volumeList',
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
];
}
return [
'volumeList',
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
String(minTime),
String(maxTime),
];
};
const columnsConfig = [
{
title: <div className="column-header-left pvc-name-header">PVC Name</div>,
dataIndex: 'pvcName',
key: 'pvcName',
ellipsis: true,
width: 120,
sorter: false,
align: 'left',
},
{
title: (
<div className="column-header-left namespace-name-header">
Namespace Name
</div>
),
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 120,
sorter: false,
align: 'left',
},
{
title: <div className="column-header-left small-col">Volume Capacity</div>,
dataIndex: 'capacity',
key: 'capacity',
ellipsis: true,
width: 120,
sorter: true,
align: 'left',
},
{
title: <div className="column-header-left small-col">Volume Utilization</div>,
dataIndex: 'usage',
key: 'usage',
width: 100,
sorter: true,
align: 'left',
},
{
title: <div className="column-header-left small-col">Volume Available</div>,
dataIndex: 'available',
key: 'available',
width: 80,
sorter: true,
align: 'left',
},
];
export const getK8sVolumesListColumns = (
groupBy: IBuilderQuery['groupBy'],
): ColumnType<K8sVolumesRowData>[] => {
if (groupBy.length > 0) {
const filteredColumns = [...columnsConfig].filter(
(column) => column.key !== 'pvcName',
);
filteredColumns.unshift(volumeGroupColumnConfig);
return filteredColumns as ColumnType<K8sVolumesRowData>[];
}
return columnsConfig as ColumnType<K8sVolumesRowData>[];
};
const dotToUnder: Record<string, keyof K8sVolumesData['meta']> = {
'k8s.namespace.name': 'k8s_namespace_name',
'k8s.node.name': 'k8s_node_name',
'k8s.pod.name': 'k8s_pod_name',
'k8s.pod.uid': 'k8s_pod_uid',
'k8s.statefulset.name': 'k8s_statefulset_name',
'k8s.cluster.name': 'k8s_cluster_name',
'k8s.persistentvolumeclaim.name': 'k8s_persistentvolumeclaim_name',
};
const getGroupByEle = (
volume: K8sVolumesData,
groupBy: IBuilderQuery['groupBy'],
): React.ReactNode => {
const groupByValues: string[] = [];
groupBy.forEach((group) => {
const rawKey = group.key as string;
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof volume.meta;
const value = volume.meta[metaKey];
groupByValues.push(value);
});
return (
<div className="pod-group">
{groupByValues.map((value) => (
<Tag key={value} color={Color.BG_SLATE_400} className="pod-group-tag-item">
{value === '' ? '<no-value>' : value}
</Tag>
))}
</div>
);
};
export const formatDataForTable = (
data: K8sVolumesData[],
groupBy: IBuilderQuery['groupBy'],
): K8sVolumesRowData[] =>
data.map((volume, index) => ({
key: index.toString(),
volumeUID: volume.persistentVolumeClaimName,
pvcName: (
<Tooltip title={volume.persistentVolumeClaimName}>
{volume.persistentVolumeClaimName || ''}
</Tooltip>
),
namespaceName: (
<Tooltip title={volume.meta.k8s_namespace_name}>
{volume.meta.k8s_namespace_name || ''}
</Tooltip>
),
available: (
<ValidateColumnValueWrapper value={volume.volumeAvailable}>
{formatBytes(volume.volumeAvailable)}
</ValidateColumnValueWrapper>
),
capacity: (
<ValidateColumnValueWrapper value={volume.volumeCapacity}>
{formatBytes(volume.volumeCapacity)}
</ValidateColumnValueWrapper>
),
usage: (
<ValidateColumnValueWrapper value={volume.volumeUsage}>
{formatBytes(volume.volumeUsage)}
</ValidateColumnValueWrapper>
),
volumeGroup: getGroupByEle(volume, groupBy),
meta: volume.meta,
...volume.meta,
groupedByMeta: volume.meta,
}));

View File

@@ -0,0 +1,155 @@
/**
* Tests for URL parameter parsing in K8s Infra Monitoring components.
*
* These tests verify the fix for the double URL decoding bug where
* components were calling decodeURIComponent() on values already
* decoded by URLSearchParams.get(), causing crashes on K8s parameters
* with special characters.
*/
import { getFiltersFromParams } from '../../commonUtils';
describe('K8sPodsList URL Parameter Parsing', () => {
describe('getFiltersFromParams', () => {
it('should return null when no filters in params', () => {
const searchParams = new URLSearchParams();
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toBeNull();
});
it('should parse filters from URL params', () => {
const filters = {
items: [
{
id: '1',
key: { key: 'k8s_namespace_name' },
op: '=',
value: 'default',
},
],
op: 'AND',
};
const searchParams = new URLSearchParams();
searchParams.set('filters', JSON.stringify(filters));
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toEqual(filters);
});
it('should handle URL-encoded filters (auto-decoded by URLSearchParams)', () => {
const filters = {
items: [
{
id: '1',
key: { key: 'k8s_pod_name' },
op: 'contains',
value: 'api-server',
},
],
op: 'AND',
};
const encodedValue = encodeURIComponent(JSON.stringify(filters));
const searchParams = new URLSearchParams(`filters=${encodedValue}`);
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toEqual(filters);
});
it('should return null on malformed JSON instead of crashing', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const searchParams = new URLSearchParams();
searchParams.set('filters', '{invalid-json}');
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toBeNull();
consoleSpy.mockRestore();
});
it('should handle filters with K8s container image names', () => {
const filters = {
items: [
{
id: '1',
key: { key: 'k8s_container_name' },
op: '=',
value: 'registry.k8s.io/coredns/coredns:v1.10.1',
},
],
op: 'AND',
};
const encodedValue = encodeURIComponent(JSON.stringify(filters));
const searchParams = new URLSearchParams(`filters=${encodedValue}`);
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toEqual(filters);
});
});
describe('regression: double decoding issue', () => {
it('should not crash when URL params are already decoded by URLSearchParams', () => {
// The key bug: URLSearchParams.get() auto-decodes, so encoding once in URL
// means .get() returns decoded value. Old code called decodeURIComponent()
// again which could crash on certain characters.
const filters = {
items: [
{
id: '1',
key: { key: 'k8s_namespace_name' },
op: '=',
value: 'kube-system',
},
],
op: 'AND',
};
const encodedValue = encodeURIComponent(JSON.stringify(filters));
const searchParams = new URLSearchParams(`filters=${encodedValue}`);
// This should work without crashing
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toEqual(filters);
});
it('should handle values with percent signs in labels', () => {
// K8s labels might contain literal "%" characters like "cpu-usage-50%"
const filters = {
items: [
{
id: '1',
key: { key: 'k8s_label' },
op: '=',
value: 'cpu-50%',
},
],
op: 'AND',
};
const encodedValue = encodeURIComponent(JSON.stringify(filters));
const searchParams = new URLSearchParams(`filters=${encodedValue}`);
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toEqual(filters);
});
it('should handle complex K8s deployment names with special chars', () => {
const filters = {
items: [
{
id: '1',
key: { key: 'k8s_deployment_name' },
op: '=',
value: 'nginx-ingress-controller',
},
],
op: 'AND',
};
const encodedValue = encodeURIComponent(JSON.stringify(filters));
const searchParams = new URLSearchParams(`filters=${encodedValue}`);
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toEqual(filters);
});
});
});

View File

@@ -0,0 +1,134 @@
import setupCommonMocks from '../../commonMocks';
setupCommonMocks();
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import PodDetails from 'container/InfraMonitoringK8s/Pods/PodDetails/PodDetails';
import { withNuqsTestingAdapter } from 'nuqs/adapters/testing';
import store from 'store';
const queryClient = new QueryClient();
const Wrapper = withNuqsTestingAdapter({ searchParams: {} });
describe('PodDetails', () => {
const mockPod = {
podName: 'test-pod',
meta: {
k8s_cluster_name: 'test-cluster',
k8s_namespace_name: 'test-namespace',
k8s_node_name: 'test-node',
},
} as any;
const mockOnClose = jest.fn();
it('should render modal with relevant metadata', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<PodDetails pod={mockPod} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const clusterNameElements = screen.getAllByText('test-cluster');
expect(clusterNameElements.length).toBeGreaterThan(0);
expect(clusterNameElements[0]).toBeInTheDocument();
const namespaceNameElements = screen.getAllByText('test-namespace');
expect(namespaceNameElements.length).toBeGreaterThan(0);
expect(namespaceNameElements[0]).toBeInTheDocument();
const nodeNameElements = screen.getAllByText('test-node');
expect(nodeNameElements.length).toBeGreaterThan(0);
expect(nodeNameElements[0]).toBeInTheDocument();
});
it('should render modal with 4 tabs', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<PodDetails pod={mockPod} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const metricsTab = screen.getByText('Metrics');
expect(metricsTab).toBeInTheDocument();
const eventsTab = screen.getByText('Events');
expect(eventsTab).toBeInTheDocument();
const logsTab = screen.getByText('Logs');
expect(logsTab).toBeInTheDocument();
const tracesTab = screen.getByText('Traces');
expect(tracesTab).toBeInTheDocument();
});
it('default tab should be metrics', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<PodDetails pod={mockPod} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
expect(metricsTab).toBeChecked();
});
it('should switch to events tab when events tab is clicked', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<PodDetails pod={mockPod} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const eventsTab = screen.getByRole('radio', { name: 'Events' });
expect(eventsTab).not.toBeChecked();
fireEvent.click(eventsTab);
expect(eventsTab).toBeChecked();
});
it('should close modal when close button is clicked', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<PodDetails pod={mockPod} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const closeButton = screen.getByRole('button', { name: 'Close' });
fireEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,390 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { MemoryRouter } from 'react-router-dom-v5-compat';
import { FeatureKeys } from 'constants/features';
import K8sVolumesList from 'container/InfraMonitoringK8s/Volumes/K8sVolumesList';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { IAppContext, IUser } from 'providers/App/types';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
// eslint-disable-next-line no-restricted-imports
import { applyMiddleware, legacy_createStore as createStore } from 'redux';
import thunk from 'redux-thunk';
import reducers from 'store/reducers';
import { act, render, screen, userEvent, waitFor } from 'tests/test-utils';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { LicenseResModel } from 'types/api/licensesV3/getActive';
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../../constants';
const SERVER_URL = 'http://localhost/api';
// jsdom does not implement IntersectionObserver — provide a no-op stub
const mockObserver = {
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
};
global.IntersectionObserver = jest
.fn()
.mockImplementation(() => mockObserver) as any;
const mockVolume = {
persistentVolumeClaimName: 'test-pvc',
volumeAvailable: 1000000,
volumeCapacity: 5000000,
volumeInodes: 100,
volumeInodesFree: 50,
volumeInodesUsed: 50,
volumeUsage: 4000000,
meta: {
k8s_cluster_name: 'test-cluster',
k8s_namespace_name: 'test-namespace',
k8s_node_name: 'test-node',
k8s_persistentvolumeclaim_name: 'test-pvc',
k8s_pod_name: 'test-pod',
k8s_pod_uid: 'test-pod-uid',
k8s_statefulset_name: '',
},
};
const mockVolumesResponse = {
status: 'success',
data: {
type: '',
records: [mockVolume],
groups: null,
total: 1,
sentAnyHostMetricsData: false,
isSendingK8SAgentMetrics: false,
},
};
/** Renders K8sVolumesList with a real Redux store so dispatched actions affect state. */
function renderWithRealStore(
initialEntries?: Record<string, any>,
): { testStore: ReturnType<typeof createStore> } {
const testStore = createStore(reducers, applyMiddleware(thunk as any));
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<NuqsTestingAdapter searchParams={initialEntries}>
<QueryClientProvider client={queryClient}>
<QueryBuilderProvider>
<MemoryRouter>
<K8sVolumesList
isFiltersVisible={false}
handleFilterVisibilityChange={jest.fn()}
quickFiltersLastUpdated={-1}
/>
</MemoryRouter>
</QueryBuilderProvider>
</QueryClientProvider>
</NuqsTestingAdapter>,
);
return { testStore };
}
describe('K8sVolumesList - useGetAggregateKeys Category Regression', () => {
let requestsMade: Array<{
url: string;
params: URLSearchParams;
body?: any;
}> = [];
beforeEach(() => {
requestsMade = [];
server.use(
rest.get(`${SERVER_URL}/v3/autocomplete/attribute_keys`, (req, res, ctx) => {
const url = req.url.toString();
const params = req.url.searchParams;
requestsMade.push({
url,
params,
});
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
attributeKeys: [],
},
}),
);
}),
rest.post(`${SERVER_URL}/v1/pvcs/list`, (req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
type: 'list',
records: [],
groups: null,
total: 0,
sentAnyHostMetricsData: false,
isSendingK8SAgentMetrics: false,
},
}),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('should call aggregate keys API with k8s_volume_capacity', async () => {
renderWithRealStore();
await waitFor(() => {
expect(requestsMade.length).toBeGreaterThan(0);
});
// Find the attribute_keys request
const attributeKeysRequest = requestsMade.find((req) =>
req.url.includes('/autocomplete/attribute_keys'),
);
expect(attributeKeysRequest).toBeDefined();
const aggregateAttribute = attributeKeysRequest?.params.get(
'aggregateAttribute',
);
expect(aggregateAttribute).toBe('k8s_volume_capacity');
});
it('should call aggregate keys API with k8s.volume.capacity when dotMetrics enabled', async () => {
jest
.spyOn(await import('providers/App/App'), 'useAppContext')
.mockReturnValue({
featureFlags: [
{
name: FeatureKeys.DOT_METRICS_ENABLED,
active: true,
usage: 0,
usage_limit: 0,
route: '',
},
],
user: { role: 'ADMIN' } as IUser,
activeLicense: (null as unknown) as LicenseResModel,
} as IAppContext);
renderWithRealStore();
await waitFor(() => {
expect(requestsMade.length).toBeGreaterThan(0);
});
const attributeKeysRequest = requestsMade.find((req) =>
req.url.includes('/autocomplete/attribute_keys'),
);
expect(attributeKeysRequest).toBeDefined();
const aggregateAttribute = attributeKeysRequest?.params.get(
'aggregateAttribute',
);
expect(aggregateAttribute).toBe('k8s.volume.capacity');
});
});
describe('K8sVolumesList', () => {
beforeEach(() => {
server.use(
rest.post('http://localhost/api/v1/pvcs/list', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(mockVolumesResponse)),
),
rest.get(
'http://localhost/api/v3/autocomplete/attribute_keys',
(_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: { attributeKeys: [] } })),
),
);
});
it('renders volume rows from API response', async () => {
renderWithRealStore();
await waitFor(async () => {
const elements = await screen.findAllByText('test-pvc');
expect(elements.length).toBeGreaterThan(0);
});
});
it('opens VolumeDetails when a volume row is clicked', async () => {
const user = userEvent.setup();
renderWithRealStore();
const pvcCells = await screen.findAllByText('test-pvc');
expect(pvcCells.length).toBeGreaterThan(0);
const row = pvcCells[0].closest('tr');
expect(row).not.toBeNull();
await user.click(row!);
await waitFor(async () => {
const cells = await screen.findAllByText('test-pvc');
expect(cells.length).toBeGreaterThan(1);
});
});
it.skip('closes VolumeDetails when the close button is clicked', async () => {
const user = userEvent.setup();
renderWithRealStore();
const pvcCells = await screen.findAllByText('test-pvc');
expect(pvcCells.length).toBeGreaterThan(0);
const row = pvcCells[0].closest('tr');
await user.click(row!);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: 'Close' }));
await waitFor(() => {
expect(
screen.queryByRole('button', { name: 'Close' }),
).not.toBeInTheDocument();
});
});
it('does not re-fetch the volumes list when time range changes after selecting a volume', async () => {
const user = userEvent.setup();
let apiCallCount = 0;
server.use(
rest.post('http://localhost/api/v1/pvcs/list', async (_req, res, ctx) => {
apiCallCount += 1;
return res(ctx.status(200), ctx.json(mockVolumesResponse));
}),
);
const { testStore } = renderWithRealStore();
await waitFor(() => expect(apiCallCount).toBe(1));
const pvcCells = await screen.findAllByText('test-pvc');
const row = pvcCells[0].closest('tr');
await user.click(row!);
await waitFor(async () => {
const cells = await screen.findAllByText('test-pvc');
expect(cells.length).toBeGreaterThan(1);
});
// Wait for nuqs URL state to fully propagate to the component
// The selectedVolumeUID is managed via nuqs (async URL state),
// so we need to ensure the state has settled before dispatching time changes
await act(async () => {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
});
const countAfterClick = apiCallCount;
// There's a specific component causing the min/max time to be updated
// After the volume loads, it triggers the change again
// And then the query to fetch data for the selected volume enters in a loop
act(() => {
testStore.dispatch({
type: UPDATE_TIME_INTERVAL,
payload: {
minTime: Date.now() * 1000000 - 30 * 60 * 1000 * 1000000,
maxTime: Date.now() * 1000000,
selectedTime: '30m',
},
} as any);
});
// Allow any potential re-fetch to settle
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
expect(apiCallCount).toBe(countAfterClick);
});
it('does not re-fetch groupedByRowData when time range changes after expanding a volume row with groupBy', async () => {
const user = userEvent.setup();
const groupByValue = [{ key: 'k8s_namespace_name' }];
let groupedByRowDataCallCount = 0;
server.use(
rest.post('http://localhost/api/v1/pvcs/list', async (req, res, ctx) => {
const body = await req.json();
// Check for both underscore and dot notation keys since dotMetricsEnabled
// may be true or false depending on test order
const isGroupedByRowDataRequest = body.filters?.items?.some(
(item: { key?: { key?: string }; value?: string }) =>
(item.key?.key === 'k8s_namespace_name' ||
item.key?.key === 'k8s.namespace.name') &&
item.value === 'test-namespace',
);
if (isGroupedByRowDataRequest) {
groupedByRowDataCallCount += 1;
}
return res(ctx.status(200), ctx.json(mockVolumesResponse));
}),
rest.get(
'http://localhost/api/v3/autocomplete/attribute_keys',
(_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: {
attributeKeys: [{ key: 'k8s_namespace_name', dataType: 'string' }],
},
}),
),
),
);
const { testStore } = renderWithRealStore({
[INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify(groupByValue),
});
await waitFor(async () => {
const elements = await screen.findAllByText('test-namespace');
return expect(elements.length).toBeGreaterThan(0);
});
const row = (await screen.findAllByText('test-namespace'))[0].closest('tr');
expect(row).not.toBeNull();
user.click(row as HTMLElement);
await waitFor(() => expect(groupedByRowDataCallCount).toBe(1));
const countAfterExpand = groupedByRowDataCallCount;
act(() => {
testStore.dispatch({
type: UPDATE_TIME_INTERVAL,
payload: {
minTime: Date.now() * 1000000 - 30 * 60 * 1000 * 1000000,
maxTime: Date.now() * 1000000,
selectedTime: '30m',
},
} as any);
});
// Allow any potential re-fetch to settle
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
expect(groupedByRowDataCallCount).toBe(countAfterExpand);
});
});

View File

@@ -0,0 +1,72 @@
import setupCommonMocks from '../../commonMocks';
setupCommonMocks();
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import VolumeDetails from 'container/InfraMonitoringK8s/Volumes/VolumeDetails/VolumeDetails';
import store from 'store';
const queryClient = new QueryClient();
describe('VolumeDetails', () => {
const mockVolume = {
persistentVolumeClaimName: 'test-volume',
meta: {
k8s_cluster_name: 'test-cluster',
k8s_namespace_name: 'test-namespace',
},
} as any;
const mockOnClose = jest.fn();
it('should render modal with relevant metadata', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<VolumeDetails
volume={mockVolume}
isModalTimeSelection
onClose={mockOnClose}
/>
</MemoryRouter>
</Provider>
</QueryClientProvider>,
);
const volumeNameElements = screen.getAllByText('test-volume');
expect(volumeNameElements.length).toBeGreaterThan(0);
expect(volumeNameElements[0]).toBeInTheDocument();
const clusterNameElements = screen.getAllByText('test-cluster');
expect(clusterNameElements.length).toBeGreaterThan(0);
expect(clusterNameElements[0]).toBeInTheDocument();
const namespaceNameElements = screen.getAllByText('test-namespace');
expect(namespaceNameElements.length).toBeGreaterThan(0);
expect(namespaceNameElements[0]).toBeInTheDocument();
});
it('should close modal when close button is clicked', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<VolumeDetails
volume={mockVolume}
isModalTimeSelection
onClose={mockOnClose}
/>
</MemoryRouter>
</Provider>
</QueryClientProvider>,
);
const closeButton = screen.getByRole('button', { name: 'Close' });
fireEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
});

View File

@@ -791,5 +791,4 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
EVENTS_FILTERS: 'eventsFilters',
HOSTS_FILTERS: 'hostsFilters',
CURRENT_PAGE: 'currentPage',
SELECTED_ITEM: 'selectedItem',
};

View File

@@ -1,6 +1,4 @@
import { useMemo } from 'react';
import { VIEWS } from 'components/HostMetricsDetail/constants';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
Options,
parseAsInteger,
@@ -224,26 +222,3 @@ export const useInfraMonitoringVolumeUID = (): UseQueryStateReturn<
INFRA_MONITORING_K8S_PARAMS_KEYS.VOLUME_UID,
parseAsString.withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringQueryFilters = (): TagFilter => {
const { currentQuery } = useQueryBuilder();
return useMemo(
() =>
currentQuery?.builder?.queryData[0]?.filters || {
items: [],
op: 'and',
},
[currentQuery?.builder?.queryData],
);
};
export const useInfraMonitoringSelectedItem = (): UseQueryStateReturn<
string,
string | undefined
> => {
return useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,
parseAsString,
);
};

View File

@@ -1,8 +1,22 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { TableColumnType as ColumnType, Tag, Tooltip } from 'antd';
import get from 'api/browser/localstorage/get';
import set from 'api/browser/localstorage/set';
import {
K8sPodsData,
K8sPodsListPayload,
} from 'api/infraMonitoring/getK8sPodsList';
import { Group } from 'lucide-react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DEFAULT_PAGE_SIZE } from './constants';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from './commonUtils';
import { DEFAULT_PAGE_SIZE, K8sCategory } from './constants';
import { OrderBySchemaType } from './schemas';
import './InfraMonitoringK8s.styles.scss';
@@ -12,6 +26,404 @@ export interface IEntityColumn {
id: string;
canRemove: boolean;
}
const columnProgressBarClassName = 'column-progress-bar';
export const defaultAddedColumns: IEntityColumn[] = [
{
label: 'Pod name',
value: 'podName',
id: 'podName',
canRemove: false,
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
canRemove: false,
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
canRemove: false,
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canRemove: false,
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
canRemove: false,
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
canRemove: false,
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canRemove: false,
},
// TODO - Re-enable the column once backend issue is fixed
// {
// label: 'Restarts',
// value: 'restarts',
// id: 'restarts',
// canRemove: false,
// },
];
export const defaultAvailableColumns = [
{
label: 'Namespace name',
value: 'namespace',
id: 'namespace',
canRemove: true,
},
{
label: 'Node name',
value: 'node',
id: 'node',
canRemove: true,
},
{
label: 'Cluster name',
value: 'cluster',
id: 'cluster',
canRemove: true,
},
];
export interface K8sPodsRowData {
key: string;
podName: React.ReactNode;
podUID: string;
cpu_request: React.ReactNode;
cpu_limit: React.ReactNode;
cpu: React.ReactNode;
memory_request: React.ReactNode;
memory_limit: React.ReactNode;
memory: React.ReactNode;
restarts: React.ReactNode;
groupedByMeta?: any;
}
export const getK8sPodsListQuery = (): K8sPodsListPayload => ({
filters: {
items: [],
op: 'and',
},
orderBy: { columnName: 'cpu', order: 'desc' },
});
const podGroupColumnConfig = {
title: (
<div className="column-header entity-group-header">
<Group size={14} /> POD GROUP
</div>
),
dataIndex: 'podGroup',
key: 'podGroup',
ellipsis: true,
width: 180,
sorter: false,
className: 'column entity-group-header',
};
export const dummyColumnConfig = {
title: <div className="column-header dummy-column">&nbsp;</div>,
dataIndex: 'dummy',
key: 'dummy',
width: 40,
sorter: false,
align: 'left',
className: 'column column-dummy',
};
const columnsConfig: ColumnType<K8sPodsRowData>[] = [
{
title: <div className="column-header pod-name-header">Pod Name</div>,
dataIndex: 'podName',
key: 'podName',
width: 180,
ellipsis: true,
sorter: false,
className: 'column column-pod-name',
},
{
title: <div className="column-header med-col">CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 180,
ellipsis: true,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header med-col">CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header">CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-heade med-col">Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 120,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header med-col">Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 120,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header med-col">Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
ellipsis: true,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
// TODO - Re-enable the column once backend issue is fixed
// {
// title: (
// <div className="column-header">
// <Tooltip title="Container Restarts">Restarts</Tooltip>
// </div>
// ),
// dataIndex: 'restarts',
// key: 'restarts',
// width: 40,
// ellipsis: true,
// sorter: true,
// align: 'left',
// className: `column ${columnProgressBarClassName}`,
// },
];
export const namespaceColumnConfig: ColumnType<K8sPodsRowData> = {
title: <div className="column-header">Namespace</div>,
dataIndex: 'namespace',
key: 'namespace',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
className: 'column column-namespace',
};
export const nodeColumnConfig: ColumnType<K8sPodsRowData> = {
title: <div className="column-header">Node</div>,
dataIndex: 'node',
key: 'node',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
className: 'column column-node',
};
export const clusterColumnConfig: ColumnType<K8sPodsRowData> = {
title: <div className="column-header">Cluster</div>,
dataIndex: 'cluster',
key: 'cluster',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
className: 'column column-cluster',
};
export const columnConfigMap: Record<string, ColumnType<K8sPodsRowData>> = {
namespace: namespaceColumnConfig,
node: nodeColumnConfig,
cluster: clusterColumnConfig,
};
export const getK8sPodsListColumns = (
addedColumns: IEntityColumn[],
groupBy: IBuilderQuery['groupBy'],
defaultOrderBy: OrderBySchemaType,
): ColumnType<K8sPodsRowData>[] => {
const updatedColumnsConfig: ColumnType<K8sPodsRowData>[] = [...columnsConfig];
for (const column of addedColumns) {
const config = columnConfigMap[column.id as keyof typeof columnConfigMap];
if (config) {
updatedColumnsConfig.push(config);
}
}
if (groupBy.length > 0) {
const filteredColumns = [...updatedColumnsConfig].filter(
(column) => column.key !== 'podName',
);
filteredColumns.unshift(podGroupColumnConfig);
return filteredColumns as ColumnType<K8sPodsRowData>[];
}
for (const column of updatedColumnsConfig) {
if (column.sorter && column.key === defaultOrderBy?.columnName) {
column.defaultSortOrder =
defaultOrderBy?.order === 'asc' ? 'ascend' : 'descend';
}
}
return updatedColumnsConfig;
};
const dotToUnder: Record<string, keyof K8sPodsData['meta']> = {
'k8s.cronjob.name': 'k8s_cronjob_name',
'k8s.daemonset.name': 'k8s_daemonset_name',
'k8s.deployment.name': 'k8s_deployment_name',
'k8s.job.name': 'k8s_job_name',
'k8s.namespace.name': 'k8s_namespace_name',
'k8s.node.name': 'k8s_node_name',
'k8s.pod.name': 'k8s_pod_name',
'k8s.pod.uid': 'k8s_pod_uid',
'k8s.statefulset.name': 'k8s_statefulset_name',
'k8s.cluster.name': 'k8s_cluster_name',
};
const getGroupByEle = (
pod: K8sPodsData,
groupBy: IBuilderQuery['groupBy'],
): React.ReactNode => {
const groupByValues: string[] = [];
groupBy.forEach((group) => {
const rawKey = group.key as string;
// Choose mapped key if present, otherwise use rawKey
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof pod.meta;
const value = pod.meta[metaKey];
groupByValues.push(value);
});
return (
<div className="pod-group">
{groupByValues.map((value) => (
<Tag key={value} color={Color.BG_SLATE_400} className="pod-group-tag-item">
{value === '' ? '<no-value>' : value}
</Tag>
))}
</div>
);
};
export const formatDataForTable = (
data: K8sPodsData[],
groupBy: IBuilderQuery['groupBy'],
): K8sPodsRowData[] =>
data.map((pod, index) => ({
key: `${pod.podUID}-${index}`,
podName: (
<Tooltip title={pod.meta.k8s_pod_name || ''}>
{pod.meta.k8s_pod_name || ''}
</Tooltip>
),
podUID: pod.podUID || '',
cpu_request: (
<ValidateColumnValueWrapper
value={pod.podCPURequest}
entity={K8sCategory.PODS}
attribute="CPU Request"
>
<div className="progress-container">
<EntityProgressBar value={pod.podCPURequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper
value={pod.podCPULimit}
entity={K8sCategory.PODS}
attribute="CPU Limit"
>
<div className="progress-container">
<EntityProgressBar value={pod.podCPULimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={pod.podCPU}>
{pod.podCPU}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper
value={pod.podMemoryRequest}
entity={K8sCategory.PODS}
attribute="Memory Request"
>
<div className="progress-container">
<EntityProgressBar value={pod.podMemoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper
value={pod.podMemoryLimit}
entity={K8sCategory.PODS}
attribute="Memory Limit"
>
<div className="progress-container">
<EntityProgressBar value={pod.podMemoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={pod.podMemory}>
{formatBytes(pod.podMemory)}
</ValidateColumnValueWrapper>
),
restarts: (
<ValidateColumnValueWrapper value={pod.restartCount}>
{pod.restartCount}
</ValidateColumnValueWrapper>
),
namespace: pod.meta.k8s_namespace_name,
node: pod.meta.k8s_node_name,
cluster: pod.meta.k8s_job_name,
meta: pod.meta,
podGroup: getGroupByEle(pod, groupBy),
...pod.meta,
groupedByMeta: pod.meta,
}));
/**
* Custom hook to manage the page size for a table.
* The page size is stored in local storage and is retrieved on initialization.

View File

@@ -284,15 +284,6 @@ export default function TableViewActions(
error,
);
}
// If the value is valid JSON (object or array), pretty-print it for copying
try {
const parsed = JSON.parse(text);
if (typeof parsed === 'object' && parsed !== null) {
return JSON.stringify(parsed, null, 2);
}
} catch {
// not JSON, return as-is
}
return text;
}, [fieldData.value]);

View File

@@ -198,14 +198,15 @@ function ServiceAccountsSettings(): JSX.Element {
<h1 className="sa-settings__title">Service Accounts</h1>
<p className="sa-settings__subtitle">
Overview of service accounts added to this workspace.{' '}
<a
href="https://signoz.io/docs/manage/administrator-guide/iam/service-accounts"
{/* Todo: to add doc links */}
{/* <a
href="https://signoz.io/docs/service-accounts"
target="_blank"
rel="noopener noreferrer"
className="sa-settings__learn-more"
>
Learn more
</a>
</a> */}
</p>
</div>

View File

@@ -695,15 +695,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
registerShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels, () =>
onClickHandler(ROUTES.ALL_CHANNELS, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts, () =>
onClickHandler(ROUTES.SERVICE_ACCOUNTS_SETTINGS, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettingsRoles, () =>
onClickHandler(ROUTES.ROLES_SETTINGS, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettingsMembers, () =>
onClickHandler(ROUTES.MEMBERS_SETTINGS, null),
);
registerShortcut(GlobalShortcuts.NavigateToLogsPipelines, () =>
onClickHandler(ROUTES.LOGS_PIPELINES, null),
);
@@ -727,9 +718,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
deregisterShortcut(GlobalShortcuts.NavigateToSettingsIngestion);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsBilling);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsRoles);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsMembers);
deregisterShortcut(GlobalShortcuts.NavigateToLogsPipelines);
deregisterShortcut(GlobalShortcuts.NavigateToLogsViews);
deregisterShortcut(GlobalShortcuts.NavigateToTracesViews);

View File

@@ -14,10 +14,6 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import NewExplorerCTA from 'container/NewExplorerCTA';
import dayjs, { Dayjs } from 'dayjs';
import {
useGlobalTimeQueryInvalidate,
useIsGlobalTimeQueryRefreshing,
} from 'hooks/globalTime';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -356,10 +352,7 @@ function DateTimeSelection({
],
);
const isRefreshingQueries = useIsGlobalTimeQueryRefreshing();
const invalidateQueries = useGlobalTimeQueryInvalidate();
const onRefreshHandler = (): void => {
invalidateQueries();
onSelectHandler(selectedTime);
onLastRefreshHandler();
};
@@ -739,11 +732,7 @@ function DateTimeSelection({
{showAutoRefresh && selectedTime !== 'custom' && (
<div className="refresh-actions">
<FormItem hidden={refreshButtonHidden} className="refresh-btn">
<Button
icon={<SyncOutlined />}
loading={!!isRefreshingQueries}
onClick={onRefreshHandler}
/>
<Button icon={<SyncOutlined />} onClick={onRefreshHandler} />
</FormItem>
<FormItem>

View File

@@ -1,2 +0,0 @@
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';

View File

@@ -1,16 +0,0 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
/**
* Use when you want to invalida any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
*/
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
const queryClient = useQueryClient();
return useCallback(async () => {
return await queryClient.invalidateQueries({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
});
}, [queryClient]);
}

View File

@@ -1,13 +0,0 @@
import { useIsFetching } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
/**
* Use when you want to know if any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY} is refreshing
*/
export function useIsGlobalTimeQueryRefreshing(): boolean {
return (
useIsFetching({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
}) > 0
);
}

View File

@@ -0,0 +1,48 @@
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import {
getK8sPodsList,
K8sPodsListPayload,
K8sPodsListResponse,
} from 'api/infraMonitoring/getK8sPodsList';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetK8sPodsList = (
requestData: K8sPodsListPayload,
options?: UseQueryOptions<
SuccessResponse<K8sPodsListResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
dotMetricsEnabled?: boolean,
) => UseQueryResult<
SuccessResponse<K8sPodsListResponse> | ErrorResponse,
Error
>;
export const useGetK8sPodsList: UseGetK8sPodsList = (
requestData,
options,
headers,
dotMetricsEnabled,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
if (options?.queryKey && typeof options.queryKey === 'string') {
return options.queryKey;
}
return [REACT_QUERY_KEY.GET_POD_LIST, requestData];
}, [options?.queryKey, requestData]);
return useQuery<SuccessResponse<K8sPodsListResponse> | ErrorResponse, Error>({
queryFn: ({ signal }) =>
getK8sPodsList(requestData, signal, headers, dotMetricsEnabled),
...options,
queryKey,
});
};

View File

@@ -0,0 +1,51 @@
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import {
getK8sVolumesList,
K8sVolumesListPayload,
K8sVolumesListResponse,
} from 'api/infraMonitoring/getK8sVolumesList';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetK8sVolumesList = (
requestData: K8sVolumesListPayload,
options?: UseQueryOptions<
SuccessResponse<K8sVolumesListResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
dotMetricsEnabled?: boolean,
) => UseQueryResult<
SuccessResponse<K8sVolumesListResponse> | ErrorResponse,
Error
>;
export const useGetK8sVolumesList: UseGetK8sVolumesList = (
requestData,
options,
headers,
dotMetricsEnabled,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
if (options?.queryKey && typeof options.queryKey === 'string') {
return options.queryKey;
}
return [REACT_QUERY_KEY.GET_VOLUME_LIST, requestData];
}, [options?.queryKey, requestData]);
return useQuery<
SuccessResponse<K8sVolumesListResponse> | ErrorResponse,
Error
>({
queryFn: ({ signal }) =>
getK8sVolumesList(requestData, signal, headers, dotMetricsEnabled),
...options,
queryKey,
});
};

View File

@@ -5,7 +5,6 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import AppRoutes from 'AppRoutes';
import { AxiosError } from 'axios';
import { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
import { ThemeProvider } from 'hooks/useDarkMode';
import { NuqsAdapter } from 'nuqs/adapters/react';
import { AppProvider } from 'providers/App/App';
@@ -52,7 +51,6 @@ if (container) {
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<GlobalTimeStoreAdapter />
<AppProvider>
<AppRoutes />
</AppProvider>

View File

@@ -143,9 +143,7 @@ function SettingsPage(): JSX.Element {
isEnabled:
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.ROLE_DETAILS
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
? true
: item.isEnabled,
}));

View File

@@ -62,16 +62,12 @@ export const getRoutes = (
settings.push(...alertChannels(t));
if (isAdmin) {
settings.push(
...membersSettings(t),
...serviceAccountsSettings(t),
...rolesSettings(t),
...roleDetails(t),
);
settings.push(...membersSettings(t), ...serviceAccountsSettings(t));
}
// todo: Sagar - check the condition for role list and details page, to whom we want to serve
if ((isCloudUser || isEnterpriseSelfHostedUser) && isAdmin) {
settings.push(...billingSettings(t));
settings.push(...billingSettings(t), ...rolesSettings(t), ...roleDetails(t));
}
settings.push(

View File

@@ -1,204 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeSelectedTime } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
describe('globalTimeStore', () => {
beforeEach(() => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
describe('initial state', () => {
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should have isRefreshEnabled as false by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should have refreshInterval as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
});
});
describe('setSelectedTime', () => {
it('should update selectedTime', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
expect(result.current.selectedTime).toBe('15m');
});
it('should update refreshInterval when provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should keep existing refreshInterval when not provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
act(() => {
result.current.setSelectedTime('1h');
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should enable refresh for relative time with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should disable refresh for relative time with refreshInterval = 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0);
});
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime, 5000);
});
expect(result.current.isRefreshEnabled).toBe(false);
expect(result.current.refreshInterval).toBe(5000);
});
it('should handle various relative time formats', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const timeFormats: GlobalTimeSelectedTime[] = [
'1m',
'5m',
'15m',
'30m',
'1h',
'3h',
'6h',
'1d',
'1w',
];
timeFormats.forEach((time) => {
act(() => {
result.current.setSelectedTime(time, 10000);
});
expect(result.current.selectedTime).toBe(time);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
});
describe('getMinMaxTime', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return min/max time for custom time range', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
const {
minTime: resultMin,
maxTime: resultMax,
} = result.current.getMinMaxTime();
expect(resultMin).toBe(minTime);
expect(resultMax).toBe(maxTime);
});
it('should compute fresh min/max time for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const { minTime, maxTime } = result.current.getMinMaxTime();
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(maxTime).toBe(now);
expect(minTime).toBe(now - fifteenMinutesNs);
});
it('should return different values on subsequent calls for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const first = result.current.getMinMaxTime();
// Advance time by 1 second
act(() => {
jest.advanceTimersByTime(1000);
});
const second = result.current.getMinMaxTime();
// maxTime should be different (1 second later)
expect(second.maxTime).toBe(first.maxTime + 1000 * NANO_SECOND_MULTIPLIER);
expect(second.minTime).toBe(first.minTime + 1000 * NANO_SECOND_MULTIPLIER);
});
});
describe('store isolation', () => {
it('should share state between multiple hook instances', () => {
const { result: result1 } = renderHook(() => useGlobalTimeStore());
const { result: result2 } = renderHook(() => useGlobalTimeStore());
act(() => {
result1.current.setSelectedTime('1h', 10000);
});
expect(result2.current.selectedTime).toBe('1h');
expect(result2.current.refreshInterval).toBe(10000);
expect(result2.current.isRefreshEnabled).toBe(true);
});
});
});

View File

@@ -1,139 +0,0 @@
import {
createCustomTimeRange,
CUSTOM_TIME_SEPARATOR,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
parseSelectedTime,
} from '../utils';
describe('globalTime/utils', () => {
describe('CUSTOM_TIME_SEPARATOR', () => {
it('should be defined as ||_||', () => {
expect(CUSTOM_TIME_SEPARATOR).toBe('||_||');
});
});
describe('isCustomTimeRange', () => {
it('should return true for custom time range strings', () => {
expect(isCustomTimeRange('1000000000||_||2000000000')).toBe(true);
expect(isCustomTimeRange('0||_||0')).toBe(true);
});
it('should return false for relative time strings', () => {
expect(isCustomTimeRange('15m')).toBe(false);
expect(isCustomTimeRange('1h')).toBe(false);
expect(isCustomTimeRange('1d')).toBe(false);
expect(isCustomTimeRange('30s')).toBe(false);
});
it('should return false for empty string', () => {
expect(isCustomTimeRange('')).toBe(false);
});
});
describe('createCustomTimeRange', () => {
it('should create a custom time range string from min and max times', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const result = createCustomTimeRange(minTime, maxTime);
expect(result).toBe(`${minTime}${CUSTOM_TIME_SEPARATOR}${maxTime}`);
});
it('should handle zero values', () => {
const result = createCustomTimeRange(0, 0);
expect(result).toBe(`0${CUSTOM_TIME_SEPARATOR}0`);
});
it('should handle large nanosecond timestamps', () => {
const minTime = 1700000000000000000;
const maxTime = 1700000001000000000;
const result = createCustomTimeRange(minTime, maxTime);
expect(result).toBe(`${minTime}${CUSTOM_TIME_SEPARATOR}${maxTime}`);
});
});
describe('parseCustomTimeRange', () => {
it('should parse a valid custom time range string', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const timeString = `${minTime}${CUSTOM_TIME_SEPARATOR}${maxTime}`;
const result = parseCustomTimeRange(timeString);
expect(result).toEqual({ minTime, maxTime });
});
it('should return null for non-custom time range strings', () => {
expect(parseCustomTimeRange('15m')).toBeNull();
expect(parseCustomTimeRange('1h')).toBeNull();
});
it('should return null for invalid numeric values', () => {
expect(parseCustomTimeRange(`abc${CUSTOM_TIME_SEPARATOR}def`)).toBeNull();
expect(parseCustomTimeRange(`123${CUSTOM_TIME_SEPARATOR}def`)).toBeNull();
expect(parseCustomTimeRange(`abc${CUSTOM_TIME_SEPARATOR}456`)).toBeNull();
});
it('should handle zero values', () => {
const result = parseCustomTimeRange(`0${CUSTOM_TIME_SEPARATOR}0`);
expect(result).toEqual({ minTime: 0, maxTime: 0 });
});
});
describe('parseSelectedTime', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should parse custom time range and return min/max values', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const timeString = createCustomTimeRange(minTime, maxTime);
const result = parseSelectedTime(timeString);
expect(result).toEqual({ minTime, maxTime });
});
it('should return fallback for invalid custom time range', () => {
const invalidCustom = `invalid${CUSTOM_TIME_SEPARATOR}values`;
const result = parseSelectedTime(invalidCustom);
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fallbackDuration = 30 * 1000 * NANO_SECOND_MULTIPLIER; // 30s in nanoseconds
expect(result.maxTime).toBe(now);
expect(result.minTime).toBe(now - fallbackDuration);
});
it('should parse relative time strings using getMinMaxForSelectedTime', () => {
const result = parseSelectedTime('15m');
const now = Date.now() * NANO_SECOND_MULTIPLIER;
// 15 minutes in nanoseconds
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.maxTime).toBe(now);
expect(result.minTime).toBe(now - fifteenMinutesNs);
});
it('should parse 1h relative time', () => {
const result = parseSelectedTime('1h');
const now = Date.now() * NANO_SECOND_MULTIPLIER;
// 1 hour in nanoseconds
const oneHourNs = 60 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.maxTime).toBe(now);
expect(result.minTime).toBe(now - oneHourNs);
});
it('should parse 1d relative time', () => {
const result = parseSelectedTime('1d');
const now = Date.now() * NANO_SECOND_MULTIPLIER;
// 1 day in nanoseconds
const oneDayNs = 24 * 60 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.maxTime).toBe(now);
expect(result.minTime).toBe(now - oneDayNs);
});
});
});

View File

@@ -1,32 +0,0 @@
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { create } from 'zustand';
import {
IGlobalTimeStoreActions,
IGlobalTimeStoreState,
ParsedTimeRange,
} from './types';
import { isCustomTimeRange, parseSelectedTime } from './utils';
export type IGlobalTimeStore = IGlobalTimeStoreState & IGlobalTimeStoreActions;
export const useGlobalTimeStore = create<IGlobalTimeStore>((set, get) => ({
selectedTime: DEFAULT_TIME_RANGE,
isRefreshEnabled: false,
refreshInterval: 0,
setSelectedTime: (selectedTime, refreshInterval): void => {
set((state) => {
const newRefreshInterval = refreshInterval ?? state.refreshInterval;
const isCustom = isCustomTimeRange(selectedTime);
return {
selectedTime,
refreshInterval: newRefreshInterval,
isRefreshEnabled: !isCustom && newRefreshInterval > 0,
};
});
},
getMinMaxTime: (selectedTime): ParsedTimeRange => {
return parseSelectedTime(selectedTime || get().selectedTime);
},
}));

View File

@@ -1,9 +0,0 @@
export { useGlobalTimeStore } from './globalTimeStore';
export type { IGlobalTimeStoreState, ParsedTimeRange } from './types';
export {
createCustomTimeRange,
CUSTOM_TIME_SEPARATOR,
isCustomTimeRange,
parseCustomTimeRange,
parseSelectedTime,
} from './utils';

View File

@@ -1,52 +0,0 @@
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
export type CustomTimeRangeSeparator = '||_||';
export type CustomTimeRange = `${number}${CustomTimeRangeSeparator}${number}`;
export type GlobalTimeSelectedTime = Time | CustomTimeRange;
export interface IGlobalTimeStoreState {
/**
* The selected time range, can be:
* - Relative duration: '1m', '5m', '15m', '1h', '1d', etc.
* - Custom range: '<minTimeUnixNano>||_||<maxTimeUnixNano>' format
*/
selectedTime: GlobalTimeSelectedTime;
/**
* Whether auto-refresh is enabled.
* Automatically computed: true for duration-based times, false for custom ranges.
*/
isRefreshEnabled: boolean;
/**
* The refresh interval in milliseconds (e.g., 5000 for 5s, 30000 for 30s)
* Only used when isRefreshEnabled is true
*/
refreshInterval: number;
}
export interface ParsedTimeRange {
minTime: number;
maxTime: number;
}
export interface IGlobalTimeStoreActions {
/**
* Set the selected time and optionally the refresh interval.
* isRefreshEnabled is automatically computed:
* - Custom time ranges: always false
* - Duration times with refreshInterval > 0: true
* - Duration times with refreshInterval = 0: false
*/
setSelectedTime: (
selectedTime: GlobalTimeSelectedTime,
refreshInterval?: number,
) => void;
/**
* Get the current min/max time values parsed from selectedTime.
* For durations, computes fresh values based on Date.now().
* For custom ranges, extracts the stored values.
*/
getMinMaxTime: (selectedItem?: GlobalTimeSelectedTime) => ParsedTimeRange;
}

View File

@@ -1,89 +0,0 @@
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import { REACT_QUERY_KEY } from '../../constants/reactQueryKeys';
import {
CustomTimeRange,
CustomTimeRangeSeparator,
GlobalTimeSelectedTime,
ParsedTimeRange,
} from './types';
/**
* Custom time range separator used in the selectedTime string
*/
export const CUSTOM_TIME_SEPARATOR: CustomTimeRangeSeparator = '||_||';
/**
* Check if selectedTime represents a custom time range
*/
export function isCustomTimeRange(
selectedTime: string,
): selectedTime is CustomTimeRange {
return selectedTime.includes(CUSTOM_TIME_SEPARATOR);
}
/**
* Create a custom time range string from min/max times (in nanoseconds)
*/
export function createCustomTimeRange(
minTime: number,
maxTime: number,
): CustomTimeRange {
return `${minTime}${CUSTOM_TIME_SEPARATOR}${maxTime}`;
}
/**
* Parse the custom time range string to get min/max times (in nanoseconds)
*/
export function parseCustomTimeRange(
selectedTime: string,
): ParsedTimeRange | null {
if (!isCustomTimeRange(selectedTime)) {
return null;
}
const [minStr, maxStr] = selectedTime.split(CUSTOM_TIME_SEPARATOR);
const minTime = parseInt(minStr, 10);
const maxTime = parseInt(maxStr, 10);
if (Number.isNaN(minTime) || Number.isNaN(maxTime)) {
return null;
}
return { minTime, maxTime };
}
export const NANO_SECOND_MULTIPLIER = 1000000;
const fallbackDurationInNanoSeconds = 30 * 1000 * NANO_SECOND_MULTIPLIER; // 30s
/**
* Parse the selectedTime string to get min/max time values.
* For relative times, computes fresh values based on Date.now().
* For custom times, extracts the stored min/max values.
*/
export function parseSelectedTime(selectedTime: string): ParsedTimeRange {
if (isCustomTimeRange(selectedTime)) {
const parsed = parseCustomTimeRange(selectedTime);
if (parsed) {
return parsed;
}
// Fallback to current time if parsing fails
const now = Date.now() * NANO_SECOND_MULTIPLIER;
return { minTime: now - fallbackDurationInNanoSeconds, maxTime: now };
}
// It's a relative time like '15m', '1h', etc.
// Use getMinMaxForSelectedTime which computes from Date.now()
return getMinMaxForSelectedTime(selectedTime as Time, 0, 0);
}
/**
* Use to build your react-query key for auto-refresh queries
*/
export function getAutoRefreshQueryKey(
selectedTime: GlobalTimeSelectedTime,
...queryParts: unknown[]
): unknown[] {
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
}

View File

@@ -5551,10 +5551,10 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/design-tokens@2.1.4":
version "2.1.4"
resolved "https://registry.yarnpkg.com/@signozhq/design-tokens/-/design-tokens-2.1.4.tgz#f209da6fbd2ac97ab4434b71472f741009306550"
integrity sha512-Ny7/VA5YGFFmZx58jMh7ATFyu7VePaJ4ySmj/DopP1hilmfdxQsKWnpqKaZJWRXrbNkc0gmq3cR7q7Z8nnN7ZQ==
"@signozhq/design-tokens@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@signozhq/design-tokens/-/design-tokens-2.1.1.tgz#9c36d433fd264410713cc0c5ebdd75ce0ebecba3"
integrity sha512-SdziCHg5Lwj+6oY6IRUPplaKZ+kTHjbrlhNj//UoAJ8aQLnRdR2F/miPzfSi4vrYw88LtXxNA9J9iJyacCp37A==
"@signozhq/dialog@^0.0.2":
version "0.0.2"

View File

@@ -1,256 +0,0 @@
grammar HavingExpression;
/*
* Parser Rules
*/
query
: expression EOF
;
// Expression with standard boolean precedence:
// - parentheses > NOT > AND > OR
expression
: orExpression
;
// OR expressions
orExpression
: andExpression ( OR andExpression )*
;
// AND expressions + optional chaining with implicit AND if no OR is present
andExpression
: primary ( AND primary | primary )*
;
// Primary: an optionally negated expression.
// NOT can be applied to a parenthesized expression or a bare comparison / IN-test.
// E.g.: NOT (count() > 100 AND sum(bytes) < 500)
// NOT count() > 100
// count() IN (1, 2, 3) -- NOT here is part of comparison, see below
// count() NOT IN (1, 2, 3)
primary
: NOT? LPAREN orExpression RPAREN
| NOT? comparison
;
/*
* Comparison between two arithmetic operands, or an IN / NOT IN membership test.
* E.g.: count() > 100, total_duration >= 500, __result_0 != 0
* count() IN (1, 2, 3), sum(bytes) NOT IN (0, -1)
* count() IN [1, 2, 3], sum(bytes) NOT IN [0, -1]
*/
comparison
: operand compOp operand
| operand NOT? IN LPAREN inList RPAREN
| operand NOT? IN LBRACK inList RBRACK
;
compOp
: EQUALS
| NOT_EQUALS
| NEQ
| LT
| LE
| GT
| GE
;
/*
* IN-list: a comma-separated list of numeric literals, each optionally signed.
* E.g.: (1, 2, 3), [100, 200, 500], (-1, 0, 1)
*/
inList
: signedNumber ( COMMA signedNumber )*
;
/*
* A signed number allows an optional leading +/- before a numeric literal.
* Used in IN-lists where a bare minus is unambiguous (no binary operand to the left).
*/
signedNumber
: (PLUS | MINUS)? NUMBER
;
/*
* Operands support additive arithmetic (+/-).
* E.g.: sum(a) + sum(b) > 1000, count() - 10 > 0
*/
operand
: operand (PLUS | MINUS) term
| term
;
/*
* Terms support multiplicative arithmetic (*, /, %)
* E.g.: count() * 2 > 100, sum(bytes) / 1024 > 10
*/
term
: term (STAR | SLASH | PERCENT) factor
| factor
;
/*
* Factors: atoms, parenthesized operands, or unary-signed sub-factors.
* E.g.: (sum(a) + sum(b)) * 2 > 100, -count() > 0, -(avg(x) + 1) > 0
* -10 (unary minus applied to the literal 10), count() - 10 > 0
*
* Note: the NUMBER rule does NOT include a leading sign, so `-10` is always
* tokenised as MINUS followed by NUMBER(10). Unary minus in `factor` handles
* negative literals just as it handles negative function calls or identifiers,
* and the binary MINUS in `operand` handles `count()-10` naturally.
*/
factor
: (PLUS | MINUS) factor
| LPAREN operand RPAREN
| atom
;
/*
* Atoms are the basic building blocks of arithmetic operands:
* - aggregate function calls: count(), sum(bytes), avg(duration)
* - identifier references: aliases, result refs (__result, __result_0, __result0)
* - numeric literals: 100, 0.5, 1e6
* - string literals: 'xyz' — recognized so we can give a friendly error
*
* String literals in HAVING are always invalid (aggregator results are numeric),
* but we accept them here so the visitor can produce a clear error message instead
* of a raw syntax error.
*/
atom
: functionCall
| identifier
| NUMBER
| STRING
;
/*
* Aggregate function calls, e.g.:
* count(), sum(bytes), avg(duration_nano)
* countIf(level='error'), sumIf(bytes, status > 400)
* p99(duration), avg(sum(cpu_usage))
*
* Function arguments are parsed as a permissive token sequence (funcArgToken+)
* so that complex aggregation expressions — including nested function calls and
* filter predicates with string literals — can be referenced verbatim in the
* HAVING expression. The visitor looks up the full call text (whitespace-free,
* via ctx.GetText()) in the column map, which stores normalized (space-stripped)
* aggregation expression keys.
*/
functionCall
: IDENTIFIER LPAREN functionArgList? RPAREN
;
functionArgList
: funcArg ( COMMA funcArg )*
;
/*
* A single function argument is one or more consecutive arg-tokens.
* Commas at the top level separate arguments; closing parens terminate the list.
*/
funcArg
: funcArgToken+
;
/*
* Permissive token set for function argument content. Covers:
* - simple identifiers: bytes, duration
* - string literals: 'error', "info"
* - numeric literals: 200, 3.14
* - comparison operators: level='error', status > 400
* - arithmetic operators: x + y
* - boolean connectives: level='error' AND status=200
* - balanced parens: nested calls like sum(duration)
*/
funcArgToken
: IDENTIFIER
| STRING
| NUMBER
| BOOL
| EQUALS | NOT_EQUALS | NEQ | LT | LE | GT | GE
| PLUS | MINUS | STAR | SLASH | PERCENT
| NOT | AND | OR
| LPAREN funcArgToken* RPAREN
;
// Identifier references: aliases, field names, result references
// Examples: total_logs, error_count, __result, __result_0, __result0, p99
identifier
: IDENTIFIER
;
/*
* Lexer Rules
*/
// Punctuation
LPAREN : '(' ;
RPAREN : ')' ;
LBRACK : '[' ;
RBRACK : ']' ;
COMMA : ',' ;
// Comparison operators
EQUALS : '=' | '==' ;
NOT_EQUALS : '!=' ;
NEQ : '<>' ; // alternate not-equals operator
LT : '<' ;
LE : '<=' ;
GT : '>' ;
GE : '>=' ;
// Arithmetic operators
PLUS : '+' ;
MINUS : '-' ;
STAR : '*' ;
SLASH : '/' ;
PERCENT : '%' ;
// Boolean logic (case-insensitive)
NOT : [Nn][Oo][Tt] ;
AND : [Aa][Nn][Dd] ;
OR : [Oo][Rr] ;
IN : [Ii][Nn] ;
// Boolean constants (case-insensitive)
BOOL
: [Tt][Rr][Uu][Ee]
| [Ff][Aa][Ll][Ss][Ee]
;
fragment SIGN : [+-] ;
// Numbers: digits, optional decimal, optional scientific notation.
// No leading sign — a leading +/- is always a separate PLUS/MINUS token, which
// lets the parser treat it as either a binary operator (count()-10) or unary
// sign (-count(), -10). Signed exponents like 1e-3 remain valid.
// E.g.: 100, 0.5, 1.5e3, .75
NUMBER
: DIGIT+ ('.' DIGIT*)? ([eE] SIGN? DIGIT+)?
| '.' DIGIT+ ([eE] SIGN? DIGIT+)?
;
// Identifiers: start with a letter or underscore, followed by alphanumeric/underscores.
// Optionally dotted for nested field paths.
// Covers: count, sum, p99, total_logs, error_count, __result, __result_0, __result0,
// service.name, span.duration
IDENTIFIER
: [a-zA-Z_] [a-zA-Z0-9_]* ( '.' [a-zA-Z_] [a-zA-Z0-9_]* )*
;
// Quoted string literals (single or double-quoted).
// These are valid tokens inside function arguments (e.g. countIf(level='error'))
// but are always rejected in comparison-operand position by the visitor.
STRING
: '\'' (~'\'')* '\''
| '"' (~'"')* '"'
;
// Skip whitespace
WS
: [ \t\r\n]+ -> skip
;
fragment DIGIT : [0-9] ;

View File

@@ -50,11 +50,6 @@ func (handler *healthOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext
)
}
func (handler *healthOpenAPIHandler) AuditDef() *pkghandler.AuditDef {
// Health endpoints are not audited since they don't represent user actions and are called frequently by monitoring systems, which would create noise in the audit logs.
return nil
}
func (provider *provider) addRegistryRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/healthz", newHealthOpenAPIHandler(
provider.authZ.OpenAccess(provider.factoryHandler.Healthz),

View File

@@ -21,15 +21,11 @@ func newTestSettings() factory.ScopedProviderSettings {
func newTestEvent(resource string, action audittypes.Action) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource, action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,
},
ResourceAttributes: audittypes.ResourceAttributes{
ResourceName: resource,
},
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource, action),
ResourceName: resource,
Action: action,
Outcome: audittypes.OutcomeSuccess,
}
}

View File

@@ -5,7 +5,7 @@ import (
"slices"
"strings"
parser "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
parser "github.com/SigNoz/signoz/pkg/parser/grammar"
"github.com/antlr4-go/antlr/v4"
"golang.org/x/exp/maps"

View File

@@ -20,12 +20,6 @@ var (
CodeLicenseUnavailable = Code{"license_unavailable"}
)
var (
// Used when reverse engineering an error from a response that doesn't have a code.
// This should never be used in the codebase, and if it is, it's a bug that should be fixed by using proper error handling and including error codes in responses.
CodeUnset = Code{"unset"}
)
var (
codeRegex = regexp.MustCompile(`^[a-z_]+$`)
)

View File

@@ -15,16 +15,14 @@ type ServeOpenAPIFunc func(openapi.OperationContext)
type Handler interface {
http.Handler
ServeOpenAPI(openapi.OperationContext)
AuditDef() *AuditDef
}
type handler struct {
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
auditDef *AuditDef
}
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef) Handler {
// Remove duplicate error status codes
openAPIDef.ErrorStatusCodes = slices.DeleteFunc(openAPIDef.ErrorStatusCodes, func(statusCode int) bool {
return statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden || statusCode == http.StatusInternalServerError
@@ -38,16 +36,10 @@ func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Ha
openAPIDef.ErrorStatusCodes = append(openAPIDef.ErrorStatusCodes, http.StatusUnauthorized, http.StatusForbidden)
}
handler := &handler{
return &handler{
handlerFunc: handlerFunc,
openAPIDef: openAPIDef,
}
for _, opt := range opts {
opt(handler)
}
return handler
}
func (handler *handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
@@ -128,8 +120,5 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
openapi.WithHTTPStatus(statusCode),
)
}
}
func (handler *handler) AuditDef() *AuditDef {
return handler.auditDef
}

View File

@@ -1,24 +0,0 @@
package handler
import (
"github.com/SigNoz/signoz/pkg/types/audittypes"
)
// Option configures optional behaviour on a handler created by New.
type Option func(*handler)
type AuditDef struct {
ResourceName string // AuthZ Typeable.Name() value, e.g. "dashboard", "user".
Action audittypes.Action // create, update, delete, login, etc.
Category audittypes.ActionCategory // access_control, configuration_change, etc.
ResourceIDParam string // Gorilla mux path param name for the resource ID.
}
// WithAudit attaches an AuditDef to the handler. The actual audit event
// emission is handled by the middleware layer, which reads the AuditDef
// from the matched route's handler.
func WithAuditDef(def AuditDef) Option {
return func(h *handler) {
h.auditDef = &def
}
}

View File

@@ -1,169 +0,0 @@
package middleware
import (
"log/slog"
"net"
"net/http"
"time"
"github.com/gorilla/mux"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"go.opentelemetry.io/otel/trace"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
const (
logMessage = "::RECEIVED-REQUEST::"
)
type Audit struct {
logger *slog.Logger
excludedRoutes map[string]struct{}
auditor auditor.Auditor
}
func NewAudit(logger *slog.Logger, excludedRoutes []string, auditor auditor.Auditor) *Audit {
excludedRoutesMap := make(map[string]struct{})
for _, route := range excludedRoutes {
excludedRoutesMap[route] = struct{}{}
}
return &Audit{
logger: logger.With(slog.String("pkg", pkgname)),
excludedRoutes: excludedRoutesMap,
auditor: auditor,
}
}
func (middleware *Audit) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
start := time.Now()
host, port, _ := net.SplitHostPort(req.Host)
path, err := mux.CurrentRoute(req).GetPathTemplate()
if err != nil {
path = req.URL.Path
}
fields := []any{
string(semconv.ClientAddressKey), req.RemoteAddr,
string(semconv.UserAgentOriginalKey), req.UserAgent(),
string(semconv.ServerAddressKey), host,
string(semconv.ServerPortKey), port,
string(semconv.HTTPRequestSizeKey), req.ContentLength,
string(semconv.HTTPRouteKey), path,
}
responseBuffer := &byteBuffer{}
writer := newResponseCapture(rw, responseBuffer)
next.ServeHTTP(writer, req)
statusCode, writeErr := writer.StatusCode(), writer.WriteError()
// Logging or Audit: skip if the matched route is in the excluded list. This allows us to exclude noisy routes (e.g. health checks) from both logging and audit.
if _, ok := middleware.excludedRoutes[path]; ok {
return
}
middleware.emitAuditEvent(req, writer, path)
fields = append(fields,
string(semconv.HTTPResponseStatusCodeKey), statusCode,
string(semconv.HTTPServerRequestDurationName), time.Since(start),
)
if writeErr != nil {
fields = append(fields, errors.Attr(writeErr))
middleware.logger.ErrorContext(req.Context(), logMessage, fields...)
} else {
if responseBuffer.Len() != 0 {
fields = append(fields, "response.body", responseBuffer.String())
}
middleware.logger.InfoContext(req.Context(), logMessage, fields...)
}
})
}
func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCapture, routeTemplate string) {
if middleware.auditor == nil {
return
}
def := auditDefFromRequest(req)
if def == nil {
return
}
// extract claims
claims, _ := authtypes.ClaimsFromContext(req.Context())
// extract status code
statusCode := writer.StatusCode()
// extract traces.
span := trace.SpanFromContext(req.Context())
// extract error details.
var errorType, errorCode string
if statusCode >= 400 {
errorType = render.ErrorTypeFromStatusCode(statusCode)
errorCode = render.ErrorCodeFromBody(writer.BodyBytes())
}
event := audittypes.NewAuditEventFromHTTPRequest(
req,
routeTemplate,
statusCode,
span.SpanContext().TraceID(),
span.SpanContext().SpanID(),
def.Action,
def.Category,
claims,
resourceIDFromRequest(req, def.ResourceIDParam),
def.ResourceName,
errorType,
errorCode,
)
middleware.auditor.Audit(req.Context(), event)
}
func auditDefFromRequest(req *http.Request) *handler.AuditDef {
route := mux.CurrentRoute(req)
if route == nil {
return nil
}
actualHandler := route.GetHandler()
if actualHandler == nil {
return nil
}
// The type assertion is necessary because route.GetHandler() returns
// http.Handler, and not every http.Handler on the mux is a handler.Handler
// (e.g. middleware wrappers, raw http.HandlerFunc registrations).
provider, ok := actualHandler.(handler.Handler)
if !ok {
return nil
}
return provider.AuditDef()
}
func resourceIDFromRequest(req *http.Request, param string) string {
if param == "" {
return ""
}
vars := mux.Vars(req)
if vars == nil {
return ""
}
return vars[param]
}

View File

@@ -0,0 +1,81 @@
package middleware
import (
"bytes"
"log/slog"
"net"
"net/http"
"time"
"github.com/gorilla/mux"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"github.com/SigNoz/signoz/pkg/errors"
)
const (
logMessage string = "::RECEIVED-REQUEST::"
)
type Logging struct {
logger *slog.Logger
excludedRoutes map[string]struct{}
}
func NewLogging(logger *slog.Logger, excludedRoutes []string) *Logging {
excludedRoutesMap := make(map[string]struct{})
for _, route := range excludedRoutes {
excludedRoutesMap[route] = struct{}{}
}
return &Logging{
logger: logger.With(slog.String("pkg", pkgname)),
excludedRoutes: excludedRoutesMap,
}
}
func (middleware *Logging) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
start := time.Now()
host, port, _ := net.SplitHostPort(req.Host)
path, err := mux.CurrentRoute(req).GetPathTemplate()
if err != nil {
path = req.URL.Path
}
fields := []any{
string(semconv.ClientAddressKey), req.RemoteAddr,
string(semconv.UserAgentOriginalKey), req.UserAgent(),
string(semconv.ServerAddressKey), host,
string(semconv.ServerPortKey), port,
string(semconv.HTTPRequestSizeKey), req.ContentLength,
string(semconv.HTTPRouteKey), path,
}
badResponseBuffer := new(bytes.Buffer)
writer := newBadResponseLoggingWriter(rw, badResponseBuffer)
next.ServeHTTP(writer, req)
// if the path is in the excludedRoutes map, don't log
if _, ok := middleware.excludedRoutes[path]; ok {
return
}
statusCode, err := writer.StatusCode(), writer.WriteError()
fields = append(fields,
string(semconv.HTTPResponseStatusCodeKey), statusCode,
string(semconv.HTTPServerRequestDurationName), time.Since(start),
)
if err != nil {
fields = append(fields, errors.Attr(err))
middleware.logger.ErrorContext(req.Context(), logMessage, fields...)
} else {
// when the status code is 400 or >=500, and the response body is not empty.
if badResponseBuffer.Len() != 0 {
fields = append(fields, "response.body", badResponseBuffer.String())
}
middleware.logger.InfoContext(req.Context(), logMessage, fields...)
}
})
}

View File

@@ -2,6 +2,7 @@ package middleware
import (
"bufio"
"io"
"net"
"net/http"
@@ -9,156 +10,118 @@ import (
)
const (
maxResponseBodyCapture int = 4096 // At most 4k bytes from response bodies.
maxResponseBodyInLogs = 4096 // At most 4k bytes from response bodies in our logs.
)
// Wraps an http.ResponseWriter to capture the status code,
// write errors, and (for error responses) a bounded slice of the body.
type responseCapture interface {
type badResponseLoggingWriter interface {
http.ResponseWriter
// StatusCode returns the HTTP status code written to the response.
// Get the status code.
StatusCode() int
// WriteError returns the error (if any) from the downstream Write call.
// Get the error while writing.
WriteError() error
// BodyBytes returns the captured response body bytes. Only populated
// for error responses (status >= 400).
BodyBytes() []byte
}
func newResponseCapture(rw http.ResponseWriter, buffer *byteBuffer) responseCapture {
b := nonFlushingResponseCapture{
func newBadResponseLoggingWriter(rw http.ResponseWriter, buffer io.Writer) badResponseLoggingWriter {
b := nonFlushingBadResponseLoggingWriter{
rw: rw,
buffer: buffer,
captureBody: false,
bodyBytesLeft: maxResponseBodyCapture,
logBody: false,
bodyBytesLeft: maxResponseBodyInLogs,
statusCode: http.StatusOK,
}
if f, ok := rw.(http.Flusher); ok {
return &flushingResponseCapture{nonFlushingResponseCapture: b, f: f}
return &flushingBadResponseLoggingWriter{b, f}
}
return &b
}
// byteBuffer is a minimal write-only buffer used to capture response bodies.
type byteBuffer struct {
buf []byte
}
func (b *byteBuffer) Write(p []byte) (int, error) {
b.buf = append(b.buf, p...)
return len(p), nil
}
func (b *byteBuffer) WriteString(s string) (int, error) {
b.buf = append(b.buf, s...)
return len(s), nil
}
func (b *byteBuffer) Bytes() []byte {
return b.buf
}
func (b *byteBuffer) Len() int {
return len(b.buf)
}
func (b *byteBuffer) String() string {
return string(b.buf)
}
type nonFlushingResponseCapture struct {
type nonFlushingBadResponseLoggingWriter struct {
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
buffer io.Writer
logBody bool
bodyBytesLeft int
statusCode int
writeError error
writeError error // The error returned when downstream Write() fails.
}
type flushingResponseCapture struct {
nonFlushingResponseCapture
// Extends nonFlushingBadResponseLoggingWriter that implements http.Flusher.
type flushingBadResponseLoggingWriter struct {
nonFlushingBadResponseLoggingWriter
f http.Flusher
}
// Unwrap is used by http.ResponseController to get access to original http.ResponseWriter.
func (writer *nonFlushingResponseCapture) Unwrap() http.ResponseWriter {
// Unwrap method is used by http.ResponseController to get access to original http.ResponseWriter.
func (writer *nonFlushingBadResponseLoggingWriter) Unwrap() http.ResponseWriter {
return writer.rw
}
// Header returns the header map that will be sent by WriteHeader.
func (writer *nonFlushingResponseCapture) Header() http.Header {
// Implements ResponseWriter.
func (writer *nonFlushingBadResponseLoggingWriter) Header() http.Header {
return writer.rw.Header()
}
// WriteHeader writes the HTTP response header.
func (writer *nonFlushingResponseCapture) WriteHeader(statusCode int) {
func (writer *nonFlushingBadResponseLoggingWriter) WriteHeader(statusCode int) {
writer.statusCode = statusCode
if statusCode >= 400 {
writer.captureBody = true
if statusCode >= 500 || statusCode == 400 {
writer.logBody = true
}
writer.rw.WriteHeader(statusCode)
}
// Write writes HTTP response data.
func (writer *nonFlushingResponseCapture) Write(data []byte) (int, error) {
// Writes HTTP response data.
func (writer *nonFlushingBadResponseLoggingWriter) Write(data []byte) (int, error) {
if writer.statusCode == 0 {
// WriteHeader has (probably) not been called, so we need to call it with StatusOK to fulfill the interface contract.
// https://godoc.org/net/http#ResponseWriter
writer.WriteHeader(http.StatusOK)
}
// 204 No Content is a success response that indicates that the request has been successfully processed and that the response body is intentionally empty.
if writer.statusCode == 204 {
return 0, nil
}
n, err := writer.rw.Write(data)
if writer.captureBody {
if writer.logBody {
writer.captureResponseBody(data)
}
if err != nil {
writer.writeError = err
}
return n, err
}
// Hijack hijacks the first response writer that is a Hijacker.
func (writer *nonFlushingResponseCapture) Hijack() (net.Conn, *bufio.ReadWriter, error) {
func (writer *nonFlushingBadResponseLoggingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj, ok := writer.rw.(http.Hijacker)
if ok {
return hj.Hijack()
}
return nil, nil, errors.NewInternalf(errors.CodeInternal, "cannot cast underlying response writer to Hijacker")
}
func (writer *nonFlushingResponseCapture) StatusCode() int {
func (writer *nonFlushingBadResponseLoggingWriter) StatusCode() int {
return writer.statusCode
}
func (writer *nonFlushingResponseCapture) WriteError() error {
func (writer *nonFlushingBadResponseLoggingWriter) WriteError() error {
return writer.writeError
}
func (writer *nonFlushingResponseCapture) BodyBytes() []byte {
return writer.buffer.Bytes()
}
func (writer *flushingResponseCapture) Flush() {
func (writer *flushingBadResponseLoggingWriter) Flush() {
writer.f.Flush()
}
func (writer *nonFlushingResponseCapture) captureResponseBody(data []byte) {
func (writer *nonFlushingBadResponseLoggingWriter) captureResponseBody(data []byte) {
if len(data) > writer.bodyBytesLeft {
_, _ = writer.buffer.Write(data[:writer.bodyBytesLeft])
_, _ = writer.buffer.WriteString("...")
_, _ = io.WriteString(writer.buffer, "...")
writer.bodyBytesLeft = 0
writer.captureBody = false
writer.logBody = false
} else {
_, _ = writer.buffer.Write(data)
writer.bodyBytesLeft -= len(data)

View File

@@ -1,88 +0,0 @@
package middleware
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestResponseCapture(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
handler http.HandlerFunc
expectedStatus int
expectedBodyBytes string
expectedClientBody string
}{
{
name: "Success_DoesNotCaptureBody",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write([]byte(`{"status":"success","data":{"id":"123"}}`))
},
expectedStatus: http.StatusOK,
expectedBodyBytes: "",
expectedClientBody: `{"status":"success","data":{"id":"123"}}`,
},
{
name: "Error_CapturesBody",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusForbidden)
_, _ = rw.Write([]byte(`{"status":"error","error":{"code":"authz_forbidden","message":"forbidden"}}`))
},
expectedStatus: http.StatusForbidden,
expectedBodyBytes: `{"status":"error","error":{"code":"authz_forbidden","message":"forbidden"}}`,
expectedClientBody: `{"status":"error","error":{"code":"authz_forbidden","message":"forbidden"}}`,
},
{
name: "Error_TruncatesAtMaxCapture",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusInternalServerError)
_, _ = rw.Write([]byte(strings.Repeat("x", maxResponseBodyCapture+100)))
},
expectedStatus: http.StatusInternalServerError,
expectedBodyBytes: strings.Repeat("x", maxResponseBodyCapture) + "...",
expectedClientBody: strings.Repeat("x", maxResponseBodyCapture+100),
},
{
name: "NoContent_SuppressesWrite",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusNoContent)
_, _ = rw.Write([]byte("should be suppressed"))
},
expectedStatus: http.StatusNoContent,
expectedBodyBytes: "",
expectedClientBody: "",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
var captured responseCapture
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
buf := &byteBuffer{}
captured = newResponseCapture(rw, buf)
testCase.handler(captured, req)
}))
defer server.Close()
resp, err := http.Get(server.URL)
assert.NoError(t, err)
defer resp.Body.Close()
clientBody, _ := io.ReadAll(resp.Body)
assert.Equal(t, testCase.expectedStatus, captured.StatusCode())
assert.Equal(t, testCase.expectedBodyBytes, string(captured.BodyBytes()))
assert.Equal(t, testCase.expectedClientBody, string(clientBody))
})
}
}

View File

@@ -5,7 +5,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
jsoniter "github.com/json-iterator/go"
"github.com/tidwall/gjson"
)
const (
@@ -43,45 +42,6 @@ func Success(rw http.ResponseWriter, httpCode int, data interface{}) {
_, _ = rw.Write(body)
}
func ErrorCodeFromBody(body []byte) string {
code := gjson.GetBytes(body, "error.code").String()
// This should never return empty since we only call this function on responses that were generated by us.
// If it does return empty, the codebase has failed to use render package for error responses somewhere, and we should fix that instead of trying to handle it here.
if code == "" {
return errors.CodeUnset.String()
}
return code
}
func ErrorTypeFromStatusCode(statusCode int) string {
// We are losing the exact type information here, but we can at least capture the error code and message for better observability.
// To get the exact type, we would need some changes in the render package to include the error type in the response, which we can consider in the future if there is a need for it.
switch statusCode {
case http.StatusBadRequest:
return errors.TypeInvalidInput.String()
case http.StatusNotFound:
return errors.TypeNotFound.String()
case http.StatusConflict:
return errors.TypeAlreadyExists.String()
case http.StatusUnauthorized:
return errors.TypeUnauthenticated.String()
case http.StatusNotImplemented:
return errors.TypeUnsupported.String()
case http.StatusForbidden:
return errors.TypeForbidden.String()
case statusClientClosedConnection:
return errors.TypeCanceled.String()
case http.StatusGatewayTimeout:
return errors.TypeTimeout.String()
case http.StatusUnavailableForLegalReasons:
return errors.TypeLicenseUnavailable.String()
default:
return errors.TypeInternal.String()
}
}
func Error(rw http.ResponseWriter, cause error) {
// Derive the http code from the error type
t, _, _, _, _, _ := errors.Unwrapb(cause)

View File

@@ -58,31 +58,6 @@ func TestSuccess(t *testing.T) {
assert.Equal(t, expected, actual)
}
func TestErrorCodeFromBody(t *testing.T) {
testCases := []struct {
name string
body []byte
wantCode string
}{
{
name: "ValidErrorResponse",
body: []byte(`{"status":"error","error":{"code":"authz_forbidden","message":"only admins can access this resource"}}`),
wantCode: "authz_forbidden",
},
{
name: "InvalidJSON",
body: []byte(`not json`),
wantCode: "unset",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.wantCode, ErrorCodeFromBody(testCase.body))
})
}
}
func TestError(t *testing.T) {
listener, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)

View File

@@ -1,21 +0,0 @@
package implcloudintegration
import (
"context"
citypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
type definitionStore struct{}
func NewDefinitionStore() citypes.ServiceDefinitionStore {
return &definitionStore{}
}
func (d *definitionStore) Get(ctx context.Context, provider citypes.CloudProviderType, serviceID citypes.ServiceID) (*citypes.ServiceDefinition, error) {
panic("unimplemented")
}
func (d *definitionStore) List(ctx context.Context, provider citypes.CloudProviderType) ([]*citypes.ServiceDefinition, error) {
panic("unimplemented")
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 85 85" fill="#fff" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round"><use xlink:href="#A" x="2.5" y="2.5"/><symbol id="A" overflow="visible"><g stroke="none"><path d="M0 41.579C0 20.293 17.84 3.157 40 3.157s40 17.136 40 38.422S62.16 80 40 80 0 62.864 0 41.579z" fill="#9d5025"/><path d="M0 38.422C0 17.136 17.84 0 40 0s40 17.136 40 38.422-17.84 38.422-40 38.422S0 59.707 0 38.422z" fill="#f58536"/><path d="M51.672 7.387v13.952H28.327V7.387zm18.061 40.378v11.364h-11.83V47.765zm-14.958 0v11.364h-11.83V47.765zm-18.206 0v11.364h-11.83V47.765zm-14.959 0v11.364H9.78V47.765z"/><path d="M14.63 37.929h2.13v11.149h-2.13z"/><path d="M14.63 37.929h17.088v2.045H14.63z"/><path d="M29.589 37.929h2.13v11.149H29.59zm18.206 0h2.13v11.149h-2.13z"/><path d="M47.795 37.929h17.088v2.045H47.795z"/><path d="M62.754 37.929h2.13v11.149h-2.129zm-40.631-7.954h2.13v8.977h-2.13zM38.935 19.28h2.13v10.859h-2.129z"/><path d="M22.123 29.116h35.32v2.045h-35.32z"/><path d="M55.314 29.975h2.13v8.977h-2.129z"/></g></symbol></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,468 +0,0 @@
{
"id": "alb",
"title": "ALB",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": false
},
"dataCollected": {
"metrics": [
{
"name": "aws_ApplicationELB_ActiveConnectionCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ActiveConnectionCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ActiveConnectionCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ActiveConnectionCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_AnomalousHostCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_AnomalousHostCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_AnomalousHostCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_AnomalousHostCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ConsumedLCUs_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ConsumedLCUs_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ConsumedLCUs_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ConsumedLCUs_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_2XX_Count_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_2XX_Count_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_2XX_Count_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_2XX_Count_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_4XX_Count_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_4XX_Count_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_4XX_Count_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_4XX_Count_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyHostCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyHostCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyHostCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyHostCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateDNS_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateDNS_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateDNS_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateDNS_sum",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateRouting_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateRouting_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateRouting_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateRouting_sum",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_MitigatedHostCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_MitigatedHostCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_MitigatedHostCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_MitigatedHostCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_NewConnectionCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_NewConnectionCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_NewConnectionCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_NewConnectionCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_PeakLCUs_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_PeakLCUs_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_PeakLCUs_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_PeakLCUs_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ProcessedBytes_count",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ProcessedBytes_max",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ProcessedBytes_min",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ProcessedBytes_sum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCountPerTarget_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCountPerTarget_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCountPerTarget_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCountPerTarget_sum",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_TargetResponseTime_count",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_TargetResponseTime_max",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_TargetResponseTime_min",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_TargetResponseTime_sum",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnHealthyHostCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnHealthyHostCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnHealthyHostCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnHealthyHostCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateDNS_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateDNS_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateDNS_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateDNS_sum",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateRouting_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateRouting_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateRouting_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateRouting_sum",
"unit": "None",
"type": "Gauge",
"description": ""
}
],
"logs": []
},
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
{
"Namespace": "AWS/ApplicationELB"
}
]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "ALB Overview",
"description": "Overview of Application Load Balancer",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,3 +0,0 @@
### Monitor Application Load Balancers with SigNoz
Collect key ALB metrics and view them with an out of the box dashboard.

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