Compare commits

...

38 Commits

Author SHA1 Message Date
Vinícius Lourenço
97c75cb9ca feat(infra-monitoring): migrate nodes to shared component 2026-04-06 10:50:07 -03:00
Vinícius Lourenço
1f50a3bd8b refactor(infra-k8s): use constant for NANO_SECOND_MULTIPLIER 2026-04-06 10:49:19 -03:00
Vinícius Lourenço
46a16a77ad fix(k8s-base-details): issue with floating division 2026-04-06 10:48:31 -03:00
Vinícius Lourenço
b4056956be fix(k8s-base-list): ensure loading always appear when loading data 2026-04-03 09:39:57 -03:00
Vinícius Lourenço
8a8cc857f9 fix(k8s-base): not showing ellipsis 2026-04-02 17:33:59 -03:00
Vinícius Lourenço
1ed468af21 refactor(utils): better organization for common code 2026-04-02 17:26:58 -03:00
Vinícius Lourenço
dfb1dcd86a fix(table-module): css of the group header not correct 2026-04-02 17:20:26 -03:00
Vinícius Lourenço
976d2d2f15 test(k8s-base-list): fix count of fetch times 2026-04-02 17:11:02 -03:00
Vinícius Lourenço
e8b5aef7c9 fix(css): table with wrong background color 2026-04-02 17:10:32 -03:00
Vinícius Lourenço
b0831ca7e2 feat(global-time-adapter): adopt new global time adapter 2026-04-02 12:01:50 -03:00
Vinícius Lourenço
4c12bebe00 fix(k8s-base-details): auto-refresh causing the entire modal to refresh 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
b81217735e chore(table.scss): fix prettify 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
6a63fa5659 refactor(base-details): move close to inside component 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
6fa1a4dd0d refactor(infra-monitoring): create hook for selected item 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
1d78dd9da0 chore(k8s-base-list): cleaning up comments 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
f0193088d4 fix(k8s-base-details): remove eslint ignore 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
0e0febf9bb test(k8s-base-list): add more details why the test fails 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
8247fcb4b9 fix(table.tsx): ensure all classes are on css modules 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
d7f55f0950 fix(dead-code): remove unused code 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
8772b1808d fix(prettify): ensure all components are correct formatted 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
db88491045 fix(css): use more semantic tokens 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
7ba97faf20 fix(empty-state): rendering svg too big 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
b5d46780d9 refactor(infra-monitoring): add tests & migrate all css to css modules 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
86e6e51c81 refactor(list): split expanded row into own component & change how the selected item is rendered 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
4ff7529856 refactor(k8s-pods): split pod new components into more config files 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
eea9c0c586 refactor(k8s-filters-panel): use our components & css modules 2026-04-02 10:56:18 -03:00
Vinícius Lourenço
d8273eab86 feat(pod-details): extract to a common generic component 2026-04-02 10:56:17 -03:00
Vinícius Lourenço
3a9c5daab8 feat(infra-monitoring): single component to be reused across pages 2026-04-02 10:56:17 -03:00
Vinicius Lourenço
419bd60a41 feat(global-time-adapter): start migration away from redux (#10780)
Some checks failed
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
* feat(global-time-adapter): start migration away from redux

* test(constant): add missing constants

* refactor(hooks): move to hooks folder
2026-04-02 11:17:49 +00:00
SagarRajput-7
b6b689902d feat: added doc links for service account and misc changes (#10804)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: added doc links for service account and misc changes

* feat: remove announcement and presisted announcement banner, since we app this in periscope library

* feat: updated banner text
2026-04-02 08:22:31 +00:00
Naman Verma
65402ca367 fix: warning instead of error for dormant metrics in query range API (#10737)
* fix: warning instead of error for dormant metrics in query range API

* fix: add missing else

* fix: keep track of present aggregations

* fix: note present aggregation after type is set

* test: integration test fix and new test

* chore: lint errors

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-04-02 07:25:29 +00:00
Tushar Vats
f71d5bf8f1 fix: added validations for having expression (#10286)
* fix: added validations for having expression

* fix: added extra validation and unit tests

* fix: added antlr based parsing for validation

* fix: added more unit tests

* fix: removed validation on having in range request validations

* fix: generated lexer files and added more unit tests

* fix: edge cases

* fix: added cmnd to scripts for generating lexer

* fix: use std libg sorting instead of selection sort

* fix: support implicit and

* fix: allow bare not in expression

* fix: added suggestion for having expression

* fix: typo

* fix: added more unit tests, handle white space difference in aggregation exp and having exp

* fix: added support for in and not, updated errors

* fix: added support for brackets list

* fix: lint error

* fix: handle non spaced expression

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-04-02 03:52:11 +00:00
Srikanth Chekuri
5abfd0732a chore: remove deprecated v3/v4 support in rules (#10760)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: remove deprecated v3/v4 support in rules

* chore: fix test

* chore: fix logs

* chore: fix logging

* chore: fix ci

* chore: address review comments
2026-04-01 19:48:37 +00:00
swapnil-signoz
4f273b296e refactor: removing dashboard overview images (#10801)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-04-01 16:25:55 +00:00
swapnil-signoz
3dc0a7c8ce feat: adding aws service definitions in types (#10798)
* feat: adding aws service definitions in types

* refactor: moving definitions to module fs
2026-04-01 14:08:46 +00:00
Liapis Nikolaos
1080553905 feat(logs): pretty-print JSON attribute values when copying to clipboard (#10778)
When copying log attribute values that contain valid JSON objects or
arrays, the value is now pretty-printed with 2-space indentation.
This makes it easy to paste into JSON tools or editors.

Non-JSON values (strings, numbers, booleans) are unaffected.

Closes #8208
2026-04-01 13:01:09 +00:00
Nikhil Soni
23e3c75d24 feat: return all spans for flamegraph under a limit (#10757)
* feat: return all spans for flamegraph under a limit

* feat: increase fg limits and add timestamp boundaries

* fix: set default value for ts boundary

* fix: use correct value for boundary end ts

* chore: change info log of flamegraph to debug
2026-04-01 10:18:36 +00:00
Pandey
42415e0873 feat(audit): handler-level AuditDef, audit middleware, and response capture (#10791)
* feat(audit): handler-level AuditDef and response-capturing wrapper

Add declarative audit instrumentation to the handler package. Routes
declare an AuditDef alongside OpenAPIDef; the handler automatically
captures the response status/body and emits an audit event via
auditor.Audit() after every request.

* refactor(audit): move audit logic to middleware, merge with logging

Move audit event emission from handler to middleware layer. The handler
package keeps only the AuditDef struct and AuditDefProvider interface.
The logging middleware now handles both request logging and audit event
emission using a single response capture, avoiding double-wrapping.

Rename badResponseLoggingWriter to responseCapture with body capture
on all 4xx/5xx responses (previously only 400 and 5xx).

* refactor(audit): rename Logging middleware to Audit, merge into single file

Delete logging.go and merge its contents into audit.go. Rename
Logging/NewLogging to Audit/NewAudit. The response.go file with
responseCapture is unchanged.

* refactor(audit): extract NewAuditEventFromHTTPRequest factory into audittypes

Move event construction to audittypes.NewAuditEventFromHTTPRequest with
an AuditEventContext struct for caller-provided fields. The audittypes
layer reads only transport fields from *http.Request and has no mux,
authtypes, or context dependencies. The middleware pre-extracts
principal, trace, error, and route fields before calling the factory.

* refactor(audit): move error parsing to render.ErrorFromBody and render.ErrorTypeFromStatusCode

Add render.ErrorFromBody to extract errors.JSON from a JSON-encoded
ErrorResponse body, and render.ErrorTypeFromStatusCode to reverse-map
HTTP status codes to error type strings. The middleware now uses these
instead of local duplicates.

* refactor(audit): move AuditDef onto Handler interface, consolidate files

Move AuditDef() onto the Handler interface directly. All Handler
implementations now carry it: handler returns the configured def,
healthOpenAPIHandler returns nil. Delete the separate AuditDefProvider
interface and audit.go handler file. Move excludedRoutes check before
audit emission so excluded routes skip both logging and audit.

* feat(audit): add option.go with AuditDef, Option, and WithAuditDef

* refactor(audit): decompose AuditEvent into attribute sub-structs, add tests

Decompose flat AuditEvent fields into typed sub-structs
(AuditEventAuditAttributes, PrincipalAttributes, ResourceAttributes,
ErrorAttributes, TransportAttributes) each with a constructor and
Put(pcommon.Map) method. Simplify NewAuditEventFromHTTPRequest to
accept authtypes.Claims and oteltrace IDs directly. Simplify the
middleware caller accordingly.

Add unit tests for the factory, outcome boundary, and principal type
derivation.

* refactor(audit): shorten attribute struct names, drop error message

Rename AuditEventAuditAttributes to AuditAttributes,
AuditEventPrincipalAttributes to PrincipalAttributes, and likewise
for Resource, Error, and Transport. The package prefix already
disambiguates.

Remove ErrorMessage from ErrorAttributes to avoid leaking sensitive
or PII data into audit logs. Error type and code are sufficient for
filtering; investigators can correlate via trace ID.

* fix(audit): update auditorserver test and otlphttp provider for new struct layout

Update newTestEvent in server_test.go to use nested AuditAttributes
and ResourceAttributes. Update otlphttpauditor provider to access
PrincipalOrgID via PrincipalAttributes. Fix godot lint on attribute
section comments.

* fix(audit): fix gjson path in ErrorCodeFromBody, add tests

Fix ErrorCodeFromBody gjson path from "errors.code" to "error.code"
to match the ErrorResponse JSON structure. Add unit tests for valid
error response and invalid JSON cases.

* fix(audit): add CodeUnset, use ErrorCodeFromBody in middleware

Add errors.CodeUnset for responses missing an error code. Update the
audit middleware to use render.ErrorCodeFromBody instead of the removed
render.ErrorFromBody.

* test(audit): add unit tests for responseCapture

Test the four meaningful behaviors: success responses don't capture
body, error responses capture body, large error bodies truncate at
4096 bytes, and 204 No Content suppresses writes entirely.

* fix(audit): check rw.Write return values in response_test.go

* style(audit): rename want prefix to expected in test fields

* refactor(audit): replace Sprintf with strings.Builder in newBody

Handle edge cases where principal email, ID, or resource ID may be
empty. The builder conditionally includes each segment, avoiding
empty parentheses or leading spaces in the audit body.

Add test cases covering all meaningful combinations: success/failure
with full/partial/empty principal, resource ID, and error details.

* chore: fix formatting

* chore: remove json tags

* fix: rebase with main
2026-04-01 10:10:52 +00:00
258 changed files with 79913 additions and 8887 deletions

View File

@@ -2313,15 +2313,6 @@ components:
- status
- error
type: object
RulestatehistorytypesAlertState:
enum:
- inactive
- pending
- recovering
- firing
- nodata
- disabled
type: string
RulestatehistorytypesGettableRuleStateHistory:
properties:
fingerprint:
@@ -2333,15 +2324,15 @@ components:
nullable: true
type: array
overallState:
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
$ref: '#/components/schemas/RuletypesAlertState'
overallStateChanged:
type: boolean
ruleID:
ruleId:
type: string
ruleName:
type: string
state:
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
$ref: '#/components/schemas/RuletypesAlertState'
stateChanged:
type: boolean
unixMilli:
@@ -2351,7 +2342,7 @@ components:
format: double
type: number
required:
- ruleID
- ruleId
- ruleName
- overallState
- overallStateChanged
@@ -2441,12 +2432,21 @@ components:
format: int64
type: integer
state:
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
$ref: '#/components/schemas/RuletypesAlertState'
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/RulestatehistorytypesAlertState'
$ref: '#/components/schemas/RuletypesAlertState'
- 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.PrincipalOrgID.IsZero() {
if event.PrincipalAttributes.PrincipalOrgID.IsZero() {
provider.settings.Logger().WarnContext(ctx, "audit event dropped as org_id is zero")
return
}
if _, err := provider.licensing.GetActive(ctx, event.PrincipalOrgID); err != nil {
if _, err := provider.licensing.GetActive(ctx, event.PrincipalAttributes.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,7 +49,6 @@ 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"
)
@@ -99,7 +98,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
)
rm, err := makeRulesManager(
reader,
signoz.Cache,
signoz.Alertmanager,
signoz.SQLStore,
@@ -229,7 +227,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.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
r.Use(middleware.NewComment().Wrap)
apiHandler.RegisterRoutes(r, am)
@@ -345,7 +343,7 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}
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) {
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) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
// create manager opts
@@ -354,7 +352,6 @@ func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertma
MetadataStore: metadataStore,
Prometheus: prometheus,
Context: context.Background(),
Reader: ch,
Querier: querier,
Logger: providerSettings.Logger,
Cache: cache,
@@ -365,7 +362,7 @@ func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertma
OrgGetter: orgGetter,
RuleStore: ruleStore,
MaintenanceStore: maintenanceStore,
SqlStore: sqlstore,
SQLStore: sqlstore,
QueryParser: queryParser,
RuleStateHistoryModule: ruleStateHistoryModule,
}

View File

@@ -5,58 +5,34 @@ 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/query-service/common"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"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"
querierV5 "github.com/SigNoz/signoz/pkg/querier"
anomalyV2 "github.com/SigNoz/signoz/ee/anomaly"
"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
reader interfaces.Reader
// querier is used for alerts migrated after the introduction of new query builder
querier querier.Querier
// 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
provider anomaly.Provider
version string
logger *slog.Logger
@@ -70,18 +46,16 @@ func NewAnomalyRule(
id string,
orgID valuer.UUID,
p *ruletypes.PostableRule,
reader interfaces.Reader,
querierV5 querierV5.Querier,
querier querier.Querier,
logger *slog.Logger,
cache cache.Cache,
opts ...baserules.RuleOption,
) (*AnomalyRule, error) {
logger.Info("creating new AnomalyRule", "rule_id", id)
logger.Info("creating new AnomalyRule", slog.String("rule.id", id))
opts = append(opts, baserules.WithLogger(logger))
baseRule, err := baserules.NewBaseRule(id, orgID, p, reader, opts...)
baseRule, err := baserules.NewBaseRule(id, orgID, p, opts...)
if err != nil {
return nil, err
}
@@ -101,93 +75,38 @@ func NewAnomalyRule(
t.seasonality = anomaly.SeasonalityDaily
}
logger.Info("using seasonality", "seasonality", t.seasonality.String())
logger.Info("using seasonality", slog.String("rule.id", id), slog.String("rule.seasonality", t.seasonality.StringValue()))
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.WithCache[*anomaly.HourlyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.HourlyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.HourlyProvider](reader),
anomaly.WithQuerier[*anomaly.HourlyProvider](querier),
anomaly.WithLogger[*anomaly.HourlyProvider](logger),
)
} else if t.seasonality == anomaly.SeasonalityDaily {
t.provider = anomaly.NewDailyProvider(
anomaly.WithCache[*anomaly.DailyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.DailyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.DailyProvider](reader),
anomaly.WithQuerier[*anomaly.DailyProvider](querier),
anomaly.WithLogger[*anomaly.DailyProvider](logger),
)
} else if t.seasonality == anomaly.SeasonalityWeekly {
t.provider = anomaly.NewWeeklyProvider(
anomaly.WithCache[*anomaly.WeeklyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.WeeklyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.WeeklyProvider](reader),
anomaly.WithQuerier[*anomaly.WeeklyProvider](querier),
anomaly.WithLogger[*anomaly.WeeklyProvider](logger),
)
}
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.querier = querier
t.version = p.Version
t.logger = logger
return &t, nil
}
func (r *AnomalyRule) Type() ruletypes.RuleType {
return RuleTypeAnomaly
return ruletypes.RuleTypeAnomaly
}
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v3.QueryRangeParamsV3, error) {
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
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())
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()))
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
@@ -203,25 +122,14 @@ func (r *AnomalyRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (*q
}
req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(r.Condition().CompositeQuery.Queries))
copy(req.CompositeQuery.Queries, r.Condition().CompositeQuery.Queries)
return req, nil
}
func (r *AnomalyRule) GetSelectedQuery() string {
return r.Condition().GetSelectedQueryName()
return req
}
func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
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")
}
params := r.prepareQueryRange(ctx, ts)
anomalies, err := r.provider.GetAnomalies(ctx, orgID, &anomaly.GetAnomaliesRequest{
anomalies, err := r.provider.GetAnomalies(ctx, orgID, &anomaly.AnomaliesRequest{
Params: params,
Seasonality: r.seasonality,
})
@@ -229,87 +137,43 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
return nil, err
}
var queryResult *v3.Result
var queryResult *qbtypes.TimeSeriesData
for _, result := range anomalies.Results {
if result.QueryName == r.GetSelectedQuery() {
if result.QueryName == r.SelectedQuery(ctx) {
queryResult = result
break
}
}
hasData := len(queryResult.AnomalyScores) > 0
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
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.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))
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)))
// Filter out new series if newGroupEvalDelay is configured
seriesToProcess := queryResult.AnomalyScores
seriesToProcess := queryResult.Aggregations[0].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, ", errors.Attr(filterErr), "rule_name", r.Name())
r.logger.ErrorContext(ctx, "error filtering new series", slog.String("rule.id", r.ID()), errors.Attr(filterErr))
} else {
seriesToProcess = filteredSeries
}
@@ -317,10 +181,10 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
for _, series := range seriesToProcess {
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)
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))
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(),
})
@@ -341,13 +205,9 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
var res ruletypes.Vector
var err error
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)
}
r.logger.InfoContext(ctx, "running query", slog.String("rule.id", r.ID()))
res, err = r.buildAndRunQuery(ctx, r.OrgID(), ts)
if err != nil {
return 0, err
}
@@ -371,7 +231,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", "rule_name", r.Name(), "formatter", valueFormatter.Name(), "value", value, "threshold", threshold)
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))
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
// Inject some convenience variables that are easier to remember for users
@@ -386,35 +246,34 @@ 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", errors.Attr(err), "data", tmplData, "rule_name", r.Name())
r.logger.ErrorContext(ctx, "expanding alert template failed", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.template_data", tmplData))
}
return result
}
lb := labels.NewBuilder(smpl.Metric).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel)
resultLabels := labels.NewBuilder(smpl.Metric).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel).Labels()
lb := ruletypes.NewBuilder(smpl.Metric...).Del(ruletypes.MetricNameLabel).Del(ruletypes.TemporalityLabel)
resultLabels := ruletypes.NewBuilder(smpl.Metric...).Del(ruletypes.MetricNameLabel).Del(ruletypes.TemporalityLabel).Labels()
for name, value := range r.Labels().Map() {
lb.Set(name, expand(value))
}
lb.Set(labels.AlertNameLabel, r.Name())
lb.Set(labels.AlertRuleIdLabel, r.ID())
lb.Set(labels.RuleSourceLabel, r.GeneratorURL())
lb.Set(ruletypes.AlertNameLabel, r.Name())
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := make(labels.Labels, 0, len(r.Annotations().Map()))
annotations := make(ruletypes.Labels, 0, len(r.Annotations().Map()))
for name, value := range r.Annotations().Map() {
annotations = append(annotations, labels.Label{Name: name, Value: expand(value)})
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
}
if smpl.IsMissing {
lb.Set(labels.AlertNameLabel, "[No data] "+r.Name())
lb.Set(labels.NoDataLabel, "true")
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")
}
lbs := lb.Labels()
@@ -422,17 +281,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", "rule_id", r.ID(), "alert", alerts[h])
err = fmt.Errorf("duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
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")
return 0, err
}
alerts[h] = &ruletypes.Alert{
Labels: lbs,
QueryResultLables: resultLabels,
QueryResultLabels: resultLabels,
Annotations: annotations,
ActiveAt: ts,
State: model.StatePending,
State: ruletypes.StatePending,
Value: smpl.V,
GeneratorURL: r.GeneratorURL(),
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
@@ -441,12 +300,12 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
}
r.logger.InfoContext(ctx, "number of alerts found", "rule_name", r.Name(), "alerts_count", len(alerts))
r.logger.InfoContext(ctx, "number of alerts found", slog.String("rule.id", r.ID()), slog.Int("alert.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 != model.StateInactive {
if alert, ok := r.Active[h]; ok && alert.State != ruletypes.StateInactive {
alert.Value = a.Value
alert.Annotations = a.Annotations
@@ -462,76 +321,76 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
r.Active[h] = a
}
itemsToAdd := []model.RuleStateHistory{}
itemsToAdd := []rulestatehistorytypes.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.QueryResultLables)
labelsJSON, err := json.Marshal(a.QueryResultLabels)
if err != nil {
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err), "labels", a.Labels)
r.logger.ErrorContext(ctx, "error marshaling labels", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.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 == model.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
if a.State == ruletypes.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
delete(r.Active, fp)
}
if a.State != model.StateInactive {
a.State = model.StateInactive
if a.State != ruletypes.StateInactive {
a.State = ruletypes.StateInactive
a.ResolvedAt = ts
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: model.StateInactive,
State: ruletypes.StateInactive,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Value: a.Value,
})
}
continue
}
if a.State == model.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration().Duration() {
a.State = model.StateFiring
if a.State == ruletypes.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration().Duration() {
a.State = ruletypes.StateFiring
a.FiredAt = ts
state := model.StateFiring
state := ruletypes.StateFiring
if a.Missing {
state = model.StateNoData
state = ruletypes.StateNoData
}
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: state,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Value: a.Value,
})
}
// We need to change firing alert to recovering if the returned sample meets recovery threshold
changeFiringToRecovering := a.State == model.StateFiring && a.IsRecovering
changeFiringToRecovering := a.State == ruletypes.StateFiring && a.IsRecovering
// We need to change recovering alerts to firing if the returned sample meets target threshold
changeRecoveringToFiring := a.State == model.StateRecovering && !a.IsRecovering && !a.Missing
changeRecoveringToFiring := a.State == ruletypes.StateRecovering && !a.IsRecovering && !a.Missing
// in any of the above case we need to update the status of alert
if changeFiringToRecovering || changeRecoveringToFiring {
state := model.StateRecovering
state := ruletypes.StateRecovering
if changeRecoveringToFiring {
state = model.StateFiring
state = ruletypes.StateFiring
}
a.State = state
r.logger.DebugContext(ctx, "converting alert state", "name", r.Name(), "state", state)
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
r.logger.DebugContext(ctx, "converting alert state", slog.String("rule.id", r.ID()), slog.Any("alert.state", state))
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: state,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Value: a.Value,
})
}

View File

@@ -2,21 +2,19 @@ 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"
"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"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"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.
@@ -24,13 +22,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.GetAnomaliesResponse
responses []*anomaly.AnomaliesResponse
callCount int
}
func (m *mockAnomalyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *anomaly.GetAnomaliesRequest) (*anomaly.GetAnomaliesResponse, error) {
func (m *mockAnomalyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *anomaly.AnomaliesRequest) (*anomaly.AnomaliesResponse, error) {
if m.callCount >= len(m.responses) {
return &anomaly.GetAnomaliesResponse{Results: []*v3.Result{}}, nil
return &anomaly.AnomaliesResponse{Results: []*qbtypes.TimeSeriesData{}}, nil
}
resp := m.responses[m.callCount]
m.callCount++
@@ -49,45 +47,46 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Test anomaly no data",
AlertType: ruletypes.AlertTypeMetric,
RuleType: RuleTypeAnomaly,
RuleType: ruletypes.RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: evalWindow,
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
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,
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,
},
},
}},
},
SelectedQuery: "A",
Seasonality: "daily",
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
Name: "Test anomaly no data",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
Name: "Test anomaly no data",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
}},
},
},
}
responseNoData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
responseNoData := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
{
QueryName: "A",
AnomalyScores: []*v3.Series{},
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{},
}},
},
},
}
@@ -115,23 +114,17 @@ 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.GetAnomaliesResponse{responseNoData},
responses: []*anomaly.AnomaliesResponse{responseNoData},
}
alertsFound, err := rule.Eval(context.Background(), evalTime)
@@ -156,46 +149,47 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Test anomaly no data with AbsentFor",
AlertType: ruletypes.AlertTypeMetric,
RuleType: RuleTypeAnomaly,
RuleType: ruletypes.RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: evalWindow,
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
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,
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,
},
},
}},
},
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,
CompareOp: ruletypes.ValueIsAbove,
Name: "Test anomaly no data with AbsentFor",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
}},
},
},
}
responseNoData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
responseNoData := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
{
QueryName: "A",
AnomalyScores: []*v3.Series{},
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{},
}},
},
},
}
@@ -229,32 +223,35 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
t1 := baseTime.Add(5 * time.Minute)
t2 := t1.Add(c.timeBetweenEvals)
responseWithData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
responseWithData := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
{
QueryName: "A",
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},
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},
},
},
},
},
}},
},
},
}
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)
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, nil, logger)
require.NoError(t, err)
rule.provider = &mockAnomalyProvider{
responses: []*anomaly.GetAnomaliesResponse{responseWithData, responseNoData},
responses: []*anomaly.AnomaliesResponse{responseWithData, responseNoData},
}
alertsFound1, err := rule.Eval(context.Background(), t1)

View File

@@ -11,9 +11,7 @@ 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"
)
@@ -23,7 +21,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)
@@ -32,10 +30,9 @@ 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),
@@ -58,11 +55,10 @@ 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),
@@ -82,13 +78,11 @@ 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),
@@ -105,7 +99,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, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
}
return task, nil
@@ -113,12 +107,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, *basemodel.ApiError) {
func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
ctx := context.Background()
if opts.Rule == nil {
return 0, basemodel.BadRequest(fmt.Errorf("rule is required"))
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "rule is required")
}
parsedRule := opts.Rule
@@ -138,15 +132,14 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
if parsedRule.RuleType == ruletypes.RuleTypeThreshold {
// add special labels for test alerts
parsedRule.Labels[labels.RuleSourceLabel] = ""
parsedRule.Labels[labels.AlertRuleIdLabel] = ""
parsedRule.Labels[ruletypes.RuleSourceLabel] = ""
parsedRule.Labels[ruletypes.AlertRuleIDLabel] = ""
// create a threshold rule
rule, err = baserules.NewThresholdRule(
alertname,
opts.OrgID,
parsedRule,
opts.Reader,
opts.Querier,
opts.Logger,
baserules.WithSendAlways(),
@@ -158,7 +151,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
if err != nil {
slog.Error("failed to prepare a new threshold rule for test", "name", alertname, errors.Attr(err))
return 0, basemodel.BadRequest(err)
return 0, err
}
} else if parsedRule.RuleType == ruletypes.RuleTypeProm {
@@ -169,7 +162,6 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
opts.OrgID,
parsedRule,
opts.Logger,
opts.Reader,
opts.ManagerOpts.Prometheus,
baserules.WithSendAlways(),
baserules.WithSendUnmatched(),
@@ -180,7 +172,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
if err != nil {
slog.Error("failed to prepare a new promql rule for test", "name", alertname, errors.Attr(err))
return 0, basemodel.BadRequest(err)
return 0, err
}
} else if parsedRule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
@@ -188,10 +180,8 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
alertname,
opts.OrgID,
parsedRule,
opts.Reader,
opts.Querier,
opts.Logger,
opts.Cache,
baserules.WithSendAlways(),
baserules.WithSendUnmatched(),
baserules.WithSQLStore(opts.SQLStore),
@@ -200,10 +190,10 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
)
if err != nil {
slog.Error("failed to prepare a new anomaly rule for test", "name", alertname, errors.Attr(err))
return 0, basemodel.BadRequest(err)
return 0, err
}
} else {
return 0, basemodel.BadRequest(fmt.Errorf("failed to derive ruletype with given information"))
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to derive ruletype with given information")
}
// set timestamp to current utc time
@@ -212,7 +202,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
alertsFound, err := rule.Eval(ctx, ts)
if err != nil {
slog.Error("evaluating rule failed", "rule", rule.Name(), errors.Attr(err))
return 0, basemodel.InternalError(fmt.Errorf("rule evaluation failed"))
return 0, err
}
rule.SendAlerts(ctx, ts, 0, time.Minute, opts.NotifyFunc)

View File

@@ -114,11 +114,8 @@ func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
},
})
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)
count, err := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
require.Nil(t, err)
assert.Equal(t, tc.ExpectAlerts, count)
if tc.ExpectAlerts > 0 {
@@ -268,11 +265,8 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
},
})
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)
count, err := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
require.Nil(t, err)
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.1",
"@signozhq/design-tokens": "2.1.4",
"@signozhq/dialog": "^0.0.2",
"@signozhq/drawer": "0.0.4",
"@signozhq/icons": "0.1.0",

View File

@@ -2710,14 +2710,6 @@ 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
@@ -2729,7 +2721,7 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
* @nullable true
*/
labels: Querybuildertypesv5LabelDTO[] | null;
overallState: RulestatehistorytypesAlertStateDTO;
overallState: RuletypesAlertStateDTO;
/**
* @type boolean
*/
@@ -2737,12 +2729,12 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
/**
* @type string
*/
ruleID: string;
ruleId: string;
/**
* @type string
*/
ruleName: string;
state: RulestatehistorytypesAlertStateDTO;
state: RuletypesAlertStateDTO;
/**
* @type boolean
*/
@@ -2840,9 +2832,17 @@ export interface RulestatehistorytypesGettableRuleStateWindowDTO {
* @format int64
*/
start: number;
state: RulestatehistorytypesAlertStateDTO;
state: RuletypesAlertStateDTO;
}
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?: RulestatehistorytypesAlertStateDTO;
state?: RuletypesAlertStateDTO;
/**
* @type string
* @description undefined

View File

@@ -1,127 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
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 { UnderscoreToDotMap } from '../utils';
export interface K8sNodesListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface K8sNodesData {
nodeUID: string;
nodeCPUUsage: number;
nodeCPUAllocatable: number;
nodeMemoryUsage: number;
nodeMemoryAllocatable: number;
meta: {
k8s_node_name: string;
k8s_node_uid: string;
k8s_cluster_name: string;
};
}
export interface K8sNodesListResponse {
status: string;
data: {
type: string;
records: K8sNodesData[];
groups: null;
total: number;
sentAnyHostMetricsData: boolean;
isSendingK8SAgentMetrics: boolean;
};
}
export const nodesMetaMap = [
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
{ dot: 'k8s.node.uid', under: 'k8s_node_uid' },
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
] as const;
export function mapNodesMeta(
raw: Record<string, unknown>,
): K8sNodesData['meta'] {
const out: Record<string, unknown> = { ...raw };
nodesMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sNodesData['meta'];
}
export const getK8sNodesList = async (
props: K8sNodesListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sNodesListResponse> | ErrorResponse> => {
try {
const requestProps =
dotMetricsEnabled && Array.isArray(props.filters?.items)
? {
...props,
filters: {
...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;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/nodes/list', requestProps, {
signal,
headers,
});
const payload: K8sNodesListResponse = response.data;
// one-liner to map dot→underscore
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapNodesMeta(record.meta as Record<string, unknown>),
}));
return {
statusCode: 200,
error: null,
message: 'Success',
payload,
params: requestProps,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,227 @@
// 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,7 +165,17 @@ 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.</p>
<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>
<Button
type="button"
className="keys-tab__learn-more"

View File

@@ -1,4 +1,11 @@
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,5 +248,35 @@ 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,6 +27,9 @@ 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 = {
@@ -47,6 +50,9 @@ 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',
@@ -74,4 +80,7 @@ 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,20 +265,19 @@ export default function Home(): JSX.Element {
return (
<div className="home-container">
<PersistedAnnouncementBanner
type="warning"
type="info"
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,12 +1,10 @@
/* 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';
@@ -15,21 +13,13 @@ 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,
@@ -39,48 +29,130 @@ 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 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 {
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 '../../EntityDetailsUtils/entityDetails.styles.scss';
import '../EntityDetailsUtils/entityDetails.styles.scss';
const TimeRangeOffset = 1000000000;
function PodDetails({
pod,
onClose,
isModalTimeSelection,
}: PodDetailProps): JSX.Element {
const { maxTime, minTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
export interface K8sDetailsMetadataConfig<T> {
label: string;
getValue: (entity: T) => string;
}
const startMs = useMemo(() => Math.floor(Number(minTime) / TimeRangeOffset), [
minTime,
]);
const endMs = useMemo(() => Math.floor(Number(maxTime) / TimeRangeOffset), [
maxTime,
]);
export interface K8sDetailsFilters {
filters: TagFilter;
start: number;
end: number;
}
const urlQuery = useUrlQuery();
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;
}
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,
}: 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 [modalTimeRange, setModalTimeRange] = useState(() => ({
startTime: startMs,
@@ -88,11 +160,12 @@ function PodDetails({
}));
const lastSelectedInterval = useRef<Time | null>(null);
const [selectedInterval, setSelectedInterval] = useState<Time>(
lastSelectedInterval.current
? lastSelectedInterval.current
: (selectedTime as Time),
: isCustomTimeRange(selectedTime)
? DEFAULT_TIME_RANGE
: selectedTime,
);
const [selectedView, setSelectedView] = useInfraMonitoringView();
@@ -107,79 +180,79 @@ function PodDetails({
] = useInfraMonitoringEventsFilters();
const isDarkMode = useIsDarkMode();
const [selectedItem, setSelectedItem] = useInfraMonitoringSelectedItem();
const urlQuery = useUrlQuery();
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 =
selectedView === VIEW_TYPES.LOGS ? logFiltersParam : tracesFiltersParam;
if (filters) {
return filters;
}
if (!entity) {
return { op: 'AND', items: [] };
}
return {
op: 'AND',
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 || '',
},
],
items: getInitialLogTracesFilters(entity),
};
}, [
pod?.meta.k8s_namespace_name,
pod?.meta.k8s_pod_name,
entity,
selectedView,
logFiltersParam,
tracesFiltersParam,
getInitialLogTracesFilters,
]);
const initialEventsFilters = useMemo(() => {
if (eventsFiltersParam) {
return eventsFiltersParam;
}
if (!entity) {
return { op: 'AND', items: [] };
}
return {
op: 'AND',
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 || '',
},
],
items: getInitialEventsFilters(entity),
};
}, [pod?.meta.k8s_pod_name, eventsFiltersParam]);
}, [entity, eventsFiltersParam, getInitialEventsFilters]);
const [logsAndTracesFilters, setLogsAndTracesFilters] = useState<
IBuilderQuery['filters']
@@ -190,14 +263,14 @@ function PodDetails({
);
useEffect(() => {
if (pod) {
if (entity) {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.Pod,
category: eventCategory,
});
}
}, [pod]);
}, [entity, eventCategory]);
useEffect(() => {
setLogsAndTracesFilters(initialFilters);
@@ -206,17 +279,16 @@ function PodDetails({
useEffect(() => {
const currentSelectedInterval = lastSelectedInterval.current || selectedTime;
setSelectedInterval(currentSelectedInterval as Time);
if (currentSelectedInterval !== 'custom') {
const { maxTime, minTime } = GetMinMax(currentSelectedInterval);
if (!isCustomTimeRange(currentSelectedInterval)) {
setSelectedInterval(currentSelectedInterval);
const { minTime, maxTime } = getMinMaxTime();
setModalTimeRange({
startTime: Math.floor(minTime / TimeRangeOffset),
endTime: Math.floor(maxTime / TimeRangeOffset),
});
}
}, [selectedTime, minTime, maxTime]);
}, [getMinMaxTime, selectedTime]);
const handleTabChange = (e: RadioChangeEvent): void => {
setSelectedView(e.target.value);
@@ -226,7 +298,7 @@ function PodDetails({
logEvent(InfraMonitoringEvents.TabChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.Pod,
category: eventCategory,
view: e.target.value,
});
};
@@ -253,38 +325,34 @@ function PodDetails({
logEvent(InfraMonitoringEvents.TimeUpdated, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.Pod,
category: eventCategory,
interval,
view: selectedView,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
[eventCategory, selectedView],
);
const handleChangeLogFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setLogsAndTracesFilters((prevFilters) => {
const primaryFilters = prevFilters?.items?.filter((item) =>
[
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
].includes(item.key?.key ?? ''),
primaryFilterKeys.includes(item.key?.key ?? ''),
);
const paginationFilter = value?.items?.find(
(item) => item.key?.key === 'id',
);
const newFilters = value?.items?.filter(
(item) =>
item.key?.key !== 'id' && item.key?.key !== QUERY_KEYS.K8S_CLUSTER_NAME,
item.key?.key !== 'id' &&
!primaryFilterKeys.includes(item.key?.key ?? ''),
);
if (newFilters && newFilters?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.Pod,
category: eventCategory,
view: selectedView,
});
}
@@ -306,26 +374,27 @@ function PodDetails({
return updatedFilters;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
[
setLogFiltersParam,
setSelectedView,
primaryFilterKeys,
eventCategory,
selectedView,
],
);
const handleChangeTracesFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setLogsAndTracesFilters((prevFilters) => {
const primaryFilters = prevFilters?.items?.filter((item) =>
[
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
].includes(item.key?.key ?? ''),
primaryFilterKeys.includes(item.key?.key ?? ''),
);
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.Pod,
category: eventCategory,
view: selectedView,
});
}
@@ -336,7 +405,7 @@ function PodDetails({
[
...(primaryFilters || []),
...(value?.items?.filter(
(item) => item.key?.key !== QUERY_KEYS.K8S_POD_NAME,
(item) => !primaryFilterKeys.includes(item.key?.key ?? ''),
) || []),
].filter((item): item is TagFilterItem => item !== undefined),
),
@@ -348,17 +417,22 @@ function PodDetails({
return updatedFilters;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
[
setTracesFiltersParam,
setSelectedView,
primaryFilterKeys,
eventCategory,
selectedView,
],
);
const handleChangeEventsFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setEventsFilters((prevFilters) => {
const podKindFilter = prevFilters?.items?.find(
const kindFilter = prevFilters?.items?.find(
(item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_KIND,
);
const podNameFilter = prevFilters?.items?.find(
const nameFilter = prevFilters?.items?.find(
(item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_NAME,
);
@@ -366,22 +440,24 @@ function PodDetails({
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.Pod,
category: eventCategory,
view: selectedView,
});
}
const updatedFilters = {
op: 'AND',
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),
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),
),
};
setEventsFiltersParam(updatedFilters);
@@ -390,8 +466,7 @@ function PodDetails({
return updatedFilters;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
[eventCategory, selectedView, setEventsFiltersParam, setSelectedView],
);
const handleExplorePagesRedirect = (): void => {
@@ -406,7 +481,7 @@ function PodDetails({
logEvent(InfraMonitoringEvents.ExploreClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.Pod,
category: eventCategory,
view: selectedView,
});
@@ -476,24 +551,28 @@ function PodDetails({
endTime: Math.floor(maxTime / TimeRangeOffset),
});
}
setSelectedView(VIEW_TYPES.METRICS);
onClose();
setSelectedItem(null);
setSelectedView(null);
setTracesFiltersParam(null);
setEventsFiltersParam(null);
setLogFiltersParam(null);
};
const entityName = entity ? getEntityName(entity) : '';
return (
<Drawer
width="70%"
title={
<>
<Divider type="vertical" />
<Typography.Text className="title">
{pod?.meta.k8s_pod_name}
</Typography.Text>
<Typography.Text className="title">{entityName}</Typography.Text>
</>
}
placement="right"
onClose={handleClose}
open={!!pod}
open={!!selectedItem}
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
@@ -502,49 +581,40 @@ function PodDetails({
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
{pod && (
{isEntityLoading && <LoadingContainer />}
{isEntityError && (
<Typography.Text type="danger">
{entityResponse?.error || 'Failed to load entity details'}
</Typography.Text>
)}
{entity && !isEntityLoading && (
<>
<div className="entity-detail-drawer__entity">
<div className="entity-details-grid">
<div className="labels-row">
<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>
{metadataConfig.map((config) => (
<Typography.Text
key={config.label}
type="secondary"
className="entity-details-metadata-label"
>
{config.label}
</Typography.Text>
))}
</div>
<div className="values-row">
<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>
{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>
);
})}
</div>
</div>
</div>
@@ -612,63 +682,54 @@ function PodDetails({
</div>
{selectedView === VIEW_TYPES.METRICS && (
<PodMetrics<K8sPodsData>
entity={pod}
<EntityMetrics<T>
entity={entity}
selectedInterval={selectedInterval}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
isModalTimeSelection={isModalTimeSelection}
entityWidgetInfo={podWidgetInfo}
getEntityQueryPayload={getPodMetricsQueryPayload}
category={K8sCategory.PODS}
queryKey="podMetrics"
isModalTimeSelection
entityWidgetInfo={entityWidgetInfo}
getEntityQueryPayload={getEntityQueryPayload}
category={category}
queryKey={`${queryKeyPrefix}Metrics`}
/>
)}
{selectedView === VIEW_TYPES.LOGS && (
<PodLogs
<EntityLogs
timeRange={modalTimeRange}
isModalTimeSelection={isModalTimeSelection}
isModalTimeSelection
handleTimeChange={handleTimeChange}
handleChangeLogFilters={handleChangeLogFilters}
logFilters={logsAndTracesFilters}
selectedInterval={selectedInterval}
queryKeyFilters={[
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
]}
queryKey="podLogs"
category={K8sCategory.PODS}
queryKeyFilters={primaryFilterKeys}
queryKey={`${queryKeyPrefix}Logs`}
category={category}
/>
)}
{selectedView === VIEW_TYPES.TRACES && (
<PodTraces
<EntityTraces
timeRange={modalTimeRange}
isModalTimeSelection={isModalTimeSelection}
isModalTimeSelection
handleTimeChange={handleTimeChange}
handleChangeTracesFilters={handleChangeTracesFilters}
tracesFilters={logsAndTracesFilters}
selectedInterval={selectedInterval}
queryKey="podTraces"
category={InfraMonitoringEvents.Pod}
queryKeyFilters={[
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
]}
queryKey={`${queryKeyPrefix}Traces`}
category={eventCategory}
queryKeyFilters={primaryFilterKeys}
/>
)}
{selectedView === VIEW_TYPES.EVENTS && (
<PodEvents
<EntityEvents
timeRange={modalTimeRange}
isModalTimeSelection={isModalTimeSelection}
isModalTimeSelection
handleTimeChange={handleTimeChange}
handleChangeEventFilters={handleChangeEventsFilters}
filters={eventsFilters}
selectedInterval={selectedInterval}
category={K8sCategory.PODS}
queryKey="podEvents"
category={category}
queryKey={`${queryKeyPrefix}Events`}
/>
)}
</>
@@ -677,4 +738,4 @@ function PodDetails({
);
}
export default PodDetails;
export default K8sBaseDetails;

View File

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

@@ -0,0 +1,615 @@
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(
() => data?.data?.map((item) => renderRowData(item, groupBy)),
[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(
() => pageData?.map((item) => renderRowData(item, groupBy)),
[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

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

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

@@ -0,0 +1,92 @@
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',
};
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] ?? '';
});
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] || '-';
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

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { VerticalAlignTopOutlined } from '@ant-design/icons';
import * as Sentry from '@sentry/react';
import type { CollapseProps } from 'antd';
import { Button, CollapseProps } from 'antd';
import { Collapse, Tooltip, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
@@ -16,6 +16,7 @@ import {
Computer,
Container,
FilePenLine,
Filter,
Group,
HardDrive,
Workflow,
@@ -67,9 +68,9 @@ export default function InfraMonitoringK8s(): JSX.Element {
const { currentQuery } = useQueryBuilder();
const handleFilterVisibilityChange = (): void => {
setShowFilters(!showFilters);
};
const handleFilterVisibilityChange = useCallback((): void => {
setShowFilters((show) => !show);
}, []);
const { handleChangeQueryData } = useQueryOperations({
index: 0,
@@ -321,6 +322,25 @@ 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">
@@ -355,19 +375,11 @@ export default function InfraMonitoringK8s(): JSX.Element {
}`}
>
{selectedCategory === K8sCategories.PODS && (
<K8sPodLists
isFiltersVisible={showFilters}
handleFilterVisibilityChange={handleFilterVisibilityChange}
quickFiltersLastUpdated={quickFiltersLastUpdated}
/>
<K8sPodLists controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.NODES && (
<K8sNodesList
isFiltersVisible={showFilters}
handleFilterVisibilityChange={handleFilterVisibilityChange}
quickFiltersLastUpdated={quickFiltersLastUpdated}
/>
<K8sNodesList controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.CLUSTERS && (

View File

@@ -1,17 +0,0 @@
.infra-monitoring-container {
.nodes-list-table {
.expanded-table-container {
padding-left: 40px;
}
.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,695 +1,116 @@
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 { K8sNodesListPayload } from 'api/infraMonitoring/getK8sNodesList';
import React, { useCallback } from 'react';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { useGetK8sNodesList } from 'hooks/infraMonitoring/useGetK8sNodesList';
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 { getK8sNodesList, K8sNodeData } from './api';
import {
GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS,
K8sCategory,
} from '../constants';
getNodeMetricsQueryPayload,
k8sNodeDetailsMetadataConfig,
k8sNodeGetEntityName,
k8sNodeGetSelectedItemFilters,
k8sNodeInitialEventsFilter,
k8sNodeInitialFilters,
k8sNodeInitialLogTracesFilter,
nodeWidgetInfo,
} from './constants';
import {
useInfraMonitoringCurrentPage,
useInfraMonitoringEventsFilters,
useInfraMonitoringGroupBy,
useInfraMonitoringLogFilters,
useInfraMonitoringNodeUID,
useInfraMonitoringOrderBy,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from '../hooks';
import K8sHeader from '../K8sHeader';
import LoadingContainer from '../LoadingContainer';
import { usePageSize } from '../utils';
import NodeDetails from './NodeDetails';
import {
defaultAddedColumns,
formatDataForTable,
getK8sNodesListColumns,
getK8sNodesListQuery,
K8sNodesRowData,
} from './utils';
import '../InfraMonitoringK8s.styles.scss';
import './K8sNodesList.styles.scss';
k8sNodesColumns,
k8sNodesColumnsConfig,
k8sNodesRenderRowData,
} from './table';
function K8sNodesList({
isFiltersVisible,
handleFilterVisibilityChange,
quickFiltersLastUpdated,
controlListPrefix,
}: {
isFiltersVisible: boolean;
handleFilterVisibilityChange: () => void;
quickFiltersLastUpdated: number;
controlListPrefix?: React.ReactNode;
}): 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 [selectedNodeUID, setSelectedNodeUID] = useInfraMonitoringNodeUID();
const { pageSize, setPageSize } = usePageSize(K8sCategory.NODES);
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
// These params are used only for clearing in handleCloseNodeDetail
const [, setView] = useInfraMonitoringView();
const [, setTracesFilters] = useInfraMonitoringTracesFilters();
const [, setEventsFilters] = useInfraMonitoringEventsFilters();
const [, setLogFilters] = useInfraMonitoringLogFilters();
const [selectedRowData, setSelectedRowData] = useState<K8sNodesRowData | 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 createFiltersForSelectedRowData = (
selectedRowData: K8sNodesRowData,
groupBy: IBuilderQuery['groupBy'],
): IBuilderQuery['filters'] => {
const baseFilters: IBuilderQuery['filters'] = {
items: [...queryFilters.items],
op: 'and',
};
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
filters.orderBy ||= {
columnName: 'cpu',
order: 'desc',
};
if (!selectedRowData) {
return baseFilters;
}
const { groupedByMeta } = selectedRowData;
for (const key of groupBy) {
baseFilters.items.push({
key: {
key: key.key,
type: null,
},
op: '=',
value: groupedByMeta[key.key],
id: key.key,
});
}
return baseFilters;
};
const fetchGroupedByRowDataQuery = useMemo(() => {
if (!selectedRowData) {
return null;
}
const baseQuery = getK8sNodesListQuery();
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(() => {
// 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 (selectedNodeUID) {
return [
'nodeList',
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
selectedRowDataKey,
];
}
return [
'nodeList',
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
selectedRowDataKey,
String(minTime),
String(maxTime),
];
}, [
queryFilters,
orderBy,
selectedNodeUID,
minTime,
maxTime,
selectedRowData,
]);
const {
data: groupedByRowData,
isFetching: isFetchingGroupedByRowData,
isLoading: isLoadingGroupedByRowData,
isError: isErrorGroupedByRowData,
refetch: fetchGroupedByRowData,
} = useGetK8sNodesList(
fetchGroupedByRowDataQuery as K8sNodesListPayload,
{
queryKey: groupedByRowDataQueryKey,
enabled: !!fetchGroupedByRowDataQuery && !!selectedRowData,
},
undefined,
dotMetricsEnabled,
);
const {
data: groupByFiltersData,
isLoading: isLoadingGroupByFilters,
} = useGetAggregateKeys(
{
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateAttribute: GetK8sEntityToAggregateAttribute(
K8sCategory.NODES,
const response = await getK8sNodesList(
filters,
signal,
undefined,
dotMetricsEnabled,
),
aggregateOperator: 'noop',
searchText: '',
tagType: '',
},
{
queryKey: [currentQuery.builder.queryData[0].dataSource, 'noop'],
},
true,
K8sCategory.NODES,
);
const query = useMemo(() => {
const baseQuery = getK8sNodesListQuery();
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 nestedNodesData = useMemo(() => {
if (!selectedRowData || !groupedByRowData?.payload?.data.records) {
return [];
}
return groupedByRowData?.payload?.data?.records || [];
}, [groupedByRowData, selectedRowData]);
const formattedGroupedByNodesData = useMemo(
() =>
formatDataForTable(groupedByRowData?.payload?.data?.records || [], groupBy),
[groupedByRowData, groupBy],
);
const queryKey = useMemo(() => {
if (selectedNodeUID) {
return [
'nodeList',
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
];
}
return [
'nodeList',
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
String(minTime),
String(maxTime),
];
}, [
selectedNodeUID,
pageSize,
currentPage,
queryFilters,
orderBy,
groupBy,
minTime,
maxTime,
]);
const { data, isFetching, isLoading, isError } = useGetK8sNodesList(
query as K8sNodesListPayload,
{
queryKey,
enabled: !!query,
keepPreviousData: true,
},
undefined,
dotMetricsEnabled,
);
const nodesData = useMemo(() => data?.payload?.data?.records || [], [data]);
const totalCount = data?.payload?.data?.total || 0;
const formattedNodesData = useMemo(
() => formatDataForTable(nodesData, groupBy),
[nodesData, groupBy],
);
const columns = useMemo(() => getK8sNodesListColumns(groupBy), [groupBy]);
const handleGroupByRowClick = (record: K8sNodesRowData): 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<K8sNodesRowData>['onChange'] = useCallback(
(
pagination: TablePaginationConfig,
_filters: Record<string, (string | number | boolean)[] | null>,
sorter: SorterResult<K8sNodesRowData> | SorterResult<K8sNodesRowData>[],
): void => {
if (pagination.current) {
setCurrentPage(pagination.current);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Node,
});
}
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.Node,
});
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
useEffect(() => {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Node,
total: data?.payload?.data?.total,
});
}, [data?.payload?.data?.total]);
const selectedNodeData = useMemo(() => {
if (!selectedNodeUID) {
return null;
}
if (groupBy.length > 0) {
// If grouped by, return the node from the formatted grouped by nodes data
return (
nestedNodesData.find((node) => node.nodeUID === selectedNodeUID) || null
);
}
// If not grouped by, return the node from the nodes data
return nodesData.find((node) => node.nodeUID === selectedNodeUID) || null;
}, [selectedNodeUID, groupBy.length, nodesData, nestedNodesData]);
const openNodeInNewTab = (record: K8sNodesRowData): void => {
const newParams = new URLSearchParams(document.location.search);
newParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.NODE_UID, record.nodeUID);
openInNewTab(
buildAbsolutePath({
relativePath: '',
urlQueryString: newParams.toString(),
}),
);
};
const handleRowClick = (
record: K8sNodesRowData,
event: React.MouseEvent,
): void => {
if (event && isModifierKeyPressed(event)) {
openNodeInNewTab(record);
return;
}
if (groupBy.length === 0) {
setSelectedRowData(null);
setSelectedNodeUID(record.nodeUID);
} else {
handleGroupByRowClick(record);
}
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Node,
});
};
const nestedColumns = useMemo(() => getK8sNodesListColumns([]), []);
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
className="expanded-table-view"
columns={nestedColumns as ColumnType<K8sNodesRowData>[]}
dataSource={formattedGroupedByNodesData}
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 (isModifierKeyPressed(event)) {
openNodeInNewTab(record);
return;
}
setSelectedNodeUID(record.nodeUID);
},
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: K8sNodesRowData,
e: React.MouseEvent<HTMLButtonElement>,
) => void;
record: K8sNodesRowData;
}): 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 handleCloseNodeDetail = (): void => {
setSelectedNodeUID(null);
setView(null);
setTracesFilters(null);
setEventsFilters(null);
setLogFilters(null);
};
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.Node,
});
return {
data: response.payload?.data.records || [],
total: response.payload?.data.total || 0,
error: response.error,
};
},
[groupByFiltersData, setCurrentPage, setGroupBy],
[dotMetricsEnabled],
);
useEffect(() => {
if (groupByFiltersData?.payload) {
setGroupByOptions(
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{ data: K8sNodeData | null; error?: string | null }> => {
const response = await getK8sNodesList(
{
filters: filters.filters,
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
undefined,
dotMetricsEnabled,
);
}
}, [groupByFiltersData]);
const onPaginationChange = (page: number, pageSize: number): void => {
setCurrentPage(page);
setPageSize(pageSize);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Node,
});
};
const records = response.payload?.data.records || [];
const showTableLoadingState =
(isFetching || isLoading) && formattedNodesData.length === 0;
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
},
[dotMetricsEnabled],
);
return (
<div className="k8s-list">
<K8sHeader
isFiltersVisible={isFiltersVisible}
handleFilterVisibilityChange={handleFilterVisibilityChange}
defaultAddedColumns={defaultAddedColumns}
handleFiltersChange={handleFiltersChange}
groupByOptions={groupByOptions}
isLoadingGroupByFilters={isLoadingGroupByFilters}
handleGroupByChange={handleGroupByChange}
selectedGroupBy={groupBy}
<>
<K8sBaseList<K8sNodeData>
controlListPrefix={controlListPrefix}
entity={K8sCategory.NODES}
showAutoRefresh={!selectedNodeData}
/>
{isError && <Typography>{data?.error || 'Something went wrong'}</Typography>}
<Table
className="k8s-list-table nodes-list-table"
dataSource={showTableLoadingState ? [] : formattedNodesData}
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,
}}
tableColumnsDefinitions={k8sNodesColumns}
tableColumns={k8sNodesColumnsConfig}
fetchListData={fetchListData}
renderRowData={k8sNodesRenderRowData}
eventCategory={InfraMonitoringEvents.Node}
/>
<NodeDetails
node={selectedNodeData}
isModalTimeSelection
onClose={handleCloseNodeDetail}
<K8sBaseDetails<K8sNodeData>
category={K8sCategory.NODES}
eventCategory={InfraMonitoringEvents.Node}
getSelectedItemFilters={k8sNodeGetSelectedItemFilters}
fetchEntityData={fetchEntityData}
getEntityName={k8sNodeGetEntityName}
getInitialLogTracesFilters={k8sNodeInitialLogTracesFilter}
getInitialEventsFilters={k8sNodeInitialEventsFilter}
primaryFilterKeys={k8sNodeInitialFilters}
metadataConfig={k8sNodeDetailsMetadataConfig}
entityWidgetInfo={nodeWidgetInfo}
getEntityQueryPayload={getNodeMetricsQueryPayload}
queryKeyPrefix="node"
/>
</div>
</>
);
}

View File

@@ -1,7 +0,0 @@
import { K8sNodesData } from 'api/infraMonitoring/getK8sNodesList';
export type NodeDetailsProps = {
node: K8sNodesData | null;
isModalTimeSelection: boolean;
onClose: () => void;
};

View File

@@ -1,635 +0,0 @@
/* eslint-disable sonarjs/no-identical-functions */
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 { Button, Divider, Drawer, Radio, Tooltip, Typography } from 'antd';
import type { RadioChangeEvent } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { K8sNodesData } from 'api/infraMonitoring/getK8sNodesList';
import { VIEW_TYPES, VIEWS } from 'components/HostMetricsDetail/constants';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { filterDuplicateFilters } from 'container/InfraMonitoringK8s/commonUtils';
import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
import NodeEvents from 'container/InfraMonitoringK8s/EntityDetailsUtils/EntityEvents';
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 GetMinMax from 'lib/getMinMax';
import {
BarChart2,
ChevronsLeftRight,
Compass,
DraftingCompass,
ScrollText,
X,
} from 'lucide-react';
import { AppState } from 'store/reducers';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
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 NodeLogs from '../../EntityDetailsUtils/EntityLogs';
import NodeMetrics from '../../EntityDetailsUtils/EntityMetrics';
import NodeTraces from '../../EntityDetailsUtils/EntityTraces';
import { QUERY_KEYS } from '../../EntityDetailsUtils/utils';
import { getNodeMetricsQueryPayload, nodeWidgetInfo } from './constants';
import { NodeDetailsProps } from './NodeDetails.interfaces';
import '../../EntityDetailsUtils/entityDetails.styles.scss';
function NodeDetails({
node,
onClose,
isModalTimeSelection,
}: NodeDetailsProps): 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 urlQuery = useUrlQuery();
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 [selectedView, setSelectedView] = useInfraMonitoringView();
const [logFiltersParam, setLogFiltersParam] = useInfraMonitoringLogFilters();
const [
tracesFiltersParam,
setTracesFiltersParam,
] = useInfraMonitoringTracesFilters();
const [
eventsFiltersParam,
setEventsFiltersParam,
] = useInfraMonitoringEventsFilters();
const isDarkMode = useIsDarkMode();
const initialFilters = useMemo(() => {
const filters =
selectedView === VIEW_TYPES.LOGS ? logFiltersParam : tracesFiltersParam;
if (filters) {
return filters;
}
return {
op: 'AND',
items: [
{
id: uuidv4(),
key: {
key: QUERY_KEYS.K8S_NODE_NAME,
dataType: DataTypes.String,
type: 'resource',
id: 'k8s_node_name--string--resource--false',
},
op: '=',
value: node?.meta.k8s_node_name || '',
},
],
};
}, [
node?.meta.k8s_node_name,
selectedView,
logFiltersParam,
tracesFiltersParam,
]);
const initialEventsFilters = useMemo(() => {
if (eventsFiltersParam) {
return eventsFiltersParam;
}
return {
op: 'AND',
items: [
{
id: uuidv4(),
key: {
key: QUERY_KEYS.K8S_OBJECT_KIND,
dataType: DataTypes.String,
type: 'resource',
id: 'k8s.object.kind--string--resource--false',
},
op: '=',
value: 'Node',
},
{
id: uuidv4(),
key: {
key: QUERY_KEYS.K8S_OBJECT_NAME,
dataType: DataTypes.String,
type: 'resource',
id: 'k8s.object.name--string--resource--false',
},
op: '=',
value: node?.meta.k8s_node_name || '',
},
],
};
}, [node?.meta.k8s_node_name, eventsFiltersParam]);
const [logAndTracesFilters, setLogAndTracesFilters] = useState<
IBuilderQuery['filters']
>(initialFilters);
const [eventsFilters, setEventsFilters] = useState<IBuilderQuery['filters']>(
initialEventsFilters,
);
useEffect(() => {
if (node) {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.Node,
});
}
}, [node]);
useEffect(() => {
setLogAndTracesFilters(initialFilters);
setEventsFilters(initialEventsFilters);
}, [initialFilters, initialEventsFilters]);
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 handleTabChange = (e: RadioChangeEvent): void => {
setSelectedView(e.target.value);
setLogFiltersParam(null);
setTracesFiltersParam(null);
setEventsFiltersParam(null);
logEvent(InfraMonitoringEvents.TabChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.Node,
view: e.target.value,
});
};
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.Node,
interval,
view: selectedView,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleChangeLogFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setLogAndTracesFilters((prevFilters) => {
const primaryFilters = prevFilters?.items?.filter((item) =>
[QUERY_KEYS.K8S_NODE_NAME, QUERY_KEYS.K8S_CLUSTER_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' && item.key?.key !== QUERY_KEYS.K8S_NODE_NAME,
);
if (newFilters && newFilters?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.Node,
view: InfraMonitoringEvents.LogsView,
});
}
const updatedFilters = {
op: 'AND',
items: filterDuplicateFilters(
[
...(primaryFilters || []),
...(newFilters || []),
...(paginationFilter ? [paginationFilter] : []),
].filter((item): item is TagFilterItem => item !== undefined),
),
};
setLogFiltersParam(updatedFilters);
setSelectedView(view);
return updatedFilters;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleChangeTracesFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setLogAndTracesFilters((prevFilters) => {
const primaryFilters = prevFilters?.items?.filter((item) =>
[QUERY_KEYS.K8S_NODE_NAME, QUERY_KEYS.K8S_CLUSTER_NAME].includes(
item.key?.key ?? '',
),
);
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.Node,
view: InfraMonitoringEvents.TracesView,
});
}
const updatedFilters = {
op: 'AND',
items: filterDuplicateFilters(
[
...(primaryFilters || []),
...(value?.items?.filter(
(item) => item.key?.key !== QUERY_KEYS.K8S_NODE_NAME,
) || []),
].filter((item): item is TagFilterItem => item !== undefined),
),
};
setTracesFiltersParam(updatedFilters);
setSelectedView(view);
return updatedFilters;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleChangeEventsFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setEventsFilters((prevFilters) => {
const nodeKindFilter = prevFilters?.items?.find(
(item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_KIND,
);
const nodeNameFilter = prevFilters?.items?.find(
(item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_NAME,
);
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.Node,
view: InfraMonitoringEvents.EventsView,
});
}
const updatedFilters = {
op: 'AND',
items: [
nodeKindFilter,
nodeNameFilter,
...(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);
setSelectedView(view);
return updatedFilters;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleExplorePagesRedirect = (): void => {
if (selectedInterval !== 'custom') {
urlQuery.set(QueryParams.relativeTime, selectedInterval);
} else {
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.startTime, modalTimeRange.startTime.toString());
urlQuery.set(QueryParams.endTime, modalTimeRange.endTime.toString());
}
logEvent(InfraMonitoringEvents.ExploreClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.Node,
view: selectedView,
});
if (selectedView === VIEW_TYPES.LOGS) {
const filtersWithoutPagination = {
...logAndTracesFilters,
items:
logAndTracesFilters?.items?.filter((item) => item.key?.key !== 'id') || [],
};
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
filters: filtersWithoutPagination,
},
],
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
} else if (selectedView === VIEW_TYPES.TRACES) {
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.traces,
aggregateOperator: TracesAggregatorOperator.NOOP,
filters: logAndTracesFilters,
},
],
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
}
};
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),
});
}
setSelectedView(VIEW_TYPES.METRICS);
onClose();
};
return (
<Drawer
width="70%"
title={
<>
<Divider type="vertical" />
<Typography.Text className="title">
{node?.meta.k8s_node_name}
</Typography.Text>
</>
}
placement="right"
onClose={handleClose}
open={!!node}
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 }} />}
>
{node && (
<>
<div className="entity-detail-drawer__entity">
<div className="entity-details-grid">
<div className="labels-row">
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
Node Name
</Typography.Text>
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
Cluster Name
</Typography.Text>
</div>
<div className="values-row">
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={node.meta.k8s_node_name}>
{node.meta.k8s_node_name}
</Tooltip>
</Typography.Text>
<Typography.Text className="entity-details-metadata-value">
<Tooltip title="Cluster name">{node.meta.k8s_cluster_name}</Tooltip>
</Typography.Text>
</div>
</div>
</div>
<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}
>
<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.METRICS && (
<NodeMetrics<K8sNodesData>
timeRange={modalTimeRange}
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
selectedInterval={selectedInterval}
entity={node}
entityWidgetInfo={nodeWidgetInfo}
getEntityQueryPayload={getNodeMetricsQueryPayload}
category={K8sCategory.NODES}
queryKey="nodeMetrics"
/>
)}
{selectedView === VIEW_TYPES.LOGS && (
<NodeLogs
timeRange={modalTimeRange}
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
handleChangeLogFilters={handleChangeLogFilters}
logFilters={logAndTracesFilters}
selectedInterval={selectedInterval}
queryKeyFilters={[QUERY_KEYS.K8S_NODE_NAME, QUERY_KEYS.K8S_CLUSTER_NAME]}
queryKey="nodeLogs"
category={K8sCategory.NODES}
/>
)}
{selectedView === VIEW_TYPES.TRACES && (
<NodeTraces
timeRange={modalTimeRange}
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
handleChangeTracesFilters={handleChangeTracesFilters}
tracesFilters={logAndTracesFilters}
selectedInterval={selectedInterval}
queryKeyFilters={[QUERY_KEYS.K8S_NODE_NAME, QUERY_KEYS.K8S_CLUSTER_NAME]}
queryKey="nodeTraces"
category={InfraMonitoringEvents.Node}
/>
)}
{selectedView === VIEW_TYPES.EVENTS && (
<NodeEvents
timeRange={modalTimeRange}
handleChangeEventFilters={handleChangeEventsFilters}
filters={eventsFilters}
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
selectedInterval={selectedInterval}
category={K8sCategory.NODES}
queryKey="nodeEvents"
/>
)}
</>
)}
</Drawer>
);
}
export default NodeDetails;

View File

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

View File

@@ -0,0 +1,128 @@
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';
export interface K8sNodesListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface K8sNodeData {
nodeUID: string;
nodeCPUUsage: number;
nodeCPUAllocatable: number;
nodeMemoryUsage: number;
nodeMemoryAllocatable: number;
meta: {
k8s_node_name: string;
k8s_node_uid: string;
k8s_cluster_name: string;
};
}
export interface K8sNodesListResponse {
status: string;
data: {
type: string;
records: K8sNodeData[];
groups: null;
total: number;
sentAnyHostMetricsData: boolean;
isSendingK8SAgentMetrics: boolean;
};
}
export const nodesMetaMap = [
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
{ dot: 'k8s.node.uid', under: 'k8s_node_uid' },
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
] as const;
export function mapNodesMeta(
raw: Record<string, unknown>,
): K8sNodeData['meta'] {
const out: Record<string, unknown> = { ...raw };
nodesMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sNodeData['meta'];
}
export const getK8sNodesList = async (
props: K8sBaseFilters,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sNodesListResponse> | ErrorResponse> => {
try {
const requestProps =
dotMetricsEnabled && Array.isArray(props.filters?.items)
? {
...props,
filters: {
...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;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/nodes/list', requestProps, {
signal,
headers,
});
const payload: K8sNodesListResponse = response.data;
// one-liner to map dot→underscore
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapNodesMeta(record.meta as Record<string, unknown>),
}));
return {
statusCode: 200,
error: null,
message: 'Success',
payload,
params: requestProps,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,11 +1,67 @@
import { K8sNodesData } from 'api/infraMonitoring/getK8sNodesList';
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 { K8sNodeData } from './api';
export const k8sNodeGetSelectedItemFilters = (
selectedItemId: string,
): TagFilter => {
return {
op: 'AND',
items: [
{
id: 'k8s_node_name',
key: {
key: 'k8s_node_name',
type: null,
},
op: '=',
value: selectedItemId,
},
],
};
};
export const k8sNodeDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sNodeData>[] = [
{ label: 'Node Name', getValue: (p): string => p.meta.k8s_node_name },
{
label: 'Cluster Name',
getValue: (p): string => p.meta.k8s_cluster_name,
},
];
export const k8sNodeInitialFilters = [
QUERY_KEYS.K8S_NODE_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
];
export const k8sNodeInitialEventsFilter = (
item: K8sNodeData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_OBJECT_KIND, 'Node'),
createFilterItem(QUERY_KEYS.K8S_OBJECT_NAME, item.meta.k8s_node_name),
];
export const k8sNodeInitialLogTracesFilter = (
item: K8sNodeData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_NODE_NAME, item.meta.k8s_node_name),
createFilterItem(QUERY_KEYS.K8S_CLUSTER_NAME, item.meta.k8s_cluster_name),
];
export const k8sNodeGetEntityName = (item: K8sNodeData): string =>
item.meta.k8s_node_name;
export const nodeWidgetInfo = [
{
title: 'CPU Usage (cores)',
@@ -50,7 +106,7 @@ export const nodeWidgetInfo = [
];
export const getNodeMetricsQueryPayload = (
node: K8sNodesData,
node: K8sNodeData,
start: number,
end: number,
dotMetricsEnabled: boolean,

View File

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

View File

@@ -0,0 +1,199 @@
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 { K8sNodeData, K8sNodesListPayload } from './api';
import styles from './table.module.scss';
export interface K8sNodesRowData {
key: string;
itemKey: string;
nodeUID: string;
nodeName: React.ReactNode;
clusterName: string;
cpu: React.ReactNode;
cpu_allocatable: React.ReactNode;
memory: React.ReactNode;
memory_allocatable: React.ReactNode;
groupedByMeta?: any;
}
export const k8sNodesColumns: IEntityColumn[] = [
{
label: 'Node Group',
value: 'nodeGroup',
id: 'nodeGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Node Name',
value: 'nodeName',
id: 'nodeName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Cluster Name',
value: 'clusterName',
id: 'clusterName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Alloc (cores)',
value: 'cpu_allocatable',
id: 'cpu_allocatable',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Memory Usage (WSS)',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Memory Alloc (bytes)',
value: 'memory_allocatable',
id: 'memory_allocatable',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const getK8sNodesListQuery = (): K8sNodesListPayload => ({
filters: {
items: [],
op: 'and',
},
orderBy: { columnName: 'cpu', order: 'desc' },
});
export const k8sNodesColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> NODE GROUP
</div>
),
dataIndex: 'nodeGroup',
key: 'nodeGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
title: <div>Node Name</div>,
dataIndex: 'nodeName',
key: 'nodeName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div>Cluster Name</div>,
dataIndex: 'clusterName',
key: 'clusterName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>CPU Alloc (cores)</div>,
dataIndex: 'cpu_allocatable',
key: 'cpu_allocatable',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Memory Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Memory Allocatable</div>,
dataIndex: 'memory_allocatable',
key: 'memory_allocatable',
width: 80,
sorter: true,
align: 'left',
},
];
export const k8sNodesRenderRowData = (
node: K8sNodeData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
node,
() => node.nodeUID || node.meta.k8s_node_uid || node.meta.k8s_node_name,
groupBy,
),
itemKey: node.meta.k8s_node_name,
nodeUID: node.nodeUID || node.meta.k8s_node_uid,
nodeName: (
<Tooltip title={node.meta.k8s_node_name}>
{node.meta.k8s_node_name || ''}
</Tooltip>
),
clusterName: node.meta.k8s_cluster_name,
cpu: (
<ValidateColumnValueWrapper value={node.nodeCPUUsage}>
{node.nodeCPUUsage}
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={node.nodeMemoryUsage}>
{formatBytes(node.nodeMemoryUsage)}
</ValidateColumnValueWrapper>
),
cpu_allocatable: (
<ValidateColumnValueWrapper value={node.nodeCPUAllocatable}>
{node.nodeCPUAllocatable}
</ValidateColumnValueWrapper>
),
memory_allocatable: (
<ValidateColumnValueWrapper value={node.nodeMemoryAllocatable}>
{formatBytes(node.nodeMemoryAllocatable)}
</ValidateColumnValueWrapper>
),
nodeGroup: getGroupByEl(node, groupBy),
...node.meta,
groupedByMeta: getGroupedByMeta(node, groupBy),
});

View File

@@ -1,225 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { TableColumnType as ColumnType, Tag, Tooltip } from 'antd';
import {
K8sNodesData,
K8sNodesListPayload,
} from 'api/infraMonitoring/getK8sNodesList';
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: 'Node Name',
value: 'nodeName',
id: 'nodeName',
canRemove: false,
},
{
label: 'Cluster Name',
value: 'clusterStatus',
id: 'clusterStatus',
canRemove: false,
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canRemove: false,
},
{
label: 'CPU Alloc (cores)',
value: 'cpu_allocatable',
id: 'cpu_allocatable',
canRemove: false,
},
{
label: 'Memory Usage (WSS)',
value: 'memory',
id: 'memory',
canRemove: false,
},
{
label: 'Memory Alloc (bytes)',
value: 'memory_allocatable',
id: 'memory_allocatable',
canRemove: false,
},
];
export interface K8sNodesRowData {
key: string;
nodeUID: string;
nodeName: React.ReactNode;
clusterName: string;
cpu: React.ReactNode;
cpu_allocatable: React.ReactNode;
memory: React.ReactNode;
memory_allocatable: React.ReactNode;
groupedByMeta?: any;
}
const nodeGroupColumnConfig = {
title: (
<div className="column-header entity-group-header">
<Group size={14} /> NODE GROUP
</div>
),
dataIndex: 'nodeGroup',
key: 'nodeGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
className: 'column entity-group-header',
};
export const getK8sNodesListQuery = (): K8sNodesListPayload => ({
filters: {
items: [],
op: 'and',
},
orderBy: { columnName: 'cpu', order: 'desc' },
});
const columnsConfig = [
{
title: <div className="column-header-left name-header">Node Name</div>,
dataIndex: 'nodeName',
key: 'nodeName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div className="column-header-left name-header">Cluster Name</div>,
dataIndex: 'clusterName',
key: 'clusterName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div className="column-header-left">CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div className="column-header-left">CPU Alloc (cores)</div>,
dataIndex: 'cpu_allocatable',
key: 'cpu_allocatable',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div className="column-header-left">Memory Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div className="column-header-left">Memory Allocatable</div>,
dataIndex: 'memory_allocatable',
key: 'memory_allocatable',
width: 80,
sorter: true,
align: 'left',
},
];
export const getK8sNodesListColumns = (
groupBy: IBuilderQuery['groupBy'],
): ColumnType<K8sNodesRowData>[] => {
if (groupBy.length > 0) {
const filteredColumns = [...columnsConfig].filter(
(column) => column.key !== 'nodeName',
);
filteredColumns.unshift(nodeGroupColumnConfig);
return filteredColumns as ColumnType<K8sNodesRowData>[];
}
return columnsConfig as ColumnType<K8sNodesRowData>[];
};
const dotToUnder: Record<string, keyof K8sNodesData['meta']> = {
'k8s.node.name': 'k8s_node_name',
'k8s.cluster.name': 'k8s_cluster_name',
'k8s.node.uid': 'k8s_node_uid',
};
const getGroupByEle = (
node: K8sNodesData,
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 node.meta;
const value = node.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: K8sNodesData[],
groupBy: IBuilderQuery['groupBy'],
): K8sNodesRowData[] =>
data.map((node, index) => ({
key: `${node.nodeUID}-${index}`,
nodeUID: node.nodeUID || '',
nodeName: (
<Tooltip title={node.meta.k8s_node_name}>
{node.meta.k8s_node_name || ''}
</Tooltip>
),
clusterName: node.meta.k8s_cluster_name,
cpu: (
<ValidateColumnValueWrapper value={node.nodeCPUUsage}>
{node.nodeCPUUsage}
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={node.nodeMemoryUsage}>
{formatBytes(node.nodeMemoryUsage)}
</ValidateColumnValueWrapper>
),
cpu_allocatable: (
<ValidateColumnValueWrapper value={node.nodeCPUAllocatable}>
{node.nodeCPUAllocatable}
</ValidateColumnValueWrapper>
),
memory_allocatable: (
<ValidateColumnValueWrapper value={node.nodeMemoryAllocatable}>
{formatBytes(node.nodeMemoryAllocatable)}
</ValidateColumnValueWrapper>
),
nodeGroup: getGroupByEle(node, groupBy),
meta: node.meta,
...node.meta,
groupedByMeta: node.meta,
}));

View File

@@ -1,753 +1,116 @@
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 React, { useCallback } from 'react';
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 {
GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS,
K8sCategory,
} from '../constants';
getPodMetricsQueryPayload,
k8sPodDetailsMetadataConfig,
k8sPodGetEntityName,
k8sPodGetSelectedItemFilters,
k8sPodInitialEventsFilter,
k8sPodInitialFilters,
k8sPodInitialLogTracesFilter,
podWidgetInfo,
} from './constants';
import {
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';
k8sPodColumns,
k8sPodColumnsConfig,
k8sPodRenderRowData,
} from './table';
function K8sPodsList({
isFiltersVisible,
handleFilterVisibilityChange,
quickFiltersLastUpdated,
controlListPrefix,
}: {
isFiltersVisible: boolean;
handleFilterVisibilityChange: () => void;
quickFiltersLastUpdated: number;
controlListPrefix?: React.ReactNode;
}): 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 {
data: groupByFiltersData,
isLoading: isLoadingGroupByFilters,
} = useGetAggregateKeys(
{
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateAttribute: GetK8sEntityToAggregateAttribute(
K8sCategory.PODS,
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
filters.orderBy ||= {
columnName: 'cpu',
order: 'desc',
};
const response = await getK8sPodsList(
filters,
signal,
undefined,
dotMetricsEnabled,
),
aggregateOperator: 'noop',
searchText: '',
tagType: '',
},
{
queryKey: [currentQuery.builder.queryData[0].dataSource, 'noop'],
},
true, // isInfraMonitoring
K8sCategory.PODS, // infraMonitoringEntity
);
// 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,
return {
data: response.payload?.data.records || [],
total: response.payload?.data.total || 0,
error: response.error,
};
},
undefined,
dotMetricsEnabled,
[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,
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,
},
op: '=',
value: groupedByMeta[key],
id: key,
});
}
return baseFilters;
};
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,
},
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,
})) || [],
signal,
undefined,
dotMetricsEnabled,
);
}
}, [groupByFiltersData]);
const handleRemoveColumn = useCallback(
(column: IEntityColumn): void => {
setAddedColumns((prev) => prev.filter((c) => c.value !== column.value));
const records = response.payload?.data.records || [];
setAvailableColumns((prev) => [...prev, column]);
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
},
[setAddedColumns, setAvailableColumns],
[dotMetricsEnabled],
);
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 (
<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}
<>
<K8sBaseList<K8sPodsData>
controlListPrefix={controlListPrefix}
entity={K8sCategory.PODS}
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,
}}
tableColumnsDefinitions={k8sPodColumns}
tableColumns={k8sPodColumnsConfig}
fetchListData={fetchListData}
renderRowData={k8sPodRenderRowData}
eventCategory={InfraMonitoringEvents.Pod}
/>
{selectedPodData && (
<PodDetails
pod={selectedPodData}
isModalTimeSelection
onClose={handleClosePodDetail}
/>
)}
</div>
<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"
/>
</>
);
}

View File

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

View File

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

View File

@@ -1,21 +1,35 @@
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 { UnderscoreToDotMap } from '../utils';
import { K8sBaseFilters } from '../Base/K8sBaseList';
export interface K8sPodsListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
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 TimeSeriesValue {
@@ -71,35 +85,9 @@ export interface K8sPodsListResponse {
};
}
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
// TODO: Remove this method once we move this to OpenAPI
export const getK8sPodsList = async (
props: K8sPodsListPayload,
props: K8sBaseFilters,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
@@ -111,7 +99,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,11 +1,69 @@
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,11 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}
.progressBar {
display: flex;
justify-content: start;
}

View File

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

@@ -1,148 +0,0 @@
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 NodeDetails from 'container/InfraMonitoringK8s/Nodes/NodeDetails/NodeDetails';
import { withNuqsTestingAdapter } from 'nuqs/adapters/testing';
import store from 'store';
const queryClient = new QueryClient();
const Wrapper = withNuqsTestingAdapter({ searchParams: {} });
describe('NodeDetails', () => {
const mockNode = {
meta: {
k8s_node_name: 'test-node',
k8s_cluster_name: 'test-cluster',
},
} as any;
const mockOnClose = jest.fn();
it('should render modal with relevant metadata', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<NodeDetails
node={mockNode}
isModalTimeSelection
onClose={mockOnClose}
/>
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const nodeNameElements = screen.getAllByText('test-node');
expect(nodeNameElements.length).toBeGreaterThan(0);
expect(nodeNameElements[0]).toBeInTheDocument();
const clusterNameElements = screen.getAllByText('test-cluster');
expect(clusterNameElements.length).toBeGreaterThan(0);
expect(clusterNameElements[0]).toBeInTheDocument();
});
it('should render modal with 4 tabs', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<NodeDetails
node={mockNode}
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>
<NodeDetails
node={mockNode}
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>
<NodeDetails
node={mockNode}
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>
<NodeDetails
node={mockNode}
isModalTimeSelection
onClose={mockOnClose}
/>
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const closeButton = screen.getByRole('button', { name: 'Close' });
fireEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
});

View File

@@ -1,155 +0,0 @@
/**
* 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

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

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

View File

@@ -1,4 +1,6 @@
import { useMemo } from 'react';
import { VIEWS } from 'components/HostMetricsDetail/constants';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
Options,
parseAsInteger,
@@ -222,3 +224,26 @@ 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,22 +1,8 @@
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 {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from './commonUtils';
import { DEFAULT_PAGE_SIZE, K8sCategory } from './constants';
import { OrderBySchemaType } from './schemas';
import { DEFAULT_PAGE_SIZE } from './constants';
import './InfraMonitoringK8s.styles.scss';
@@ -26,404 +12,6 @@ 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,6 +284,15 @@ 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,15 +198,14 @@ 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.{' '}
{/* Todo: to add doc links */}
{/* <a
href="https://signoz.io/docs/service-accounts"
<a
href="https://signoz.io/docs/manage/administrator-guide/iam/service-accounts"
target="_blank"
rel="noopener noreferrer"
className="sa-settings__learn-more"
>
Learn more
</a> */}
</a>
</p>
</div>

View File

@@ -695,6 +695,15 @@ 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),
);
@@ -718,6 +727,9 @@ 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,6 +14,10 @@ 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';
@@ -352,7 +356,10 @@ function DateTimeSelection({
],
);
const isRefreshingQueries = useIsGlobalTimeQueryRefreshing();
const invalidateQueries = useGlobalTimeQueryInvalidate();
const onRefreshHandler = (): void => {
invalidateQueries();
onSelectHandler(selectedTime);
onLastRefreshHandler();
};
@@ -732,7 +739,11 @@ function DateTimeSelection({
{showAutoRefresh && selectedTime !== 'custom' && (
<div className="refresh-actions">
<FormItem hidden={refreshButtonHidden} className="refresh-btn">
<Button icon={<SyncOutlined />} onClick={onRefreshHandler} />
<Button
icon={<SyncOutlined />}
loading={!!isRefreshingQueries}
onClick={onRefreshHandler}
/>
</FormItem>
<FormItem>

View File

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

View File

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

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

@@ -1,48 +0,0 @@
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import {
getK8sNodesList,
K8sNodesListPayload,
K8sNodesListResponse,
} from 'api/infraMonitoring/getK8sNodesList';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetK8sNodesList = (
requestData: K8sNodesListPayload,
options?: UseQueryOptions<
SuccessResponse<K8sNodesListResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
dotMetricsEnabled?: boolean,
) => UseQueryResult<
SuccessResponse<K8sNodesListResponse> | ErrorResponse,
Error
>;
export const useGetK8sNodesList: UseGetK8sNodesList = (
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_NODE_LIST, requestData];
}, [options?.queryKey, requestData]);
return useQuery<SuccessResponse<K8sNodesListResponse> | ErrorResponse, Error>({
queryFn: ({ signal }) =>
getK8sNodesList(requestData, signal, headers, dotMetricsEnabled),
...options,
queryKey,
});
};

View File

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

@@ -5,6 +5,7 @@ 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';
@@ -51,6 +52,7 @@ if (container) {
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<GlobalTimeStoreAdapter />
<AppProvider>
<AppRoutes />
</AppProvider>

View File

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

View File

@@ -62,12 +62,16 @@ export const getRoutes = (
settings.push(...alertChannels(t));
if (isAdmin) {
settings.push(...membersSettings(t), ...serviceAccountsSettings(t));
settings.push(
...membersSettings(t),
...serviceAccountsSettings(t),
...rolesSettings(t),
...roleDetails(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), ...rolesSettings(t), ...roleDetails(t));
settings.push(...billingSettings(t));
}
settings.push(

View File

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

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

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

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

View File

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

@@ -0,0 +1,89 @@
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.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/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/dialog@^0.0.2":
version "0.0.2"

256
grammar/HavingExpression.g4 Normal file
View File

@@ -0,0 +1,256 @@
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,6 +50,11 @@ 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,11 +21,15 @@ func newTestSettings() factory.ScopedProviderSettings {
func newTestEvent(resource string, action audittypes.Action) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource, action),
ResourceName: resource,
Action: action,
Outcome: audittypes.OutcomeSuccess,
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource, action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,
},
ResourceAttributes: audittypes.ResourceAttributes{
ResourceName: resource,
},
}
}

View File

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

View File

@@ -20,6 +20,12 @@ 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,14 +15,16 @@ 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) Handler {
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) 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
@@ -36,10 +38,16 @@ func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef) Handler {
openAPIDef.ErrorStatusCodes = append(openAPIDef.ErrorStatusCodes, http.StatusUnauthorized, http.StatusForbidden)
}
return &handler{
handler := &handler{
handlerFunc: handlerFunc,
openAPIDef: openAPIDef,
}
for _, opt := range opts {
opt(handler)
}
return handler
}
func (handler *handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
@@ -120,5 +128,8 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
openapi.WithHTTPStatus(statusCode),
)
}
}
func (handler *handler) AuditDef() *AuditDef {
return handler.auditDef
}

View File

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

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

@@ -1,81 +0,0 @@
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,7 +2,6 @@ package middleware
import (
"bufio"
"io"
"net"
"net/http"
@@ -10,118 +9,156 @@ import (
)
const (
maxResponseBodyInLogs = 4096 // At most 4k bytes from response bodies in our logs.
maxResponseBodyCapture int = 4096 // At most 4k bytes from response bodies.
)
type badResponseLoggingWriter interface {
// Wraps an http.ResponseWriter to capture the status code,
// write errors, and (for error responses) a bounded slice of the body.
type responseCapture interface {
http.ResponseWriter
// Get the status code.
// StatusCode returns the HTTP status code written to the response.
StatusCode() int
// Get the error while writing.
// WriteError returns the error (if any) from the downstream Write call.
WriteError() error
// BodyBytes returns the captured response body bytes. Only populated
// for error responses (status >= 400).
BodyBytes() []byte
}
func newBadResponseLoggingWriter(rw http.ResponseWriter, buffer io.Writer) badResponseLoggingWriter {
b := nonFlushingBadResponseLoggingWriter{
func newResponseCapture(rw http.ResponseWriter, buffer *byteBuffer) responseCapture {
b := nonFlushingResponseCapture{
rw: rw,
buffer: buffer,
logBody: false,
bodyBytesLeft: maxResponseBodyInLogs,
captureBody: false,
bodyBytesLeft: maxResponseBodyCapture,
statusCode: http.StatusOK,
}
if f, ok := rw.(http.Flusher); ok {
return &flushingBadResponseLoggingWriter{b, f}
return &flushingResponseCapture{nonFlushingResponseCapture: b, f: f}
}
return &b
}
type nonFlushingBadResponseLoggingWriter struct {
rw http.ResponseWriter
buffer io.Writer
logBody bool
bodyBytesLeft int
statusCode int
writeError error // The error returned when downstream Write() fails.
// byteBuffer is a minimal write-only buffer used to capture response bodies.
type byteBuffer struct {
buf []byte
}
// Extends nonFlushingBadResponseLoggingWriter that implements http.Flusher.
type flushingBadResponseLoggingWriter struct {
nonFlushingBadResponseLoggingWriter
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 {
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
bodyBytesLeft int
statusCode int
writeError error
}
type flushingResponseCapture struct {
nonFlushingResponseCapture
f http.Flusher
}
// Unwrap method is used by http.ResponseController to get access to original http.ResponseWriter.
func (writer *nonFlushingBadResponseLoggingWriter) Unwrap() http.ResponseWriter {
// Unwrap is used by http.ResponseController to get access to original http.ResponseWriter.
func (writer *nonFlushingResponseCapture) Unwrap() http.ResponseWriter {
return writer.rw
}
// Header returns the header map that will be sent by WriteHeader.
// Implements ResponseWriter.
func (writer *nonFlushingBadResponseLoggingWriter) Header() http.Header {
func (writer *nonFlushingResponseCapture) Header() http.Header {
return writer.rw.Header()
}
// WriteHeader writes the HTTP response header.
func (writer *nonFlushingBadResponseLoggingWriter) WriteHeader(statusCode int) {
func (writer *nonFlushingResponseCapture) WriteHeader(statusCode int) {
writer.statusCode = statusCode
if statusCode >= 500 || statusCode == 400 {
writer.logBody = true
if statusCode >= 400 {
writer.captureBody = true
}
writer.rw.WriteHeader(statusCode)
}
// Writes HTTP response data.
func (writer *nonFlushingBadResponseLoggingWriter) Write(data []byte) (int, error) {
// Write writes HTTP response data.
func (writer *nonFlushingResponseCapture) 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.logBody {
if writer.captureBody {
writer.captureResponseBody(data)
}
if err != nil {
writer.writeError = err
}
return n, err
}
// Hijack hijacks the first response writer that is a Hijacker.
func (writer *nonFlushingBadResponseLoggingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
func (writer *nonFlushingResponseCapture) 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 *nonFlushingBadResponseLoggingWriter) StatusCode() int {
func (writer *nonFlushingResponseCapture) StatusCode() int {
return writer.statusCode
}
func (writer *nonFlushingBadResponseLoggingWriter) WriteError() error {
func (writer *nonFlushingResponseCapture) WriteError() error {
return writer.writeError
}
func (writer *flushingBadResponseLoggingWriter) Flush() {
func (writer *nonFlushingResponseCapture) BodyBytes() []byte {
return writer.buffer.Bytes()
}
func (writer *flushingResponseCapture) Flush() {
writer.f.Flush()
}
func (writer *nonFlushingBadResponseLoggingWriter) captureResponseBody(data []byte) {
func (writer *nonFlushingResponseCapture) captureResponseBody(data []byte) {
if len(data) > writer.bodyBytesLeft {
_, _ = writer.buffer.Write(data[:writer.bodyBytesLeft])
_, _ = io.WriteString(writer.buffer, "...")
_, _ = writer.buffer.WriteString("...")
writer.bodyBytesLeft = 0
writer.logBody = false
writer.captureBody = false
} else {
_, _ = writer.buffer.Write(data)
writer.bodyBytesLeft -= len(data)

View File

@@ -0,0 +1,88 @@
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,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
jsoniter "github.com/json-iterator/go"
"github.com/tidwall/gjson"
)
const (
@@ -42,6 +43,45 @@ 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,6 +58,31 @@ 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

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

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,468 @@
{
"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

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

View File

@@ -0,0 +1,14 @@
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
<stop stop-color="#4D27A8" offset="0%"></stop>
<stop stop-color="#A166FF" offset="100%"></stop>
</linearGradient>
</defs>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g fill="url(#linearGradient-1)">
<rect id="Rectangle" x="0" y="0" width="24" height="24"></rect>
</g>
<path d="M6,6.76751613 L8,5.43446738 L8,18.5659476 L6,17.2328988 L6,6.76751613 Z M5,6.49950633 L5,17.4999086 C5,17.6669147 5.084,17.8239204 5.223,17.9159238 L8.223,19.9159969 C8.307,19.971999 8.403,20 8.5,20 C8.581,20 8.662,19.9809993 8.736,19.9409978 C8.898,19.8539947 9,19.6849885 9,19.4999817 L9,16.9998903 L10,16.9998903 L10,15.9998537 L9,15.9998537 L9,7.99956118 L10,7.99956118 L10,6.99952461 L9,6.99952461 L9,4.49943319 C9,4.31542646 8.898,4.14542025 8.736,4.0594171 C8.574,3.97241392 8.377,3.98141425 8.223,4.08341798 L5.223,6.08349112 C5.084,6.17649452 5,6.33250022 5,6.49950633 L5,6.49950633 Z M19,17.2328988 L17,18.5659476 L17,5.43446738 L19,6.76751613 L19,17.2328988 Z M19.777,6.08349112 L16.777,4.08341798 C16.623,3.98141425 16.426,3.97241392 16.264,4.0594171 C16.102,4.14542025 16,4.31542646 16,4.49943319 L16,6.99952461 L15,6.99952461 L15,7.99956118 L16,7.99956118 L16,15.9998537 L15,15.9998537 L15,16.9998903 L16,16.9998903 L16,19.4999817 C16,19.6849885 16.102,19.8539947 16.264,19.9409978 C16.338,19.9809993 16.419,20 16.5,20 C16.597,20 16.693,19.971999 16.777,19.9159969 L19.777,17.9159238 C19.916,17.8239204 20,17.6669147 20,17.4999086 L20,6.49950633 C20,6.33250022 19.916,6.17649452 19.777,6.08349112 L19.777,6.08349112 Z M13,7.99956118 L14,7.99956118 L14,6.99952461 L13,6.99952461 L13,7.99956118 Z M11,7.99956118 L12,7.99956118 L12,6.99952461 L11,6.99952461 L11,7.99956118 Z M13,16.9998903 L14,16.9998903 L14,15.9998537 L13,15.9998537 L13,16.9998903 Z M11,16.9998903 L12,16.9998903 L12,15.9998537 L11,15.9998537 L11,16.9998903 Z M13.18,14.884813 L10.18,12.3847215 C10.065,12.288718 10,12.1487129 10,11.9997075 C10,11.851702 10.065,11.7106969 10.18,11.6156934 L13.18,9.11560199 L13.82,9.88463011 L11.281,11.9997075 L13.82,14.1157848 L13.18,14.884813 Z" id="Amazon-API-Gateway_Icon_16_Squid" fill="#FFFFFF"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

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