From be7a687088d0dd018c40f07321fe4bfa4a8d5ae3 Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Wed, 4 Sep 2024 18:09:40 +0530 Subject: [PATCH 01/18] chore: make prepare task configurable (#5806) --- pkg/query-service/rules/manager.go | 203 ++++++++++++---------- pkg/query-service/rules/prom_rule.go | 8 +- pkg/query-service/rules/prom_rule_task.go | 5 +- pkg/query-service/rules/promrule_test.go | 12 +- 4 files changed, 123 insertions(+), 105 deletions(-) diff --git a/pkg/query-service/rules/manager.go b/pkg/query-service/rules/manager.go index c21873f230..768c753cb8 100644 --- a/pkg/query-service/rules/manager.go +++ b/pkg/query-service/rules/manager.go @@ -12,8 +12,6 @@ import ( "github.com/google/uuid" - "github.com/go-kit/log" - "go.uber.org/zap" "errors" @@ -27,6 +25,17 @@ import ( "go.signoz.io/signoz/pkg/query-service/utils/labels" ) +type PrepareTaskOptions struct { + Rule *PostableRule + TaskName string + RuleDB RuleDB + Logger *zap.Logger + Reader interfaces.Reader + FF interfaces.FeatureLookup + ManagerOpts *ManagerOptions + NotifyFunc NotifyFunc +} + const taskNamesuffix = "webAppEditor" func ruleIdFromTaskName(n string) string { @@ -56,13 +65,15 @@ type ManagerOptions struct { DBConn *sqlx.DB Context context.Context - Logger log.Logger + Logger *zap.Logger ResendDelay time.Duration DisableRules bool FeatureFlags interfaces.FeatureLookup Reader interfaces.Reader EvalDelay time.Duration + + PrepareTaskFunc func(opts PrepareTaskOptions) (Task, error) } // The Manager manages recording and alerting rules. @@ -78,10 +89,12 @@ type Manager struct { // datastore to store alert definitions ruleDB RuleDB - logger log.Logger + logger *zap.Logger featureFlags interfaces.FeatureLookup reader interfaces.Reader + + prepareTaskFunc func(opts PrepareTaskOptions) (Task, error) } func defaultOptions(o *ManagerOptions) *ManagerOptions { @@ -94,9 +107,69 @@ func defaultOptions(o *ManagerOptions) *ManagerOptions { if o.ResendDelay == time.Duration(0) { o.ResendDelay = 1 * time.Minute } + if o.Logger == nil { + o.Logger = zap.L() + } + if o.PrepareTaskFunc == nil { + o.PrepareTaskFunc = defaultPrepareTaskFunc + } return o } +func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) { + + rules := make([]Rule, 0) + var task Task + + ruleId := ruleIdFromTaskName(opts.TaskName) + if opts.Rule.RuleType == RuleTypeThreshold { + // create a threshold rule + tr, err := NewThresholdRule( + ruleId, + opts.Rule, + ThresholdRuleOpts{ + EvalDelay: opts.ManagerOpts.EvalDelay, + }, + opts.FF, + opts.Reader, + ) + + if err != nil { + return task, err + } + + rules = append(rules, tr) + + // create ch rule task for evalution + task = newTask(TaskTypeCh, opts.TaskName, taskNamesuffix, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.RuleDB) + + } else if opts.Rule.RuleType == RuleTypeProm { + + // create promql rule + pr, err := NewPromRule( + ruleId, + opts.Rule, + opts.Logger, + PromRuleOpts{}, + opts.Reader, + ) + + if err != nil { + return task, err + } + + rules = append(rules, pr) + + // create promql rule task for evalution + task = newTask(TaskTypeProm, opts.TaskName, taskNamesuffix, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.RuleDB) + + } else { + return nil, fmt.Errorf("unsupported rule type. Supported types: %s, %s", RuleTypeProm, RuleTypeThreshold) + } + + return task, nil +} + // NewManager returns an implementation of Manager, ready to be started // by calling the Run method. func NewManager(o *ManagerOptions) (*Manager, error) { @@ -116,15 +189,16 @@ func NewManager(o *ManagerOptions) (*Manager, error) { telemetry.GetInstance().SetAlertsInfoCallback(db.GetAlertsInfo) m := &Manager{ - tasks: map[string]Task{}, - rules: map[string]Rule{}, - notifier: notifier, - ruleDB: db, - opts: o, - block: make(chan struct{}), - logger: o.Logger, - featureFlags: o.FeatureFlags, - reader: o.Reader, + tasks: map[string]Task{}, + rules: map[string]Rule{}, + notifier: notifier, + ruleDB: db, + opts: o, + block: make(chan struct{}), + logger: o.Logger, + featureFlags: o.FeatureFlags, + reader: o.Reader, + prepareTaskFunc: o.PrepareTaskFunc, } return m, nil } @@ -251,13 +325,26 @@ func (m *Manager) editTask(rule *PostableRule, taskName string) error { zap.L().Debug("editing a rule task", zap.String("name", taskName)) - newTask, err := m.prepareTask(false, rule, taskName) + newTask, err := m.prepareTaskFunc(PrepareTaskOptions{ + Rule: rule, + TaskName: taskName, + RuleDB: m.ruleDB, + Logger: m.logger, + Reader: m.reader, + FF: m.featureFlags, + ManagerOpts: m.opts, + NotifyFunc: m.prepareNotifyFunc(), + }) if err != nil { zap.L().Error("loading tasks failed", zap.Error(err)) return errors.New("error preparing rule with given parameters, previous rule set restored") } + for _, r := range newTask.Rules() { + m.rules[r.ID()] = r + } + // If there is an old task with the same identifier, stop it and wait for // it to finish the current iteration. Then copy it into the new group. oldTask, ok := m.tasks[taskName] @@ -357,7 +444,20 @@ func (m *Manager) addTask(rule *PostableRule, taskName string) error { defer m.mtx.Unlock() zap.L().Debug("adding a new rule task", zap.String("name", taskName)) - newTask, err := m.prepareTask(false, rule, taskName) + newTask, err := m.prepareTaskFunc(PrepareTaskOptions{ + Rule: rule, + TaskName: taskName, + RuleDB: m.ruleDB, + Logger: m.logger, + Reader: m.reader, + FF: m.featureFlags, + ManagerOpts: m.opts, + NotifyFunc: m.prepareNotifyFunc(), + }) + + for _, r := range newTask.Rules() { + m.rules[r.ID()] = r + } if err != nil { zap.L().Error("creating rule task failed", zap.String("name", taskName), zap.Error(err)) @@ -382,77 +482,6 @@ func (m *Manager) addTask(rule *PostableRule, taskName string) error { return nil } -// prepareTask prepares a rule task from postable rule -func (m *Manager) prepareTask(acquireLock bool, r *PostableRule, taskName string) (Task, error) { - - if acquireLock { - m.mtx.Lock() - defer m.mtx.Unlock() - } - - rules := make([]Rule, 0) - var task Task - - if r.AlertName == "" { - zap.L().Error("task load failed, at least one rule must be set", zap.String("name", taskName)) - return task, fmt.Errorf("task load failed, at least one rule must be set") - } - - ruleId := ruleIdFromTaskName(taskName) - if r.RuleType == RuleTypeThreshold { - // create a threshold rule - tr, err := NewThresholdRule( - ruleId, - r, - ThresholdRuleOpts{ - EvalDelay: m.opts.EvalDelay, - }, - m.featureFlags, - m.reader, - ) - - if err != nil { - return task, err - } - - rules = append(rules, tr) - - // create ch rule task for evalution - task = newTask(TaskTypeCh, taskName, taskNamesuffix, time.Duration(r.Frequency), rules, m.opts, m.prepareNotifyFunc(), m.ruleDB) - - // add rule to memory - m.rules[ruleId] = tr - - } else if r.RuleType == RuleTypeProm { - - // create promql rule - pr, err := NewPromRule( - ruleId, - r, - log.With(m.logger, "alert", r.AlertName), - PromRuleOpts{}, - m.reader, - ) - - if err != nil { - return task, err - } - - rules = append(rules, pr) - - // create promql rule task for evalution - task = newTask(TaskTypeProm, taskName, taskNamesuffix, time.Duration(r.Frequency), rules, m.opts, m.prepareNotifyFunc(), m.ruleDB) - - // add rule to memory - m.rules[ruleId] = pr - - } else { - return nil, fmt.Errorf("unsupported rule type. Supported types: %s, %s", RuleTypeProm, RuleTypeThreshold) - } - - return task, nil -} - // RuleTasks returns the list of manager's rule tasks. func (m *Manager) RuleTasks() []Task { m.mtx.RLock() @@ -783,7 +812,7 @@ func (m *Manager) TestNotification(ctx context.Context, ruleStr string) (int, *m rule, err = NewPromRule( alertname, parsedRule, - log.With(m.logger, "alert", alertname), + m.logger, PromRuleOpts{ SendAlways: true, }, diff --git a/pkg/query-service/rules/prom_rule.go b/pkg/query-service/rules/prom_rule.go index 06f9ae311d..c8159c49c8 100644 --- a/pkg/query-service/rules/prom_rule.go +++ b/pkg/query-service/rules/prom_rule.go @@ -8,8 +8,6 @@ import ( "sync" "time" - "github.com/go-kit/log" - "github.com/go-kit/log/level" "go.uber.org/zap" plabels "github.com/prometheus/prometheus/model/labels" @@ -54,7 +52,7 @@ type PromRule struct { // map of active alerts active map[uint64]*Alert - logger log.Logger + logger *zap.Logger opts PromRuleOpts reader interfaces.Reader @@ -63,7 +61,7 @@ type PromRule struct { func NewPromRule( id string, postableRule *PostableRule, - logger log.Logger, + logger *zap.Logger, opts PromRuleOpts, reader interfaces.Reader, ) (*PromRule, error) { @@ -405,7 +403,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) ( result, err := tmpl.Expand() if err != nil { result = fmt.Sprintf("", err) - level.Warn(r.logger).Log("msg", "Expanding alert template failed", "err", err, "data", tmplData) + r.logger.Warn("Expanding alert template failed", zap.Error(err), zap.Any("data", tmplData)) } return result } diff --git a/pkg/query-service/rules/prom_rule_task.go b/pkg/query-service/rules/prom_rule_task.go index 13c24ca1fa..f2f11cd494 100644 --- a/pkg/query-service/rules/prom_rule_task.go +++ b/pkg/query-service/rules/prom_rule_task.go @@ -7,7 +7,6 @@ import ( "sync" "time" - "github.com/go-kit/log" opentracing "github.com/opentracing/opentracing-go" plabels "github.com/prometheus/prometheus/model/labels" "go.signoz.io/signoz/pkg/query-service/common" @@ -33,7 +32,7 @@ type PromRuleTask struct { terminated chan struct{} pause bool - logger log.Logger + logger *zap.Logger notify NotifyFunc ruleDB RuleDB @@ -60,7 +59,7 @@ func newPromRuleTask(name, file string, frequency time.Duration, rules []Rule, o terminated: make(chan struct{}), notify: notify, ruleDB: ruleDB, - logger: log.With(opts.Logger, "group", name), + logger: opts.Logger, } } diff --git a/pkg/query-service/rules/promrule_test.go b/pkg/query-service/rules/promrule_test.go index a06b510f2e..6b67253668 100644 --- a/pkg/query-service/rules/promrule_test.go +++ b/pkg/query-service/rules/promrule_test.go @@ -7,17 +7,9 @@ import ( pql "github.com/prometheus/prometheus/promql" "github.com/stretchr/testify/assert" v3 "go.signoz.io/signoz/pkg/query-service/model/v3" + "go.uber.org/zap" ) -type testLogger struct { - t *testing.T -} - -func (l testLogger) Log(args ...interface{}) error { - l.t.Log(args...) - return nil -} - func TestPromRuleShouldAlert(t *testing.T) { postableRule := PostableRule{ AlertName: "Test Rule", @@ -611,7 +603,7 @@ func TestPromRuleShouldAlert(t *testing.T) { postableRule.RuleCondition.MatchType = MatchType(c.matchType) postableRule.RuleCondition.Target = &c.target - rule, err := NewPromRule("69", &postableRule, testLogger{t}, PromRuleOpts{}, nil) + rule, err := NewPromRule("69", &postableRule, zap.NewNop(), PromRuleOpts{}, nil) if err != nil { assert.NoError(t, err) } From 3544ffdcc67f8dc64ff229bacb3c27e923e24548 Mon Sep 17 00:00:00 2001 From: CheetoDa <31571545+Calm-Rock@users.noreply.github.com> Date: Wed, 4 Sep 2024 18:24:54 +0530 Subject: [PATCH 02/18] chore: fixed hostmetrics dashboard link (#5851) --- .../md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md | 2 +- .../md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md | 2 +- .../md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md | 2 +- .../md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md | 2 +- .../AwsMonitoring/ECSEc2/md-docs/ecsEc2-createDaemonService.md | 2 +- .../ECSExternal/md-docs/ecsExternal-createDaemonService.md | 2 +- .../md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md | 2 +- .../md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md | 2 +- .../md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md | 2 +- .../md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md index 5be4c4a528..18c5352f97 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)     diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md index 5be4c4a528..18c5352f97 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)     diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md index 5be4c4a528..18c5352f97 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)     diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md index 5be4c4a528..18c5352f97 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)     diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSEc2/md-docs/ecsEc2-createDaemonService.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSEc2/md-docs/ecsEc2-createDaemonService.md index 83bb67039b..2c313c455a 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSEc2/md-docs/ecsEc2-createDaemonService.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSEc2/md-docs/ecsEc2-createDaemonService.md @@ -51,7 +51,7 @@ aws ecs list-tasks --cluster ${CLUSTER_NAME} --region ${REGION} To verify that the data is being sent to SigNoz Cloud, you can go to the dashboard section of SigNoz and import one of the following dashboards below: - [instancemetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/chore/ecs-dashboards/ecs-infra-metrics/instance-metrics.json) -- [hostmetrics-with-variable.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics-with-variable.json) +- [hostmetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics.json)   diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSExternal/md-docs/ecsExternal-createDaemonService.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSExternal/md-docs/ecsExternal-createDaemonService.md index 83bb67039b..2c313c455a 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSExternal/md-docs/ecsExternal-createDaemonService.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSExternal/md-docs/ecsExternal-createDaemonService.md @@ -51,7 +51,7 @@ aws ecs list-tasks --cluster ${CLUSTER_NAME} --region ${REGION} To verify that the data is being sent to SigNoz Cloud, you can go to the dashboard section of SigNoz and import one of the following dashboards below: - [instancemetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/chore/ecs-dashboards/ecs-infra-metrics/instance-metrics.json) -- [hostmetrics-with-variable.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics-with-variable.json) +- [hostmetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics.json)   diff --git a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md index 97c686e0e7..b6009cb839 100644 --- a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json) ### Step 2: Import hostmetrics JSON file to SigNoz Cloud diff --git a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md index 97c686e0e7..b6009cb839 100644 --- a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json) ### Step 2: Import hostmetrics JSON file to SigNoz Cloud diff --git a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md index 97c686e0e7..b6009cb839 100644 --- a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json) ### Step 2: Import hostmetrics JSON file to SigNoz Cloud diff --git a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md index 97c686e0e7..b6009cb839 100644 --- a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json) ### Step 2: Import hostmetrics JSON file to SigNoz Cloud From 6019b38da55829346e95c421d70a1b3cf7eab264 Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Wed, 4 Sep 2024 18:30:04 +0530 Subject: [PATCH 03/18] fix: use better value for threshold value in alert description (#5844) --- pkg/query-service/rules/prom_rule.go | 27 ++ pkg/query-service/rules/promrule_test.go | 223 ++++++++++------ pkg/query-service/rules/threshold_rule.go | 28 ++ .../rules/threshold_rule_test.go | 244 ++++++++++++------ 4 files changed, 351 insertions(+), 171 deletions(-) diff --git a/pkg/query-service/rules/prom_rule.go b/pkg/query-service/rules/prom_rule.go index c8159c49c8..a9890a9503 100644 --- a/pkg/query-service/rules/prom_rule.go +++ b/pkg/query-service/rules/prom_rule.go @@ -591,6 +591,16 @@ func (r *PromRule) shouldAlert(series pql.Series) (pql.Sample, bool) { break } } + // use min value from the series + if shouldAlert { + var minValue float64 = math.Inf(1) + for _, smpl := range series.Floats { + if smpl.F < minValue { + minValue = smpl.F + } + } + alertSmpl = pql.Sample{F: minValue, Metric: series.Metric} + } } else if r.compareOp() == ValueIsBelow { for _, smpl := range series.Floats { if smpl.F >= r.targetVal() { @@ -598,6 +608,15 @@ func (r *PromRule) shouldAlert(series pql.Series) (pql.Sample, bool) { break } } + if shouldAlert { + var maxValue float64 = math.Inf(-1) + for _, smpl := range series.Floats { + if smpl.F > maxValue { + maxValue = smpl.F + } + } + alertSmpl = pql.Sample{F: maxValue, Metric: series.Metric} + } } else if r.compareOp() == ValueIsEq { for _, smpl := range series.Floats { if smpl.F != r.targetVal() { @@ -612,6 +631,14 @@ func (r *PromRule) shouldAlert(series pql.Series) (pql.Sample, bool) { break } } + if shouldAlert { + for _, smpl := range series.Floats { + if !math.IsInf(smpl.F, 0) && !math.IsNaN(smpl.F) { + alertSmpl = pql.Sample{F: smpl.F, Metric: series.Metric} + break + } + } + } } case OnAverage: // If the average of all samples matches the condition, the rule is firing. diff --git a/pkg/query-service/rules/promrule_test.go b/pkg/query-service/rules/promrule_test.go index 6b67253668..fef7630bbd 100644 --- a/pkg/query-service/rules/promrule_test.go +++ b/pkg/query-service/rules/promrule_test.go @@ -30,11 +30,12 @@ func TestPromRuleShouldAlert(t *testing.T) { } cases := []struct { - values pql.Series - expectAlert bool - compareOp string - matchType string - target float64 + values pql.Series + expectAlert bool + compareOp string + matchType string + target float64 + expectedAlertSample v3.Point }{ // Test cases for Equals Always { @@ -47,10 +48,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 0.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "2", // Always - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "2", // Always + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: pql.Series{ @@ -108,10 +110,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 0.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: pql.Series{ @@ -123,10 +126,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: pql.Series{ @@ -138,10 +142,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: pql.Series{ @@ -169,10 +174,43 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "2", // Always - target: 1.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "2", // Always + target: 1.5, + expectedAlertSample: v3.Point{Value: 2.0}, + }, + { + values: pql.Series{ + Floats: []pql.FPoint{ + {F: 11.0}, + {F: 4.0}, + {F: 3.0}, + {F: 7.0}, + {F: 12.0}, + }, + }, + expectAlert: true, + compareOp: "1", // Above + matchType: "2", // Always + target: 2.0, + expectedAlertSample: v3.Point{Value: 3.0}, + }, + { + values: pql.Series{ + Floats: []pql.FPoint{ + {F: 11.0}, + {F: 4.0}, + {F: 3.0}, + {F: 7.0}, + {F: 12.0}, + }, + }, + expectAlert: true, + compareOp: "2", // Below + matchType: "2", // Always + target: 13.0, + expectedAlertSample: v3.Point{Value: 12.0}, }, { values: pql.Series{ @@ -200,10 +238,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "1", // Once - target: 4.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "1", // Once + target: 4.5, + expectedAlertSample: v3.Point{Value: 10.0}, }, { values: pql.Series{ @@ -261,10 +300,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "2", // Always - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "2", // Always + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: pql.Series{ @@ -292,10 +332,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 0.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: pql.Series{ @@ -322,10 +363,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: pql.Series{ @@ -337,10 +379,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, // Test cases for Less Than Always { @@ -353,10 +396,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.5}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "2", // Always - target: 4, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "2", // Always + target: 4, + expectedAlertSample: v3.Point{Value: 1.5}, }, { values: pql.Series{ @@ -384,10 +428,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.5}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "1", // Once - target: 4, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "1", // Once + target: 4, + expectedAlertSample: v3.Point{Value: 2.5}, }, { values: pql.Series{ @@ -415,10 +460,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "3", // OnAverage - target: 6.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "3", // OnAverage + target: 6.0, + expectedAlertSample: v3.Point{Value: 6.0}, }, { values: pql.Series{ @@ -445,10 +491,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "3", // OnAverage - target: 4.5, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "3", // OnAverage + target: 4.5, + expectedAlertSample: v3.Point{Value: 6.0}, }, { values: pql.Series{ @@ -475,10 +522,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "3", // OnAverage - target: 4.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "3", // OnAverage + target: 4.5, + expectedAlertSample: v3.Point{Value: 6.0}, }, { values: pql.Series{ @@ -490,10 +538,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "3", // OnAverage - target: 12.0, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "3", // OnAverage + target: 12.0, + expectedAlertSample: v3.Point{Value: 6.0}, }, // Test cases for InTotal { @@ -506,10 +555,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "4", // InTotal - target: 30.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "4", // InTotal + target: 30.0, + expectedAlertSample: v3.Point{Value: 30.0}, }, { values: pql.Series{ @@ -532,10 +582,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 10.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "4", // InTotal - target: 9.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "4", // InTotal + target: 9.0, + expectedAlertSample: v3.Point{Value: 10.0}, }, { values: pql.Series{ @@ -555,10 +606,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 10.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "4", // InTotal - target: 10.0, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "4", // InTotal + target: 10.0, + expectedAlertSample: v3.Point{Value: 20.0}, }, { values: pql.Series{ @@ -579,10 +631,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 10.0}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "4", // InTotal - target: 30.0, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "4", // InTotal + target: 30.0, + expectedAlertSample: v3.Point{Value: 20.0}, }, { values: pql.Series{ diff --git a/pkg/query-service/rules/threshold_rule.go b/pkg/query-service/rules/threshold_rule.go index 9bdecbc63d..e657af9288 100644 --- a/pkg/query-service/rules/threshold_rule.go +++ b/pkg/query-service/rules/threshold_rule.go @@ -1205,6 +1205,16 @@ func (r *ThresholdRule) shouldAlert(series v3.Series) (Sample, bool) { break } } + // use min value from the series + if shouldAlert { + var minValue float64 = math.Inf(1) + for _, smpl := range series.Points { + if smpl.Value < minValue { + minValue = smpl.Value + } + } + alertSmpl = Sample{Point: Point{V: minValue}, Metric: lblsNormalized, MetricOrig: lbls} + } } else if r.compareOp() == ValueIsBelow { for _, smpl := range series.Points { if smpl.Value >= r.targetVal() { @@ -1212,6 +1222,15 @@ func (r *ThresholdRule) shouldAlert(series v3.Series) (Sample, bool) { break } } + if shouldAlert { + var maxValue float64 = math.Inf(-1) + for _, smpl := range series.Points { + if smpl.Value > maxValue { + maxValue = smpl.Value + } + } + alertSmpl = Sample{Point: Point{V: maxValue}, Metric: lblsNormalized, MetricOrig: lbls} + } } else if r.compareOp() == ValueIsEq { for _, smpl := range series.Points { if smpl.Value != r.targetVal() { @@ -1226,6 +1245,15 @@ func (r *ThresholdRule) shouldAlert(series v3.Series) (Sample, bool) { break } } + // use any non-inf or nan value from the series + if shouldAlert { + for _, smpl := range series.Points { + if !math.IsInf(smpl.Value, 0) && !math.IsNaN(smpl.Value) { + alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lblsNormalized, MetricOrig: lbls} + break + } + } + } } case OnAverage: // If the average of all samples matches the condition, the rule is firing. diff --git a/pkg/query-service/rules/threshold_rule_test.go b/pkg/query-service/rules/threshold_rule_test.go index 05bd613900..6cfeac83d9 100644 --- a/pkg/query-service/rules/threshold_rule_test.go +++ b/pkg/query-service/rules/threshold_rule_test.go @@ -42,11 +42,12 @@ func TestThresholdRuleShouldAlert(t *testing.T) { } cases := []struct { - values v3.Series - expectAlert bool - compareOp string - matchType string - target float64 + values v3.Series + expectAlert bool + compareOp string + matchType string + target float64 + expectedAlertSample v3.Point }{ // Test cases for Equals Always { @@ -59,10 +60,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 0.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "2", // Always - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "2", // Always + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: v3.Series{ @@ -120,10 +122,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 0.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: v3.Series{ @@ -135,10 +138,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: v3.Series{ @@ -150,10 +154,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: v3.Series{ @@ -181,10 +186,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "2", // Always - target: 1.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "2", // Always + target: 1.5, + expectedAlertSample: v3.Point{Value: 2.0}, }, { values: v3.Series{ @@ -212,10 +218,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "1", // Once - target: 4.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "1", // Once + target: 4.5, + expectedAlertSample: v3.Point{Value: 10.0}, }, { values: v3.Series{ @@ -273,10 +280,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "2", // Always - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "2", // Always + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: v3.Series{ @@ -304,10 +312,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 0.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: v3.Series{ @@ -334,10 +343,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: v3.Series{ @@ -349,10 +359,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, // Test cases for Less Than Always { @@ -365,10 +376,27 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.5}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "2", // Always - target: 4, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "2", // Always + target: 4, + expectedAlertSample: v3.Point{Value: 1.5}, + }, + { + values: v3.Series{ + Points: []v3.Point{ + {Value: 1.5}, + {Value: 2.5}, + {Value: 1.5}, + {Value: 3.5}, + {Value: 1.5}, + }, + }, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "2", // Always + target: 4, + expectedAlertSample: v3.Point{Value: 3.5}, }, { values: v3.Series{ @@ -396,10 +424,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.5}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "1", // Once - target: 4, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "1", // Once + target: 4, + expectedAlertSample: v3.Point{Value: 2.5}, }, { values: v3.Series{ @@ -427,10 +456,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "3", // OnAverage - target: 6.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "3", // OnAverage + target: 6.0, + expectedAlertSample: v3.Point{Value: 6.0}, }, { values: v3.Series{ @@ -457,10 +487,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "3", // OnAverage - target: 4.5, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "3", // OnAverage + target: 4.5, + expectedAlertSample: v3.Point{Value: 6.0}, }, { values: v3.Series{ @@ -487,10 +518,43 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "3", // OnAverage - target: 4.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "3", // OnAverage + target: 4.5, + expectedAlertSample: v3.Point{Value: 6.0}, + }, + { + values: v3.Series{ + Points: []v3.Point{ + {Value: 11.0}, + {Value: 4.0}, + {Value: 3.0}, + {Value: 7.0}, + {Value: 12.0}, + }, + }, + expectAlert: true, + compareOp: "1", // Above + matchType: "2", // Always + target: 2.0, + expectedAlertSample: v3.Point{Value: 3.0}, + }, + { + values: v3.Series{ + Points: []v3.Point{ + {Value: 11.0}, + {Value: 4.0}, + {Value: 3.0}, + {Value: 7.0}, + {Value: 12.0}, + }, + }, + expectAlert: true, + compareOp: "2", // Below + matchType: "2", // Always + target: 13.0, + expectedAlertSample: v3.Point{Value: 12.0}, }, { values: v3.Series{ @@ -502,10 +566,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "3", // OnAverage - target: 12.0, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "3", // OnAverage + target: 12.0, + expectedAlertSample: v3.Point{Value: 6.0}, }, // Test cases for InTotal { @@ -518,10 +583,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "4", // InTotal - target: 30.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "4", // InTotal + target: 30.0, + expectedAlertSample: v3.Point{Value: 30.0}, }, { values: v3.Series{ @@ -544,10 +610,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 10.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "4", // InTotal - target: 9.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "4", // InTotal + target: 9.0, + expectedAlertSample: v3.Point{Value: 10.0}, }, { values: v3.Series{ @@ -567,10 +634,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 10.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "4", // InTotal - target: 10.0, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "4", // InTotal + target: 10.0, + expectedAlertSample: v3.Point{Value: 20.0}, }, { values: v3.Series{ @@ -591,10 +659,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 10.0}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "4", // InTotal - target: 30.0, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "4", // InTotal + target: 30.0, + expectedAlertSample: v3.Point{Value: 20.0}, }, { values: v3.Series{ @@ -626,8 +695,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { values.Points[i].Timestamp = time.Now().UnixMilli() } - _, shoulAlert := rule.shouldAlert(c.values) + smpl, shoulAlert := rule.shouldAlert(c.values) assert.Equal(t, c.expectAlert, shoulAlert, "Test case %d", idx) + if shoulAlert { + assert.Equal(t, c.expectedAlertSample.Value, smpl.V, "Test case %d", idx) + } } } From e97d0ea51c4c58c153537f113221802b18429a44 Mon Sep 17 00:00:00 2001 From: Yunus M Date: Wed, 4 Sep 2024 21:26:10 +0530 Subject: [PATCH 04/18] Feat: alert history (#5774) * feat: tabs and filters for alert history page (#5655) * feat: alert history page route and component setup * feat: alert history basic tabs and fitlers UI * feat: route based tabs for alert history and overview and improve the UI to match designs * chore: unused components and files cleanup * chore: improve alert history and overview route paths * chore: use parent selector in scss files * chore: alert -> alerts * feat: alert rule details metadata header (#5675) * feat: alert history basic tabs and fitlers UI * feat: route based tabs for alert history and overview and improve the UI to match designs * chore: unused components and files cleanup * feat: copy to clipboard component * feat: see more component * feat: key value label component * feat: alert rule details meta data header * fix: apply the missing changes * chore: uncomment the alert status with static data * chore: compress the alert status svg icons and define props, types, and defaultProps * feat: alert rule history skeleton using static data (#5688) * feat: alert history basic tabs and fitlers UI * feat: route based tabs for alert history and overview and improve the UI to match designs * feat: top contributors UI using static data * feat: avg. resolution time and total triggered stats card UI using static data * feat: tabs component * feat: timeline tabs and filters * feat: overall status graph UI using dummy data with graph placeholder * feat: timeline table and pagination UI using dummy data * fix: bugfix in reset tabs * feat: add popover to go to logs/traces to top contributors and timeline table * chore: remove comments * chore: rename AlertIcon to AlertState * fix: add cursor pointer to timeline table rows * feat: add parent tabs to alert history * chore: add icon to the configure tab * fix: display popover on hovering the more button in see more component * fix: wrap key value label * feat: alert rule history enable/disable toggle UI * Feat: get alert history data from API (#5718) * feat: alert history basic tabs and fitlers UI * feat: route based tabs for alert history and overview and improve the UI to match designs * feat: data state renderer component * feat: get total triggered and avg. resolution cards data from API * fix: hide stats card if we get NaN * chore: improve rule stats types * feat: get top contributors data from API * feat: get timeline table data from API * fix: properly render change percentage indicator * feat: total triggered and avg resolution empty states * fix: fix stats height issue that would cause short border-right in empty case * feat: top contributors empty state * fix: fix table and graph borders * feat: build alert timeline labels filter and handle client side filtering * fix: select the first tab on clicking reset * feat: set param and send in payload on clicking timeline filter tabs * Feat: alert history timeline remaining subtasks except graphs (#5720) * feat: alert history basic tabs and fitlers UI * feat: route based tabs for alert history and overview and improve the UI to match designs * feat: implement timeline table sorting * chore: add initial count to see more and alert labels * chore: move PaginationInfoText component to /periscope * chore: implement top contributor rows using Ant Table * feat: top contributors view all * fix: hide border for last row and prevent layout shift in top contributors by specifying height * feat: properly display duration in average resolution time * fix: properly display normal alert rule state * feat: add/remove view all top contributors param to url on opening/closing view all * feat: calculate start and end time from relative time and add/remove param to url * fix: fix console warnings * fix: enable timeline table query only if start and end times exist * feat: handle enable/disable alert rule toggle request * chore: replace string values with constants * fix: hide stats card if only past data is available + remove unnecessary states from AlertState * fix: redirect configure alert rule to alert overview tab * fix: display total triggers in timeline chart wrapper based on API response data * fix: choosing the same relative time doesn't udpate start and end time * Feat: total triggered and avg. resolution time graph (#5750) * feat: alert history basic tabs and fitlers UI * feat: route based tabs for alert history and overview and improve the UI to match designs * feat: handle enable/disable alert rule toggle request * feat: stats card line chart * fix: overall improvements to stats card graph * fix: overall UI improvements to match the Figma screens * chore: remove duplicate hook * fix: make the changes w.r.t timeline table API changes to prevent breaking the page * fix: update stats card null check based on updated API response * feat: stats card no previous data UI * feat: redirect to 404 page if rule id is invalid * chore: improve alert enable toggle success toast message * feat: get top contributors row and timeline table row related logs and traces links from API * feat: get total items from API and make pagination work * feat: implement timeline filters based on API response * fix: in case of current and target units, convert the value unit in timeline table * fix: timeline table y axis unit null check * fix: hide stats card graph if only a single entry is there in timeseries * chore: redirect alert from all alerts to overview tab * fix: prevent adding extra unnecessary params on clicking alerts top level tabs * chore: use conditional alert popover in timeline table and import the scss file * fix: prevent infinity if we receive totalPastTriggers as '0' * fix: improve UI to be pixel perfect based on figma designs * fix: fix the incorrect change direction * fix: add height to top contributors row * feat: alert history light mode * fix: remove the extra padding from alert overview query builder tabs * chore: overall improvements * chore: remove mock file * fix: overall improvements * fix: add dark mode support for top contributors empty state * chore: improve timeline chart placeholder bg in light mode * Feat: alert history horizontal timeline chart (#5773) * feat: timeline horizontal chart * fix: remove the labels from horizontal timeline chart * chore: add null check to timeline chart * chore: hide cursor from timeline chart * fix: fix the blank container being displayed in loading state * fix: alert history UI fixes (#5776) * fix: remove extra padding from alert overview query section tabs * fix: add padding to alert overview container * fix: improve breadcrumb click behavior * chore: temporarily hide reset button from alert details timepicker * fix: improve breadcrumb click behavior * chore: hide alert firing since * fix: don't use the data state renderer for timeline table * fix: alert history pr review changes (#5778) * chore: rename alert history scss files in pascal case * fix: use proper variables * chore: use color variable for action button dropdown item * chore: improve the directory structure for alert history components * chore: move inline style to scss file and extract dropdown renderer component * chore: use colors from Color instead of css variables inside tsx files * chore: return null in default case * chore: update alert details spinner tip * chore: timelinePlugin warnings and remove file wide warning disabling * chore: change Arial to Geist Mono in timeline plugin * feat: alert history remaining feats (#5825) * fix: add switch case for inactive state to alert state component * feat: add API enabled label search similar to Query Builder * feat: add reset button to date and time picker * feat: add vertical timeline chart using static data * chore: use Colors instead of hex + dummy data for 90 days * fix: label search light mode UI * fix: remove placeholder logic, and display vertical charts if more than 1 day * chore: extract dayjs manipulate types to a constant * fix: hide the overflow of top contributors card * fix: throw instead of return error to prevent breaking alert history page in case of error * chore: temporarily comment alert history vertical charts * chore: calculate start and end times from relative time and remove query params (#5828) * chore: calculate start and end times from relative time and remove query params * fix: hide reset button if selected time is 30m * feat: alert history dropdown functionality (#5833) * feat: alert history dropdown actions * chore: use query keys from react query key constant * fix: properly handle error states for alert rule APIs * fix: handle dropdown state using onOpenChange to fix clicking delete not closing the dropdown * Fix: bugfixes and overall improvements to alert history (#5841) * fix: don't display severity label * chore: remove id from alert header * chore: add tooltip to enable/disable alert toggle * chore: update enable/disbale toast message * fix: set default relative time to 6h if relative time is not provided * chore: update empty top contributors text and remove configure alert * chore: temporarily hide value column from timeline column * fix: use correct links for logs and traces in alert popover * fix: properly set timeline table offset * fix: display all values in graph * fix: resolve conflicts * chore: remove style for value column in timeline table * chore: temporarily hide labels search * fix: incorrect current page in pagination info text * chore: remove label QB search * chore: remove value column * chore: remove commented code * fix: show traces button when trace link is available * fix: display horizontal chart even for a single entry * fix: show inactive state in horizontal similar to normal state * fix: properly render inactive state in horizontal chart * fix: properly handle preserving alert toggle between overview and history tabs * feat: get page size from query param * chore: remove commented code + minor refactor * chore: remove tsconfi.tmp * fix: don't add default relative time if start and times exist in the url * feat: display date range preview for stat cards * chore: remove custom dropdown renderer component * Fix: UI feedback changes (#5852) * fix: add divider before delete button * fix: timeline section title color in lightmode * fix: remove the extra border from alert history tabs * fix: populate alert rule disabled state on toggling alert state (#5854) --------- Co-authored-by: Shaheer Kochai --- frontend/public/locales/en-GB/titles.json | 4 +- frontend/public/locales/en/titles.json | 2 + frontend/src/AppRoutes/index.tsx | 35 +- frontend/src/AppRoutes/pageComponents.ts | 8 + frontend/src/AppRoutes/routes.ts | 16 + frontend/src/api/alerts/create.ts | 24 +- frontend/src/api/alerts/delete.ts | 20 +- frontend/src/api/alerts/get.ts | 22 +- frontend/src/api/alerts/patch.ts | 24 +- frontend/src/api/alerts/put.ts | 24 +- frontend/src/api/alerts/ruleStats.ts | 28 + frontend/src/api/alerts/timelineGraph.ts | 33 + frontend/src/api/alerts/timelineTable.ts | 36 + frontend/src/api/alerts/topContributors.ts | 33 + .../src/assets/AlertHistory/ConfigureIcon.tsx | 41 ++ frontend/src/assets/AlertHistory/LogsIcon.tsx | 65 ++ .../AlertHistory/SeverityCriticalIcon.tsx | 39 ++ .../assets/AlertHistory/SeverityErrorIcon.tsx | 42 ++ .../assets/AlertHistory/SeverityInfoIcon.tsx | 46 ++ .../AlertHistory/SeverityWarningIcon.tsx | 42 ++ .../AlertDetailsFilters/Filters.styles.scss | 14 + .../AlertDetailsFilters/Filters.tsx | 11 + .../TabsAndFilters/Tabs/Tabs.styles.scss | 5 + .../components/TabsAndFilters/Tabs/Tabs.tsx | 41 ++ .../TabsAndFilters/TabsAndFilters.styles.scss | 18 + .../TabsAndFilters/TabsAndFilters.tsx | 16 + .../components/TabsAndFilters/constants.ts | 5 + frontend/src/constants/global.ts | 13 + frontend/src/constants/reactQueryKeys.ts | 9 + frontend/src/constants/routes.ts | 2 + .../AlertHistory/AlertHistory.styles.scss | 5 + .../container/AlertHistory/AlertHistory.tsx | 22 + .../AlertPopover/AlertPopover.styles.scss | 3 + .../AlertPopover/AlertPopover.tsx | 114 ++++ .../AverageResolutionCard.tsx | 28 + .../Statistics/Statistics.styles.scss | 14 + .../AlertHistory/Statistics/Statistics.tsx | 23 + .../StatsCard/StatsCard.styles.scss | 112 ++++ .../Statistics/StatsCard/StatsCard.tsx | 158 +++++ .../StatsCard/StatsGraph/StatsGraph.tsx | 90 +++ .../Statistics/StatsCard/utils.ts | 12 + .../StatsCardsRenderer/StatsCardsRenderer.tsx | 102 +++ .../TopContributorsCard.styles.scss | 191 ++++++ .../TopContributorsCard.tsx | 84 +++ .../TopContributorsContent.tsx | 32 + .../TopContributorsRows.tsx | 87 +++ .../TopContributorsCard/ViewAllDrawer.tsx | 46 ++ .../Statistics/TopContributorsCard/types.ts | 6 + .../TopContributorsRenderer.tsx | 42 ++ .../TotalTriggeredCard/TotalTriggeredCard.tsx | 26 + .../Timeline/Graph/Graph.styles.scss | 52 ++ .../AlertHistory/Timeline/Graph/Graph.tsx | 184 +++++ .../AlertHistory/Timeline/Graph/constants.ts | 33 + .../Timeline/GraphWrapper/GraphWrapper.tsx | 67 ++ .../Timeline/Table/Table.styles.scss | 134 ++++ .../AlertHistory/Timeline/Table/Table.tsx | 56 ++ .../AlertHistory/Timeline/Table/types.ts | 9 + .../Timeline/Table/useTimelineTable.tsx | 53 ++ .../TabsAndFilters/TabsAndFilters.styles.scss | 32 + .../TabsAndFilters/TabsAndFilters.tsx | 90 +++ .../Timeline/Timeline.styles.scss | 22 + .../AlertHistory/Timeline/Timeline.tsx | 32 + .../AlertHistory/Timeline/constants.ts | 2 + .../src/container/AlertHistory/constants.ts | 1 + frontend/src/container/AlertHistory/index.tsx | 3 + frontend/src/container/AlertHistory/types.ts | 15 + frontend/src/container/AppLayout/index.tsx | 4 + .../FormAlertRules/QuerySection.styles.scss | 4 + .../src/container/FormAlertRules/index.tsx | 3 +- .../container/ListAlertRules/ListAlert.tsx | 2 +- .../DateTimeSelectionV2.styles.scss | 12 + .../TopNav/DateTimeSelectionV2/config.ts | 2 + .../TopNav/DateTimeSelectionV2/index.tsx | 152 +++-- .../src/lib/uPlotLib/plugins/heatmapPlugin.ts | 49 ++ .../lib/uPlotLib/plugins/timelinePlugin.ts | 632 ++++++++++++++++++ .../AlertDetails/AlertDetails.styles.scss | 189 ++++++ .../src/pages/AlertDetails/AlertDetails.tsx | 123 ++++ .../ActionButtons/ActionButtons.styles.scss | 63 ++ .../ActionButtons/ActionButtons.tsx | 111 +++ .../AlertHeader/AlertHeader.styles.scss | 50 ++ .../AlertDetails/AlertHeader/AlertHeader.tsx | 66 ++ .../AlertLabels/AlertLabels.styles.scss | 5 + .../AlertHeader/AlertLabels/AlertLabels.tsx | 31 + .../AlertSeverity/AlertSeverity.styles.scss | 40 ++ .../AlertSeverity/AlertSeverity.tsx | 42 ++ .../AlertState/AlertState.styles.scss | 10 + .../AlertHeader/AlertState/AlertState.tsx | 73 ++ .../AlertStatus/AlertStatus.styles.scss | 22 + .../AlertHeader/AlertStatus/AlertStatus.tsx | 54 ++ .../AlertHeader/AlertStatus/types.ts | 18 + frontend/src/pages/AlertDetails/hooks.tsx | 525 +++++++++++++++ frontend/src/pages/AlertDetails/index.tsx | 3 + frontend/src/pages/AlertDetails/types.ts | 6 + frontend/src/pages/AlertHistory/index.tsx | 3 + frontend/src/pages/AlertList/index.tsx | 45 +- .../src/pages/EditRules/EditRules.styles.scss | 31 +- frontend/src/pages/EditRules/index.tsx | 19 +- .../CopyToClipboard.styles.scss | 39 ++ .../CopyToClipboard/CopyToClipboard.tsx | 54 ++ .../components/CopyToClipboard/index.tsx | 3 + .../DataStateRenderer/DataStateRenderer.tsx | 46 ++ .../components/DataStateRenderer/index.tsx | 3 + .../KeyValueLabel/KeyValueLabel.styles.scss | 37 + .../KeyValueLabel/KeyValueLabel.tsx | 18 + .../components/KeyValueLabel/index.tsx | 3 + .../PaginationInfoText/PaginationInfoText.tsx | 24 + .../components/SeeMore/SeeMore.styles.scss | 26 + .../periscope/components/SeeMore/SeeMore.tsx | 48 ++ .../periscope/components/SeeMore/index.tsx | 3 + .../components/Tabs2/Tabs2.styles.scss | 48 ++ .../src/periscope/components/Tabs2/Tabs2.tsx | 80 +++ .../src/periscope/components/Tabs2/index.tsx | 3 + frontend/src/providers/Alert.tsx | 43 ++ frontend/src/types/api/alerts/def.ts | 66 +- frontend/src/types/api/alerts/ruleStats.ts | 7 + .../src/types/api/alerts/timelineGraph.ts | 7 + .../src/types/api/alerts/timelineTable.ts | 13 + .../src/types/api/alerts/topContributors.ts | 7 + frontend/src/utils/calculateChange.ts | 31 + frontend/src/utils/permission/index.ts | 2 + frontend/src/utils/timeUtils.ts | 97 +++ 121 files changed, 5630 insertions(+), 167 deletions(-) create mode 100644 frontend/src/api/alerts/ruleStats.ts create mode 100644 frontend/src/api/alerts/timelineGraph.ts create mode 100644 frontend/src/api/alerts/timelineTable.ts create mode 100644 frontend/src/api/alerts/topContributors.ts create mode 100644 frontend/src/assets/AlertHistory/ConfigureIcon.tsx create mode 100644 frontend/src/assets/AlertHistory/LogsIcon.tsx create mode 100644 frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx create mode 100644 frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx create mode 100644 frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx create mode 100644 frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx create mode 100644 frontend/src/components/AlertDetailsFilters/Filters.styles.scss create mode 100644 frontend/src/components/AlertDetailsFilters/Filters.tsx create mode 100644 frontend/src/components/TabsAndFilters/Tabs/Tabs.styles.scss create mode 100644 frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx create mode 100644 frontend/src/components/TabsAndFilters/TabsAndFilters.styles.scss create mode 100644 frontend/src/components/TabsAndFilters/TabsAndFilters.tsx create mode 100644 frontend/src/components/TabsAndFilters/constants.ts create mode 100644 frontend/src/container/AlertHistory/AlertHistory.styles.scss create mode 100644 frontend/src/container/AlertHistory/AlertHistory.tsx create mode 100644 frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss create mode 100644 frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/Statistics.styles.scss create mode 100644 frontend/src/container/AlertHistory/Statistics/Statistics.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.styles.scss create mode 100644 frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/StatsCard/utils.ts create mode 100644 frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx create mode 100644 frontend/src/container/AlertHistory/Timeline/Graph/Graph.styles.scss create mode 100644 frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx create mode 100644 frontend/src/container/AlertHistory/Timeline/Graph/constants.ts create mode 100644 frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx create mode 100644 frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss create mode 100644 frontend/src/container/AlertHistory/Timeline/Table/Table.tsx create mode 100644 frontend/src/container/AlertHistory/Timeline/Table/types.ts create mode 100644 frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx create mode 100644 frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.styles.scss create mode 100644 frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx create mode 100644 frontend/src/container/AlertHistory/Timeline/Timeline.styles.scss create mode 100644 frontend/src/container/AlertHistory/Timeline/Timeline.tsx create mode 100644 frontend/src/container/AlertHistory/Timeline/constants.ts create mode 100644 frontend/src/container/AlertHistory/constants.ts create mode 100644 frontend/src/container/AlertHistory/index.tsx create mode 100644 frontend/src/container/AlertHistory/types.ts create mode 100644 frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts create mode 100644 frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts create mode 100644 frontend/src/pages/AlertDetails/AlertDetails.styles.scss create mode 100644 frontend/src/pages/AlertDetails/AlertDetails.tsx create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.styles.scss create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.styles.scss create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.styles.scss create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.styles.scss create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.tsx create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.styles.scss create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.styles.scss create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.tsx create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/types.ts create mode 100644 frontend/src/pages/AlertDetails/hooks.tsx create mode 100644 frontend/src/pages/AlertDetails/index.tsx create mode 100644 frontend/src/pages/AlertDetails/types.ts create mode 100644 frontend/src/pages/AlertHistory/index.tsx create mode 100644 frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.styles.scss create mode 100644 frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.tsx create mode 100644 frontend/src/periscope/components/CopyToClipboard/index.tsx create mode 100644 frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx create mode 100644 frontend/src/periscope/components/DataStateRenderer/index.tsx create mode 100644 frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.styles.scss create mode 100644 frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx create mode 100644 frontend/src/periscope/components/KeyValueLabel/index.tsx create mode 100644 frontend/src/periscope/components/PaginationInfoText/PaginationInfoText.tsx create mode 100644 frontend/src/periscope/components/SeeMore/SeeMore.styles.scss create mode 100644 frontend/src/periscope/components/SeeMore/SeeMore.tsx create mode 100644 frontend/src/periscope/components/SeeMore/index.tsx create mode 100644 frontend/src/periscope/components/Tabs2/Tabs2.styles.scss create mode 100644 frontend/src/periscope/components/Tabs2/Tabs2.tsx create mode 100644 frontend/src/periscope/components/Tabs2/index.tsx create mode 100644 frontend/src/providers/Alert.tsx create mode 100644 frontend/src/types/api/alerts/ruleStats.ts create mode 100644 frontend/src/types/api/alerts/timelineGraph.ts create mode 100644 frontend/src/types/api/alerts/timelineTable.ts create mode 100644 frontend/src/types/api/alerts/topContributors.ts create mode 100644 frontend/src/utils/calculateChange.ts diff --git a/frontend/public/locales/en-GB/titles.json b/frontend/public/locales/en-GB/titles.json index 0eb98e9960..6cfe6e0238 100644 --- a/frontend/public/locales/en-GB/titles.json +++ b/frontend/public/locales/en-GB/titles.json @@ -38,5 +38,7 @@ "LIST_LICENSES": "SigNoz | List of Licenses", "WORKSPACE_LOCKED": "SigNoz | Workspace Locked", "SUPPORT": "SigNoz | Support", - "DEFAULT": "Open source Observability Platform | SigNoz" + "DEFAULT": "Open source Observability Platform | SigNoz", + "ALERT_HISTORY": "SigNoz | Alert Rule History", + "ALERT_OVERVIEW": "SigNoz | Alert Rule Overview" } diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json index 4aa2b65dc0..126b8a7ac1 100644 --- a/frontend/public/locales/en/titles.json +++ b/frontend/public/locales/en/titles.json @@ -50,5 +50,7 @@ "DEFAULT": "Open source Observability Platform | SigNoz", "SHORTCUTS": "SigNoz | Shortcuts", "INTEGRATIONS": "SigNoz | Integrations", + "ALERT_HISTORY": "SigNoz | Alert Rule History", + "ALERT_OVERVIEW": "SigNoz | Alert Rule Overview", "MESSAGING_QUEUES": "SigNoz | Messaging Queues" } diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index 23e7ea9644..b900255172 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -19,6 +19,7 @@ import { ResourceProvider } from 'hooks/useResourceAttribute'; import history from 'lib/history'; import { identity, pick, pickBy } from 'lodash-es'; import posthog from 'posthog-js'; +import AlertRuleProvider from 'providers/Alert'; import { DashboardProvider } from 'providers/Dashboard/Dashboard'; import { QueryBuilderProvider } from 'providers/QueryBuilder'; import { Suspense, useEffect, useState } from 'react'; @@ -236,22 +237,24 @@ function App(): JSX.Element { - - }> - - {routes.map(({ path, component, exact }) => ( - - ))} - - - - - + + + }> + + {routes.map(({ path, component, exact }) => ( + + ))} + + + + + + diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index bce075cef3..0a7764149b 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -92,6 +92,14 @@ export const CreateNewAlerts = Loadable( () => import(/* webpackChunkName: "Create Alerts" */ 'pages/CreateAlert'), ); +export const AlertHistory = Loadable( + () => import(/* webpackChunkName: "Alert History" */ 'pages/AlertList'), +); + +export const AlertOverview = Loadable( + () => import(/* webpackChunkName: "Alert Overview" */ 'pages/AlertList'), +); + export const CreateAlertChannelAlerts = Loadable( () => import(/* webpackChunkName: "Create Channels" */ 'pages/AlertChannelCreate'), diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index 98fdbed392..42ce00c0fb 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -2,6 +2,8 @@ import ROUTES from 'constants/routes'; import { RouteProps } from 'react-router-dom'; import { + AlertHistory, + AlertOverview, AllAlertChannels, AllErrors, APIKeys, @@ -171,6 +173,20 @@ const routes: AppRoutes[] = [ isPrivate: true, key: 'ALERTS_NEW', }, + { + path: ROUTES.ALERT_HISTORY, + exact: true, + component: AlertHistory, + isPrivate: true, + key: 'ALERT_HISTORY', + }, + { + path: ROUTES.ALERT_OVERVIEW, + exact: true, + component: AlertOverview, + isPrivate: true, + key: 'ALERT_OVERVIEW', + }, { path: ROUTES.TRACE, exact: true, diff --git a/frontend/src/api/alerts/create.ts b/frontend/src/api/alerts/create.ts index cad7917815..744183fa4b 100644 --- a/frontend/src/api/alerts/create.ts +++ b/frontend/src/api/alerts/create.ts @@ -1,26 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/create'; const create = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.post('/rules', { - ...props.data, - }); + const response = await axios.post('/rules', { + ...props.data, + }); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; export default create; diff --git a/frontend/src/api/alerts/delete.ts b/frontend/src/api/alerts/delete.ts index 278e3e2935..56407f3c40 100644 --- a/frontend/src/api/alerts/delete.ts +++ b/frontend/src/api/alerts/delete.ts @@ -1,24 +1,18 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/delete'; const deleteAlerts = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.delete(`/rules/${props.id}`); + const response = await axios.delete(`/rules/${props.id}`); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data.rules, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data.rules, + }; }; export default deleteAlerts; diff --git a/frontend/src/api/alerts/get.ts b/frontend/src/api/alerts/get.ts index 0437f8d1d8..15a741287e 100644 --- a/frontend/src/api/alerts/get.ts +++ b/frontend/src/api/alerts/get.ts @@ -1,24 +1,16 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/get'; const get = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.get(`/rules/${props.id}`); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + const response = await axios.get(`/rules/${props.id}`); + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; }; - export default get; diff --git a/frontend/src/api/alerts/patch.ts b/frontend/src/api/alerts/patch.ts index 920b53ae9f..cb64a1046f 100644 --- a/frontend/src/api/alerts/patch.ts +++ b/frontend/src/api/alerts/patch.ts @@ -1,26 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/patch'; const patch = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.patch(`/rules/${props.id}`, { - ...props.data, - }); + const response = await axios.patch(`/rules/${props.id}`, { + ...props.data, + }); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; export default patch; diff --git a/frontend/src/api/alerts/put.ts b/frontend/src/api/alerts/put.ts index b8c34e96bd..77d98d3c49 100644 --- a/frontend/src/api/alerts/put.ts +++ b/frontend/src/api/alerts/put.ts @@ -1,26 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/save'; const put = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.put(`/rules/${props.id}`, { - ...props.data, - }); + const response = await axios.put(`/rules/${props.id}`, { + ...props.data, + }); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; export default put; diff --git a/frontend/src/api/alerts/ruleStats.ts b/frontend/src/api/alerts/ruleStats.ts new file mode 100644 index 0000000000..2e09751e0f --- /dev/null +++ b/frontend/src/api/alerts/ruleStats.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleStatsPayload } from 'types/api/alerts/def'; +import { RuleStatsProps } from 'types/api/alerts/ruleStats'; + +const ruleStats = async ( + props: RuleStatsProps, +): Promise | ErrorResponse> => { + try { + const response = await axios.post(`/rules/${props.id}/history/stats`, { + start: props.start, + end: props.end, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default ruleStats; diff --git a/frontend/src/api/alerts/timelineGraph.ts b/frontend/src/api/alerts/timelineGraph.ts new file mode 100644 index 0000000000..8073943d72 --- /dev/null +++ b/frontend/src/api/alerts/timelineGraph.ts @@ -0,0 +1,33 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleTimelineGraphResponsePayload } from 'types/api/alerts/def'; +import { GetTimelineGraphRequestProps } from 'types/api/alerts/timelineGraph'; + +const timelineGraph = async ( + props: GetTimelineGraphRequestProps, +): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.post( + `/rules/${props.id}/history/overall_status`, + { + start: props.start, + end: props.end, + }, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default timelineGraph; diff --git a/frontend/src/api/alerts/timelineTable.ts b/frontend/src/api/alerts/timelineTable.ts new file mode 100644 index 0000000000..8d7f3edee7 --- /dev/null +++ b/frontend/src/api/alerts/timelineTable.ts @@ -0,0 +1,36 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleTimelineTableResponsePayload } from 'types/api/alerts/def'; +import { GetTimelineTableRequestProps } from 'types/api/alerts/timelineTable'; + +const timelineTable = async ( + props: GetTimelineTableRequestProps, +): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.post(`/rules/${props.id}/history/timeline`, { + start: props.start, + end: props.end, + offset: props.offset, + limit: props.limit, + order: props.order, + state: props.state, + // TODO(shaheer): implement filters + filters: props.filters, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default timelineTable; diff --git a/frontend/src/api/alerts/topContributors.ts b/frontend/src/api/alerts/topContributors.ts new file mode 100644 index 0000000000..7d3f2baec1 --- /dev/null +++ b/frontend/src/api/alerts/topContributors.ts @@ -0,0 +1,33 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleTopContributorsPayload } from 'types/api/alerts/def'; +import { TopContributorsProps } from 'types/api/alerts/topContributors'; + +const topContributors = async ( + props: TopContributorsProps, +): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.post( + `/rules/${props.id}/history/top_contributors`, + { + start: props.start, + end: props.end, + }, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default topContributors; diff --git a/frontend/src/assets/AlertHistory/ConfigureIcon.tsx b/frontend/src/assets/AlertHistory/ConfigureIcon.tsx new file mode 100644 index 0000000000..05268b8f5f --- /dev/null +++ b/frontend/src/assets/AlertHistory/ConfigureIcon.tsx @@ -0,0 +1,41 @@ +interface ConfigureIconProps { + width?: number; + height?: number; + fill?: string; +} + +function ConfigureIcon({ + width, + height, + fill, +}: ConfigureIconProps): JSX.Element { + return ( + + + + + ); +} + +ConfigureIcon.defaultProps = { + width: 16, + height: 16, + fill: 'none', +}; +export default ConfigureIcon; diff --git a/frontend/src/assets/AlertHistory/LogsIcon.tsx b/frontend/src/assets/AlertHistory/LogsIcon.tsx new file mode 100644 index 0000000000..8ffcaaa90b --- /dev/null +++ b/frontend/src/assets/AlertHistory/LogsIcon.tsx @@ -0,0 +1,65 @@ +interface LogsIconProps { + width?: number; + height?: number; + fill?: string; + strokeColor?: string; + strokeWidth?: number; +} + +function LogsIcon({ + width, + height, + fill, + strokeColor, + strokeWidth, +}: LogsIconProps): JSX.Element { + return ( + + + + + + + + + ); +} + +LogsIcon.defaultProps = { + width: 14, + height: 14, + fill: 'none', + strokeColor: '#C0C1C3', + strokeWidth: 1.167, +}; + +export default LogsIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx b/frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx new file mode 100644 index 0000000000..67d0977fe8 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx @@ -0,0 +1,39 @@ +interface SeverityCriticalIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; +} + +function SeverityCriticalIcon({ + width, + height, + fill, + stroke, +}: SeverityCriticalIconProps): JSX.Element { + return ( + + + + ); +} + +SeverityCriticalIcon.defaultProps = { + width: 6, + height: 6, + fill: 'none', + stroke: '#F56C87', +}; + +export default SeverityCriticalIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx b/frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx new file mode 100644 index 0000000000..a402289a62 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx @@ -0,0 +1,42 @@ +interface SeverityErrorIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; + strokeWidth?: string; +} + +function SeverityErrorIcon({ + width, + height, + fill, + stroke, + strokeWidth, +}: SeverityErrorIconProps): JSX.Element { + return ( + + + + ); +} + +SeverityErrorIcon.defaultProps = { + width: 2, + height: 6, + fill: 'none', + stroke: '#F56C87', + strokeWidth: '1.02083', +}; + +export default SeverityErrorIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx b/frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx new file mode 100644 index 0000000000..72316b2244 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx @@ -0,0 +1,46 @@ +interface SeverityInfoIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; +} + +function SeverityInfoIcon({ + width, + height, + fill, + stroke, +}: SeverityInfoIconProps): JSX.Element { + return ( + + + + + ); +} + +SeverityInfoIcon.defaultProps = { + width: 14, + height: 14, + fill: 'none', + stroke: '#7190F9', +}; + +export default SeverityInfoIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx b/frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx new file mode 100644 index 0000000000..204d615a21 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx @@ -0,0 +1,42 @@ +interface SeverityWarningIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; + strokeWidth?: string; +} + +function SeverityWarningIcon({ + width, + height, + fill, + stroke, + strokeWidth, +}: SeverityWarningIconProps): JSX.Element { + return ( + + + + ); +} + +SeverityWarningIcon.defaultProps = { + width: 2, + height: 6, + fill: 'none', + stroke: '#FFD778', + strokeWidth: '0.978299', +}; + +export default SeverityWarningIcon; diff --git a/frontend/src/components/AlertDetailsFilters/Filters.styles.scss b/frontend/src/components/AlertDetailsFilters/Filters.styles.scss new file mode 100644 index 0000000000..6869dd4366 --- /dev/null +++ b/frontend/src/components/AlertDetailsFilters/Filters.styles.scss @@ -0,0 +1,14 @@ +.reset-button { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-ink-300); + border: 1px solid var(--bg-slate-400); +} + +.lightMode { + .reset-button { + background: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-300); + } +} diff --git a/frontend/src/components/AlertDetailsFilters/Filters.tsx b/frontend/src/components/AlertDetailsFilters/Filters.tsx new file mode 100644 index 0000000000..baf109bf1d --- /dev/null +++ b/frontend/src/components/AlertDetailsFilters/Filters.tsx @@ -0,0 +1,11 @@ +import './Filters.styles.scss'; + +import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2'; + +export function Filters(): JSX.Element { + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/TabsAndFilters/Tabs/Tabs.styles.scss b/frontend/src/components/TabsAndFilters/Tabs/Tabs.styles.scss new file mode 100644 index 0000000000..f3c2ea622a --- /dev/null +++ b/frontend/src/components/TabsAndFilters/Tabs/Tabs.styles.scss @@ -0,0 +1,5 @@ +.tab-title { + display: flex; + gap: 4px; + align-items: center; +} diff --git a/frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx b/frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx new file mode 100644 index 0000000000..981c291146 --- /dev/null +++ b/frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx @@ -0,0 +1,41 @@ +import './Tabs.styles.scss'; + +import { Radio } from 'antd'; +import { RadioChangeEvent } from 'antd/lib'; +import { History, Table } from 'lucide-react'; +import { useState } from 'react'; + +import { ALERT_TABS } from '../constants'; + +export function Tabs(): JSX.Element { + const [selectedTab, setSelectedTab] = useState('overview'); + + const handleTabChange = (e: RadioChangeEvent): void => { + setSelectedTab(e.target.value); + }; + + return ( + + +
+ + Overview + + + +
+ + History +
+
+ + ); +} diff --git a/frontend/src/components/TabsAndFilters/TabsAndFilters.styles.scss b/frontend/src/components/TabsAndFilters/TabsAndFilters.styles.scss new file mode 100644 index 0000000000..5115eabe2e --- /dev/null +++ b/frontend/src/components/TabsAndFilters/TabsAndFilters.styles.scss @@ -0,0 +1,18 @@ +@mixin flex-center { + display: flex; + justify-content: space-between; + align-items: center; +} + +.tabs-and-filters { + @include flex-center; + margin-top: 1rem; + margin-bottom: 1rem; + .filters { + @include flex-center; + gap: 16px; + .reset-button { + @include flex-center; + } + } +} diff --git a/frontend/src/components/TabsAndFilters/TabsAndFilters.tsx b/frontend/src/components/TabsAndFilters/TabsAndFilters.tsx new file mode 100644 index 0000000000..ac6738d491 --- /dev/null +++ b/frontend/src/components/TabsAndFilters/TabsAndFilters.tsx @@ -0,0 +1,16 @@ +import './TabsAndFilters.styles.scss'; + +import { Filters } from 'components/AlertDetailsFilters/Filters'; + +import { Tabs } from './Tabs/Tabs'; + +function TabsAndFilters(): JSX.Element { + return ( +
+ + +
+ ); +} + +export default TabsAndFilters; diff --git a/frontend/src/components/TabsAndFilters/constants.ts b/frontend/src/components/TabsAndFilters/constants.ts new file mode 100644 index 0000000000..b052c0e4cf --- /dev/null +++ b/frontend/src/components/TabsAndFilters/constants.ts @@ -0,0 +1,5 @@ +export const ALERT_TABS = { + OVERVIEW: 'OVERVIEW', + HISTORY: 'HISTORY', + ACTIVITY: 'ACTIVITY', +} as const; diff --git a/frontend/src/constants/global.ts b/frontend/src/constants/global.ts index 42fb29720b..dfa096470d 100644 --- a/frontend/src/constants/global.ts +++ b/frontend/src/constants/global.ts @@ -1,4 +1,17 @@ +import { ManipulateType } from 'dayjs'; + const MAX_RPS_LIMIT = 100; export { MAX_RPS_LIMIT }; export const LEGEND = 'legend'; + +export const DAYJS_MANIPULATE_TYPES: { [key: string]: ManipulateType } = { + DAY: 'day', + WEEK: 'week', + MONTH: 'month', + YEAR: 'year', + HOUR: 'hour', + MINUTE: 'minute', + SECOND: 'second', + MILLISECOND: 'millisecond', +}; diff --git a/frontend/src/constants/reactQueryKeys.ts b/frontend/src/constants/reactQueryKeys.ts index 52ae235ef6..ec2353abbf 100644 --- a/frontend/src/constants/reactQueryKeys.ts +++ b/frontend/src/constants/reactQueryKeys.ts @@ -8,5 +8,14 @@ export const REACT_QUERY_KEY = { GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS', DELETE_DASHBOARD: 'DELETE_DASHBOARD', LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW', + ALERT_RULE_DETAILS: 'ALERT_RULE_DETAILS', + ALERT_RULE_STATS: 'ALERT_RULE_STATS', + ALERT_RULE_TOP_CONTRIBUTORS: 'ALERT_RULE_TOP_CONTRIBUTORS', + ALERT_RULE_TIMELINE_TABLE: 'ALERT_RULE_TIMELINE_TABLE', + ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH', GET_CONSUMER_LAG_DETAILS: 'GET_CONSUMER_LAG_DETAILS', + TOGGLE_ALERT_STATE: 'TOGGLE_ALERT_STATE', + GET_ALL_ALLERTS: 'GET_ALL_ALLERTS', + REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE', + DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE', }; diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 8f76cd0386..b4f43ee684 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -22,6 +22,8 @@ const ROUTES = { EDIT_ALERTS: '/alerts/edit', LIST_ALL_ALERT: '/alerts', ALERTS_NEW: '/alerts/new', + ALERT_HISTORY: '/alerts/history', + ALERT_OVERVIEW: '/alerts/overview', ALL_CHANNELS: '/settings/channels', CHANNELS_NEW: '/settings/channels/new', CHANNELS_EDIT: '/settings/channels/:id', diff --git a/frontend/src/container/AlertHistory/AlertHistory.styles.scss b/frontend/src/container/AlertHistory/AlertHistory.styles.scss new file mode 100644 index 0000000000..39fce3ca29 --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertHistory.styles.scss @@ -0,0 +1,5 @@ +.alert-history { + display: flex; + flex-direction: column; + gap: 24px; +} diff --git a/frontend/src/container/AlertHistory/AlertHistory.tsx b/frontend/src/container/AlertHistory/AlertHistory.tsx new file mode 100644 index 0000000000..0776cfcebb --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertHistory.tsx @@ -0,0 +1,22 @@ +import './AlertHistory.styles.scss'; + +import { useState } from 'react'; + +import Statistics from './Statistics/Statistics'; +import Timeline from './Timeline/Timeline'; + +function AlertHistory(): JSX.Element { + const [totalCurrentTriggers, setTotalCurrentTriggers] = useState(0); + + return ( +
+ + +
+ ); +} + +export default AlertHistory; diff --git a/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss new file mode 100644 index 0000000000..43d645efa5 --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss @@ -0,0 +1,3 @@ +.alert-popover { + cursor: pointer; +} diff --git a/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx new file mode 100644 index 0000000000..83605a61d3 --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx @@ -0,0 +1,114 @@ +import './AlertPopover.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Popover } from 'antd'; +import LogsIcon from 'assets/AlertHistory/LogsIcon'; +import ROUTES from 'constants/routes'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { DraftingCompass } from 'lucide-react'; +import React from 'react'; +import { Link } from 'react-router-dom'; + +type Props = { + children: React.ReactNode; + relatedTracesLink?: string; + relatedLogsLink?: string; +}; + +function PopoverContent({ + relatedTracesLink, + relatedLogsLink, +}: { + relatedTracesLink?: Props['relatedTracesLink']; + relatedLogsLink?: Props['relatedLogsLink']; +}): JSX.Element { + const isDarkMode = useIsDarkMode(); + return ( +
+ {!!relatedLogsLink && ( + +
+ +
+
View Logs
+ + )} + {!!relatedTracesLink && ( + +
+ +
+
View Traces
+ + )} +
+ ); +} +PopoverContent.defaultProps = { + relatedTracesLink: '', + relatedLogsLink: '', +}; + +function AlertPopover({ + children, + relatedTracesLink, + relatedLogsLink, +}: Props): JSX.Element { + return ( +
+ + } + trigger="click" + > + {children} + +
+ ); +} + +AlertPopover.defaultProps = { + relatedTracesLink: '', + relatedLogsLink: '', +}; + +type ConditionalAlertPopoverProps = { + relatedTracesLink: string; + relatedLogsLink: string; + children: React.ReactNode; +}; +export function ConditionalAlertPopover({ + children, + relatedTracesLink, + relatedLogsLink, +}: ConditionalAlertPopoverProps): JSX.Element { + if (relatedTracesLink || relatedLogsLink) { + return ( + + {children} + + ); + } + return
{children}
; +} +export default AlertPopover; diff --git a/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx new file mode 100644 index 0000000000..f55c4385ce --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx @@ -0,0 +1,28 @@ +import { AlertRuleStats } from 'types/api/alerts/def'; +import { formatTime } from 'utils/timeUtils'; + +import StatsCard from '../StatsCard/StatsCard'; + +type TotalTriggeredCardProps = { + currentAvgResolutionTime: AlertRuleStats['currentAvgResolutionTime']; + pastAvgResolutionTime: AlertRuleStats['pastAvgResolutionTime']; + timeSeries: AlertRuleStats['currentAvgResolutionTimeSeries']['values']; +}; + +function AverageResolutionCard({ + currentAvgResolutionTime, + pastAvgResolutionTime, + timeSeries, +}: TotalTriggeredCardProps): JSX.Element { + return ( + + ); +} + +export default AverageResolutionCard; diff --git a/frontend/src/container/AlertHistory/Statistics/Statistics.styles.scss b/frontend/src/container/AlertHistory/Statistics/Statistics.styles.scss new file mode 100644 index 0000000000..cc0a5b1b43 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/Statistics.styles.scss @@ -0,0 +1,14 @@ +.statistics { + display: flex; + justify-content: space-between; + height: 280px; + border: 1px solid var(--bg-slate-500); + border-radius: 4px; + margin: 0 16px; +} + +.lightMode { + .statistics { + border: 1px solid var(--bg-vanilla-300); + } +} diff --git a/frontend/src/container/AlertHistory/Statistics/Statistics.tsx b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx new file mode 100644 index 0000000000..7158e0c069 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx @@ -0,0 +1,23 @@ +import './Statistics.styles.scss'; + +import { AlertRuleStats } from 'types/api/alerts/def'; + +import StatsCardsRenderer from './StatsCardsRenderer/StatsCardsRenderer'; +import TopContributorsRenderer from './TopContributorsRenderer/TopContributorsRenderer'; + +function Statistics({ + setTotalCurrentTriggers, + totalCurrentTriggers, +}: { + setTotalCurrentTriggers: (value: number) => void; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { + return ( +
+ + +
+ ); +} + +export default Statistics; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.styles.scss new file mode 100644 index 0000000000..bb9d3c3e72 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.styles.scss @@ -0,0 +1,112 @@ +.stats-card { + width: 21.7%; + border-right: 1px solid var(--bg-slate-500); + padding: 9px 12px 13px; + + &--empty { + justify-content: normal; + } + + &__title-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + + .title { + text-transform: uppercase; + font-size: 13px; + line-height: 22px; + color: var(--bg-vanilla-400); + font-weight: 500; + } + .duration-indicator { + display: flex; + align-items: center; + gap: 4px; + .icon { + display: flex; + align-self: center; + } + .text { + text-transform: uppercase; + color: var(--text-slate-200); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.48px; + } + } + } + &__stats { + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 4px; + .count-label { + color: var(--text-vanilla-100); + font-family: 'Geist Mono'; + font-size: 24px; + line-height: 36px; + } + } + &__graph { + margin-top: 80px; + + .graph { + width: 100%; + height: 72px; + } + } +} + +.change-percentage { + width: max-content; + display: flex; + padding: 4px 8px; + border-radius: 20px; + align-items: center; + gap: 4px; + + &--success { + background: rgba(37, 225, 146, 0.1); + color: var(--bg-forest-500); + } + &--error { + background: rgba(229, 72, 77, 0.1); + color: var(--bg-cherry-500); + } + &--no-previous-data { + color: var(--text-robin-500); + background: rgba(78, 116, 248, 0.1); + padding: 4px 16px; + } + &__icon { + display: flex; + align-self: center; + } + &__label { + font-size: 12px; + font-weight: 500; + line-height: 16px; + } +} + +.lightMode { + .stats-card { + border-color: var(--bg-vanilla-300); + &__title-wrapper { + .title { + color: var(--text-ink-400); + } + .duration-indicator { + .text { + color: var(--text-ink-200); + } + } + } + &__stats { + .count-label { + color: var(--text-ink-100); + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx new file mode 100644 index 0000000000..f204579f93 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx @@ -0,0 +1,158 @@ +import './StatsCard.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Tooltip } from 'antd'; +import { QueryParams } from 'constants/query'; +import useUrlQuery from 'hooks/useUrlQuery'; +import { ArrowDownLeft, ArrowUpRight, Calendar } from 'lucide-react'; +import { AlertRuleStats } from 'types/api/alerts/def'; +import { calculateChange } from 'utils/calculateChange'; + +import StatsGraph from './StatsGraph/StatsGraph'; +import { + convertTimestampToLocaleDateString, + extractDayFromTimestamp, +} from './utils'; + +type ChangePercentageProps = { + percentage: number; + direction: number; + duration: string | null; +}; +function ChangePercentage({ + percentage, + direction, + duration, +}: ChangePercentageProps): JSX.Element { + if (direction > 0) { + return ( +
+
+ +
+
+ {percentage}% vs Last {duration} +
+
+ ); + } + if (direction < 0) { + return ( +
+
+ +
+
+ {percentage}% vs Last {duration} +
+
+ ); + } + + return ( +
+
no previous data
+
+ ); +} + +type StatsCardProps = { + totalCurrentCount?: number; + totalPastCount?: number; + title: string; + isEmpty?: boolean; + emptyMessage?: string; + displayValue?: string | number; + timeSeries?: AlertRuleStats['currentTriggersSeries']['values']; +}; + +function StatsCard({ + displayValue, + totalCurrentCount, + totalPastCount, + title, + isEmpty, + emptyMessage, + timeSeries = [], +}: StatsCardProps): JSX.Element { + const urlQuery = useUrlQuery(); + + const relativeTime = urlQuery.get('relativeTime'); + + const { changePercentage, changeDirection } = calculateChange( + totalCurrentCount, + totalPastCount, + ); + + const startTime = urlQuery.get(QueryParams.startTime); + const endTime = urlQuery.get(QueryParams.endTime); + + let displayTime = relativeTime; + + if (!displayTime && startTime && endTime) { + const formattedStartDate = extractDayFromTimestamp(startTime); + const formattedEndDate = extractDayFromTimestamp(endTime); + displayTime = `${formattedStartDate} to ${formattedEndDate}`; + } + + if (!displayTime) { + displayTime = ''; + } + const formattedStartTimeForTooltip = convertTimestampToLocaleDateString( + startTime, + ); + const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime); + + return ( +
+
+
{title}
+
+
+ +
+ {relativeTime ? ( +
{displayTime}
+ ) : ( + +
{displayTime}
+
+ )} +
+
+ +
+
+ {isEmpty ? emptyMessage : displayValue || totalCurrentCount} +
+ + +
+ +
+
+ {!isEmpty && timeSeries.length > 1 && ( + + )} +
+
+
+ ); +} + +StatsCard.defaultProps = { + totalCurrentCount: 0, + totalPastCount: 0, + isEmpty: false, + emptyMessage: 'No Data', + displayValue: '', + timeSeries: [], +}; + +export default StatsCard; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx new file mode 100644 index 0000000000..26c381d706 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx @@ -0,0 +1,90 @@ +import { Color } from '@signozhq/design-tokens'; +import Uplot from 'components/Uplot'; +import { useResizeObserver } from 'hooks/useDimensions'; +import { useMemo, useRef } from 'react'; +import { AlertRuleStats } from 'types/api/alerts/def'; + +type Props = { + timeSeries: AlertRuleStats['currentTriggersSeries']['values']; + changeDirection: number; +}; + +const getStyle = ( + changeDirection: number, +): { stroke: string; fill: string } => { + if (changeDirection === 0) { + return { + stroke: Color.BG_ROBIN_500, + fill: 'rgba(78, 116, 248, 0.20)', + }; + } + if (changeDirection > 0) { + return { + stroke: Color.BG_FOREST_500, + fill: 'rgba(37, 225, 146, 0.20)', + }; + } + return { + stroke: Color.BG_CHERRY_500, + fill: ' rgba(229, 72, 77, 0.20)', + }; +}; + +function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element { + const { xData, yData } = useMemo( + () => ({ + xData: timeSeries.map((item) => item.timestamp), + yData: timeSeries.map((item) => Number(item.value)), + }), + [timeSeries], + ); + + const graphRef = useRef(null); + + const containerDimensions = useResizeObserver(graphRef); + + const options: uPlot.Options = useMemo( + () => ({ + width: containerDimensions.width, + height: containerDimensions.height, + + legend: { + show: false, + }, + cursor: { + x: false, + y: false, + drag: { + x: false, + y: false, + }, + }, + padding: [0, 0, 2, 0], + series: [ + {}, + { + ...getStyle(changeDirection), + points: { + show: false, + }, + width: 1.4, + }, + ], + axes: [ + { show: false }, + { + show: false, + }, + ], + }), + [changeDirection, containerDimensions.height, containerDimensions.width], + ); + + return ( +
+ +
+ ); +} + +export default StatsGraph; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/utils.ts b/frontend/src/container/AlertHistory/Statistics/StatsCard/utils.ts new file mode 100644 index 0000000000..a2584aad37 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/utils.ts @@ -0,0 +1,12 @@ +export const extractDayFromTimestamp = (timestamp: string | null): string => { + if (!timestamp) return ''; + const date = new Date(parseInt(timestamp, 10)); + return date.getDate().toString(); +}; + +export const convertTimestampToLocaleDateString = ( + timestamp: string | null, +): string => { + if (!timestamp) return ''; + return new Date(parseInt(timestamp, 10)).toLocaleString(); +}; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx new file mode 100644 index 0000000000..e8859131df --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx @@ -0,0 +1,102 @@ +import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; +import { useEffect } from 'react'; + +import AverageResolutionCard from '../AverageResolutionCard/AverageResolutionCard'; +import StatsCard from '../StatsCard/StatsCard'; +import TotalTriggeredCard from '../TotalTriggeredCard/TotalTriggeredCard'; + +const hasTotalTriggeredStats = ( + totalCurrentTriggers: number | string, + totalPastTriggers: number | string, +): boolean => + (Number(totalCurrentTriggers) > 0 && Number(totalPastTriggers) > 0) || + Number(totalCurrentTriggers) > 0; + +const hasAvgResolutionTimeStats = ( + currentAvgResolutionTime: number | string, + pastAvgResolutionTime: number | string, +): boolean => + (Number(currentAvgResolutionTime) > 0 && Number(pastAvgResolutionTime) > 0) || + Number(currentAvgResolutionTime) > 0; + +type StatsCardsRendererProps = { + setTotalCurrentTriggers: (value: number) => void; +}; + +// TODO(shaheer): render the DataStateRenderer inside the TotalTriggeredCard/AverageResolutionCard, it should display the title +function StatsCardsRenderer({ + setTotalCurrentTriggers, +}: StatsCardsRendererProps): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsStats(); + + useEffect(() => { + if (data?.payload?.data?.totalCurrentTriggers !== undefined) { + setTotalCurrentTriggers(data.payload.data.totalCurrentTriggers); + } + }, [data, setTotalCurrentTriggers]); + + return ( + + {(data): JSX.Element => { + const { + currentAvgResolutionTime, + pastAvgResolutionTime, + totalCurrentTriggers, + totalPastTriggers, + currentAvgResolutionTimeSeries, + currentTriggersSeries, + } = data; + + return ( + <> + {hasTotalTriggeredStats(totalCurrentTriggers, totalPastTriggers) ? ( + + ) : ( + + )} + + {hasAvgResolutionTimeStats( + currentAvgResolutionTime, + pastAvgResolutionTime, + ) ? ( + + ) : ( + + )} + + ); + }} + + ); +} + +export default StatsCardsRenderer; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss new file mode 100644 index 0000000000..4b3c0a6069 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss @@ -0,0 +1,191 @@ +.top-contributors-card { + width: 56.6%; + overflow: hidden; + + &--view-all { + width: auto; + } + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + + border-bottom: 1px solid var(--bg-slate-500); + .title { + color: var(--text-vanilla-400); + font-size: 13px; + font-weight: 500; + line-height: 22px; + letter-spacing: 0.52px; + text-transform: uppercase; + } + .view-all { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + padding: 0; + height: 20px; + &:hover { + background-color: transparent !important; + } + + .label { + color: var(--text-vanilla-400); + font-size: 14px; + line-height: 20px; + letter-spacing: -0.07px; + } + .icon { + display: flex; + } + } + } + .contributors-row { + height: 80px; + } + &__content { + .ant-table { + &-cell { + padding: 12px !important; + } + } + .contributors-row { + background: var(--bg-ink-500); + + td { + border: none !important; + } + &:not(:last-of-type) td { + border-bottom: 1px solid var(--bg-slate-500) !important; + } + } + .total-contribution { + color: var(--text-robin-500); + font-family: 'Geist Mono'; + font-size: 12px; + font-weight: 500; + letter-spacing: -0.06px; + padding: 4px 8px; + background: rgba(78, 116, 248, 0.1); + border-radius: 50px; + width: max-content; + } + } + .empty-content { + margin: 16px 12px; + padding: 40px 45px; + display: flex; + flex-direction: column; + gap: 12px; + border: 1px dashed var(--bg-slate-500); + border-radius: 6px; + + &__icon { + font-family: Inter; + font-size: 20px; + line-height: 26px; + letter-spacing: -0.103px; + } + &__text { + color: var(--text-vanilla-400); + line-height: 18px; + .bold-text { + color: var(--text-vanilla-100); + font-weight: 500; + } + } + &__button-wrapper { + margin-top: 12px; + .configure-alert-rule-button { + padding: 8px 16px; + border-radius: 2px; + background: var(--bg-slate-400); + border-width: 0; + color: var(--text-vanilla-100); + line-height: 24px; + font-size: 12px; + font-weight: 500; + display: flex; + align-items: center; + } + } + } +} + +.ant-popover-inner:has(.contributor-row-popover-buttons) { + padding: 0 !important; +} +.contributor-row-popover-buttons { + display: flex; + flex-direction: column; + border: 1px solid var(--bg-slate-400); + + &__button { + display: flex; + align-items: center; + gap: 6px; + padding: 12px 15px; + color: var(--text-vanilla-400); + font-size: 14px; + letter-spacing: 0.14px; + width: 160px; + cursor: pointer; + + &:hover { + background: var(--bg-slate-400); + } + + .icon { + display: flex; + } + } +} + +.view-all-drawer { + border-radius: 4px; +} + +.lightMode { + .ant-table { + background: inherit; + } + + .top-contributors-card { + &__header { + border-color: var(--bg-vanilla-300); + .title { + color: var(--text-ink-400); + } + .view-all { + .label { + color: var(--text-ink-400); + } + } + } + &__content { + .contributors-row { + background: inherit; + &:not(:last-of-type) td { + border-bottom: 1px solid var(--bg-vanilla-300) !important; + } + } + } + .empty-content { + border-color: var(--bg-vanilla-300); + &__text { + color: var(--text-ink-400); + .bold-text { + color: var(--text-ink-500); + } + } + &__button-wrapper { + .configure-alert-rule-button { + background: var(--bg-vanilla-300); + color: var(--text-ink-500); + } + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx new file mode 100644 index 0000000000..d3cd0bb756 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx @@ -0,0 +1,84 @@ +import './TopContributorsCard.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import history from 'lib/history'; +import { ArrowRight } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; + +import TopContributorsContent from './TopContributorsContent'; +import { TopContributorsCardProps } from './types'; +import ViewAllDrawer from './ViewAllDrawer'; + +function TopContributorsCard({ + topContributorsData, + totalCurrentTriggers, +}: TopContributorsCardProps): JSX.Element { + const { search } = useLocation(); + const searchParams = useMemo(() => new URLSearchParams(search), [search]); + + const viewAllTopContributorsParam = searchParams.get('viewAllTopContributors'); + + const [isViewAllVisible, setIsViewAllVisible] = useState( + !!viewAllTopContributorsParam ?? false, + ); + + const isDarkMode = useIsDarkMode(); + + const toggleViewAllParam = (isOpen: boolean): void => { + if (isOpen) { + searchParams.set('viewAllTopContributors', 'true'); + } else { + searchParams.delete('viewAllTopContributors'); + } + }; + + const toggleViewAllDrawer = (): void => { + setIsViewAllVisible((prev) => { + const newState = !prev; + + toggleViewAllParam(newState); + + return newState; + }); + history.push({ search: searchParams.toString() }); + }; + + return ( + <> +
+
+
top contributors
+ {topContributorsData.length > 3 && ( + + )} +
+ + +
+ {isViewAllVisible && ( + + )} + + ); +} + +export default TopContributorsCard; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx new file mode 100644 index 0000000000..b458871f71 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx @@ -0,0 +1,32 @@ +import TopContributorsRows from './TopContributorsRows'; +import { TopContributorsCardProps } from './types'; + +function TopContributorsContent({ + topContributorsData, + totalCurrentTriggers, +}: TopContributorsCardProps): JSX.Element { + const isEmpty = !topContributorsData.length; + + if (isEmpty) { + return ( +
+
ℹ️
+
+ Top contributors highlight the most frequently triggering group-by + attributes in multi-dimensional alerts +
+
+ ); + } + + return ( +
+ +
+ ); +} + +export default TopContributorsContent; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx new file mode 100644 index 0000000000..85857605f8 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx @@ -0,0 +1,87 @@ +import { Color } from '@signozhq/design-tokens'; +import { Progress, Table } from 'antd'; +import { ColumnsType } from 'antd/es/table'; +import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover'; +import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; +import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText'; +import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; + +function TopContributorsRows({ + topContributors, + totalCurrentTriggers, +}: { + topContributors: AlertRuleTopContributors[]; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { + const columns: ColumnsType = [ + { + title: 'labels', + dataIndex: 'labels', + key: 'labels', + width: '51%', + render: ( + labels: AlertRuleTopContributors['labels'], + record, + ): JSX.Element => ( + +
+ +
+
+ ), + }, + { + title: 'progressBar', + dataIndex: 'count', + key: 'progressBar', + width: '39%', + render: (count: AlertRuleTopContributors['count'], record): JSX.Element => ( + + + + ), + }, + { + title: 'count', + dataIndex: 'count', + key: 'count', + width: '10%', + render: (count: AlertRuleTopContributors['count'], record): JSX.Element => ( + +
+ {count}/{totalCurrentTriggers} +
+
+ ), + }, + ]; + + return ( +
`top-contributor-${row.fingerprint}`} + columns={columns} + showHeader={false} + dataSource={topContributors} + pagination={ + topContributors.length > 10 ? { showTotal: PaginationInfoText } : false + } + /> + ); +} + +export default TopContributorsRows; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx new file mode 100644 index 0000000000..1d49c87afd --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx @@ -0,0 +1,46 @@ +import { Color } from '@signozhq/design-tokens'; +import { Drawer } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; + +import TopContributorsRows from './TopContributorsRows'; + +function ViewAllDrawer({ + isViewAllVisible, + toggleViewAllDrawer, + totalCurrentTriggers, + topContributorsData, +}: { + isViewAllVisible: boolean; + toggleViewAllDrawer: () => void; + topContributorsData: AlertRuleTopContributors[]; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { + const isDarkMode = useIsDarkMode(); + return ( + +
+
+ +
+
+
+ ); +} + +export default ViewAllDrawer; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts new file mode 100644 index 0000000000..f44d2ded99 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts @@ -0,0 +1,6 @@ +import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; + +export type TopContributorsCardProps = { + topContributorsData: AlertRuleTopContributors[]; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx new file mode 100644 index 0000000000..b773579ca0 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx @@ -0,0 +1,42 @@ +import { useGetAlertRuleDetailsTopContributors } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; +import { AlertRuleStats } from 'types/api/alerts/def'; + +import TopContributorsCard from '../TopContributorsCard/TopContributorsCard'; + +type TopContributorsRendererProps = { + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}; + +function TopContributorsRenderer({ + totalCurrentTriggers, +}: TopContributorsRendererProps): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsTopContributors(); + const response = data?.payload?.data; + + // TODO(shaheer): render the DataStateRenderer inside the TopContributorsCard, it should display the title and view all + return ( + + {(topContributorsData): JSX.Element => ( + + )} + + ); +} + +export default TopContributorsRenderer; diff --git a/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx new file mode 100644 index 0000000000..0e4f412894 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx @@ -0,0 +1,26 @@ +import { AlertRuleStats } from 'types/api/alerts/def'; + +import StatsCard from '../StatsCard/StatsCard'; + +type TotalTriggeredCardProps = { + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; + totalPastTriggers: AlertRuleStats['totalPastTriggers']; + timeSeries: AlertRuleStats['currentTriggersSeries']['values']; +}; + +function TotalTriggeredCard({ + totalCurrentTriggers, + totalPastTriggers, + timeSeries, +}: TotalTriggeredCardProps): JSX.Element { + return ( + + ); +} + +export default TotalTriggeredCard; diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/Graph.styles.scss b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.styles.scss new file mode 100644 index 0000000000..3ea30fe25a --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.styles.scss @@ -0,0 +1,52 @@ +.timeline-graph { + display: flex; + flex-direction: column; + gap: 24px; + background: var(--bg-ink-400); + padding: 12px; + border-radius: 4px; + border: 1px solid var(--bg-slate-500); + height: 150px; + + &__title { + width: max-content; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid #1d212d; + background: rgba(29, 33, 45, 0.5); + color: #ebebeb; + font-size: 12px; + line-height: 18px; + letter-spacing: -0.06px; + } + &__chart { + .chart-placeholder { + width: 100%; + height: 52px; + background: rgba(255, 255, 255, 0.1215686275); + display: flex; + align-items: center; + justify-content: center; + .chart-icon { + font-size: 2rem; + } + } + } +} + +.lightMode { + .timeline-graph { + background: var(--bg-vanilla-200); + border-color: var(--bg-vanilla-300); + &__title { + background: var(--bg-vanilla-100); + color: var(--text-ink-400); + border-color: var(--bg-vanilla-300); + } + &__chart { + .chart-placeholder { + background: var(--bg-vanilla-300); + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx new file mode 100644 index 0000000000..a0534691df --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx @@ -0,0 +1,184 @@ +import { Color } from '@signozhq/design-tokens'; +import Uplot from 'components/Uplot'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { useResizeObserver } from 'hooks/useDimensions'; +import heatmapPlugin from 'lib/uPlotLib/plugins/heatmapPlugin'; +import timelinePlugin from 'lib/uPlotLib/plugins/timelinePlugin'; +import { useMemo, useRef } from 'react'; +import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def'; +import uPlot, { AlignedData } from 'uplot'; + +import { ALERT_STATUS, TIMELINE_OPTIONS } from './constants'; + +type Props = { type: string; data: AlertRuleTimelineGraphResponse[] }; + +function HorizontalTimelineGraph({ + width, + isDarkMode, + data, +}: { + width: number; + isDarkMode: boolean; + data: AlertRuleTimelineGraphResponse[]; +}): JSX.Element { + const transformedData: AlignedData = useMemo(() => { + if (!data?.length) { + return [[], []]; + } + + // add a first and last entry to make sure the graph displays all the data + const FIVE_MINUTES_IN_SECONDS = 300; + + const timestamps = [ + data[0].start / 1000 - FIVE_MINUTES_IN_SECONDS, // 5 minutes before the first entry + ...data.map((item) => item.start / 1000), + data[data.length - 1].end / 1000, // end value of last entry + ]; + + const states = [ + ALERT_STATUS[data[0].state], // Same state as the first entry + ...data.map((item) => ALERT_STATUS[item.state]), + ALERT_STATUS[data[data.length - 1].state], // Same state as the last entry + ]; + + return [timestamps, states]; + }, [data]); + + const options: uPlot.Options = useMemo( + () => ({ + width, + height: 85, + cursor: { show: false }, + + axes: [ + { + gap: 10, + stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400, + }, + { show: false }, + ], + legend: { + show: false, + }, + padding: [null, 0, null, 0], + series: [ + { + label: 'Time', + }, + { + label: 'States', + }, + ], + plugins: + transformedData?.length > 1 + ? [ + timelinePlugin({ + count: transformedData.length - 1, + ...TIMELINE_OPTIONS, + }), + ] + : [], + }), + [width, isDarkMode, transformedData], + ); + return ; +} + +const transformVerticalTimelineGraph = (data: any[]): any => [ + data.map((item: { timestamp: any }) => item.timestamp), + Array(data.length).fill(0), + Array(data.length).fill(10), + Array(data.length).fill([0, 1, 2, 3, 4, 5]), + data.map((item: { value: number }) => { + const count = Math.floor(item.value / 10); + return [...Array(count).fill(1), 2]; + }), +]; + +const datatest: any[] = []; +const now = Math.floor(Date.now() / 1000); // current timestamp in seconds +const oneDay = 24 * 60 * 60; // one day in seconds + +for (let i = 0; i < 90; i++) { + const timestamp = now - i * oneDay; + const startOfDay = timestamp - (timestamp % oneDay); + datatest.push({ + timestamp: startOfDay, + value: Math.floor(Math.random() * 30) + 1, + }); +} + +function VerticalTimelineGraph({ + isDarkMode, + width, +}: { + width: number; + isDarkMode: boolean; +}): JSX.Element { + const transformedData = useMemo( + () => transformVerticalTimelineGraph(datatest), + [], + ); + + const options: uPlot.Options = useMemo( + () => ({ + width, + height: 90, + plugins: [heatmapPlugin()], + cursor: { show: false }, + legend: { + show: false, + }, + axes: [ + { + gap: 10, + stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400, + }, + { show: false }, + ], + series: [ + {}, + { + paths: (): null => null, + points: { show: false }, + }, + { + paths: (): null => null, + points: { show: false }, + }, + ], + }), + [isDarkMode, width], + ); + return ; +} + +function Graph({ type, data }: Props): JSX.Element | null { + const graphRef = useRef(null); + + const isDarkMode = useIsDarkMode(); + + const containerDimensions = useResizeObserver(graphRef); + + if (type === 'horizontal') { + return ( +
+ +
+ ); + } + return ( +
+ +
+ ); +} + +export default Graph; diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts b/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts new file mode 100644 index 0000000000..b56499a0d0 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts @@ -0,0 +1,33 @@ +import { Color } from '@signozhq/design-tokens'; + +export const ALERT_STATUS: { [key: string]: number } = { + firing: 0, + inactive: 1, + normal: 1, + 'no-data': 2, + disabled: 3, + muted: 4, +}; + +export const STATE_VS_COLOR: { + [key: string]: { stroke: string; fill: string }; +}[] = [ + {}, + { + 0: { stroke: Color.BG_CHERRY_500, fill: Color.BG_CHERRY_500 }, + 1: { stroke: Color.BG_FOREST_500, fill: Color.BG_FOREST_500 }, + 2: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 }, + 3: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 }, + 4: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 }, + }, +]; + +export const TIMELINE_OPTIONS = { + mode: 1, + fill: (seriesIdx: any, _: any, value: any): any => + STATE_VS_COLOR[seriesIdx][value].fill, + stroke: (seriesIdx: any, _: any, value: any): any => + STATE_VS_COLOR[seriesIdx][value].stroke, + laneWidthOption: 0.3, + showGrid: false, +}; diff --git a/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx b/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx new file mode 100644 index 0000000000..05690a9041 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx @@ -0,0 +1,67 @@ +import '../Graph/Graph.styles.scss'; + +import useUrlQuery from 'hooks/useUrlQuery'; +import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; + +import Graph from '../Graph/Graph'; + +function GraphWrapper({ + totalCurrentTriggers, +}: { + totalCurrentTriggers: number; +}): JSX.Element { + const urlQuery = useUrlQuery(); + + const relativeTime = urlQuery.get('relativeTime'); + + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsTimelineGraphData(); + + // TODO(shaheer): uncomment when the API is ready for + // const { startTime } = useAlertHistoryQueryParams(); + + // const [isVerticalGraph, setIsVerticalGraph] = useState(false); + + // useEffect(() => { + // const checkVerticalGraph = (): void => { + // if (startTime) { + // const startTimeDate = dayjs(Number(startTime)); + // const twentyFourHoursAgo = dayjs().subtract( + // HORIZONTAL_GRAPH_HOURS_THRESHOLD, + // DAYJS_MANIPULATE_TYPES.HOUR, + // ); + + // setIsVerticalGraph(startTimeDate.isBefore(twentyFourHoursAgo)); + // } + // }; + + // checkVerticalGraph(); + // }, [startTime]); + + return ( +
+
+ {totalCurrentTriggers} triggers in {relativeTime} +
+
+ + {(data): JSX.Element => } + +
+
+ ); +} + +export default GraphWrapper; diff --git a/frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss b/frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss new file mode 100644 index 0000000000..9d31e0b0ea --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss @@ -0,0 +1,134 @@ +.timeline-table { + border-top: 1px solid var(--text-slate-500); + border-radius: 6px; + overflow: hidden; + margin-top: 4px; + min-height: 600px; + .ant-table { + background: var(--bg-ink-500); + &-cell { + padding: 12px 16px !important; + vertical-align: baseline; + &::before { + display: none; + } + } + &-thead > tr > th { + border-color: var(--bg-slate-500); + background: var(--bg-ink-500); + font-size: 12px; + font-weight: 500; + padding: 12px 16px 8px !important; + &:last-of-type + // TODO(shaheer): uncomment when we display value column + // , + // &:nth-last-of-type(2) + { + text-align: right; + } + } + &-tbody > tr > td { + border: none; + &:last-of-type, + &:nth-last-of-type(2) { + text-align: right; + } + } + } + + .label-filter { + padding: 6px 8px; + border-radius: 4px; + background: var(--text-ink-400); + border-width: 0; + line-height: 18px; + & ::placeholder { + color: var(--text-vanilla-400); + font-size: 12px; + letter-spacing: 0.6px; + text-transform: uppercase; + font-weight: 500; + } + } + .alert-rule { + &-value, + &-created-at { + font-size: 14px; + color: var(--text-vanilla-400); + } + &-value { + font-weight: 500; + line-height: 20px; + } + &-created-at { + line-height: 18px; + letter-spacing: -0.07px; + } + } + .ant-table.ant-table-middle { + border-bottom: 1px solid var(--bg-slate-500); + border-left: 1px solid var(--bg-slate-500); + border-right: 1px solid var(--bg-slate-500); + + border-radius: 6px; + } + .ant-pagination-item { + &-active { + display: flex; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + padding: 1px 8px; + border-radius: 2px; + background: var(--bg-robin-500); + & > a { + color: var(--text-ink-500); + line-height: 20px; + font-weight: 500; + } + } + } + .alert-history-label-search { + .ant-select-selector { + border: none; + } + } +} + +.lightMode { + .timeline-table { + border-color: var(--bg-vanilla-300); + + .ant-table { + background: var(--bg-vanilla-100); + &-thead { + & > tr > th { + background: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-300); + } + } + &.ant-table-middle { + border-color: var(--bg-vanilla-300); + } + } + .alert-history-label-search { + .ant-select-selector { + background: var(--bg-vanilla-200); + } + } + + .alert-rule { + &-value, + &-created-at { + color: var(--text-ink-400); + } + } + .ant-pagination-item { + &-active > a { + color: var(--text-vanilla-100); + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx new file mode 100644 index 0000000000..f3144b88e6 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx @@ -0,0 +1,56 @@ +import './Table.styles.scss'; + +import { Table } from 'antd'; +import { + useGetAlertRuleDetailsTimelineTable, + useTimelineTable, +} from 'pages/AlertDetails/hooks'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { timelineTableColumns } from './useTimelineTable'; + +function TimelineTable(): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsTimelineTable(); + + const { timelineData, totalItems } = useMemo(() => { + const response = data?.payload?.data; + return { + timelineData: response?.items, + totalItems: response?.total, + }; + }, [data?.payload?.data]); + + const { paginationConfig, onChangeHandler } = useTimelineTable({ + totalItems: totalItems ?? 0, + }); + + const { t } = useTranslation('common'); + + if (isError || !isValidRuleId || !ruleId) { + return
{t('something_went_wrong')}
; + } + + return ( +
+
`${row.fingerprint}-${row.value}-${row.unixMilli}`} + columns={timelineTableColumns()} + dataSource={timelineData} + pagination={paginationConfig} + size="middle" + onChange={onChangeHandler} + loading={isLoading || isRefetching} + /> + + ); +} + +export default TimelineTable; diff --git a/frontend/src/container/AlertHistory/Timeline/Table/types.ts b/frontend/src/container/AlertHistory/Timeline/Table/types.ts new file mode 100644 index 0000000000..badf649867 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/types.ts @@ -0,0 +1,9 @@ +import { + AlertRuleTimelineTableResponse, + AlertRuleTimelineTableResponsePayload, +} from 'types/api/alerts/def'; + +export type TimelineTableProps = { + timelineData: AlertRuleTimelineTableResponse[]; + totalItems: AlertRuleTimelineTableResponsePayload['data']['total']; +}; diff --git a/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx b/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx new file mode 100644 index 0000000000..5a42fcd5bd --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx @@ -0,0 +1,53 @@ +import { ColumnsType } from 'antd/es/table'; +import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover'; +import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; +import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState'; +import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def'; +import { formatEpochTimestamp } from 'utils/timeUtils'; + +export const timelineTableColumns = (): ColumnsType => [ + { + title: 'STATE', + dataIndex: 'state', + sorter: true, + width: '12.5%', + render: (value, record): JSX.Element => ( + +
+ +
+
+ ), + }, + { + title: 'LABELS', + dataIndex: 'labels', + width: '54.5%', + render: (labels, record): JSX.Element => ( + +
+ +
+
+ ), + }, + { + title: 'CREATED AT', + dataIndex: 'unixMilli', + width: '32.5%', + render: (value, record): JSX.Element => ( + +
{formatEpochTimestamp(value)}
+
+ ), + }, +]; diff --git a/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.styles.scss b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.styles.scss new file mode 100644 index 0000000000..c153ba65fc --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.styles.scss @@ -0,0 +1,32 @@ +.timeline-tabs-and-filters { + display: flex; + justify-content: space-between; + align-items: center; + .reset-button, + .top-5-contributors { + display: flex; + align-items: center; + gap: 10px; + } + .coming-soon { + display: inline-flex; + padding: 4px 8px; + border-radius: 20px; + border: 1px solid rgba(173, 127, 88, 0.2); + background: rgba(173, 127, 88, 0.1); + justify-content: center; + align-items: center; + gap: 5px; + + &__text { + color: var(--text-sienna-400); + font-size: 10px; + font-weight: 500; + letter-spacing: -0.05px; + line-height: normal; + } + &__icon { + display: flex; + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx new file mode 100644 index 0000000000..515cef1616 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx @@ -0,0 +1,90 @@ +import './TabsAndFilters.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { TimelineFilter, TimelineTab } from 'container/AlertHistory/types'; +import history from 'lib/history'; +import { Info } from 'lucide-react'; +import Tabs2 from 'periscope/components/Tabs2'; +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; + +function ComingSoon(): JSX.Element { + return ( +
+
Coming Soon
+
+ +
+
+ ); +} +function TimelineTabs(): JSX.Element { + const tabs = [ + { + value: TimelineTab.OVERALL_STATUS, + label: 'Overall Status', + }, + { + value: TimelineTab.TOP_5_CONTRIBUTORS, + label: ( +
+ Top 5 Contributors + +
+ ), + disabled: true, + }, + ]; + + return ; +} + +function TimelineFilters(): JSX.Element { + const { search } = useLocation(); + const searchParams = useMemo(() => new URLSearchParams(search), [search]); + + const initialSelectedTab = useMemo( + () => searchParams.get('timelineFilter') ?? TimelineFilter.ALL, + [searchParams], + ); + + const handleFilter = (value: TimelineFilter): void => { + searchParams.set('timelineFilter', value); + history.push({ search: searchParams.toString() }); + }; + + const tabs = [ + { + value: TimelineFilter.ALL, + label: 'All', + }, + { + value: TimelineFilter.FIRED, + label: 'Fired', + }, + { + value: TimelineFilter.RESOLVED, + label: 'Resolved', + }, + ]; + + return ( + + ); +} + +function TabsAndFilters(): JSX.Element { + return ( +
+ + +
+ ); +} + +export default TabsAndFilters; diff --git a/frontend/src/container/AlertHistory/Timeline/Timeline.styles.scss b/frontend/src/container/AlertHistory/Timeline/Timeline.styles.scss new file mode 100644 index 0000000000..1d6b4d7990 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Timeline.styles.scss @@ -0,0 +1,22 @@ +.timeline { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0 16px; + + &__title { + color: var(--text-vanilla-100); + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: -0.07px; + } +} + +.lightMode { + .timeline { + &__title { + color: var(--text-ink-400); + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/Timeline.tsx b/frontend/src/container/AlertHistory/Timeline/Timeline.tsx new file mode 100644 index 0000000000..18430f7144 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Timeline.tsx @@ -0,0 +1,32 @@ +import './Timeline.styles.scss'; + +import GraphWrapper from './GraphWrapper/GraphWrapper'; +import TimelineTable from './Table/Table'; +import TabsAndFilters from './TabsAndFilters/TabsAndFilters'; + +function TimelineTableRenderer(): JSX.Element { + return ; +} + +function Timeline({ + totalCurrentTriggers, +}: { + totalCurrentTriggers: number; +}): JSX.Element { + return ( +
+
Timeline
+
+ +
+
+ +
+
+ +
+
+ ); +} + +export default Timeline; diff --git a/frontend/src/container/AlertHistory/Timeline/constants.ts b/frontend/src/container/AlertHistory/Timeline/constants.ts new file mode 100644 index 0000000000..2f1652437f --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/constants.ts @@ -0,0 +1,2 @@ +// setting to 25 hours because we want to display the horizontal graph when the user selects 'Last 1 day' from date and time selector +export const HORIZONTAL_GRAPH_HOURS_THRESHOLD = 25; diff --git a/frontend/src/container/AlertHistory/constants.ts b/frontend/src/container/AlertHistory/constants.ts new file mode 100644 index 0000000000..2253a27677 --- /dev/null +++ b/frontend/src/container/AlertHistory/constants.ts @@ -0,0 +1 @@ +export const TIMELINE_TABLE_PAGE_SIZE = 20; diff --git a/frontend/src/container/AlertHistory/index.tsx b/frontend/src/container/AlertHistory/index.tsx new file mode 100644 index 0000000000..3a99a130a6 --- /dev/null +++ b/frontend/src/container/AlertHistory/index.tsx @@ -0,0 +1,3 @@ +import AlertHistory from './AlertHistory'; + +export default AlertHistory; diff --git a/frontend/src/container/AlertHistory/types.ts b/frontend/src/container/AlertHistory/types.ts new file mode 100644 index 0000000000..797a557eed --- /dev/null +++ b/frontend/src/container/AlertHistory/types.ts @@ -0,0 +1,15 @@ +export enum AlertDetailsTab { + OVERVIEW = 'OVERVIEW', + HISTORY = 'HISTORY', +} + +export enum TimelineTab { + OVERALL_STATUS = 'OVERALL_STATUS', + TOP_5_CONTRIBUTORS = 'TOP_5_CONTRIBUTORS', +} + +export enum TimelineFilter { + ALL = 'ALL', + FIRED = 'FIRED', + RESOLVED = 'RESOLVED', +} diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index e821e67104..85a58aac99 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -253,6 +253,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element { routeKey === 'MESSAGING_QUEUES' || routeKey === 'MESSAGING_QUEUES_DETAIL'; const isDashboardListView = (): boolean => routeKey === 'ALL_DASHBOARD'; + const isAlertHistory = (): boolean => routeKey === 'ALERT_HISTORY'; + const isAlertOverview = (): boolean => routeKey === 'ALERT_OVERVIEW'; const isDashboardView = (): boolean => { /** * need to match using regex here as the getRoute function will not work for @@ -341,6 +343,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element { isDashboardView() || isDashboardWidgetView() || isDashboardListView() || + isAlertHistory() || + isAlertOverview() || isMessagingQueues() ? 0 : '0 1rem', diff --git a/frontend/src/container/FormAlertRules/QuerySection.styles.scss b/frontend/src/container/FormAlertRules/QuerySection.styles.scss index ee3f4892af..303f6d45d8 100644 --- a/frontend/src/container/FormAlertRules/QuerySection.styles.scss +++ b/frontend/src/container/FormAlertRules/QuerySection.styles.scss @@ -42,6 +42,10 @@ display: flex; align-items: center; } + + .ant-tabs-tab-btn { + padding: 0 !important; + } } .lightMode { diff --git a/frontend/src/container/FormAlertRules/index.tsx b/frontend/src/container/FormAlertRules/index.tsx index 965b21aa5a..f53a6b2cfe 100644 --- a/frontend/src/container/FormAlertRules/index.tsx +++ b/frontend/src/container/FormAlertRules/index.tsx @@ -19,6 +19,7 @@ import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts'; import { FeatureKeys } from 'constants/features'; import { QueryParams } from 'constants/query'; import { PANEL_TYPES } from 'constants/queryBuilder'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import ROUTES from 'constants/routes'; import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag'; import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag'; @@ -369,7 +370,7 @@ function FormAlertRules({ }); // invalidate rule in cache - ruleCache.invalidateQueries(['ruleId', ruleId]); + ruleCache.invalidateQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]); // eslint-disable-next-line sonarjs/no-identical-functions setTimeout(() => { diff --git a/frontend/src/container/ListAlertRules/ListAlert.tsx b/frontend/src/container/ListAlertRules/ListAlert.tsx index 3ba953be73..f446d55f90 100644 --- a/frontend/src/container/ListAlertRules/ListAlert.tsx +++ b/frontend/src/container/ListAlertRules/ListAlert.tsx @@ -139,7 +139,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element { params.set(QueryParams.ruleId, record.id.toString()); setEditLoader(false); - history.push(`${ROUTES.EDIT_ALERTS}?${params.toString()}`); + history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`); }) .catch(handleError) .finally(() => setEditLoader(false)); diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss b/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss index 22efca5009..601b513f8f 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss @@ -62,6 +62,14 @@ .shareable-link-popover { margin-left: 8px; } + .reset-button { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-ink-300); + border: 1px solid var(--bg-slate-400); + margin-right: 16px; + } } .share-modal-content { @@ -296,4 +304,8 @@ } } } + .reset-button { + background: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-300); + } } diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts b/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts index b652a68202..e0b73deb33 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts @@ -208,6 +208,8 @@ export const routesToSkip = [ ROUTES.DASHBOARD, ROUTES.DASHBOARD_WIDGET, ROUTES.SERVICE_TOP_LEVEL_OPERATIONS, + ROUTES.ALERT_HISTORY, + ROUTES.ALERT_OVERVIEW, ROUTES.MESSAGING_QUEUES, ROUTES.MESSAGING_QUEUES_DETAIL, ]; diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx index 3895d8b38c..cd4fb97d4f 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx @@ -27,7 +27,7 @@ import GetMinMax, { isValidTimeFormat } from 'lib/getMinMax'; import getTimeString from 'lib/getTimeString'; import history from 'lib/history'; import { isObject } from 'lodash-es'; -import { Check, Copy, Info, Send } from 'lucide-react'; +import { Check, Copy, Info, Send, Undo } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useQueryClient } from 'react-query'; import { connect, useSelector } from 'react-redux'; @@ -44,6 +44,7 @@ import { GlobalReducer } from 'types/reducer/globalTime'; import AutoRefresh from '../AutoRefreshV2'; import { DateTimeRangeType } from '../CustomDateTimeModal'; +import { RelativeTimeMap } from '../DateTimeSelection/config'; import { convertOldTimeToNewValidCustomTimeFormat, CustomTimeType, @@ -63,7 +64,9 @@ function DateTimeSelection({ location, updateTimeInterval, globalTimeLoading, + showResetButton = false, showOldExplorerCTA = false, + defaultRelativeTime = RelativeTimeMap['6hr'] as Time, }: Props): JSX.Element { const [formSelector] = Form.useForm(); @@ -242,22 +245,25 @@ function DateTimeSelection({ return defaultSelectedOption; }; - const updateLocalStorageForRoutes = (value: Time | string): void => { - const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION); - if (preRoutes !== null) { - const preRoutesObject = JSON.parse(preRoutes); - - const preRoute = { - ...preRoutesObject, - }; - preRoute[location.pathname] = value; - - setLocalStorageKey( - LOCALSTORAGE.METRICS_TIME_IN_DURATION, - JSON.stringify(preRoute), - ); - } - }; + const updateLocalStorageForRoutes = useCallback( + (value: Time | string): void => { + const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION); + if (preRoutes !== null) { + const preRoutesObject = JSON.parse(preRoutes); + + const preRoute = { + ...preRoutesObject, + }; + preRoute[location.pathname] = value; + + setLocalStorageKey( + LOCALSTORAGE.METRICS_TIME_IN_DURATION, + JSON.stringify(preRoute), + ); + } + }, + [location.pathname], + ); const onLastRefreshHandler = useCallback(() => { const currentTime = dayjs(); @@ -297,48 +303,65 @@ function DateTimeSelection({ [location.pathname], ); - const onSelectHandler = (value: Time | CustomTimeType): void => { - if (value !== 'custom') { - setIsOpen(false); - updateTimeInterval(value); - updateLocalStorageForRoutes(value); - setIsValidteRelativeTime(true); - if (refreshButtonHidden) { - setRefreshButtonHidden(false); - } - } else { - setRefreshButtonHidden(true); - setCustomDTPickerVisible(true); - setIsValidteRelativeTime(false); - setEnableAbsoluteTime(false); + const onSelectHandler = useCallback( + (value: Time | CustomTimeType): void => { + if (value !== 'custom') { + setIsOpen(false); + updateTimeInterval(value); + updateLocalStorageForRoutes(value); + setIsValidteRelativeTime(true); + if (refreshButtonHidden) { + setRefreshButtonHidden(false); + } + } else { + setRefreshButtonHidden(true); + setCustomDTPickerVisible(true); + setIsValidteRelativeTime(false); + setEnableAbsoluteTime(false); - return; - } + return; + } - if (!isLogsExplorerPage) { - urlQuery.delete('startTime'); - urlQuery.delete('endTime'); + if (!isLogsExplorerPage) { + urlQuery.delete('startTime'); + urlQuery.delete('endTime'); - urlQuery.set(QueryParams.relativeTime, value); + urlQuery.set(QueryParams.relativeTime, value); - const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; - history.replace(generatedUrl); - } + const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; + history.replace(generatedUrl); + } - // For logs explorer - time range handling is managed in useCopyLogLink.ts:52 + // For logs explorer - time range handling is managed in useCopyLogLink.ts:52 - if (!stagedQuery) { - return; - } - // the second boolean param directs the qb about the time change so to merge the query and retain the current state - // we removed update step interval to stop auto updating the value on time change - initQueryBuilderData(stagedQuery, true); - }; + if (!stagedQuery) { + return; + } + // the second boolean param directs the qb about the time change so to merge the query and retain the current state + // we removed update step interval to stop auto updating the value on time change + initQueryBuilderData(stagedQuery, true); + }, + [ + initQueryBuilderData, + isLogsExplorerPage, + location.pathname, + refreshButtonHidden, + stagedQuery, + updateLocalStorageForRoutes, + updateTimeInterval, + urlQuery, + ], + ); const onRefreshHandler = (): void => { onSelectHandler(selectedTime); onLastRefreshHandler(); }; + const handleReset = useCallback(() => { + if (defaultRelativeTime) { + onSelectHandler(defaultRelativeTime); + } + }, [defaultRelativeTime, onSelectHandler]); const onCustomDateHandler = (dateTimeRange: DateTimeRangeType): void => { if (dateTimeRange !== null) { @@ -446,6 +469,22 @@ function DateTimeSelection({ } const currentRoute = location.pathname; + + // set the default relative time for alert history and overview pages if relative time is not specified + if ( + (!urlQuery.has(QueryParams.startTime) || + !urlQuery.has(QueryParams.endTime)) && + !urlQuery.has(QueryParams.relativeTime) && + (currentRoute === ROUTES.ALERT_OVERVIEW || + currentRoute === ROUTES.ALERT_HISTORY) + ) { + updateTimeInterval(defaultRelativeTime); + urlQuery.set(QueryParams.relativeTime, defaultRelativeTime); + const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; + history.replace(generatedUrl); + return; + } + const time = getDefaultTime(currentRoute); const currentOptions = getOptions(currentRoute); @@ -575,6 +614,19 @@ function DateTimeSelection({ return (
+ {showResetButton && selectedTime !== defaultRelativeTime && ( + + + + )} {showOldExplorerCTA && (
@@ -666,11 +718,15 @@ interface DateTimeSelectionV2Props { showAutoRefresh: boolean; hideShareModal?: boolean; showOldExplorerCTA?: boolean; + showResetButton?: boolean; + defaultRelativeTime?: Time; } DateTimeSelection.defaultProps = { hideShareModal: false, showOldExplorerCTA: false, + showResetButton: false, + defaultRelativeTime: RelativeTimeMap['6hr'] as Time, }; interface DispatchProps { updateTimeInterval: ( diff --git a/frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts b/frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts new file mode 100644 index 0000000000..d2eb2c09e0 --- /dev/null +++ b/frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts @@ -0,0 +1,49 @@ +import { Color } from '@signozhq/design-tokens'; +import uPlot from 'uplot'; + +const bucketIncr = 5; + +function heatmapPlugin(): uPlot.Plugin { + function fillStyle(count: number): string { + const colors = [Color.BG_CHERRY_500, Color.BG_SLATE_400]; + return colors[count - 1]; + } + + return { + hooks: { + draw: (u: uPlot): void => { + const { ctx, data } = u; + + const yData = (data[3] as unknown) as number[][]; + const yQtys = (data[4] as unknown) as number[][]; + const yHgt = Math.floor( + u.valToPos(bucketIncr, 'y', true) - u.valToPos(0, 'y', true), + ); + + ctx.save(); + ctx.beginPath(); + ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + ctx.clip(); + + yData.forEach((yVals, xi) => { + const xPos = Math.floor(u.valToPos(data[0][xi], 'x', true)); + + // const maxCount = yQtys[xi].reduce( + // (acc, val) => Math.max(val, acc), + // -Infinity, + // ); + + yVals.forEach((yVal, yi) => { + const yPos = Math.floor(u.valToPos(yVal, 'y', true)); + + ctx.fillStyle = fillStyle(yQtys[xi][yi]); + ctx.fillRect(xPos - 4, yPos, 30, yHgt); + }); + }); + + ctx.restore(); + }, + }, + }; +} +export default heatmapPlugin; diff --git a/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts b/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts new file mode 100644 index 0000000000..b740fb2b2c --- /dev/null +++ b/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts @@ -0,0 +1,632 @@ +import uPlot from 'uplot'; + +export function pointWithin( + px: number, + py: number, + rlft: number, + rtop: number, + rrgt: number, + rbtm: number, +): boolean { + return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm; +} +const MAX_OBJECTS = 10; +const MAX_LEVELS = 4; + +export class Quadtree { + x: number; + + y: number; + + w: number; + + h: number; + + l: number; + + o: any[]; + + q: Quadtree[] | null; + + constructor(x: number, y: number, w: number, h: number, l?: number) { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + this.l = l || 0; + this.o = []; + this.q = null; + } + + split(): void { + const w = this.w / 2; + const h = this.h / 2; + const l = this.l + 1; + + this.q = [ + // top right + new Quadtree(this.x + w, this.y, w, h, l), + // top left + new Quadtree(this.x, this.y, w, h, l), + // bottom left + new Quadtree(this.x, this.y + h, w, h, l), + // bottom right + new Quadtree(this.x + w, this.y + h, w, h, l), + ]; + } + + quads( + x: number, + y: number, + w: number, + h: number, + cb: (quad: Quadtree) => void, + ): void { + const { q } = this; + const hzMid = this.x + this.w / 2; + const vtMid = this.y + this.h / 2; + const startIsNorth = y < vtMid; + const startIsWest = x < hzMid; + const endIsEast = x + w > hzMid; + const endIsSouth = y + h > vtMid; + if (q) { + // top-right quad + if (startIsNorth && endIsEast) { + cb(q[0]); + } + // top-left quad + if (startIsWest && startIsNorth) { + cb(q[1]); + } + // bottom-left quad + if (startIsWest && endIsSouth) { + cb(q[2]); + } + // bottom-right quad + if (endIsEast && endIsSouth) { + cb(q[3]); + } + } + } + + add(o: any): void { + if (this.q != null) { + this.quads(o.x, o.y, o.w, o.h, (q) => { + q.add(o); + }); + } else { + const os = this.o; + + os.push(o); + + if (os.length > MAX_OBJECTS && this.l < MAX_LEVELS) { + this.split(); + + for (let i = 0; i < os.length; i++) { + const oi = os[i]; + + this.quads(oi.x, oi.y, oi.w, oi.h, (q) => { + q.add(oi); + }); + } + + this.o.length = 0; + } + } + } + + get(x: number, y: number, w: number, h: number, cb: (o: any) => void): void { + const os = this.o; + + for (let i = 0; i < os.length; i++) { + cb(os[i]); + } + + if (this.q != null) { + this.quads(x, y, w, h, (q) => { + q.get(x, y, w, h, cb); + }); + } + } + + clear(): void { + this.o.length = 0; + this.q = null; + } +} + +Object.assign(Quadtree.prototype, { + split: Quadtree.prototype.split, + quads: Quadtree.prototype.quads, + add: Quadtree.prototype.add, + get: Quadtree.prototype.get, + clear: Quadtree.prototype.clear, +}); + +const { round, min, ceil } = Math; + +function roundDec(val: number, dec: number): number { + return Math.round(val * 10 ** dec) / 10 ** dec; +} + +export const SPACE_BETWEEN = 1; +export const SPACE_AROUND = 2; +export const SPACE_EVENLY = 3; +export const inf = Infinity; + +const coord = (i: number, offs: number, iwid: number, gap: number): number => + roundDec(offs + i * (iwid + gap), 6); + +export function distr( + numItems: number, + sizeFactor: number, + justify: number, + onlyIdx: number | null, + each: (i: number, offPct: number, dimPct: number) => void, +): void { + const space = 1 - sizeFactor; + + let gap = 0; + if (justify === SPACE_BETWEEN) { + gap = space / (numItems - 1); + } else if (justify === SPACE_AROUND) { + gap = space / numItems; + } else if (justify === SPACE_EVENLY) { + gap = space / (numItems + 1); + } + + if (Number.isNaN(gap) || gap === Infinity) gap = 0; + + let offs = 0; + if (justify === SPACE_AROUND) { + offs = gap / 2; + } else if (justify === SPACE_EVENLY) { + offs = gap; + } + + const iwid = sizeFactor / numItems; + const iwidRounded = roundDec(iwid, 6); + + if (onlyIdx == null) { + for (let i = 0; i < numItems; i++) + each(i, coord(i, offs, iwid, gap), iwidRounded); + } else each(onlyIdx, coord(onlyIdx, offs, iwid, gap), iwidRounded); +} + +function timelinePlugin(opts: any): any { + const { mode, count, fill, stroke, laneWidthOption, showGrid } = opts; + + const pxRatio = devicePixelRatio; + + const laneWidth = laneWidthOption ?? 0.9; + + const laneDistr = SPACE_BETWEEN; + + const font = `${round(14 * pxRatio)}px Geist Mono`; + + function walk( + yIdx: number | null, + count: number, + dim: number, + draw: (iy: number, y0: number, hgt: number) => void, + ): void { + distr( + count, + laneWidth, + laneDistr, + yIdx, + (i: number, offPct: number, dimPct: number) => { + const laneOffPx = dim * offPct; + const laneWidPx = dim * dimPct; + + draw(i, laneOffPx, laneWidPx); + }, + ); + } + + const size = opts.size ?? [0.6, Infinity]; + const align = opts.align ?? 0; + + const gapFactor = 1 - size[0]; + const maxWidth = (size[1] ?? inf) * pxRatio; + + const fillPaths = new Map(); + const strokePaths = new Map(); + + function drawBoxes(ctx: CanvasRenderingContext2D): void { + fillPaths.forEach((fillPath, fillStyle) => { + ctx.fillStyle = fillStyle; + ctx.fill(fillPath); + }); + + strokePaths.forEach((strokePath, strokeStyle) => { + ctx.strokeStyle = strokeStyle; + ctx.stroke(strokePath); + }); + + fillPaths.clear(); + strokePaths.clear(); + } + let qt: Quadtree; + + function putBox( + ctx: CanvasRenderingContext2D, + rect: (path: Path2D, x: number, y: number, w: number, h: number) => void, + xOff: number, + yOff: number, + lft: number, + top: number, + wid: number, + hgt: number, + strokeWidth: number, + iy: number, + ix: number, + value: number | null, + ): void { + const fillStyle = fill(iy + 1, ix, value); + let fillPath = fillPaths.get(fillStyle); + + if (fillPath == null) fillPaths.set(fillStyle, (fillPath = new Path2D())); + + rect(fillPath, lft, top, wid, hgt); + + if (strokeWidth) { + const strokeStyle = stroke(iy + 1, ix, value); + let strokePath = strokePaths.get(strokeStyle); + + if (strokePath == null) + strokePaths.set(strokeStyle, (strokePath = new Path2D())); + + rect( + strokePath, + lft + strokeWidth / 2, + top + strokeWidth / 2, + wid - strokeWidth, + hgt - strokeWidth, + ); + } + + qt.add({ + x: round(lft - xOff), + y: round(top - yOff), + w: wid, + h: hgt, + sidx: iy + 1, + didx: ix, + }); + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + function drawPaths(u: uPlot, sidx: number, idx0: number, idx1: number): null { + uPlot.orient( + u, + sidx, + ( + series, + dataX, + dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + yDim, + moveTo, + lineTo, + rect, + ) => { + const strokeWidth = round((series.width || 0) * pxRatio); + + u.ctx.save(); + rect(u.ctx, u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + + walk(sidx - 1, count, yDim, (iy: number, y0: number, hgt: number) => { + // draw spans + if (mode === 1) { + for (let ix = 0; ix < dataY.length; ix++) { + if (dataY[ix] != null) { + const lft = round(valToPosX(dataX[ix], scaleX, xDim, xOff)); + + let nextIx = ix; + // eslint-disable-next-line no-empty + while (dataY[++nextIx] === undefined && nextIx < dataY.length) {} + + // to now (not to end of chart) + const rgt = + nextIx === dataY.length + ? xOff + xDim + strokeWidth + : round(valToPosX(dataX[nextIx], scaleX, xDim, xOff)); + + putBox( + u.ctx, + rect, + xOff, + yOff, + lft, + round(yOff + y0), + rgt - lft, + round(hgt), + strokeWidth, + iy, + ix, + dataY[ix], + ); + + ix = nextIx - 1; + } + } + } + // draw matrix + else { + const colWid = + valToPosX(dataX[1], scaleX, xDim, xOff) - + valToPosX(dataX[0], scaleX, xDim, xOff); + const gapWid = colWid * gapFactor; + const barWid = round(min(maxWidth, colWid - gapWid) - strokeWidth); + let xShift; + if (align === 1) { + xShift = 0; + } else if (align === -1) { + xShift = barWid; + } else { + xShift = barWid / 2; + } + + for (let ix = idx0; ix <= idx1; ix++) { + if (dataY[ix] != null) { + // TODO: all xPos can be pre-computed once for all series in aligned set + const lft = valToPosX(dataX[ix], scaleX, xDim, xOff); + + putBox( + u.ctx, + rect, + xOff, + yOff, + round(lft - xShift), + round(yOff + y0), + barWid, + round(hgt), + strokeWidth, + iy, + ix, + dataY[ix], + ); + } + } + } + }); + + // eslint-disable-next-line no-param-reassign + u.ctx.lineWidth = strokeWidth; + drawBoxes(u.ctx); + + u.ctx.restore(); + }, + ); + + return null; + } + const yMids = Array(count).fill(0); + function drawPoints(u: uPlot, sidx: number): boolean { + u.ctx.save(); + u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + + const { ctx } = u; + ctx.font = font; + ctx.fillStyle = 'black'; + ctx.textAlign = mode === 1 ? 'left' : 'center'; + ctx.textBaseline = 'middle'; + + uPlot.orient( + u, + sidx, + ( + series, + dataX, + dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + ) => { + const strokeWidth = round((series.width || 0) * pxRatio); + const textOffset = mode === 1 ? strokeWidth + 2 : 0; + + const y = round(yOff + yMids[sidx - 1]); + if (opts.displayTimelineValue) { + for (let ix = 0; ix < dataY.length; ix++) { + if (dataY[ix] != null) { + const x = valToPosX(dataX[ix], scaleX, xDim, xOff) + textOffset; + u.ctx.fillText(String(dataY[ix]), x, y); + } + } + } + }, + ); + + u.ctx.restore(); + + return false; + } + + const hovered = Array(count).fill(null); + + const ySplits = Array(count).fill(0); + + const fmtDate = uPlot.fmtDate('{YYYY}-{MM}-{DD} {HH}:{mm}:{ss}'); + let legendTimeValueEl: HTMLElement | null = null; + + return { + hooks: { + init: (u: uPlot): void => { + legendTimeValueEl = u.root.querySelector('.u-series:first-child .u-value'); + }, + drawClear: (u: uPlot): void => { + qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); + + qt.clear(); + + // force-clear the path cache to cause drawBars() to rebuild new quadtree + u.series.forEach((s: any) => { + // eslint-disable-next-line no-param-reassign + s._paths = null; + }); + }, + setCursor: (u: { + posToVal: (arg0: any, arg1: string) => any; + cursor: { left: any }; + scales: { x: { time: any } }; + }): any => { + if (mode === 1 && legendTimeValueEl) { + const val = u.posToVal(u.cursor.left, 'x'); + legendTimeValueEl.textContent = u.scales.x.time + ? fmtDate(new Date(val * 1e3)) + : val.toFixed(2); + } + }, + }, + // eslint-disable-next-line sonarjs/cognitive-complexity + opts: (u: { series: { label: any }[] }, opts: any): any => { + uPlot.assign(opts, { + cursor: { + // x: false, + y: false, + dataIdx: ( + u: { cursor: { left: number } }, + seriesIdx: number, + closestIdx: any, + ) => { + if (seriesIdx === 0) return closestIdx; + + const cx = round(u.cursor.left * pxRatio); + + if (cx >= 0) { + const cy = yMids[seriesIdx - 1]; + + hovered[seriesIdx - 1] = null; + + qt.get(cx, cy, 1, 1, (o: { x: any; y: any; w: any; h: any }) => { + if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) + hovered[seriesIdx - 1] = o; + }); + } + + return hovered[seriesIdx - 1]?.didx; + }, + points: { + fill: 'rgba(0,0,0,0.3)', + bbox: (u: any, seriesIdx: number) => { + const hRect = hovered[seriesIdx - 1]; + + return { + left: hRect ? round(hRect.x / devicePixelRatio) : -10, + top: hRect ? round(hRect.y / devicePixelRatio) : -10, + width: hRect ? round(hRect.w / devicePixelRatio) : 0, + height: hRect ? round(hRect.h / devicePixelRatio) : 0, + }; + }, + }, + }, + scales: { + x: { + range(u: { data: number[][] }, min: number, max: number) { + if (mode === 2) { + const colWid = u.data[0][1] - u.data[0][0]; + const scalePad = colWid / 2; + + // eslint-disable-next-line no-param-reassign + if (min <= u.data[0][0]) min = u.data[0][0] - scalePad; + + const lastIdx = u.data[0].length - 1; + + // eslint-disable-next-line no-param-reassign + if (max >= u.data[0][lastIdx]) max = u.data[0][lastIdx] + scalePad; + } + + return [min, max]; + }, + }, + y: { + range: [0, 1], + }, + }, + }); + + uPlot.assign(opts.axes[0], { + splits: + mode === 2 + ? ( + u: { data: any[][] }, + scaleMin: number, + scaleMax: number, + foundIncr: number, + ): any => { + const splits = []; + + const dataIncr = u.data[0][1] - u.data[0][0]; + const skipFactor = ceil(foundIncr / dataIncr); + + for (let i = 0; i < u.data[0].length; i += skipFactor) { + const v = u.data[0][i]; + + if (v >= scaleMin && v <= scaleMax) splits.push(v); + } + + return splits; + } + : null, + grid: { + show: showGrid ?? mode !== 2, + }, + }); + + uPlot.assign(opts.axes[1], { + splits: (u: { + bbox: { height: any }; + posToVal: (arg0: number, arg1: string) => any; + }) => { + walk(null, count, u.bbox.height, (iy: any, y0: number, hgt: number) => { + // vertical midpoints of each series' timeline (stored relative to .u-over) + yMids[iy] = round(y0 + hgt / 2); + ySplits[iy] = u.posToVal(yMids[iy] / pxRatio, 'y'); + }); + + return ySplits; + }, + values: () => + Array(count) + .fill(null) + .map((v, i) => u.series[i + 1].label), + gap: 15, + size: 70, + grid: { show: false }, + ticks: { show: false }, + + side: 3, + }); + + opts.series.forEach((s: any, i: number) => { + if (i > 0) { + uPlot.assign(s, { + // width: 0, + // pxAlign: false, + // stroke: "rgba(255,0,0,0.5)", + paths: drawPaths, + points: { + show: drawPoints, + }, + }); + } + }); + }, + }; +} + +export default timelinePlugin; diff --git a/frontend/src/pages/AlertDetails/AlertDetails.styles.scss b/frontend/src/pages/AlertDetails/AlertDetails.styles.scss new file mode 100644 index 0000000000..62eeb96ae0 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertDetails.styles.scss @@ -0,0 +1,189 @@ +@mixin flex-center { + display: flex; + justify-content: space-between; + align-items: center; +} + +.alert-details-tabs { + .top-level-tab.periscope-tab { + padding: 2px 0; + } + .ant-tabs { + &-nav { + margin-bottom: 0 !important; + &::before { + border-bottom: 1px solid var(--bg-slate-500) !important; + } + } + &-tab { + &[data-node-key='TriggeredAlerts'] { + margin-left: 16px; + } + &:not(:first-of-type) { + margin-left: 24px !important; + } + .periscope-tab { + font-size: 14px; + color: var(--text-vanilla-100); + line-height: 20px; + letter-spacing: -0.07px; + gap: 10px; + } + [aria-selected='false'] { + .periscope-tab { + color: var(--text-vanilla-400); + } + } + } + } +} + +.alert-details { + margin-top: 10px; + .divider { + border-color: var(--bg-slate-500); + margin: 16px 0; + } + .breadcrumb-divider { + margin-top: 10px; + } + &__breadcrumb { + ol { + align-items: center; + } + padding-left: 16px; + .breadcrumb-item { + color: var(--text-vanilla-400); + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + padding: 0; + } + + .ant-breadcrumb-separator, + .breadcrumb-item--last { + color: var(--text-vanilla-500); + font-family: 'Geist Mono'; + } + } + .tabs-and-filters { + margin: 1rem 0; + + .ant-tabs { + &-ink-bar { + background-color: transparent; + } + &-nav { + &-wrap { + padding: 0 16px 16px 16px; + } + &::before { + border-bottom: none !important; + } + } + &-tab { + margin-left: 0 !important; + padding: 0; + + &-btn { + padding: 6px 17px; + color: var(--text-vanilla-400) !important; + letter-spacing: -0.07px; + font-size: 14px; + + &[aria-selected='true'] { + color: var(--text-vanilla-100) !important; + } + } + &-active { + background: var(--bg-slate-400, #1d212d); + } + } + &-extra-content { + padding: 0 16px 16px; + } + &-nav-list { + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-400); + border-radius: 2px; + } + } + + .tab-item { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + } + .filters { + @include flex-center; + gap: 16px; + .reset-button { + @include flex-center; + } + } + } +} + +.lightMode { + .alert-details { + &-tabs { + .ant-tabs-nav { + &::before { + border-bottom: 1px solid var(--bg-vanilla-300) !important; + } + } + } + &__breadcrumb { + .ant-breadcrumb-link { + color: var(--text-ink-400); + } + .ant-breadcrumb-separator, + span.ant-breadcrumb-link { + color: var(--text-ink-500); + } + } + .tabs-and-filters { + .ant-tabs { + &-nav-list { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-300); + } + &-tab { + &-btn { + &[aria-selected='true'] { + color: var(--text-robin-500) !important; + } + color: var(--text-ink-400) !important; + } + &-active { + background: var(--bg-vanilla-100); + } + } + } + } + .divider { + border-color: var(--bg-vanilla-300); + } + } + + .alert-details-tabs { + .ant-tabs { + &-nav { + &::before { + border: none !important; + } + } + &-tab { + .periscope-tab { + color: var(--text-ink-300); + } + [aria-selected='true'] { + .periscope-tab { + color: var(--text-ink-400); + } + } + } + } + } +} diff --git a/frontend/src/pages/AlertDetails/AlertDetails.tsx b/frontend/src/pages/AlertDetails/AlertDetails.tsx new file mode 100644 index 0000000000..c79478fb77 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertDetails.tsx @@ -0,0 +1,123 @@ +import './AlertDetails.styles.scss'; + +import { Breadcrumb, Button, Divider } from 'antd'; +import { Filters } from 'components/AlertDetailsFilters/Filters'; +import NotFound from 'components/NotFound'; +import RouteTab from 'components/RouteTab'; +import Spinner from 'components/Spinner'; +import ROUTES from 'constants/routes'; +import history from 'lib/history'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLocation } from 'react-router-dom'; + +import AlertHeader from './AlertHeader/AlertHeader'; +import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks'; +import { AlertDetailsStatusRendererProps } from './types'; + +function AlertDetailsStatusRenderer({ + isLoading, + isError, + isRefetching, + data, +}: AlertDetailsStatusRendererProps): JSX.Element { + const alertRuleDetails = useMemo(() => data?.payload?.data, [data]); + const { t } = useTranslation('common'); + + if (isLoading || isRefetching) { + return ; + } + + if (isError) { + return
{data?.error || t('something_went_wrong')}
; + } + + return ; +} + +function BreadCrumbItem({ + title, + isLast, + route, +}: { + title: string | null; + isLast?: boolean; + route?: string; +}): JSX.Element { + if (isLast) { + return
{title}
; + } + const handleNavigate = (): void => { + if (!route) { + return; + } + history.push(ROUTES.LIST_ALL_ALERT); + }; + + return ( + + ); +} + +BreadCrumbItem.defaultProps = { + isLast: false, + route: '', +}; + +function AlertDetails(): JSX.Element { + const { pathname } = useLocation(); + const { routes } = useRouteTabUtils(); + + const { + isLoading, + isRefetching, + isError, + ruleId, + isValidRuleId, + alertDetailsResponse, + } = useGetAlertRuleDetails(); + + if ( + isError || + !isValidRuleId || + (alertDetailsResponse && alertDetailsResponse.statusCode !== 200) + ) { + return ; + } + + return ( +
+ + ), + }, + { + title: , + }, + ]} + /> + + + + +
+ } + /> +
+
+ ); +} + +export default AlertDetails; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.styles.scss new file mode 100644 index 0000000000..edd94a5bcd --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.styles.scss @@ -0,0 +1,63 @@ +.alert-action-buttons { + display: flex; + align-items: center; + gap: 12px; + color: var(--bg-slate-400); + .ant-divider-vertical { + height: 16px; + border-color: var(--bg-slate-400); + margin: 0; + } + .dropdown-icon { + margin-right: 4px; + } +} +.dropdown-menu { + border-radius: 4px; + box-shadow: none; + background: linear-gradient( + 138.7deg, + rgba(18, 19, 23, 0.8) 0%, + rgba(18, 19, 23, 0.9) 98.68% + ); + + .dropdown-divider { + margin: 0; + } + + .delete-button { + border: none; + display: flex; + align-items: center; + width: 100%; + + &, + & span { + &:hover { + background: var(--bg-slate-400); + color: var(--bg-cherry-400); + } + color: var(--bg-cherry-400); + font-size: 14px; + } + } +} + +.lightMode { + .alert-action-buttons { + .ant-divider-vertical { + border-color: var(--bg-vanilla-300); + } + } + .dropdown-menu { + background: inherit; + .delete-button { + &, + &span { + &:hover { + background: var(--bg-vanilla-300); + } + } + } + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx new file mode 100644 index 0000000000..186a34676b --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx @@ -0,0 +1,111 @@ +import './ActionButtons.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Divider, Dropdown, MenuProps, Switch, Tooltip } from 'antd'; +import { QueryParams } from 'constants/query'; +import ROUTES from 'constants/routes'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import useUrlQuery from 'hooks/useUrlQuery'; +import history from 'lib/history'; +import { Copy, Ellipsis, PenLine, Trash2 } from 'lucide-react'; +import { + useAlertRuleDelete, + useAlertRuleDuplicate, + useAlertRuleStatusToggle, +} from 'pages/AlertDetails/hooks'; +import CopyToClipboard from 'periscope/components/CopyToClipboard'; +import { useAlertRule } from 'providers/Alert'; +import React from 'react'; +import { CSSProperties } from 'styled-components'; +import { AlertDef } from 'types/api/alerts/def'; + +import { AlertHeaderProps } from '../AlertHeader'; + +const menuItemStyle: CSSProperties = { + fontSize: '14px', + letterSpacing: '0.14px', +}; +function AlertActionButtons({ + ruleId, + alertDetails, +}: { + ruleId: string; + alertDetails: AlertHeaderProps['alertDetails']; +}): JSX.Element { + const { isAlertRuleDisabled } = useAlertRule(); + const { handleAlertStateToggle } = useAlertRuleStatusToggle({ ruleId }); + + const { handleAlertDuplicate } = useAlertRuleDuplicate({ + alertDetails: (alertDetails as unknown) as AlertDef, + }); + const { handleAlertDelete } = useAlertRuleDelete({ ruleId: Number(ruleId) }); + + const params = useUrlQuery(); + + const handleRename = React.useCallback(() => { + params.set(QueryParams.ruleId, String(ruleId)); + history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`); + }, [params, ruleId]); + + const menu: MenuProps['items'] = React.useMemo( + () => [ + { + key: 'rename-rule', + label: 'Rename', + icon: , + onClick: (): void => handleRename(), + style: menuItemStyle, + }, + { + key: 'duplicate-rule', + label: 'Duplicate', + icon: , + onClick: (): void => handleAlertDuplicate(), + style: menuItemStyle, + }, + { type: 'divider' }, + { + key: 'delete-rule', + label: 'Delete', + icon: , + onClick: (): void => handleAlertDelete(), + style: { + ...menuItemStyle, + color: Color.BG_CHERRY_400, + }, + }, + ], + [handleAlertDelete, handleAlertDuplicate, handleRename], + ); + const isDarkMode = useIsDarkMode(); + + return ( +
+ + {isAlertRuleDisabled !== undefined && ( + + )} + + + + + + + + + + +
+ ); +} + +export default AlertActionButtons; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.styles.scss new file mode 100644 index 0000000000..10a05f2258 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.styles.scss @@ -0,0 +1,50 @@ +.alert-info { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 0 16px; + + &__info-wrapper { + display: flex; + flex-direction: column; + gap: 8px; + height: 54px; + + .top-section { + display: flex; + align-items: center; + justify-content: space-between; + .alert-title-wrapper { + display: flex; + align-items: center; + gap: 8px; + .alert-title { + font-size: 16px; + font-weight: 500; + color: var(--text-vanilla-100); + line-height: 24px; + letter-spacing: -0.08px; + } + } + } + .bottom-section { + display: flex; + align-items: center; + gap: 24px; + } + } +} + +.lightMode { + .alert-info { + &__info-wrapper { + .top-section { + .alert-title-wrapper { + .alert-title { + color: var(--text-ink-100); + } + } + } + } + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx new file mode 100644 index 0000000000..f4ff7b933b --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx @@ -0,0 +1,66 @@ +import './AlertHeader.styles.scss'; + +import { useAlertRule } from 'providers/Alert'; +import { useEffect, useMemo } from 'react'; + +import AlertActionButtons from './ActionButtons/ActionButtons'; +import AlertLabels from './AlertLabels/AlertLabels'; +import AlertSeverity from './AlertSeverity/AlertSeverity'; +import AlertState from './AlertState/AlertState'; + +export type AlertHeaderProps = { + alertDetails: { + state: string; + alert: string; + id: string; + labels: Record; + disabled: boolean; + }; +}; +function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element { + const { state, alert, labels, disabled } = alertDetails; + + const labelsWithoutSeverity = useMemo( + () => + Object.fromEntries( + Object.entries(labels).filter(([key]) => key !== 'severity'), + ), + [labels], + ); + + const { isAlertRuleDisabled, setIsAlertRuleDisabled } = useAlertRule(); + + useEffect(() => { + if (isAlertRuleDisabled === undefined) { + setIsAlertRuleDisabled(disabled); + } + }, [disabled, setIsAlertRuleDisabled, isAlertRuleDisabled]); + + return ( +
+
+
+
+ +
{alert}
+
+
+
+ + + {/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */} + {/* */} + +
+
+
+ +
+
+ ); +} + +export default AlertHeader; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.styles.scss new file mode 100644 index 0000000000..3468bad7ec --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.styles.scss @@ -0,0 +1,5 @@ +.alert-labels { + display: flex; + flex-wrap: wrap; + gap: 4px 6px; +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx new file mode 100644 index 0000000000..bdc5eaa019 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx @@ -0,0 +1,31 @@ +import './AlertLabels.styles.scss'; + +import KeyValueLabel from 'periscope/components/KeyValueLabel'; +import SeeMore from 'periscope/components/SeeMore'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AlertLabelsProps = { + labels: Record; + initialCount?: number; +}; + +function AlertLabels({ + labels, + initialCount = 2, +}: AlertLabelsProps): JSX.Element { + return ( +
+ + {Object.entries(labels).map(([key, value]) => ( + + ))} + +
+ ); +} + +AlertLabels.defaultProps = { + initialCount: 2, +}; + +export default AlertLabels; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.styles.scss new file mode 100644 index 0000000000..ba0226a11d --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.styles.scss @@ -0,0 +1,40 @@ +@mixin severity-styles($background, $text-color) { + .alert-severity__icon { + background: $background; + } + .alert-severity__text { + color: $text-color; + } +} + +.alert-severity { + display: flex; + align-items: center; + gap: 8px; + + overflow: hidden; + &__icon { + display: flex; + align-items: center; + justify-content: center; + height: 14px; + width: 14px; + border-radius: 3.5px; + } + &__text { + color: var(--text-sakura-400); + font-size: 14px; + line-height: 18px; + } + + &--critical, + &--error { + @include severity-styles(rgba(245, 108, 135, 0.2), var(--text-sakura-400)); + } + &--warning { + @include severity-styles(rgba(255, 215, 120, 0.2), var(--text-amber-400)); + } + &--info { + @include severity-styles(rgba(113, 144, 249, 0.2), var(--text-robin-400)); + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.tsx new file mode 100644 index 0000000000..90e7c14de4 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.tsx @@ -0,0 +1,42 @@ +import './AlertSeverity.styles.scss'; + +import SeverityCriticalIcon from 'assets/AlertHistory/SeverityCriticalIcon'; +import SeverityErrorIcon from 'assets/AlertHistory/SeverityErrorIcon'; +import SeverityInfoIcon from 'assets/AlertHistory/SeverityInfoIcon'; +import SeverityWarningIcon from 'assets/AlertHistory/SeverityWarningIcon'; + +export default function AlertSeverity({ + severity, +}: { + severity: string; +}): JSX.Element { + const severityConfig: Record> = { + critical: { + text: 'Critical', + className: 'alert-severity--critical', + icon: , + }, + error: { + text: 'Error', + className: 'alert-severity--error', + icon: , + }, + warning: { + text: 'Warning', + className: 'alert-severity--warning', + icon: , + }, + info: { + text: 'Info', + className: 'alert-severity--info', + icon: , + }, + }; + const severityDetails = severityConfig[severity]; + return ( +
+
{severityDetails.icon}
+
{severityDetails.text}
+
+ ); +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.styles.scss new file mode 100644 index 0000000000..582494e54a --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.styles.scss @@ -0,0 +1,10 @@ +.alert-state { + display: flex; + align-items: center; + gap: 6px; + &__label { + font-size: 14px; + line-height: 18px; + letter-spacing: -0.07px; + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx new file mode 100644 index 0000000000..d2be316d8a --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx @@ -0,0 +1,73 @@ +import './AlertState.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { BellOff, CircleCheck, CircleOff, Flame } from 'lucide-react'; + +type AlertStateProps = { + state: string; + showLabel?: boolean; +}; + +export default function AlertState({ + state, + showLabel, +}: AlertStateProps): JSX.Element { + let icon; + let label; + const isDarkMode = useIsDarkMode(); + switch (state) { + case 'no-data': + icon = ( + + ); + label = No Data; + break; + + case 'disabled': + icon = ( + + ); + label = Muted; + break; + case 'firing': + icon = ( + + ); + label = Firing; + break; + + case 'normal': + case 'inactive': + icon = ( + + ); + label = Resolved; + break; + + default: + icon = null; + } + + return ( +
+ {icon} {showLabel &&
{label}
} +
+ ); +} + +AlertState.defaultProps = { + showLabel: false, +}; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.styles.scss new file mode 100644 index 0000000000..97549bf21d --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.styles.scss @@ -0,0 +1,22 @@ +.alert-status-info { + gap: 6px; + color: var(--text-vanilla-400); + &__icon { + display: flex; + align-items: baseline; + } + &, + &__details { + display: flex; + align-items: center; + } + &__details { + gap: 3px; + } +} + +.lightMode { + .alert-status-info { + color: var(--text-ink-400); + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.tsx new file mode 100644 index 0000000000..dd06d107bb --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.tsx @@ -0,0 +1,54 @@ +import './AlertStatus.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { CircleCheck, Siren } from 'lucide-react'; +import { useMemo } from 'react'; +import { getDurationFromNow } from 'utils/timeUtils'; + +import { AlertStatusProps, StatusConfig } from './types'; + +export default function AlertStatus({ + status, + timestamp, +}: AlertStatusProps): JSX.Element { + const statusConfig: StatusConfig = useMemo( + () => ({ + firing: { + icon: , + text: 'Firing since', + extraInfo: timestamp ? ( + <> +
+
{getDurationFromNow(timestamp)}
+ + ) : null, + className: 'alert-status-info--firing', + }, + resolved: { + icon: ( + + ), + text: 'Resolved', + extraInfo: null, + className: 'alert-status-info--resolved', + }, + }), + [timestamp], + ); + + const currentStatus = statusConfig[status]; + + return ( +
+
{currentStatus.icon}
+
+
{currentStatus.text}
+ {currentStatus.extraInfo} +
+
+ ); +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/types.ts b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/types.ts new file mode 100644 index 0000000000..c297480f38 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/types.ts @@ -0,0 +1,18 @@ +export type AlertStatusProps = + | { status: 'firing'; timestamp: number } + | { status: 'resolved'; timestamp?: number }; + +export type StatusConfig = { + firing: { + icon: JSX.Element; + text: string; + extraInfo: JSX.Element | null; + className: string; + }; + resolved: { + icon: JSX.Element; + text: string; + extraInfo: JSX.Element | null; + className: string; + }; +}; diff --git a/frontend/src/pages/AlertDetails/hooks.tsx b/frontend/src/pages/AlertDetails/hooks.tsx new file mode 100644 index 0000000000..fc6219b195 --- /dev/null +++ b/frontend/src/pages/AlertDetails/hooks.tsx @@ -0,0 +1,525 @@ +import { FilterValue, SorterResult } from 'antd/es/table/interface'; +import { TablePaginationConfig, TableProps } from 'antd/lib'; +import deleteAlerts from 'api/alerts/delete'; +import get from 'api/alerts/get'; +import getAll from 'api/alerts/getAll'; +import patchAlert from 'api/alerts/patch'; +import ruleStats from 'api/alerts/ruleStats'; +import save from 'api/alerts/save'; +import timelineGraph from 'api/alerts/timelineGraph'; +import timelineTable from 'api/alerts/timelineTable'; +import topContributors from 'api/alerts/topContributors'; +import { TabRoutes } from 'components/RouteTab/types'; +import { QueryParams } from 'constants/query'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import ROUTES from 'constants/routes'; +import AlertHistory from 'container/AlertHistory'; +import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants'; +import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types'; +import { urlKey } from 'container/AllError/utils'; +import useAxiosError from 'hooks/useAxiosError'; +import { useNotifications } from 'hooks/useNotifications'; +import useUrlQuery from 'hooks/useUrlQuery'; +import createQueryParams from 'lib/createQueryParams'; +import GetMinMax from 'lib/getMinMax'; +import history from 'lib/history'; +import { History, Table } from 'lucide-react'; +import EditRules from 'pages/EditRules'; +import { OrderPreferenceItems } from 'pages/Logs/config'; +import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText'; +import { useAlertRule } from 'providers/Alert'; +import { useCallback, useMemo } from 'react'; +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { useSelector } from 'react-redux'; +import { generatePath, useLocation } from 'react-router-dom'; +import { AppState } from 'store/reducers'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + AlertDef, + AlertRuleStatsPayload, + AlertRuleTimelineGraphResponsePayload, + AlertRuleTimelineTableResponse, + AlertRuleTimelineTableResponsePayload, + AlertRuleTopContributorsPayload, +} from 'types/api/alerts/def'; +import { PayloadProps } from 'types/api/alerts/get'; +import { GlobalReducer } from 'types/reducer/globalTime'; +import { nanoToMilli } from 'utils/timeUtils'; + +export const useAlertHistoryQueryParams = (): { + ruleId: string | null; + startTime: number; + endTime: number; + hasStartAndEndParams: boolean; + params: URLSearchParams; +} => { + const params = useUrlQuery(); + + const globalTime = useSelector( + (state) => state.globalTime, + ); + const startTime = params.get(QueryParams.startTime); + const endTime = params.get(QueryParams.endTime); + + const intStartTime = parseInt(startTime || '0', 10); + const intEndTime = parseInt(endTime || '0', 10); + const hasStartAndEndParams = !!intStartTime && !!intEndTime; + + const { maxTime, minTime } = useMemo(() => { + if (hasStartAndEndParams) + return GetMinMax('custom', [intStartTime, intEndTime]); + return GetMinMax(globalTime.selectedTime); + }, [hasStartAndEndParams, intStartTime, intEndTime, globalTime.selectedTime]); + + const ruleId = params.get(QueryParams.ruleId); + + return { + ruleId, + startTime: Math.floor(nanoToMilli(minTime)), + endTime: Math.floor(nanoToMilli(maxTime)), + hasStartAndEndParams, + params, + }; +}; +export const useRouteTabUtils = (): { routes: TabRoutes[] } => { + const urlQuery = useUrlQuery(); + + const getRouteUrl = (tab: AlertDetailsTab): string => { + let route = ''; + let params = urlQuery.toString(); + const ruleIdKey = QueryParams.ruleId; + const relativeTimeKey = QueryParams.relativeTime; + + switch (tab) { + case AlertDetailsTab.OVERVIEW: + route = ROUTES.ALERT_OVERVIEW; + break; + case AlertDetailsTab.HISTORY: + params = `${ruleIdKey}=${urlQuery.get( + ruleIdKey, + )}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`; + route = ROUTES.ALERT_HISTORY; + break; + default: + return ''; + } + + return `${generatePath(route)}?${params}`; + }; + + const routes = [ + { + Component: EditRules, + name: ( +
+
+ Overview + + ), + route: getRouteUrl(AlertDetailsTab.OVERVIEW), + key: ROUTES.ALERT_OVERVIEW, + }, + { + Component: AlertHistory, + name: ( +
+ + History +
+ ), + route: getRouteUrl(AlertDetailsTab.HISTORY), + key: ROUTES.ALERT_HISTORY, + }, + ]; + + return { routes }; +}; +type Props = { + ruleId: string | null; + isValidRuleId: boolean; + alertDetailsResponse: + | SuccessResponse + | ErrorResponse + | undefined; + isLoading: boolean; + isRefetching: boolean; + isError: boolean; +}; + +export const useGetAlertRuleDetails = (): Props => { + const { ruleId } = useAlertHistoryQueryParams(); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + + const { + isLoading, + data: alertDetailsResponse, + isRefetching, + isError, + } = useQuery([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId], { + queryFn: () => + get({ + id: parseInt(ruleId || '', 10), + }), + enabled: isValidRuleId, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + return { + ruleId, + isLoading, + alertDetailsResponse, + isRefetching, + isError, + isValidRuleId, + }; +}; + +type GetAlertRuleDetailsApiProps = { + isLoading: boolean; + isRefetching: boolean; + isError: boolean; + isValidRuleId: boolean; + ruleId: string | null; +}; + +type GetAlertRuleDetailsStatsProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsStats = (): GetAlertRuleDetailsStatsProps => { + const { ruleId, startTime, endTime } = useAlertHistoryQueryParams(); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + + const { isLoading, isRefetching, isError, data } = useQuery( + [REACT_QUERY_KEY.ALERT_RULE_STATS, ruleId, startTime, endTime], + { + queryFn: () => + ruleStats({ + id: parseInt(ruleId || '', 10), + start: startTime, + end: endTime, + }), + enabled: isValidRuleId && !!startTime && !!endTime, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; +}; + +type GetAlertRuleDetailsTopContributorsProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsTopContributors = (): GetAlertRuleDetailsTopContributorsProps => { + const { ruleId, startTime, endTime } = useAlertHistoryQueryParams(); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + + const { isLoading, isRefetching, isError, data } = useQuery( + [REACT_QUERY_KEY.ALERT_RULE_TOP_CONTRIBUTORS, ruleId, startTime, endTime], + { + queryFn: () => + topContributors({ + id: parseInt(ruleId || '', 10), + start: startTime, + end: endTime, + }), + enabled: isValidRuleId, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; +}; + +type GetAlertRuleDetailsTimelineTableProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsTimelineTable = (): GetAlertRuleDetailsTimelineTableProps => { + const { ruleId, startTime, endTime, params } = useAlertHistoryQueryParams(); + const { updatedOrder, offset } = useMemo( + () => ({ + updatedOrder: params.get(urlKey.order) ?? OrderPreferenceItems.ASC, + offset: parseInt(params.get(urlKey.offset) ?? '1', 10), + }), + [params], + ); + + const timelineFilter = params.get('timelineFilter'); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + const hasStartAndEnd = startTime !== null && endTime !== null; + + const { isLoading, isRefetching, isError, data } = useQuery( + [ + REACT_QUERY_KEY.ALERT_RULE_TIMELINE_TABLE, + ruleId, + startTime, + endTime, + timelineFilter, + updatedOrder, + offset, + ], + { + queryFn: () => + timelineTable({ + id: parseInt(ruleId || '', 10), + start: startTime, + end: endTime, + limit: TIMELINE_TABLE_PAGE_SIZE, + order: updatedOrder, + offset, + + ...(timelineFilter && timelineFilter !== TimelineFilter.ALL + ? { + state: timelineFilter === TimelineFilter.FIRED ? 'firing' : 'normal', + } + : {}), + }), + enabled: isValidRuleId && hasStartAndEnd, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; +}; + +export const useTimelineTable = ({ + totalItems, +}: { + totalItems: number; +}): { + paginationConfig: TablePaginationConfig; + onChangeHandler: ( + pagination: TablePaginationConfig, + sorter: any, + filters: any, + extra: any, + ) => void; +} => { + const { pathname } = useLocation(); + + const { search } = useLocation(); + + const params = useMemo(() => new URLSearchParams(search), [search]); + + const offset = params.get('offset') ?? '0'; + + const onChangeHandler: TableProps['onChange'] = useCallback( + ( + pagination: TablePaginationConfig, + filters: Record, + sorter: + | SorterResult[] + | SorterResult, + ) => { + if (!Array.isArray(sorter)) { + const { pageSize = 0, current = 0 } = pagination; + const { order } = sorter; + const updatedOrder = order === 'ascend' ? 'asc' : 'desc'; + const params = new URLSearchParams(window.location.search); + + history.replace( + `${pathname}?${createQueryParams({ + ...Object.fromEntries(params), + order: updatedOrder, + offset: current * TIMELINE_TABLE_PAGE_SIZE - TIMELINE_TABLE_PAGE_SIZE, + pageSize, + })}`, + ); + } + }, + [pathname], + ); + + const offsetInt = parseInt(offset, 10); + const pageSize = params.get('pageSize') ?? String(TIMELINE_TABLE_PAGE_SIZE); + const pageSizeInt = parseInt(pageSize, 10); + + const paginationConfig: TablePaginationConfig = { + pageSize: pageSizeInt, + showTotal: PaginationInfoText, + current: offsetInt / TIMELINE_TABLE_PAGE_SIZE + 1, + showSizeChanger: false, + hideOnSinglePage: true, + total: totalItems, + }; + + return { paginationConfig, onChangeHandler }; +}; + +export const useAlertRuleStatusToggle = ({ + ruleId, +}: { + ruleId: string; +}): { + handleAlertStateToggle: (state: boolean) => void; +} => { + const { isAlertRuleDisabled, setIsAlertRuleDisabled } = useAlertRule(); + const { notifications } = useNotifications(); + + const queryClient = useQueryClient(); + const handleError = useAxiosError(); + + const { mutate: toggleAlertState } = useMutation( + [REACT_QUERY_KEY.TOGGLE_ALERT_STATE, ruleId], + patchAlert, + { + onMutate: () => { + setIsAlertRuleDisabled((prev) => !prev); + }, + onSuccess: () => { + notifications.success({ + message: `Alert has been ${isAlertRuleDisabled ? 'enabled' : 'disabled'}.`, + }); + }, + onError: (error) => { + queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS]); + handleError(error); + }, + }, + ); + + const handleAlertStateToggle = (): void => { + const args = { + id: parseInt(ruleId, 10), + data: { disabled: !isAlertRuleDisabled }, + }; + toggleAlertState(args); + }; + + return { handleAlertStateToggle }; +}; + +export const useAlertRuleDuplicate = ({ + alertDetails, +}: { + alertDetails: AlertDef; +}): { + handleAlertDuplicate: () => void; +} => { + const { notifications } = useNotifications(); + + const params = useUrlQuery(); + + const { refetch } = useQuery(REACT_QUERY_KEY.GET_ALL_ALLERTS, { + queryFn: getAll, + cacheTime: 0, + }); + const handleError = useAxiosError(); + const { mutate: duplicateAlert } = useMutation( + [REACT_QUERY_KEY.DUPLICATE_ALERT_RULE], + save, + { + onSuccess: async () => { + notifications.success({ + message: `Success`, + }); + + const { data: allAlertsData } = await refetch(); + + if ( + allAlertsData && + allAlertsData.payload && + allAlertsData.payload.length > 0 + ) { + const clonedAlert = + allAlertsData.payload[allAlertsData.payload.length - 1]; + params.set(QueryParams.ruleId, String(clonedAlert.id)); + history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`); + } + }, + onError: handleError, + }, + ); + + const handleAlertDuplicate = (): void => { + const args = { + data: { ...alertDetails, alert: alertDetails.alert?.concat(' - Copy') }, + }; + duplicateAlert(args); + }; + + return { handleAlertDuplicate }; +}; + +export const useAlertRuleDelete = ({ + ruleId, +}: { + ruleId: number; +}): { + handleAlertDelete: () => void; +} => { + const { notifications } = useNotifications(); + const handleError = useAxiosError(); + + const { mutate: deleteAlert } = useMutation( + [REACT_QUERY_KEY.REMOVE_ALERT_RULE, ruleId], + deleteAlerts, + { + onSuccess: async () => { + notifications.success({ + message: `Success`, + }); + + history.push(ROUTES.LIST_ALL_ALERT); + }, + onError: handleError, + }, + ); + + const handleAlertDelete = (): void => { + const args = { id: ruleId }; + deleteAlert(args); + }; + + return { handleAlertDelete }; +}; + +type GetAlertRuleDetailsTimelineGraphProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsTimelineGraphData = (): GetAlertRuleDetailsTimelineGraphProps => { + const { ruleId, startTime, endTime } = useAlertHistoryQueryParams(); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + const hasStartAndEnd = startTime !== null && endTime !== null; + + const { isLoading, isRefetching, isError, data } = useQuery( + [REACT_QUERY_KEY.ALERT_RULE_TIMELINE_GRAPH, ruleId, startTime, endTime], + { + queryFn: () => + timelineGraph({ + id: parseInt(ruleId || '', 10), + start: startTime, + end: endTime, + }), + enabled: isValidRuleId && hasStartAndEnd, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; +}; diff --git a/frontend/src/pages/AlertDetails/index.tsx b/frontend/src/pages/AlertDetails/index.tsx new file mode 100644 index 0000000000..aa6eb0b819 --- /dev/null +++ b/frontend/src/pages/AlertDetails/index.tsx @@ -0,0 +1,3 @@ +import AlertDetails from './AlertDetails'; + +export default AlertDetails; diff --git a/frontend/src/pages/AlertDetails/types.ts b/frontend/src/pages/AlertDetails/types.ts new file mode 100644 index 0000000000..f68fa9c512 --- /dev/null +++ b/frontend/src/pages/AlertDetails/types.ts @@ -0,0 +1,6 @@ +export type AlertDetailsStatusRendererProps = { + isLoading: boolean; + isError: boolean; + isRefetching: boolean; + data: any; +}; diff --git a/frontend/src/pages/AlertHistory/index.tsx b/frontend/src/pages/AlertHistory/index.tsx new file mode 100644 index 0000000000..7a7b0d01d8 --- /dev/null +++ b/frontend/src/pages/AlertHistory/index.tsx @@ -0,0 +1,3 @@ +import AlertHistory from 'container/AlertHistory'; + +export default AlertHistory; diff --git a/frontend/src/pages/AlertList/index.tsx b/frontend/src/pages/AlertList/index.tsx index 1bf3d9a6ea..19d746e8f0 100644 --- a/frontend/src/pages/AlertList/index.tsx +++ b/frontend/src/pages/AlertList/index.tsx @@ -1,10 +1,14 @@ import { Tabs } from 'antd'; import { TabsProps } from 'antd/lib'; +import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon'; +import ROUTES from 'constants/routes'; import AllAlertRules from 'container/ListAlertRules'; import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime'; import TriggeredAlerts from 'container/TriggeredAlerts'; import useUrlQuery from 'hooks/useUrlQuery'; import history from 'lib/history'; +import { GalleryVerticalEnd, Pyramid } from 'lucide-react'; +import AlertDetails from 'pages/AlertDetails'; import { useLocation } from 'react-router-dom'; function AllAlertList(): JSX.Element { @@ -12,15 +16,40 @@ function AllAlertList(): JSX.Element { const location = useLocation(); const tab = urlQuery.get('tab'); + const isAlertHistory = location.pathname === ROUTES.ALERT_HISTORY; + const isAlertOverview = location.pathname === ROUTES.ALERT_OVERVIEW; + + const search = urlQuery.get('search'); + const items: TabsProps['items'] = [ - { label: 'Alert Rules', key: 'AlertRules', children: }, { - label: 'Triggered Alerts', + label: ( +
+ + Triggered Alerts +
+ ), key: 'TriggeredAlerts', children: , }, { - label: 'Configuration', + label: ( +
+ + Alert Rules +
+ ), + key: 'AlertRules', + children: + isAlertHistory || isAlertOverview ? : , + }, + { + label: ( +
+ + Configuration +
+ ), key: 'Configuration', children: , }, @@ -33,8 +62,16 @@ function AllAlertList(): JSX.Element { activeKey={tab || 'AlertRules'} onChange={(tab): void => { urlQuery.set('tab', tab); - history.replace(`${location.pathname}?${urlQuery.toString()}`); + let params = `tab=${tab}`; + + if (search) { + params += `&search=${search}`; + } + history.replace(`/alerts?${params}`); }} + className={`${ + isAlertHistory || isAlertOverview ? 'alert-details-tabs' : '' + }`} /> ); } diff --git a/frontend/src/pages/EditRules/EditRules.styles.scss b/frontend/src/pages/EditRules/EditRules.styles.scss index 412cddd1ad..a01a6e7ab7 100644 --- a/frontend/src/pages/EditRules/EditRules.styles.scss +++ b/frontend/src/pages/EditRules/EditRules.styles.scss @@ -1,32 +1,33 @@ .edit-rules-container { - display: flex; - justify-content: center; - align-items: center; - margin-top: 5rem; + padding: 0 16px; + &--error { + display: flex; + justify-content: center; + align-items: center; + margin-top: 5rem; + } } - .edit-rules-card { - width: 20rem; - padding: 1rem; + width: 20rem; + padding: 1rem; } .content { - font-style: normal; + font-style: normal; font-weight: 300; font-size: 18px; line-height: 20px; display: flex; align-items: center; - justify-content: center; - text-align: center; + justify-content: center; + text-align: center; margin: 0; } .btn-container { - display: flex; - justify-content: center; - align-items: center; - margin-top: 2rem; + display: flex; + justify-content: center; + align-items: center; + margin-top: 2rem; } - diff --git a/frontend/src/pages/EditRules/index.tsx b/frontend/src/pages/EditRules/index.tsx index cccfc6aee2..372a8a199e 100644 --- a/frontend/src/pages/EditRules/index.tsx +++ b/frontend/src/pages/EditRules/index.tsx @@ -4,6 +4,7 @@ import { Button, Card } from 'antd'; import get from 'api/alerts/get'; import Spinner from 'components/Spinner'; import { QueryParams } from 'constants/query'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import ROUTES from 'constants/routes'; import EditRulesContainer from 'container/EditRules'; import { useNotifications } from 'hooks/useNotifications'; @@ -21,19 +22,21 @@ import { function EditRules(): JSX.Element { const params = useUrlQuery(); - const ruleId = params.get('ruleId'); + const ruleId = params.get(QueryParams.ruleId); const { t } = useTranslation('common'); const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; const { isLoading, data, isRefetching, isError } = useQuery( - ['ruleId', ruleId], + [REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId], { queryFn: () => get({ id: parseInt(ruleId || '', 10), }), enabled: isValidRuleId, + refetchOnMount: false, + refetchOnWindowFocus: false, }, ); @@ -62,7 +65,7 @@ function EditRules(): JSX.Element { (data?.payload?.data === undefined && !isLoading) ) { return ( -
+

{data?.message === errorMessageReceivedFromBackend @@ -84,10 +87,12 @@ function EditRules(): JSX.Element { } return ( - +

+ +
); } diff --git a/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.styles.scss b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.styles.scss new file mode 100644 index 0000000000..7a55632ae6 --- /dev/null +++ b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.styles.scss @@ -0,0 +1,39 @@ +.copy-to-clipboard { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + padding: 4px 6px; + width: 100px; + + &:hover { + background-color: transparent !important; + } + + .ant-btn-icon { + margin: 0 !important; + } + & > * { + color: var(--text-vanilla-400); + font-weight: 400; + line-height: 20px; + letter-spacing: -0.07px; + } + + &--success { + & span, + &:hover { + color: var(--bg-forest-400); + } + } +} + +.lightMode { + .copy-to-clipboard { + &:not(&--success) { + & > * { + color: var(--text-ink-400); + } + } + } +} diff --git a/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.tsx b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.tsx new file mode 100644 index 0000000000..598f6e5a3f --- /dev/null +++ b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.tsx @@ -0,0 +1,54 @@ +import './CopyToClipboard.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { CircleCheck, Link2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useCopyToClipboard } from 'react-use'; + +function CopyToClipboard({ textToCopy }: { textToCopy: string }): JSX.Element { + const [state, copyToClipboard] = useCopyToClipboard(); + const [success, setSuccess] = useState(false); + const isDarkMode = useIsDarkMode(); + + useEffect(() => { + let timer: string | number | NodeJS.Timeout | undefined; + if (state.value) { + setSuccess(true); + timer = setTimeout(() => setSuccess(false), 1000); + } + + return (): void => clearTimeout(timer); + }, [state]); + + if (success) { + return ( + + ); + } + + return ( + + ); +} + +export default CopyToClipboard; diff --git a/frontend/src/periscope/components/CopyToClipboard/index.tsx b/frontend/src/periscope/components/CopyToClipboard/index.tsx new file mode 100644 index 0000000000..7b6b62c1b5 --- /dev/null +++ b/frontend/src/periscope/components/CopyToClipboard/index.tsx @@ -0,0 +1,3 @@ +import CopyToClipboard from './CopyToClipboard'; + +export default CopyToClipboard; diff --git a/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx b/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx new file mode 100644 index 0000000000..7d6c6eb5a1 --- /dev/null +++ b/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx @@ -0,0 +1,46 @@ +import Spinner from 'components/Spinner'; +import { useTranslation } from 'react-i18next'; + +interface DataStateRendererProps { + isLoading: boolean; + isRefetching: boolean; + isError: boolean; + data: T | null; + errorMessage?: string; + loadingMessage?: string; + children: (data: T) => React.ReactNode; +} + +/** + * TODO(shaheer): add empty state and optionally accept empty state custom component + * TODO(shaheer): optionally accept custom error state component + * TODO(shaheer): optionally accept custom loading state component + */ +function DataStateRenderer({ + isLoading, + isRefetching, + isError, + data, + errorMessage, + loadingMessage, + children, +}: DataStateRendererProps): JSX.Element { + const { t } = useTranslation('common'); + + if (isLoading || isRefetching || !data) { + return ; + } + + if (isError || data === null) { + return
{errorMessage ?? t('something_went_wrong')}
; + } + + return <>{children(data)}; +} + +DataStateRenderer.defaultProps = { + errorMessage: '', + loadingMessage: 'Loading...', +}; + +export default DataStateRenderer; diff --git a/frontend/src/periscope/components/DataStateRenderer/index.tsx b/frontend/src/periscope/components/DataStateRenderer/index.tsx new file mode 100644 index 0000000000..e4afdfa3bd --- /dev/null +++ b/frontend/src/periscope/components/DataStateRenderer/index.tsx @@ -0,0 +1,3 @@ +import DataStateRenderer from './DataStateRenderer'; + +export default DataStateRenderer; diff --git a/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.styles.scss b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.styles.scss new file mode 100644 index 0000000000..88ae57f4e8 --- /dev/null +++ b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.styles.scss @@ -0,0 +1,37 @@ +.key-value-label { + display: flex; + align-items: center; + border: 1px solid var(--bg-slate-400); + border-radius: 2px; + flex-wrap: wrap; + + &__key, + &__value { + padding: 1px 6px; + font-size: 14px; + font-weight: 400; + line-height: 18px; + letter-spacing: -0.005em; + } + &__key { + background: var(--bg-ink-400); + border-radius: 2px 0 0 2px; + } + &__value { + background: var(--bg-slate-400); + } + color: var(--text-vanilla-400); +} + +.lightMode { + .key-value-label { + border-color: var(--bg-vanilla-400); + color: var(--text-ink-400); + &__key { + background: var(--bg-vanilla-300); + } + &__value { + background: var(--bg-vanilla-200); + } + } +} diff --git a/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx new file mode 100644 index 0000000000..aa14dd6380 --- /dev/null +++ b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx @@ -0,0 +1,18 @@ +import './KeyValueLabel.styles.scss'; + +type KeyValueLabelProps = { badgeKey: string; badgeValue: string }; + +export default function KeyValueLabel({ + badgeKey, + badgeValue, +}: KeyValueLabelProps): JSX.Element | null { + if (!badgeKey || !badgeValue) { + return null; + } + return ( +
+
{badgeKey}
+
{badgeValue}
+
+ ); +} diff --git a/frontend/src/periscope/components/KeyValueLabel/index.tsx b/frontend/src/periscope/components/KeyValueLabel/index.tsx new file mode 100644 index 0000000000..7341e057e8 --- /dev/null +++ b/frontend/src/periscope/components/KeyValueLabel/index.tsx @@ -0,0 +1,3 @@ +import KeyValueLabel from './KeyValueLabel'; + +export default KeyValueLabel; diff --git a/frontend/src/periscope/components/PaginationInfoText/PaginationInfoText.tsx b/frontend/src/periscope/components/PaginationInfoText/PaginationInfoText.tsx new file mode 100644 index 0000000000..205e1d3db8 --- /dev/null +++ b/frontend/src/periscope/components/PaginationInfoText/PaginationInfoText.tsx @@ -0,0 +1,24 @@ +import { Typography } from 'antd'; + +function PaginationInfoText( + total: number, + [start, end]: number[], +): JSX.Element { + return ( + + + {start} — {end} + + of {total} + + ); +} + +export default PaginationInfoText; diff --git a/frontend/src/periscope/components/SeeMore/SeeMore.styles.scss b/frontend/src/periscope/components/SeeMore/SeeMore.styles.scss new file mode 100644 index 0000000000..002b04294b --- /dev/null +++ b/frontend/src/periscope/components/SeeMore/SeeMore.styles.scss @@ -0,0 +1,26 @@ +.see-more-button { + background: none; + padding: 2px; + font-size: 14px; + line-height: 18px; + letter-spacing: -0.005em; + color: var(--text-vanilla-400); + border: none; + cursor: pointer; +} + +.see-more-popover-content { + display: flex; + gap: 6px; + flex-wrap: wrap; + width: 300px; +} + +.lightMode { + .see-more-button { + color: var(--text-ink-400); + } + .see-more-popover-content { + background: var(--bg-vanilla-100); + } +} diff --git a/frontend/src/periscope/components/SeeMore/SeeMore.tsx b/frontend/src/periscope/components/SeeMore/SeeMore.tsx new file mode 100644 index 0000000000..f94da8a564 --- /dev/null +++ b/frontend/src/periscope/components/SeeMore/SeeMore.tsx @@ -0,0 +1,48 @@ +import './SeeMore.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Popover } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; + +type SeeMoreProps = { + children: JSX.Element[]; + initialCount?: number; + moreLabel: string; +}; + +function SeeMore({ + children, + initialCount = 2, + moreLabel, +}: SeeMoreProps): JSX.Element { + const remainingCount = children.length - initialCount; + const isDarkMode = useIsDarkMode(); + + return ( + <> + {children.slice(0, initialCount)} + {remainingCount > 0 && ( + + {children.slice(initialCount)} +
+ } + > + + + )} + + ); +} + +SeeMore.defaultProps = { + initialCount: 2, +}; + +export default SeeMore; diff --git a/frontend/src/periscope/components/SeeMore/index.tsx b/frontend/src/periscope/components/SeeMore/index.tsx new file mode 100644 index 0000000000..9ee14a54c9 --- /dev/null +++ b/frontend/src/periscope/components/SeeMore/index.tsx @@ -0,0 +1,3 @@ +import SeeMore from './SeeMore'; + +export default SeeMore; diff --git a/frontend/src/periscope/components/Tabs2/Tabs2.styles.scss b/frontend/src/periscope/components/Tabs2/Tabs2.styles.scss new file mode 100644 index 0000000000..59b5156cdd --- /dev/null +++ b/frontend/src/periscope/components/Tabs2/Tabs2.styles.scss @@ -0,0 +1,48 @@ +.tabs-wrapper { + display: flex; + align-items: center; + gap: 12px; + + .tab { + &.ant-btn-default { + box-shadow: none; + display: flex; + align-items: center; + gap: 10px; + color: var(--text-vanilla-400); + background: var(--bg-ink-400); + font-size: 14px; + line-height: 20px; + letter-spacing: -0.07px; + padding: 6px 24px; + border-color: var(--bg-slate-400); + justify-content: center; + } + &.reset-button { + .ant-btn-icon { + margin: 0; + } + padding: 6px 12px; + } + &.selected { + color: var(--text-vanilla-100); + background: var(--bg-slate-400); + } + } +} + +.lightMode { + .tabs-wrapper { + .tab { + &.ant-btn-default { + color: var(--text-ink-400); + background: var(--bg-vanilla-300); + border-color: var(--bg-vanilla-300); + } + &.selected { + color: var(--text-robin-500); + background: var(--bg-vanilla-100); + } + } + } +} diff --git a/frontend/src/periscope/components/Tabs2/Tabs2.tsx b/frontend/src/periscope/components/Tabs2/Tabs2.tsx new file mode 100644 index 0000000000..051d80365e --- /dev/null +++ b/frontend/src/periscope/components/Tabs2/Tabs2.tsx @@ -0,0 +1,80 @@ +import './Tabs2.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button } from 'antd'; +import { TimelineFilter } from 'container/AlertHistory/types'; +import { Undo } from 'lucide-react'; +import { useState } from 'react'; + +interface Tab { + value: string; + label: string | JSX.Element; + disabled?: boolean; + icon?: string | JSX.Element; +} + +interface TimelineTabsProps { + tabs: Tab[]; + onSelectTab?: (selectedTab: TimelineFilter) => void; + initialSelectedTab?: string; + hasResetButton?: boolean; + buttonMinWidth?: string; +} + +function Tabs2({ + tabs, + onSelectTab, + initialSelectedTab, + hasResetButton, + buttonMinWidth = '114px', +}: TimelineTabsProps): JSX.Element { + const [selectedTab, setSelectedTab] = useState( + initialSelectedTab || tabs[0].value, + ); + + const handleTabClick = (tabValue: string): void => { + setSelectedTab(tabValue); + if (onSelectTab) { + onSelectTab(tabValue as TimelineFilter); + } + }; + + return ( +
+ {hasResetButton && selectedTab !== tabs[0].value && ( + + )} + + {tabs.map((tab) => ( + + ))} + +
+ ); +} + +Tabs2.defaultProps = { + initialSelectedTab: '', + onSelectTab: (): void => {}, + hasResetButton: false, + buttonMinWidth: '114px', +}; + +export default Tabs2; diff --git a/frontend/src/periscope/components/Tabs2/index.tsx b/frontend/src/periscope/components/Tabs2/index.tsx new file mode 100644 index 0000000000..0338314a3a --- /dev/null +++ b/frontend/src/periscope/components/Tabs2/index.tsx @@ -0,0 +1,3 @@ +import Tabs2 from './Tabs2'; + +export default Tabs2; diff --git a/frontend/src/providers/Alert.tsx b/frontend/src/providers/Alert.tsx new file mode 100644 index 0000000000..337eec9ba5 --- /dev/null +++ b/frontend/src/providers/Alert.tsx @@ -0,0 +1,43 @@ +import React, { createContext, useContext, useState } from 'react'; + +interface AlertRuleContextType { + isAlertRuleDisabled: boolean | undefined; + setIsAlertRuleDisabled: React.Dispatch< + React.SetStateAction + >; +} + +const AlertRuleContext = createContext( + undefined, +); + +function AlertRuleProvider({ + children, +}: { + children: React.ReactNode; +}): JSX.Element { + const [isAlertRuleDisabled, setIsAlertRuleDisabled] = useState< + boolean | undefined + >(undefined); + + const value = React.useMemo( + () => ({ isAlertRuleDisabled, setIsAlertRuleDisabled }), + [isAlertRuleDisabled], + ); + + return ( + + {children} + + ); +} + +export const useAlertRule = (): AlertRuleContextType => { + const context = useContext(AlertRuleContext); + if (context === undefined) { + throw new Error('useAlertRule must be used within an AlertRuleProvider'); + } + return context; +}; + +export default AlertRuleProvider; diff --git a/frontend/src/types/api/alerts/def.ts b/frontend/src/types/api/alerts/def.ts index c773cb78a2..9393ccd5a0 100644 --- a/frontend/src/types/api/alerts/def.ts +++ b/frontend/src/types/api/alerts/def.ts @@ -38,7 +38,71 @@ export interface RuleCondition { alertOnAbsent?: boolean | undefined; absentFor?: number | undefined; } - export interface Labels { [key: string]: string; } + +export interface AlertRuleStats { + totalCurrentTriggers: number; + totalPastTriggers: number; + currentTriggersSeries: CurrentTriggersSeries; + pastTriggersSeries: CurrentTriggersSeries | null; + currentAvgResolutionTime: number; + pastAvgResolutionTime: number; + currentAvgResolutionTimeSeries: CurrentTriggersSeries; + pastAvgResolutionTimeSeries: any | null; +} + +interface CurrentTriggersSeries { + labels: Labels; + labelsArray: any | null; + values: StatsTimeSeriesItem[]; +} + +export interface StatsTimeSeriesItem { + timestamp: number; + value: string; +} + +export type AlertRuleStatsPayload = { + data: AlertRuleStats; +}; + +export interface AlertRuleTopContributors { + fingerprint: number; + labels: Labels; + count: number; + relatedLogsLink: string; + relatedTracesLink: string; +} +export type AlertRuleTopContributorsPayload = { + data: AlertRuleTopContributors[]; +}; + +export interface AlertRuleTimelineTableResponse { + ruleID: string; + ruleName: string; + overallState: string; + overallStateChanged: boolean; + state: string; + stateChanged: boolean; + unixMilli: number; + labels: Labels; + fingerprint: number; + value: number; + relatedTracesLink: string; + relatedLogsLink: string; +} +export type AlertRuleTimelineTableResponsePayload = { + data: { items: AlertRuleTimelineTableResponse[]; total: number }; +}; +type AlertState = 'firing' | 'normal' | 'no-data' | 'muted'; + +export interface AlertRuleTimelineGraphResponse { + start: number; + end: number; + state: AlertState; +} +export type AlertRuleTimelineGraphResponsePayload = { + data: AlertRuleTimelineGraphResponse[]; +}; diff --git a/frontend/src/types/api/alerts/ruleStats.ts b/frontend/src/types/api/alerts/ruleStats.ts new file mode 100644 index 0000000000..2669a4c6be --- /dev/null +++ b/frontend/src/types/api/alerts/ruleStats.ts @@ -0,0 +1,7 @@ +import { AlertDef } from './def'; + +export interface RuleStatsProps { + id: AlertDef['id']; + start: number; + end: number; +} diff --git a/frontend/src/types/api/alerts/timelineGraph.ts b/frontend/src/types/api/alerts/timelineGraph.ts new file mode 100644 index 0000000000..99e9601f1e --- /dev/null +++ b/frontend/src/types/api/alerts/timelineGraph.ts @@ -0,0 +1,7 @@ +import { AlertDef } from './def'; + +export interface GetTimelineGraphRequestProps { + id: AlertDef['id']; + start: number; + end: number; +} diff --git a/frontend/src/types/api/alerts/timelineTable.ts b/frontend/src/types/api/alerts/timelineTable.ts new file mode 100644 index 0000000000..b2e27a4d1c --- /dev/null +++ b/frontend/src/types/api/alerts/timelineTable.ts @@ -0,0 +1,13 @@ +import { TagFilter } from '../queryBuilder/queryBuilderData'; +import { AlertDef } from './def'; + +export interface GetTimelineTableRequestProps { + id: AlertDef['id']; + start: number; + end: number; + offset: number; + limit: number; + order: string; + filters?: TagFilter; + state?: string; +} diff --git a/frontend/src/types/api/alerts/topContributors.ts b/frontend/src/types/api/alerts/topContributors.ts new file mode 100644 index 0000000000..74acb4b871 --- /dev/null +++ b/frontend/src/types/api/alerts/topContributors.ts @@ -0,0 +1,7 @@ +import { AlertDef } from './def'; + +export interface TopContributorsProps { + id: AlertDef['id']; + start: number; + end: number; +} diff --git a/frontend/src/utils/calculateChange.ts b/frontend/src/utils/calculateChange.ts new file mode 100644 index 0000000000..4e3d912f0d --- /dev/null +++ b/frontend/src/utils/calculateChange.ts @@ -0,0 +1,31 @@ +export function calculateChange( + totalCurrentTriggers: number | undefined, + totalPastTriggers: number | undefined, +): { changePercentage: number; changeDirection: number } { + if ( + totalCurrentTriggers === undefined || + totalPastTriggers === undefined || + [0, '0'].includes(totalPastTriggers) + ) { + return { changePercentage: 0, changeDirection: 0 }; + } + + let changePercentage = + ((totalCurrentTriggers - totalPastTriggers) / totalPastTriggers) * 100; + + let changeDirection = 0; + + if (changePercentage < 0) { + changeDirection = -1; + } else if (changePercentage > 0) { + changeDirection = 1; + } + + changePercentage = Math.abs(changePercentage); + changePercentage = Math.round(changePercentage); + + return { + changePercentage, + changeDirection, + }; +} diff --git a/frontend/src/utils/permission/index.ts b/frontend/src/utils/permission/index.ts index 1845e77941..8a35121f57 100644 --- a/frontend/src/utils/permission/index.ts +++ b/frontend/src/utils/permission/index.ts @@ -64,6 +64,8 @@ export const routePermission: Record = { ERROR_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'], HOME_PAGE: ['ADMIN', 'EDITOR', 'VIEWER'], LIST_ALL_ALERT: ['ADMIN', 'EDITOR', 'VIEWER'], + ALERT_HISTORY: ['ADMIN', 'EDITOR', 'VIEWER'], + ALERT_OVERVIEW: ['ADMIN'], LOGIN: ['ADMIN', 'EDITOR', 'VIEWER'], NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR'], PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'], diff --git a/frontend/src/utils/timeUtils.ts b/frontend/src/utils/timeUtils.ts index 277c0c04af..5eb795bf45 100644 --- a/frontend/src/utils/timeUtils.ts +++ b/frontend/src/utils/timeUtils.ts @@ -1,8 +1,11 @@ import dayjs from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; +import duration from 'dayjs/plugin/duration'; dayjs.extend(customParseFormat); +dayjs.extend(duration); + export function toUTCEpoch(time: number): number { const x = new Date(); return time + x.getTimezoneOffset() * 60 * 1000; @@ -28,3 +31,97 @@ export const getRemainingDays = (billingEndDate: number): number => { return Math.ceil(timeDifference / (1000 * 60 * 60 * 24)); }; + +/** + * Calculates the duration from the given epoch timestamp to the current time. + * + * + * @param {number} epochTimestamp + * @returns {string} - human readable string representing the duration from the given epoch timestamp to the current time e.g. "3d 14h" + */ +export const getDurationFromNow = (epochTimestamp: number): string => { + const now = dayjs(); + const inputTime = dayjs(epochTimestamp); + const duration = dayjs.duration(now.diff(inputTime)); + + const days = duration.days(); + const hours = duration.hours(); + const minutes = duration.minutes(); + const seconds = duration.seconds(); + + let result = ''; + if (days > 0) result += `${days}d `; + if (hours > 0) result += `${hours}h `; + if (minutes > 0) result += `${minutes}m `; + if (seconds > 0) result += `${seconds}s`; + + return result.trim(); +}; + +/** + * Formats an epoch timestamp into a human-readable date and time string. + * + * @param {number} epoch - The epoch timestamp to format. + * @returns {string} - The formatted date and time string in the format "MMM D, YYYY ⎯ HH:MM:SS". + */ +export function formatEpochTimestamp(epoch: number): string { + const date = new Date(epoch); + + const optionsDate: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + }; + + const optionsTime: Intl.DateTimeFormatOptions = { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }; + + const formattedDate = date.toLocaleDateString('en-US', optionsDate); + const formattedTime = date.toLocaleTimeString('en-US', optionsTime); + + return `${formattedDate} ⎯ ${formattedTime}`; +} + +/** + * Converts a given number of seconds into a human-readable format. + * @param {number} seconds The number of seconds to convert. + * @returns {string} The formatted time string, either in days (e.g., "1.2d"), hours (e.g., "1.2h"), minutes (e.g., "~7m"), or seconds (e.g., "~45s"). + */ + +export function formatTime(seconds: number): string { + const days = seconds / 86400; + + if (days >= 1) { + return `${days.toFixed(1)}d`; + } + + const hours = seconds / 3600; + if (hours >= 1) { + return `${hours.toFixed(1)}h`; + } + + const minutes = seconds / 60; + if (minutes >= 1) { + return `${minutes.toFixed(1)}m`; + } + + return `${seconds.toFixed(1)}s`; +} + +export const nanoToMilli = (nanoseconds: number): number => + nanoseconds / 1_000_000; + +export const epochToTimeString = (epochMs: number): string => { + console.log({ epochMs }); + const date = new Date(epochMs); + const options: Intl.DateTimeFormatOptions = { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }; + return date.toLocaleTimeString('en-US', options); +}; From 5942c758f0ed79299513ea59221bacc7e780926e Mon Sep 17 00:00:00 2001 From: Vishal Sharma Date: Thu, 5 Sep 2024 17:00:18 +0530 Subject: [PATCH 05/18] fix: broken links (#5867) * fix: broken links * Update eks-monitorUsingDashboard.md * Update eks-monitorUsingDashboard.md --------- Co-authored-by: CheetoDa <31571545+Calm-Rock@users.noreply.github.com> --- .../Modules/AwsMonitoring/EKS/eks-monitorUsingDashboard.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EKS/eks-monitorUsingDashboard.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EKS/eks-monitorUsingDashboard.md index 77bd5cb87c..bbdba36523 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EKS/eks-monitorUsingDashboard.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EKS/eks-monitorUsingDashboard.md @@ -1,9 +1,8 @@ ## Monitor using Dashboards -To visualize the Kubernetes Metrics, you can use one of the following pre-built Dashboards: +To visualize the Kubernetes Metrics, you can use following pre-built Dashboards: -- [K8s Node-Level Metrics](https://github.com/SigNoz/dashboards/blob/main/k8s-node-%26-pod-metrics/k8s-node-level-metrics.json) -- [K8s Pod_level Metrics](https://github.com/SigNoz/dashboards/blob/main/k8s-node-%26-pod-metrics/k8s-pod-level-metrics.json) +- [K8s Infra Metrics](https://github.com/SigNoz/dashboards/tree/main/k8s-infra-metrics) You should copy the JSON data in these files and create a New Dashboard in the Dashboard Tab of SigNoz. @@ -13,4 +12,4 @@ By following the previous step, you should also be able to see Kubernetes Pod lo   -To send traces for your application deployed on your Kubernetes cluster, checkout the Application monitoring section of onboarding. \ No newline at end of file +To send traces for your application deployed on your Kubernetes cluster, checkout the Application monitoring section of onboarding. From ba95ca682b12eb86b482fdfa12033f131804396f Mon Sep 17 00:00:00 2001 From: Vishal Sharma Date: Thu, 5 Sep 2024 17:00:33 +0530 Subject: [PATCH 06/18] chore: update posthog-js to 1.160.3 (#5869) --- frontend/package.json | 2 +- frontend/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 34e08ea263..26ffd31d5c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -88,7 +88,7 @@ "lucide-react": "0.379.0", "mini-css-extract-plugin": "2.4.5", "papaparse": "5.4.1", - "posthog-js": "1.142.1", + "posthog-js": "1.160.3", "rc-tween-one": "3.0.6", "react": "18.2.0", "react-addons-update": "15.6.3", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index d12754da95..a9186bea9a 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -13715,10 +13715,10 @@ postcss@8.4.38, postcss@^8.0.0, postcss@^8.1.1, postcss@^8.3.7, postcss@^8.4.21, picocolors "^1.0.0" source-map-js "^1.2.0" -posthog-js@1.142.1: - version "1.142.1" - resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.142.1.tgz#3b91229732938c5c76b5ee6d410698a267e073e9" - integrity sha512-yqeWTWitlb0sCaH5v6s7UJ+pPspzf/lkzPaSE5pMMXRM2i2KNsMoZEAZqbPCW8fQ8QL6lHs6d8PLjHrvbR288w== +posthog-js@1.160.3: + version "1.160.3" + resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.160.3.tgz#17c8af4c9ffa2d795d925ca1e7146e61cd5ccabd" + integrity sha512-mGvxOIlWPtdPx8EI0MQ81wNKlnH2K0n4RqwQOl044b34BCKiFVzZ7Hc7geMuZNaRAvCi5/5zyGeWHcAYZQxiMQ== dependencies: fflate "^0.4.8" preact "^10.19.3" From 4a9847abdd4cc02d0bac89c1215200203a2133d9 Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Fri, 6 Sep 2024 10:24:47 +0530 Subject: [PATCH 07/18] feat: implement quick filters for the new logs explorer page (#5799) * feat: logs quick filter * feat: added open button in the closed state * fix: build issues * chore: minor css * feat: handle changes for last used query,states and reset * feat: refactor some code * feat: handle on change functionality * fix: handle only and all * chore: handle empty edge cases * feat: added necessary tooltips * feat: use tag instead of tooltip icon * feat: handle light mode designs * feat: added correct facets * feat: added resize observer for the graph resize * chore: added local storage state for the toggle * chore: make refresh text configurable * feat: added environment and fix build * feat: handle the cases for = and != operators * feat: design changes and zoom out * feat: minor css issue * fix: light mode designs * fix: handle the case for state initialization * fix: onDelete query the last used index should be set to 0 --- .../Checkbox/Checkbox.styles.scss | 145 +++++ .../FilterRenderers/Checkbox/Checkbox.tsx | 503 ++++++++++++++++++ .../FilterRenderers/Slider/Slider.styles.scss | 0 .../FilterRenderers/Slider/Slider.tsx | 14 + .../QuickFilters/QuickFilters.styles.scss | 93 ++++ .../components/QuickFilters/QuickFilters.tsx | 124 +++++ frontend/src/constants/localStorage.ts | 1 + .../QueryBuilder/QueryBuilder.styles.scss | 6 + .../container/QueryBuilder/QueryBuilder.tsx | 17 +- .../QBEntityOptions.styles.scss | 6 + .../QBEntityOptions/QBEntityOptions.tsx | 13 + .../QueryBuilder/components/Query/Query.tsx | 1 + .../ToolbarActions/LeftToolbarActions.tsx | 12 + .../ToolbarActions/ToolbarActions.styles.scss | 11 + .../tests/ToolbarActions.test.tsx | 4 + .../TimeSeriesView/TimeSeriesView.tsx | 14 +- frontend/src/container/Toolbar/Toolbar.tsx | 13 +- .../TopNav/DateTimeSelectionV2/index.tsx | 5 +- .../queryBuilder/useQueryBuilderOperations.ts | 9 +- .../LogsExplorer/LogsExplorer.styles.scss | 46 +- .../__tests__/LogsExplorer.test.tsx | 2 + frontend/src/pages/LogsExplorer/index.tsx | 117 ++-- frontend/src/pages/LogsExplorer/utils.ts | 19 - frontend/src/pages/LogsExplorer/utils.tsx | 113 ++++ .../__test__/TracesExplorer.test.tsx | 8 + frontend/src/providers/QueryBuilder.tsx | 8 + frontend/src/types/common/queryBuilder.ts | 2 + 27 files changed, 1221 insertions(+), 85 deletions(-) create mode 100644 frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss create mode 100644 frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx create mode 100644 frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.styles.scss create mode 100644 frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx create mode 100644 frontend/src/components/QuickFilters/QuickFilters.styles.scss create mode 100644 frontend/src/components/QuickFilters/QuickFilters.tsx delete mode 100644 frontend/src/pages/LogsExplorer/utils.ts create mode 100644 frontend/src/pages/LogsExplorer/utils.tsx diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss new file mode 100644 index 0000000000..c46d9975f4 --- /dev/null +++ b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss @@ -0,0 +1,145 @@ +.checkbox-filter { + display: flex; + flex-direction: column; + padding: 12px; + gap: 12px; + border-bottom: 1px solid var(--bg-slate-400); + .filter-header-checkbox { + display: flex; + align-items: center; + justify-content: space-between; + + .left-action { + display: flex; + align-items: center; + gap: 6px; + + .title { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; + letter-spacing: -0.07px; + text-transform: capitalize; + } + } + + .right-action { + display: flex; + align-items: center; + + .clear-all { + font-size: 12px; + color: var(--bg-robin-500); + cursor: pointer; + } + } + } + + .values { + display: flex; + flex-direction: column; + gap: 8px; + + .value { + display: flex; + align-items: center; + gap: 8px; + + .checkbox-value-section { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + cursor: pointer; + + &.filter-disabled { + cursor: not-allowed; + + .value-string { + color: var(--bg-slate-200); + } + + .only-btn { + cursor: not-allowed; + color: var(--bg-slate-200); + } + + .toggle-btn { + cursor: not-allowed; + color: var(--bg-slate-200); + } + } + + .value-string { + } + + .only-btn { + display: none; + } + .toggle-btn { + display: none; + } + + .toggle-btn:hover { + background-color: unset; + } + + .only-btn:hover { + background-color: unset; + } + } + + .checkbox-value-section:hover { + .toggle-btn { + display: none; + } + .only-btn { + display: flex; + align-items: center; + justify-content: center; + height: 21px; + } + } + } + + .value:hover { + .toggle-btn { + display: flex; + align-items: center; + justify-content: center; + height: 21px; + } + } + } + + .no-data { + align-self: center; + } + + .show-more { + display: flex; + align-items: center; + justify-content: center; + + .show-more-text { + color: var(--bg-robin-500); + cursor: pointer; + } + } +} + +.lightMode { + .checkbox-filter { + border-bottom: 1px solid var(--bg-vanilla-300); + .filter-header-checkbox { + .left-action { + .title { + color: var(--bg-ink-400); + } + } + } + } +} diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx new file mode 100644 index 0000000000..fc9a71a7b1 --- /dev/null +++ b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx @@ -0,0 +1,503 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable sonarjs/no-identical-functions */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import './Checkbox.styles.scss'; + +import { Button, Checkbox, Input, Skeleton, Typography } from 'antd'; +import cx from 'classnames'; +import { IQuickFiltersConfig } from 'components/QuickFilters/QuickFilters'; +import { OPERATORS } from 'constants/queryBuilder'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { cloneDeep, isArray, isEmpty, isEqual } from 'lodash-es'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; +import { v4 as uuid } from 'uuid'; + +const SELECTED_OPERATORS = [OPERATORS['='], 'in']; +const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'nin']; + +function setDefaultValues( + values: string[], + trueOrFalse: boolean, +): Record { + const defaultState: Record = {}; + values.forEach((val) => { + defaultState[val] = trueOrFalse; + }); + return defaultState; +} +interface ICheckboxProps { + filter: IQuickFiltersConfig; +} + +export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { + const { filter } = props; + const [searchText, setSearchText] = useState(''); + const [isOpen, setIsOpen] = useState(filter.defaultOpen); + const [visibleItemsCount, setVisibleItemsCount] = useState(10); + + const { + lastUsedQuery, + currentQuery, + redirectWithQueryBuilderData, + } = useQueryBuilder(); + + const { data, isLoading } = useGetAggregateValues( + { + aggregateOperator: 'noop', + dataSource: DataSource.LOGS, + aggregateAttribute: '', + attributeKey: filter.attributeKey.key, + filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY, + tagType: filter.attributeKey.type || '', + searchText: searchText ?? '', + }, + { + enabled: isOpen, + keepPreviousData: true, + }, + ); + + const attributeValues: string[] = useMemo( + () => + ((Object.values(data?.payload || {}).find((el) => !!el) || + []) as string[]).filter((val) => !isEmpty(val)), + [data?.payload], + ); + const currentAttributeKeys = attributeValues.slice(0, visibleItemsCount); + + // derive the state of each filter key here in the renderer itself and keep it in sync with staged query + // also we need to keep a note of last focussed query. + // eslint-disable-next-line sonarjs/cognitive-complexity + const currentFilterState = useMemo(() => { + let filterState: Record = setDefaultValues( + attributeValues, + false, + ); + const filterSync = currentQuery?.builder.queryData?.[ + lastUsedQuery || 0 + ]?.filters?.items.find((item) => isEqual(item.key, filter.attributeKey)); + + if (filterSync) { + if (SELECTED_OPERATORS.includes(filterSync.op)) { + if (isArray(filterSync.value)) { + filterSync.value.forEach((val) => { + filterState[val] = true; + }); + } else if (typeof filterSync.value === 'string') { + filterState[filterSync.value] = true; + } else if (typeof filterSync.value === 'boolean') { + filterState[String(filterSync.value)] = true; + } else if (typeof filterSync.value === 'number') { + filterState[String(filterSync.value)] = true; + } + } else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) { + filterState = setDefaultValues(attributeValues, true); + if (isArray(filterSync.value)) { + filterSync.value.forEach((val) => { + filterState[val] = false; + }); + } else if (typeof filterSync.value === 'string') { + filterState[filterSync.value] = false; + } else if (typeof filterSync.value === 'boolean') { + filterState[String(filterSync.value)] = false; + } else if (typeof filterSync.value === 'number') { + filterState[String(filterSync.value)] = false; + } + } + } else { + filterState = setDefaultValues(attributeValues, true); + } + return filterState; + }, [ + attributeValues, + currentQuery?.builder.queryData, + filter.attributeKey, + lastUsedQuery, + ]); + + // disable the filter when there are multiple entries of the same attribute key present in the filter bar + const isFilterDisabled = useMemo( + () => + (currentQuery?.builder?.queryData?.[ + lastUsedQuery || 0 + ]?.filters?.items?.filter((item) => isEqual(item.key, filter.attributeKey)) + ?.length || 0) > 1, + + [currentQuery?.builder?.queryData, lastUsedQuery, filter.attributeKey], + ); + + // variable to check if the current filter has multiple values to its name in the key op value section + const isMultipleValuesTrueForTheKey = + Object.values(currentFilterState).filter((val) => val).length > 1; + + const handleClearFilterAttribute = (): void => { + const preparedQuery: Query = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: currentQuery.builder.queryData.map((item, idx) => ({ + ...item, + filters: { + ...item.filters, + items: + idx === lastUsedQuery + ? item.filters.items.filter( + (fil) => !isEqual(fil.key, filter.attributeKey), + ) + : [...item.filters.items], + }, + })), + }, + }; + redirectWithQueryBuilderData(preparedQuery); + }; + + const isSomeFilterPresentForCurrentAttribute = currentQuery.builder.queryData?.[ + lastUsedQuery || 0 + ]?.filters?.items?.some((item) => isEqual(item.key, filter.attributeKey)); + + const onChange = ( + value: string, + checked: boolean, + isOnlyOrAllClicked: boolean, + // eslint-disable-next-line sonarjs/cognitive-complexity + ): void => { + const query = cloneDeep(currentQuery.builder.queryData?.[lastUsedQuery || 0]); + + // if only or all are clicked we do not need to worry about anything just override whatever we have + // by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL. + if (isOnlyOrAllClicked && query?.filters?.items) { + const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute + ? currentFilterState[value] && !isMultipleValuesTrueForTheKey + ? 'All' + : 'Only' + : 'Only'; + query.filters.items = query.filters.items.filter( + (q) => !isEqual(q.key, filter.attributeKey), + ); + if (isOnlyOrAll === 'Only') { + const newFilterItem: TagFilterItem = { + id: uuid(), + op: getOperatorValue(OPERATORS.IN), + key: filter.attributeKey, + value, + }; + query.filters.items = [...query.filters.items, newFilterItem]; + } + } else if (query?.filters?.items) { + if ( + query.filters?.items?.some((item) => isEqual(item.key, filter.attributeKey)) + ) { + // if there is already a running filter for the current attribute key then + // we split the cases by which particular operator is present right now! + const currentFilter = query.filters?.items?.find((q) => + isEqual(q.key, filter.attributeKey), + ); + if (currentFilter) { + const runningOperator = currentFilter?.op; + switch (runningOperator) { + case 'in': + if (checked) { + // if it's an IN operator then if we are checking another value it get's added to the + // filter clause. example - key IN [value1, currentSelectedValue] + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: [...currentFilter.value, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else { + // if the current state wasn't an array we make it one and add our value + const newFilter = { + ...currentFilter, + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else if (!checked) { + // if we are removing some value when the running operator is IN we filter. + // example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: currentFilter.value.filter((val) => val !== value), + }; + + if (newFilter.value.length === 0) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } else { + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else { + // if not an array remove the whole thing altogether! + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + } + break; + case 'nin': + // if the current running operator is NIN then when unchecking the value it gets + // added to the clause like key NIN [value1 , currentUnselectedValue] + if (!checked) { + // in case of array add the currentUnselectedValue to the list. + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: [...currentFilter.value, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else { + // in case of not an array make it one! + const newFilter = { + ...currentFilter, + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else if (checked) { + // opposite of above! + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: currentFilter.value.filter((val) => val !== value), + }; + + if (newFilter.value.length === 0) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } else { + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + } + break; + case '=': + if (checked) { + const newFilter = { + ...currentFilter, + op: getOperatorValue(OPERATORS.IN), + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else if (!checked) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + break; + case '!=': + if (!checked) { + const newFilter = { + ...currentFilter, + op: getOperatorValue(OPERATORS.NIN), + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else if (checked) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + break; + default: + break; + } + } + } else { + // case - when there is no filter for the current key that means all are selected right now. + const newFilterItem: TagFilterItem = { + id: uuid(), + op: getOperatorValue(OPERATORS.NIN), + key: filter.attributeKey, + value, + }; + query.filters.items = [...query.filters.items, newFilterItem]; + } + } + const finalQuery = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: [ + ...currentQuery.builder.queryData.map((q, idx) => { + if (idx === lastUsedQuery) { + return query; + } + return q; + }), + ], + }, + }; + + redirectWithQueryBuilderData(finalQuery); + }; + + return ( +
+
+
+ {isOpen ? ( + { + setIsOpen(false); + setVisibleItemsCount(10); + }} + /> + ) : ( + setIsOpen(true)} + cursor="pointer" + /> + )} + {filter.title} +
+
+ {isOpen && ( + + Clear All + + )} +
+
+ {isOpen && isLoading && !attributeValues.length && ( +
+ +
+ )} + {isOpen && !isLoading && ( + <> +
+ setSearchText(e.target.value)} + disabled={isFilterDisabled} + /> +
+ {attributeValues.length > 0 ? ( +
+ {currentAttributeKeys.map((value: string) => ( +
+ onChange(value, e.target.checked, false)} + checked={currentFilterState[value]} + disabled={isFilterDisabled} + rootClassName="check-box" + /> + +
{ + if (isFilterDisabled) { + return; + } + onChange(value, currentFilterState[value], true); + }} + > + {filter.customRendererForValue ? ( + filter.customRendererForValue(value) + ) : ( + + {value} + + )} + + +
+
+ ))} +
+ ) : ( +
+ No values found{' '} +
+ )} + {visibleItemsCount < attributeValues?.length && ( +
+ setVisibleItemsCount((prev) => prev + 10)} + > + Show More... + +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.styles.scss b/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.styles.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx b/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx new file mode 100644 index 0000000000..f7cd9547e8 --- /dev/null +++ b/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx @@ -0,0 +1,14 @@ +import './Slider.styles.scss'; + +import { IQuickFiltersConfig } from 'components/QuickFilters/QuickFilters'; + +interface ISliderProps { + filter: IQuickFiltersConfig; +} + +// not needed for now build when required +export default function Slider(props: ISliderProps): JSX.Element { + const { filter } = props; + console.log(filter); + return
Slider
; +} diff --git a/frontend/src/components/QuickFilters/QuickFilters.styles.scss b/frontend/src/components/QuickFilters/QuickFilters.styles.scss new file mode 100644 index 0000000000..d5c3460891 --- /dev/null +++ b/frontend/src/components/QuickFilters/QuickFilters.styles.scss @@ -0,0 +1,93 @@ +.quick-filters { + display: flex; + flex-direction: column; + height: 100%; + border-right: 1px solid var(--bg-slate-400); + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10.5px; + border-bottom: 1px solid var(--bg-slate-400); + + .left-actions { + display: flex; + align-items: center; + gap: 6px; + + .text { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; + letter-spacing: -0.07px; + } + + .sync-tag { + display: flex; + padding: 5px 9px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 2px; + border: 1px solid rgba(78, 116, 248, 0.2); + background: rgba(78, 116, 248, 0.1); + color: var(--bg-robin-500); + font-family: 'Geist Mono'; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; /* 128.571% */ + text-transform: uppercase; + } + } + + .right-actions { + display: flex; + align-items: center; + gap: 12px; + + .divider-filter { + width: 1px; + height: 14px; + background: #161922; + } + + .sync-icon { + background-color: var(--bg-ink-500); + border: 0; + box-shadow: none; + } + } + } +} + +.lightMode { + .quick-filters { + background-color: var(--bg-vanilla-100); + border-right: 1px solid var(--bg-vanilla-300); + + .header { + border-bottom: 1px solid var(--bg-vanilla-300); + + .left-actions { + .text { + color: var(--bg-ink-400); + } + + .sync-icon { + background-color: var(--bg-vanilla-100); + } + } + .right-actions { + .sync-icon { + background-color: var(--bg-vanilla-100); + } + } + } + } +} diff --git a/frontend/src/components/QuickFilters/QuickFilters.tsx b/frontend/src/components/QuickFilters/QuickFilters.tsx new file mode 100644 index 0000000000..a706e35aef --- /dev/null +++ b/frontend/src/components/QuickFilters/QuickFilters.tsx @@ -0,0 +1,124 @@ +import './QuickFilters.styles.scss'; + +import { + FilterOutlined, + SyncOutlined, + VerticalAlignTopOutlined, +} from '@ant-design/icons'; +import { Tooltip, Typography } from 'antd'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { cloneDeep } from 'lodash-es'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; + +import Checkbox from './FilterRenderers/Checkbox/Checkbox'; +import Slider from './FilterRenderers/Slider/Slider'; + +export enum FiltersType { + SLIDER = 'SLIDER', + CHECKBOX = 'CHECKBOX', +} + +export enum MinMax { + MIN = 'MIN', + MAX = 'MAX', +} + +export enum SpecficFilterOperations { + ALL = 'ALL', + ONLY = 'ONLY', +} + +export interface IQuickFiltersConfig { + type: FiltersType; + title: string; + attributeKey: BaseAutocompleteData; + customRendererForValue?: (value: string) => JSX.Element; + defaultOpen: boolean; +} + +interface IQuickFiltersProps { + config: IQuickFiltersConfig[]; + handleFilterVisibilityChange: () => void; +} + +export default function QuickFilters(props: IQuickFiltersProps): JSX.Element { + const { config, handleFilterVisibilityChange } = props; + + const { + currentQuery, + lastUsedQuery, + redirectWithQueryBuilderData, + } = useQueryBuilder(); + + // clear all the filters for the query which is in sync with filters + const handleReset = (): void => { + const updatedQuery = cloneDeep( + currentQuery?.builder.queryData?.[lastUsedQuery || 0], + ); + + if (!updatedQuery) { + return; + } + + if (updatedQuery?.filters?.items) { + updatedQuery.filters.items = []; + } + + const preparedQuery: Query = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: currentQuery.builder.queryData.map((item, idx) => ({ + ...item, + filters: { + ...item.filters, + items: idx === lastUsedQuery ? [] : [...item.filters.items], + }, + })), + }, + }; + redirectWithQueryBuilderData(preparedQuery); + }; + + const lastQueryName = + currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName; + return ( +
+
+
+ + Filters for + + {lastQueryName} + +
+
+ + + +
+ + + +
+
+ +
+ {config.map((filter) => { + switch (filter.type) { + case FiltersType.CHECKBOX: + return ; + case FiltersType.SLIDER: + return ; + default: + return ; + } + })} +
+
+ ); +} diff --git a/frontend/src/constants/localStorage.ts b/frontend/src/constants/localStorage.ts index c7e8b81179..bab93a7ff1 100644 --- a/frontend/src/constants/localStorage.ts +++ b/frontend/src/constants/localStorage.ts @@ -19,4 +19,5 @@ export enum LOCALSTORAGE { SHOW_EXPLORER_TOOLBAR = 'SHOW_EXPLORER_TOOLBAR', PINNED_ATTRIBUTES = 'PINNED_ATTRIBUTES', THEME_ANALYTICS_V1 = 'THEME_ANALYTICS_V1', + SHOW_LOGS_QUICK_FILTERS = 'SHOW_LOGS_QUICK_FILTERS', } diff --git a/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss b/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss index dbb7a962ef..7cac6794c5 100644 --- a/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss +++ b/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss @@ -77,6 +77,12 @@ border: 1px solid rgba(242, 71, 105, 0.4); color: var(--bg-sakura-400); } + + &.sync-btn { + border: 1px solid rgba(78, 116, 248, 0.2); + background: rgba(78, 116, 248, 0.1); + color: var(--bg-robin-500); + } } &.formula-btn { diff --git a/frontend/src/container/QueryBuilder/QueryBuilder.tsx b/frontend/src/container/QueryBuilder/QueryBuilder.tsx index 844f9e3ab3..5726087e6d 100644 --- a/frontend/src/container/QueryBuilder/QueryBuilder.tsx +++ b/frontend/src/container/QueryBuilder/QueryBuilder.tsx @@ -1,17 +1,20 @@ import './QueryBuilder.styles.scss'; import { Button, Col, Divider, Row, Tooltip, Typography } from 'antd'; +import cx from 'classnames'; import { MAX_FORMULAS, MAX_QUERIES, OPERATORS, PANEL_TYPES, } from 'constants/queryBuilder'; +import ROUTES from 'constants/routes'; // ** Hooks import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { DatabaseZap, Sigma } from 'lucide-react'; // ** Constants import { memo, useEffect, useMemo, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; import { DataSource } from 'types/common/queryBuilder'; // ** Components @@ -35,6 +38,8 @@ export const QueryBuilder = memo(function QueryBuilder({ handleSetConfig, panelType, initialDataSource, + setLastUsedQuery, + lastUsedQuery, } = useQueryBuilder(); const containerRef = useRef(null); @@ -46,6 +51,10 @@ export const QueryBuilder = memo(function QueryBuilder({ [config], ); + const { pathname } = useLocation(); + + const isLogsExplorerPage = pathname === ROUTES.LOGS_EXPLORER; + useEffect(() => { if (currentDataSource !== initialDataSource || newPanelType !== panelType) { if (newPanelType === PANEL_TYPES.BAR) { @@ -212,6 +221,7 @@ export const QueryBuilder = memo(function QueryBuilder({
setLastUsedQuery(index)} className="query" id={`qb-query-${query.queryName}`} > @@ -265,10 +275,13 @@ export const QueryBuilder = memo(function QueryBuilder({ {!isListViewPanel && ( - {currentQuery.builder.queryData.map((query) => ( + {currentQuery.builder.queryData.map((query, index) => ( + + )}
)} - {!hasSelectedTimeError && !refreshButtonHidden && ( + {!hasSelectedTimeError && !refreshButtonHidden && showRefreshText && ( 1) { removeQueryBuilderEntityByIndex('queryData', index); } - }, [removeQueryBuilderEntityByIndex, index, currentQuery]); + setLastUsedQuery(0); + }, [ + currentQuery.builder.queryData.length, + setLastUsedQuery, + removeQueryBuilderEntityByIndex, + index, + ]); const handleChangeQueryData: HandleChangeQueryData = useCallback( (key, value) => { diff --git a/frontend/src/pages/LogsExplorer/LogsExplorer.styles.scss b/frontend/src/pages/LogsExplorer/LogsExplorer.styles.scss index 95d53fe9a4..82d3f5bffc 100644 --- a/frontend/src/pages/LogsExplorer/LogsExplorer.styles.scss +++ b/frontend/src/pages/LogsExplorer/LogsExplorer.styles.scss @@ -1,11 +1,35 @@ -.log-explorer-query-container { - display: flex; - flex-direction: column; - flex: 1; - - .logs-explorer-views { - flex: 1; - display: flex; - flex-direction: column; - } -} \ No newline at end of file +.logs-module-page { + display: flex; + height: 100%; + .log-quick-filter-left-section { + width: 0%; + flex-shrink: 0; + } + + .log-module-right-section { + display: flex; + flex-direction: column; + width: 100%; + .log-explorer-query-container { + display: flex; + flex-direction: column; + flex: 1; + + .logs-explorer-views { + flex: 1; + display: flex; + flex-direction: column; + } + } + } + + &.filter-visible { + .log-quick-filter-left-section { + width: 260px; + } + + .log-module-right-section { + width: calc(100% - 260px); + } + } +} diff --git a/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx b/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx index fab08d51a8..4970d6cf17 100644 --- a/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx +++ b/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx @@ -189,6 +189,8 @@ describe('Logs Explorer Tests', () => { initialDataSource: null, panelType: PANEL_TYPES.TIME_SERIES, isEnabledQuery: false, + lastUsedQuery: 0, + setLastUsedQuery: noop, handleSetQueryData: noop, handleSetFormulaData: noop, handleSetQueryItemData: noop, diff --git a/frontend/src/pages/LogsExplorer/index.tsx b/frontend/src/pages/LogsExplorer/index.tsx index 8873d04e39..9e23b34c2c 100644 --- a/frontend/src/pages/LogsExplorer/index.tsx +++ b/frontend/src/pages/LogsExplorer/index.tsx @@ -1,25 +1,40 @@ import './LogsExplorer.styles.scss'; import * as Sentry from '@sentry/react'; +import getLocalStorageKey from 'api/browser/localstorage/get'; +import setLocalStorageApi from 'api/browser/localstorage/set'; +import cx from 'classnames'; import ExplorerCard from 'components/ExplorerCard/ExplorerCard'; +import QuickFilters from 'components/QuickFilters/QuickFilters'; +import { LOCALSTORAGE } from 'constants/localStorage'; import LogExplorerQuerySection from 'container/LogExplorerQuerySection'; import LogsExplorerViews from 'container/LogsExplorerViews'; import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions'; import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions'; import Toolbar from 'container/Toolbar/Toolbar'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { isNull } from 'lodash-es'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; import { useEffect, useMemo, useRef, useState } from 'react'; import { DataSource } from 'types/common/queryBuilder'; import { WrapperStyled } from './styles'; -import { SELECTED_VIEWS } from './utils'; +import { LogsQuickFiltersConfig, SELECTED_VIEWS } from './utils'; function LogsExplorer(): JSX.Element { const [showFrequencyChart, setShowFrequencyChart] = useState(true); const [selectedView, setSelectedView] = useState( SELECTED_VIEWS.SEARCH, ); + const [showFilters, setShowFilters] = useState(() => { + const localStorageValue = getLocalStorageKey( + LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, + ); + if (!isNull(localStorageValue)) { + return localStorageValue === 'true'; + } + return true; + }); const { handleRunQuery, currentQuery } = useQueryBuilder(); @@ -37,6 +52,14 @@ function LogsExplorer(): JSX.Element { setSelectedView(view); }; + const handleFilterVisibilityChange = (): void => { + setLocalStorageApi( + LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, + String(!showFilters), + ); + setShowFilters((prev) => !prev); + }; + // Switch to query builder view if there are more than 1 queries useEffect(() => { if (currentQuery.builder.queryData.length > 1) { @@ -90,46 +113,60 @@ function LogsExplorer(): JSX.Element { return ( }> - - } - rightActions={ - - } - showOldCTA - /> - - -
-
- - - -
-
- + {showFilters && ( +
+ -
-
-
+ + )} +
+ + } + rightActions={ + + } + showOldCTA + /> + + +
+
+ + + +
+
+ +
+
+
+
+
); } diff --git a/frontend/src/pages/LogsExplorer/utils.ts b/frontend/src/pages/LogsExplorer/utils.ts deleted file mode 100644 index 0fedaaece4..0000000000 --- a/frontend/src/pages/LogsExplorer/utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Query } from 'types/api/queryBuilder/queryBuilderData'; - -export const prepareQueryWithDefaultTimestamp = (query: Query): Query => ({ - ...query, - builder: { - ...query.builder, - queryData: query.builder.queryData?.map((item) => ({ - ...item, - orderBy: [{ columnName: 'timestamp', order: 'desc' }], - })), - }, -}); - -// eslint-disable-next-line @typescript-eslint/naming-convention -export enum SELECTED_VIEWS { - SEARCH = 'search', - QUERY_BUILDER = 'query-builder', - CLICKHOUSE = 'clickhouse', -} diff --git a/frontend/src/pages/LogsExplorer/utils.tsx b/frontend/src/pages/LogsExplorer/utils.tsx new file mode 100644 index 0000000000..7a197bd467 --- /dev/null +++ b/frontend/src/pages/LogsExplorer/utils.tsx @@ -0,0 +1,113 @@ +import { + FiltersType, + IQuickFiltersConfig, +} from 'components/QuickFilters/QuickFilters'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; + +export const prepareQueryWithDefaultTimestamp = (query: Query): Query => ({ + ...query, + builder: { + ...query.builder, + queryData: query.builder.queryData?.map((item) => ({ + ...item, + orderBy: [{ columnName: 'timestamp', order: 'desc' }], + })), + }, +}); + +// eslint-disable-next-line @typescript-eslint/naming-convention +export enum SELECTED_VIEWS { + SEARCH = 'search', + QUERY_BUILDER = 'query-builder', + CLICKHOUSE = 'clickhouse', +} + +export const LogsQuickFiltersConfig: IQuickFiltersConfig[] = [ + { + type: FiltersType.CHECKBOX, + title: 'Severity Text', + attributeKey: { + key: 'severity_text', + dataType: DataTypes.String, + type: '', + isColumn: true, + isJSON: false, + id: 'severity_text--string----true', + }, + defaultOpen: true, + }, + { + type: FiltersType.CHECKBOX, + title: 'Environment', + attributeKey: { + key: 'deployment.environment', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Service Name', + attributeKey: { + key: 'service.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: true, + isJSON: false, + id: 'service.name--string--resource--true', + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Hostname', + attributeKey: { + key: 'hostname', + dataType: DataTypes.String, + type: 'tag', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Cluster Name', + attributeKey: { + key: 'k8s.cluster.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Deployment Name', + attributeKey: { + key: 'k8s.deployment.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Namespace Name', + attributeKey: { + key: 'k8s.namespace.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: true, + isJSON: false, + }, + defaultOpen: false, + }, +]; diff --git a/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx b/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx index a28776f0d0..4a3fa8018e 100644 --- a/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx +++ b/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx @@ -77,6 +77,14 @@ jest.mock( }, ); +window.ResizeObserver = + window.ResizeObserver || + jest.fn().mockImplementation(() => ({ + disconnect: jest.fn(), + observe: jest.fn(), + unobserve: jest.fn(), + })); + const successNotification = jest.fn(); jest.mock('hooks/useNotifications', () => ({ __esModule: true, diff --git a/frontend/src/providers/QueryBuilder.tsx b/frontend/src/providers/QueryBuilder.tsx index c3b50bbc7e..305372eea6 100644 --- a/frontend/src/providers/QueryBuilder.tsx +++ b/frontend/src/providers/QueryBuilder.tsx @@ -62,6 +62,8 @@ import { v4 as uuid } from 'uuid'; export const QueryBuilderContext = createContext({ currentQuery: initialQueriesMap.metrics, supersetQuery: initialQueriesMap.metrics, + lastUsedQuery: null, + setLastUsedQuery: () => {}, setSupersetQuery: () => {}, stagedQuery: initialQueriesMap.metrics, initialDataSource: null, @@ -117,6 +119,7 @@ export function QueryBuilderProvider({ const [currentQuery, setCurrentQuery] = useState(queryState); const [supersetQuery, setSupersetQuery] = useState(queryState); + const [lastUsedQuery, setLastUsedQuery] = useState(0); const [stagedQuery, setStagedQuery] = useState(null); const [queryType, setQueryType] = useState(queryTypeParam); @@ -230,6 +233,8 @@ export function QueryBuilderProvider({ timeUpdated ? merge(currentQuery, newQueryState) : newQueryState, ); setQueryType(type); + // this is required to reset the last used query when navigating or initializing the query builder + setLastUsedQuery(0); }, [prepareQueryBuilderData, currentQuery], ); @@ -857,6 +862,8 @@ export function QueryBuilderProvider({ () => ({ currentQuery: query, supersetQuery: superQuery, + lastUsedQuery, + setLastUsedQuery, setSupersetQuery, stagedQuery, initialDataSource, @@ -884,6 +891,7 @@ export function QueryBuilderProvider({ [ query, superQuery, + lastUsedQuery, stagedQuery, initialDataSource, panelType, diff --git a/frontend/src/types/common/queryBuilder.ts b/frontend/src/types/common/queryBuilder.ts index 4a67619a61..fd3b4c0530 100644 --- a/frontend/src/types/common/queryBuilder.ts +++ b/frontend/src/types/common/queryBuilder.ts @@ -189,6 +189,8 @@ export type QueryBuilderData = { export type QueryBuilderContextType = { currentQuery: Query; stagedQuery: Query | null; + lastUsedQuery: number | null; + setLastUsedQuery: Dispatch>; supersetQuery: Query; setSupersetQuery: Dispatch>; initialDataSource: DataSource | null; From 266894b0f836e8fd26a933e099af500b35fa1cbf Mon Sep 17 00:00:00 2001 From: Yunus M Date: Fri, 6 Sep 2024 11:17:56 +0530 Subject: [PATCH 08/18] fix: strip starting and ending quotes from field value on copy to clipboard (#5831) --- .../LogDetailedView/TableView/TableViewActions.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/container/LogDetailedView/TableView/TableViewActions.tsx b/frontend/src/container/LogDetailedView/TableView/TableViewActions.tsx index 63912ffa82..74b30bf6de 100644 --- a/frontend/src/container/LogDetailedView/TableView/TableViewActions.tsx +++ b/frontend/src/container/LogDetailedView/TableView/TableViewActions.tsx @@ -67,7 +67,6 @@ export function TableViewActions( ); const [isOpen, setIsOpen] = useState(false); - const textToCopy = fieldData.value; if (record.field === 'body') { const parsedBody = recursiveParseJSON(fieldData.value); @@ -89,6 +88,17 @@ export function TableViewActions( : { __html: '' }; const fieldFilterKey = filterKeyForField(fieldData.field); + let textToCopy = fieldData.value; + + // remove starting and ending quotes from the value + try { + textToCopy = textToCopy.replace(/^"|"$/g, ''); + } catch (error) { + console.error( + 'Failed to remove starting and ending quotes from the value', + error, + ); + } return (
From 23704b00ce1b427d1cec701e89f21edad2ec9cd3 Mon Sep 17 00:00:00 2001 From: Yunus M Date: Fri, 6 Sep 2024 11:20:47 +0530 Subject: [PATCH 09/18] feat: show RPS message only if user is on trail and trail is not converted to sub (#5860) * feat: show rps message only if user is on trail and trail is not converted to sub * feat: show rps message only if user is on trail and trail is not converted to sub --- .../ServiceMetrics/ServiceMetricTable.tsx | 7 ++++++- .../ServiceTraces/ServiceTracesTable.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricTable.tsx b/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricTable.tsx index 33a4dd729b..6430cc9c8f 100644 --- a/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricTable.tsx +++ b/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricTable.tsx @@ -69,7 +69,12 @@ function ServiceMetricTable({ const [RPS, setRPS] = useState(0); useEffect(() => { - if (!isFetching && licenseData?.payload?.onTrial && isCloudUserVal) { + if ( + !isFetching && + licenseData?.payload?.onTrial && + !licenseData?.payload?.trialConvertedToSubscription && + isCloudUserVal + ) { if (services.length > 0) { const rps = getTotalRPS(services); setRPS(rps); diff --git a/frontend/src/container/ServiceApplication/ServiceTraces/ServiceTracesTable.tsx b/frontend/src/container/ServiceApplication/ServiceTraces/ServiceTracesTable.tsx index 8b03027a16..6633b7a1aa 100644 --- a/frontend/src/container/ServiceApplication/ServiceTraces/ServiceTracesTable.tsx +++ b/frontend/src/container/ServiceApplication/ServiceTraces/ServiceTracesTable.tsx @@ -26,7 +26,12 @@ function ServiceTraceTable({ const tableColumns = useMemo(() => getColumns(search, false), [search]); useEffect(() => { - if (!isFetching && licenseData?.payload?.onTrial && isCloudUserVal) { + if ( + !isFetching && + licenseData?.payload?.onTrial && + !licenseData?.payload?.trialConvertedToSubscription && + isCloudUserVal + ) { if (services.length > 0) { const rps = getTotalRPS(services); setRPS(rps); From b9ab6d3fd4a67da00e03320a3be2dd99f83b7bd5 Mon Sep 17 00:00:00 2001 From: Yunus M Date: Fri, 6 Sep 2024 11:21:07 +0530 Subject: [PATCH 10/18] feat: show add credit card chat icon only to logged in users (#5863) --- frontend/src/container/AppLayout/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index 85a58aac99..60b26b8db2 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -78,6 +78,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const isCloudUserVal = isCloudUser(); const showAddCreditCardModal = + isLoggedIn && isChatSupportEnabled && isCloudUserVal && !isPremiumChatSupportEnabled && From 4214e36d22f632557b4d4af40f687993f56199df Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:22:54 +0530 Subject: [PATCH 11/18] fix: added default fallback for selectedColumns, when the attributeKeys call gives empty (#5847) --- frontend/src/container/OptionsMenu/useOptionsMenu.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/container/OptionsMenu/useOptionsMenu.ts b/frontend/src/container/OptionsMenu/useOptionsMenu.ts index 7b3cfce035..a4a91d82f4 100644 --- a/frontend/src/container/OptionsMenu/useOptionsMenu.ts +++ b/frontend/src/container/OptionsMenu/useOptionsMenu.ts @@ -140,6 +140,11 @@ const useOptionsMenu = ({ return col; }) .filter(Boolean) as BaseAutocompleteData[]; + + // this is the last point where we can set the default columns and if uptil now also we have an empty array then we will set the default columns + if (!initialSelected || !initialSelected?.length) { + initialSelected = defaultTraceSelectedColumns; + } } return initialSelected || []; From 7a10fe2b8c871575bb6c8ee5f064132f59296c83 Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:23:28 +0530 Subject: [PATCH 12/18] chore: hide old trace explorer cta btn from trace explorer page (#5850) --- frontend/src/pages/TracesExplorer/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/TracesExplorer/index.tsx b/frontend/src/pages/TracesExplorer/index.tsx index bb25a37f86..b865fd02bd 100644 --- a/frontend/src/pages/TracesExplorer/index.tsx +++ b/frontend/src/pages/TracesExplorer/index.tsx @@ -259,7 +259,7 @@ function TracesExplorer(): JSX.Element { )}
- +
From 4eb533fff879140474ac16ebce8fdb35b908c78c Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:50:02 +0530 Subject: [PATCH 13/18] fix: added start and end time info text to educate user better around the schedule timelines (#5837) * fix: added start and end time info text to educate user better around the schedule timelines * fix: changed the start and endtime info text * fix: changed the start and endtime info text * fix: comment resolved --- .../PlannedDowntime.styles.scss | 20 +++ .../PlannedDowntime/PlannedDowntimeForm.tsx | 139 +++++++++++++++++- .../PlannedDowntime/PlannedDowntimeutils.ts | 2 +- 3 files changed, 153 insertions(+), 8 deletions(-) diff --git a/frontend/src/container/PlannedDowntime/PlannedDowntime.styles.scss b/frontend/src/container/PlannedDowntime/PlannedDowntime.styles.scss index 41949142fa..b81e4d1e51 100644 --- a/frontend/src/container/PlannedDowntime/PlannedDowntime.styles.scss +++ b/frontend/src/container/PlannedDowntime/PlannedDowntime.styles.scss @@ -77,6 +77,18 @@ color: var(--bg-vanilla-400); } } + + .formItemWithBullet { + margin-bottom: 0; + } + + .scheduleTimeInfoText { + margin-top: 8px; + margin-bottom: 20px; + font-size: 12px; + font-weight: 400; + color: var(--bg-vanilla-400); + } } .alert-rule-tags { @@ -543,5 +555,13 @@ background: var(--bg-vanilla-100); } } + + .scheduleTimeInfoText { + color: var(--bg-slate-300); + } + + .alert-rule-info { + color: var(--bg-slate-300); + } } } diff --git a/frontend/src/container/PlannedDowntime/PlannedDowntimeForm.tsx b/frontend/src/container/PlannedDowntime/PlannedDowntimeForm.tsx index 76b0507558..94d1a5d6eb 100644 --- a/frontend/src/container/PlannedDowntime/PlannedDowntimeForm.tsx +++ b/frontend/src/container/PlannedDowntime/PlannedDowntimeForm.tsx @@ -41,7 +41,7 @@ import { getAlertOptionsFromIds, getDurationInfo, getEndTime, - handleTimeConvertion, + handleTimeConversion, isScheduleRecurring, recurrenceOptions, recurrenceOptionWithSubmenu, @@ -52,6 +52,10 @@ dayjs.locale('en'); dayjs.extend(utc); dayjs.extend(timezone); +const TIME_FORMAT = 'HH:mm'; +const DATE_FORMAT = 'Do MMM YYYY'; +const ORDINAL_FORMAT = 'Do'; + interface PlannedDowntimeFormData { name: string; startTime: dayjs.Dayjs | string; @@ -105,6 +109,10 @@ export function PlannedDowntimeForm( ?.unit || 'm', ); + const [formData, setFormData] = useState( + initialValues?.schedule as PlannedDowntimeFormData, + ); + const [recurrenceType, setRecurrenceType] = useState( (initialValues.schedule?.recurrence?.repeatType as string) || recurrenceOptions.doesNotRepeat.value, @@ -131,7 +139,7 @@ export function PlannedDowntimeForm( .filter((alert) => alert !== undefined) as string[], name: values.name, schedule: { - startTime: handleTimeConvertion( + startTime: handleTimeConversion( values.startTime, timezoneInitialValue, values.timezone, @@ -139,7 +147,7 @@ export function PlannedDowntimeForm( ), timezone: values.timezone, endTime: values.endTime - ? handleTimeConvertion( + ? handleTimeConversion( values.endTime, timezoneInitialValue, values.timezone, @@ -196,14 +204,14 @@ export function PlannedDowntimeForm( ? `${values.recurrence?.duration}${durationUnit}` : undefined, endTime: !isEmpty(values.endTime) - ? handleTimeConvertion( + ? handleTimeConversion( values.endTime, timezoneInitialValue, values.timezone, !isEditMode, ) : undefined, - startTime: handleTimeConvertion( + startTime: handleTimeConversion( values.startTime, timezoneInitialValue, values.timezone, @@ -300,6 +308,116 @@ export function PlannedDowntimeForm( }), ); + const getTimezoneFormattedTime = ( + time: string | dayjs.Dayjs, + timeZone?: string, + isEditMode?: boolean, + format?: string, + ): string => { + if (!time) { + return ''; + } + if (!timeZone) { + return dayjs(time).format(format); + } + return dayjs(time).tz(timeZone, isEditMode).format(format); + }; + + const startTimeText = useMemo((): string => { + let startTime = formData?.startTime; + if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) { + startTime = formData?.recurrence?.startTime || formData?.startTime || ''; + } + + if (!startTime) { + return ''; + } + + if (formData.timezone) { + startTime = handleTimeConversion( + startTime, + timezoneInitialValue, + formData?.timezone, + !isEditMode, + ); + } + const daysOfWeek = formData?.recurrence?.repeatOn; + + const formattedStartTime = getTimezoneFormattedTime( + startTime, + formData.timezone, + !isEditMode, + TIME_FORMAT, + ); + + const formattedStartDate = getTimezoneFormattedTime( + startTime, + formData.timezone, + !isEditMode, + DATE_FORMAT, + ); + + const ordinalFormat = getTimezoneFormattedTime( + startTime, + formData.timezone, + !isEditMode, + ORDINAL_FORMAT, + ); + + const formattedDaysOfWeek = daysOfWeek?.join(', '); + switch (recurrenceType) { + case 'daily': + return `Scheduled from ${formattedStartDate}, daily starting at ${formattedStartTime}.`; + case 'monthly': + return `Scheduled from ${formattedStartDate}, monthly on the ${ordinalFormat} starting at ${formattedStartTime}.`; + case 'weekly': + return `Scheduled from ${formattedStartDate}, weekly ${ + formattedDaysOfWeek ? `on [${formattedDaysOfWeek}]` : '' + } starting at ${formattedStartTime}`; + default: + return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`; + } + }, [formData, recurrenceType, isEditMode, timezoneInitialValue]); + + const endTimeText = useMemo((): string => { + let endTime = formData?.endTime; + if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) { + endTime = formData?.recurrence?.endTime || ''; + + if (!isEditMode && !endTime) { + endTime = formData?.endTime || ''; + } + } + + if (!endTime) { + return ''; + } + + if (formData.timezone) { + endTime = handleTimeConversion( + endTime, + timezoneInitialValue, + formData?.timezone, + !isEditMode, + ); + } + + const formattedEndTime = getTimezoneFormattedTime( + endTime, + formData.timezone, + !isEditMode, + TIME_FORMAT, + ); + + const formattedEndDate = getTimezoneFormattedTime( + endTime, + formData.timezone, + !isEditMode, + DATE_FORMAT, + ); + return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`; + }, [formData, recurrenceType, isEditMode, timezoneInitialValue]); + return ( { setRecurrenceType(form.getFieldValue('recurrence')?.repeatType as string); + setFormData(form.getFieldsValue()); }} autoComplete="off" > @@ -333,7 +452,7 @@ export function PlannedDowntimeForm( label="Starts from" name="startTime" rules={formValidationRules} - className="formItemWithBullet" + className={!isEmpty(startTimeText) ? 'formItemWithBullet' : ''} getValueProps={(value): any => ({ value: value ? dayjs(value).tz(timezoneInitialValue) : undefined, })} @@ -348,6 +467,9 @@ export function PlannedDowntimeForm( popupClassName="datePicker" /> + {!isEmpty(startTimeText) && ( +
{startTimeText}
+ )} ({ value: value ? dayjs(value).tz(timezoneInitialValue) : undefined, })} @@ -426,6 +548,9 @@ export function PlannedDowntimeForm( popupClassName="datePicker" /> + {!isEmpty(endTimeText) && ( +
{endTimeText}
+ )}
Silence Alerts diff --git a/frontend/src/container/PlannedDowntime/PlannedDowntimeutils.ts b/frontend/src/container/PlannedDowntime/PlannedDowntimeutils.ts index 7d0745dc5e..feba0cb13e 100644 --- a/frontend/src/container/PlannedDowntime/PlannedDowntimeutils.ts +++ b/frontend/src/container/PlannedDowntime/PlannedDowntimeutils.ts @@ -262,7 +262,7 @@ export function formatWithTimezone( return `${parsedDate?.substring(0, 19)}${targetOffset}`; } -export function handleTimeConvertion( +export function handleTimeConversion( dateValue: string | dayjs.Dayjs, timezoneInit?: string, timezone?: string, From 292b3f418eaca688bfab78de2a57f0649ed76c6f Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:53:05 +0530 Subject: [PATCH 14/18] chore: dashboard detail - panel data fetched - telemetry (#5871) * chore: dashboard detail - panel data fetched - telemetry * chore: dashboard detail - code refactor --- .../GridCardLayout/GridCard/index.tsx | 5 ++++- .../GridCardLayout/GridCard/utils.ts | 20 +++++++++++++++++++ .../GridCardLayout/GridCardLayout.tsx | 17 ++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/frontend/src/container/GridCardLayout/GridCard/index.tsx b/frontend/src/container/GridCardLayout/GridCard/index.tsx index 444978f61d..a618f807a5 100644 --- a/frontend/src/container/GridCardLayout/GridCard/index.tsx +++ b/frontend/src/container/GridCardLayout/GridCard/index.tsx @@ -22,6 +22,7 @@ import { getSortedSeriesData } from 'utils/getSortedSeriesData'; import EmptyWidget from '../EmptyWidget'; import { MenuItemKeys } from '../WidgetHeader/contants'; import { GridCardGraphProps } from './types'; +import { isDataAvailableByPanelType } from './utils'; import WidgetGraphComponent from './WidgetGraphComponent'; function GridCardGraph({ @@ -182,7 +183,9 @@ function GridCardGraph({ setErrorMessage(error.message); }, onSettled: (data) => { - dataAvailable?.(Boolean(data?.payload?.data?.result?.length)); + dataAvailable?.( + isDataAvailableByPanelType(data?.payload?.data, widget?.panelTypes), + ); }, }, ); diff --git a/frontend/src/container/GridCardLayout/GridCard/utils.ts b/frontend/src/container/GridCardLayout/GridCard/utils.ts index e14903c33d..ec60e662fa 100644 --- a/frontend/src/container/GridCardLayout/GridCard/utils.ts +++ b/frontend/src/container/GridCardLayout/GridCard/utils.ts @@ -1,6 +1,8 @@ /* eslint-disable sonarjs/cognitive-complexity */ import { LOCALSTORAGE } from 'constants/localStorage'; +import { PANEL_TYPES } from 'constants/queryBuilder'; import getLabelName from 'lib/getLabelName'; +import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { QueryData } from 'types/api/widgets/getQuery'; import { LegendEntryProps } from './FullView/types'; @@ -131,3 +133,21 @@ export const toggleGraphsVisibilityInChart = ({ lineChartRef?.current?.toggleGraph(index, showLegendData); }); }; + +export const isDataAvailableByPanelType = ( + data?: MetricRangePayloadProps['data'], + panelType?: string, +): boolean => { + const getPanelData = (): any[] | undefined => { + switch (panelType) { + case PANEL_TYPES.TABLE: + return (data?.result?.[0] as any)?.table?.rows; + case PANEL_TYPES.LIST: + return data?.newResult?.data?.result?.[0]?.list as any[]; + default: + return data?.result; + } + }; + + return Boolean(getPanelData()?.length); +}; diff --git a/frontend/src/container/GridCardLayout/GridCardLayout.tsx b/frontend/src/container/GridCardLayout/GridCardLayout.tsx index a96599b127..4600b46dd1 100644 --- a/frontend/src/container/GridCardLayout/GridCardLayout.tsx +++ b/frontend/src/container/GridCardLayout/GridCardLayout.tsx @@ -438,6 +438,10 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element { : true, [selectedDashboard], ); + + let isDataAvailableInAnyWidget = false; + const isLogEventCalled = useRef(false); + return isDashboardEmpty ? ( ) : ( @@ -516,6 +520,18 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element { ); } + const checkIfDataExists = (isDataAvailable: boolean): void => { + if (!isDataAvailableInAnyWidget && isDataAvailable) { + isDataAvailableInAnyWidget = true; + } + if (!isLogEventCalled.current && isDataAvailableInAnyWidget) { + isLogEventCalled.current = true; + logEvent('Dashboard Detail: Panel data fetched', { + isDataAvailableInAnyWidget, + }); + } + }; + return ( From 47d1caf078df721b0df51000c2aaa6724b2d1601 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:57:24 +0530 Subject: [PATCH 15/18] chore(deps): bump axios from 1.6.4 to 1.7.4 in /frontend (#5734) Bumps [axios](https://github.com/axios/axios) from 1.6.4 to 1.7.4. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.6.4...v1.7.4) --- updated-dependencies: - dependency-name: axios dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package.json | 2 +- frontend/yarn.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 26ffd31d5c..51097f7696 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,7 +51,7 @@ "ansi-to-html": "0.7.2", "antd": "5.11.0", "antd-table-saveas-excel": "2.2.1", - "axios": "1.6.4", + "axios": "1.7.4", "babel-eslint": "^10.1.0", "babel-jest": "^29.6.4", "babel-loader": "9.1.3", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index a9186bea9a..2ef8b540e0 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -5626,12 +5626,12 @@ axe-core@^4.6.2: resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz" integrity sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ== -axios@1.6.4: - version "1.6.4" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.4.tgz#184ee1f63d412caffcf30d2c50982253c3ee86e0" - integrity sha512-heJnIs6N4aa1eSthhN9M5ioILu8Wi8vmQW9iHQ9NUvfkJb0lEEDUiIdQNAuBtfUt3FxReaKdpQA5DbmMOqzF/A== +axios@1.7.4: + version "1.7.4" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.4.tgz#4c8ded1b43683c8dd362973c393f3ede24052aa2" + integrity sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw== dependencies: - follow-redirects "^1.15.4" + follow-redirects "^1.15.6" form-data "^4.0.0" proxy-from-env "^1.1.0" @@ -8925,7 +8925,7 @@ flubber@^0.4.2: svgpath "^2.2.1" topojson-client "^3.0.0" -follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.15.4, follow-redirects@^1.15.6: +follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.15.6: version "1.15.6" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== From 0db2784d6b0403e78d815d8119ff3661337726ec Mon Sep 17 00:00:00 2001 From: Vishal Sharma Date: Fri, 6 Sep 2024 14:46:18 +0530 Subject: [PATCH 16/18] =?UTF-8?q?chore:=20calculate=20user=20count=20dynam?= =?UTF-8?q?ically=20and=20set=20user=20role=20in=20identity=E2=80=A6=20(#5?= =?UTF-8?q?870)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: calculate user count dynamically and set user role in identity event * chore: move to callbacks --------- Co-authored-by: Srikanth Chekuri --- pkg/query-service/dao/sqlite/connection.go | 4 ++- pkg/query-service/dao/sqlite/rbac.go | 16 +++++++++ pkg/query-service/telemetry/telemetry.go | 38 ++++++++++++++++------ 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/pkg/query-service/dao/sqlite/connection.go b/pkg/query-service/dao/sqlite/connection.go index d7e5ad5de9..a4373d5ecd 100644 --- a/pkg/query-service/dao/sqlite/connection.go +++ b/pkg/query-service/dao/sqlite/connection.go @@ -103,6 +103,9 @@ func InitDB(dataSourceName string) (*ModelDaoSqlite, error) { return nil, err } + telemetry.GetInstance().SetUserCountCallback(mds.GetUserCount) + telemetry.GetInstance().SetUserRoleCallback(mds.GetUserRole) + return mds, nil } @@ -140,7 +143,6 @@ func (mds *ModelDaoSqlite) initializeOrgPreferences(ctx context.Context) error { users, _ := mds.GetUsers(ctx) countUsers := len(users) - telemetry.GetInstance().SetCountUsers(int8(countUsers)) if countUsers > 0 { telemetry.GetInstance().SetCompanyDomain(users[countUsers-1].Email) telemetry.GetInstance().SetUserEmail(users[countUsers-1].Email) diff --git a/pkg/query-service/dao/sqlite/rbac.go b/pkg/query-service/dao/sqlite/rbac.go index aba9beb065..bb594ac463 100644 --- a/pkg/query-service/dao/sqlite/rbac.go +++ b/pkg/query-service/dao/sqlite/rbac.go @@ -612,3 +612,19 @@ func (mds *ModelDaoSqlite) PrecheckLogin(ctx context.Context, email, sourceUrl s return resp, nil } + +func (mds *ModelDaoSqlite) GetUserRole(ctx context.Context, groupId string) (string, error) { + role, err := mds.GetGroup(ctx, groupId) + if err != nil || role == nil { + return "", err + } + return role.Name, nil +} + +func (mds *ModelDaoSqlite) GetUserCount(ctx context.Context) (int, error) { + users, err := mds.GetUsers(ctx) + if err != nil { + return 0, err + } + return len(users), nil +} diff --git a/pkg/query-service/telemetry/telemetry.go b/pkg/query-service/telemetry/telemetry.go index c916135f4e..88f3a09542 100644 --- a/pkg/query-service/telemetry/telemetry.go +++ b/pkg/query-service/telemetry/telemetry.go @@ -176,16 +176,25 @@ type Telemetry struct { rateLimits map[string]int8 activeUser map[string]int8 patTokenUser bool - countUsers int8 mutex sync.RWMutex alertsInfoCallback func(ctx context.Context) (*model.AlertsInfo, error) + userCountCallback func(ctx context.Context) (int, error) + userRoleCallback func(ctx context.Context, groupId string) (string, error) } func (a *Telemetry) SetAlertsInfoCallback(callback func(ctx context.Context) (*model.AlertsInfo, error)) { a.alertsInfoCallback = callback } +func (a *Telemetry) SetUserCountCallback(callback func(ctx context.Context) (int, error)) { + a.userCountCallback = callback +} + +func (a *Telemetry) SetUserRoleCallback(callback func(ctx context.Context, groupId string) (string, error)) { + a.userRoleCallback = callback +} + func createTelemetry() { // Do not do anything in CI (not even resolving the outbound IP address) if testing.Testing() { @@ -259,6 +268,8 @@ func createTelemetry() { metricsTTL, _ := telemetry.reader.GetTTL(ctx, &model.GetTTLParams{Type: constants.MetricsTTL}) logsTTL, _ := telemetry.reader.GetTTL(ctx, &model.GetTTLParams{Type: constants.LogsTTL}) + userCount, _ := telemetry.userCountCallback(ctx) + data := map[string]interface{}{ "totalSpans": totalSpans, "spansInLastHeartBeatInterval": spansInLastHeartBeatInterval, @@ -266,7 +277,7 @@ func createTelemetry() { "getSamplesInfoInLastHeartBeatInterval": getSamplesInfoInLastHeartBeatInterval, "totalLogs": totalLogs, "getLogsInfoInLastHeartBeatInterval": getLogsInfoInLastHeartBeatInterval, - "countUsers": telemetry.countUsers, + "countUsers": userCount, "metricsTTLStatus": metricsTTL.Status, "tracesTTLStatus": traceTTL.Status, "logsTTLStatus": logsTTL.Status, @@ -450,11 +461,22 @@ func (a *Telemetry) IdentifyUser(user *model.User) { if !a.isTelemetryEnabled() || a.isTelemetryAnonymous() { return } + // extract user group from user.groupId + role, _ := a.userRoleCallback(context.Background(), user.GroupId) + if a.saasOperator != nil { - a.saasOperator.Enqueue(analytics.Identify{ - UserId: a.userEmail, - Traits: analytics.NewTraits().SetName(user.Name).SetEmail(user.Email), - }) + if role != "" { + a.saasOperator.Enqueue(analytics.Identify{ + UserId: a.userEmail, + Traits: analytics.NewTraits().SetName(user.Name).SetEmail(user.Email).Set("role", role), + }) + } else { + a.saasOperator.Enqueue(analytics.Identify{ + UserId: a.userEmail, + Traits: analytics.NewTraits().SetName(user.Name).SetEmail(user.Email), + }) + } + a.saasOperator.Enqueue(analytics.Group{ UserId: a.userEmail, GroupId: a.getCompanyDomain(), @@ -474,10 +496,6 @@ func (a *Telemetry) IdentifyUser(user *model.User) { }) } -func (a *Telemetry) SetCountUsers(countUsers int8) { - a.countUsers = countUsers -} - func (a *Telemetry) SetUserEmail(email string) { a.userEmail = email } From ae857d3fcd11f74b702ac882b829518e5e0abae2 Mon Sep 17 00:00:00 2001 From: Sudeep MP Date: Fri, 6 Sep 2024 14:26:13 +0100 Subject: [PATCH 17/18] feat(paywall blocker): improvements for trial end blocker screen (#5756) * feat: add view templates option to dashboard menu * feat: increase dropdown overlay width Set the dropdown overlay width to 200px to provide breathing space for the dropdown button. Added flex to wrap the dropdown button to create space between the right icon and the left elements. * feat(paywall blocker): improvements for trial end blocker screen - added new components locally for rendering static contents - fixed SCSS code for better readablity - seperated data to specific file - added alert info style for the non admin users message * chore: fixed few conditions * feat(paywall title): added contact us to modal title * feat: non admin users communication styles * chore: added useState for the sidebar collapse state to be false * test(WorkspaceLocked): update Jest test to sync with recent UX copy changes * feat(workspaceLocked): added locale added English and English-GB translations for workspace locked messages * feat: reverted the translation for and sidebar collapse fix - I have removed the scope for unitest having locale support - remove the useEffect way to set sidebar collapse, instead added it in app layout - removed the opacity effect on tabs * refactor(workspaceLocked): refactor appLayout component to simplify the isWorkspaceLocked function * refactor(workspaceLocked): simplify isWorkspaceLocked by converting it to a constant expression * refactor(workspaceLocked): refactor modal classname and variable --------- Co-authored-by: Pranay Prateek --- .../public/locales/en-GB/workspaceLocked.json | 22 ++ .../public/locales/en/workspaceLocked.json | 22 ++ frontend/src/container/AppLayout/index.tsx | 11 +- .../WorkspaceLocked/CustomerStoryCard.tsx | 35 ++ .../src/pages/WorkspaceLocked/InfoBlocks.tsx | 30 ++ .../WorkspaceLocked.styles.scss | 159 ++++++++- .../WorkspaceLocked/WorkspaceLocked.test.tsx | 16 +- .../pages/WorkspaceLocked/WorkspaceLocked.tsx | 303 ++++++++++++++---- .../customerStoryCard.styles.scss | 33 ++ .../WorkspaceLocked/workspaceLocked.data.ts | 156 +++++++++ 10 files changed, 715 insertions(+), 72 deletions(-) create mode 100644 frontend/public/locales/en-GB/workspaceLocked.json create mode 100644 frontend/public/locales/en/workspaceLocked.json create mode 100644 frontend/src/pages/WorkspaceLocked/CustomerStoryCard.tsx create mode 100644 frontend/src/pages/WorkspaceLocked/InfoBlocks.tsx create mode 100644 frontend/src/pages/WorkspaceLocked/customerStoryCard.styles.scss create mode 100644 frontend/src/pages/WorkspaceLocked/workspaceLocked.data.ts diff --git a/frontend/public/locales/en-GB/workspaceLocked.json b/frontend/public/locales/en-GB/workspaceLocked.json new file mode 100644 index 0000000000..1eb6a0da1c --- /dev/null +++ b/frontend/public/locales/en-GB/workspaceLocked.json @@ -0,0 +1,22 @@ +{ + "trialPlanExpired": "Trial Plan Expired", + "gotQuestions": "Got Questions?", + "contactUs": "Contact Us", + "upgradeToContinue": "Upgrade to Continue", + "upgradeNow": "Upgrade now to keep enjoying all the great features you’ve been using.", + "yourDataIsSafe": "Your data is safe with us until", + "actNow": "Act now to avoid any disruptions and continue where you left off.", + "contactAdmin": "Contact your admin to proceed with the upgrade.", + "continueMyJourney": "Continue My Journey", + "needMoreTime": "Need More Time?", + "extendTrial": "Extend Trial", + "extendTrialMsgPart1": "If you have a specific reason why you were not able to finish your PoC in the trial period, please write to us on", + "extendTrialMsgPart2": "with the reason. Sometimes we can extend trial by a few days on a case by case basis", + "whyChooseSignoz": "Why choose Signoz", + "enterpriseGradeObservability": "Enterprise-grade Observability", + "observabilityDescription": "Get access to observability at any scale with advanced security and compliance.", + "continueToUpgrade": "Continue to Upgrade", + "youAreInGoodCompany": "You are in good company", + "faqs": "FAQs", + "somethingWentWrong": "Something went wrong" +} diff --git a/frontend/public/locales/en/workspaceLocked.json b/frontend/public/locales/en/workspaceLocked.json new file mode 100644 index 0000000000..1eb6a0da1c --- /dev/null +++ b/frontend/public/locales/en/workspaceLocked.json @@ -0,0 +1,22 @@ +{ + "trialPlanExpired": "Trial Plan Expired", + "gotQuestions": "Got Questions?", + "contactUs": "Contact Us", + "upgradeToContinue": "Upgrade to Continue", + "upgradeNow": "Upgrade now to keep enjoying all the great features you’ve been using.", + "yourDataIsSafe": "Your data is safe with us until", + "actNow": "Act now to avoid any disruptions and continue where you left off.", + "contactAdmin": "Contact your admin to proceed with the upgrade.", + "continueMyJourney": "Continue My Journey", + "needMoreTime": "Need More Time?", + "extendTrial": "Extend Trial", + "extendTrialMsgPart1": "If you have a specific reason why you were not able to finish your PoC in the trial period, please write to us on", + "extendTrialMsgPart2": "with the reason. Sometimes we can extend trial by a few days on a case by case basis", + "whyChooseSignoz": "Why choose Signoz", + "enterpriseGradeObservability": "Enterprise-grade Observability", + "observabilityDescription": "Get access to observability at any scale with advanced security and compliance.", + "continueToUpgrade": "Continue to Upgrade", + "youAreInGoodCompany": "You are in good company", + "faqs": "FAQs", + "somethingWentWrong": "Something went wrong" +} diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index 60b26b8db2..4cf2e0f5bb 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -214,7 +214,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const pageTitle = t(routeKey); const renderFullScreen = pathname === ROUTES.GET_STARTED || - pathname === ROUTES.WORKSPACE_LOCKED || pathname === ROUTES.GET_STARTED_APPLICATION_MONITORING || pathname === ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING || pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT || @@ -282,6 +281,14 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const isSideNavCollapsed = getLocalStorageKey(IS_SIDEBAR_COLLAPSED); + /** + * Note: Right now we don't have a page-level method to pass the sidebar collapse state. + * Since the use case for overriding is not widely needed, we are setting it here + * so that the workspace locked page will have an expanded sidebar regardless of how users + * have set it or what is stored in localStorage. This will not affect the localStorage config. + */ + const isWorkspaceLocked = pathname === ROUTES.WORKSPACE_LOCKED; + return ( )}
+ + + } + title={personName} + description={role} + /> + {message} + + + + ); +} +export default CustomerStoryCard; diff --git a/frontend/src/pages/WorkspaceLocked/InfoBlocks.tsx b/frontend/src/pages/WorkspaceLocked/InfoBlocks.tsx new file mode 100644 index 0000000000..90bec521d7 --- /dev/null +++ b/frontend/src/pages/WorkspaceLocked/InfoBlocks.tsx @@ -0,0 +1,30 @@ +import { Col, Row, Space, Typography } from 'antd'; + +interface InfoItem { + title: string; + description: string; + id: string; // Add a unique identifier +} + +interface InfoBlocksProps { + items: InfoItem[]; +} + +function InfoBlocks({ items }: InfoBlocksProps): JSX.Element { + return ( + + {items.map((item) => ( + +
+ {item.title} + + + {item.description} + + + ))} + + ); +} + +export default InfoBlocks; diff --git a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss index c35284241a..131601bfb0 100644 --- a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss +++ b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss @@ -1,16 +1,161 @@ -.workspace-locked-container { - text-align: center; - padding: 48px; - margin: 24px; +$light-theme: 'lightMode'; + +@keyframes gradientFlow { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } } -.workpace-locked-details { - width: 50%; - margin: 0 auto; +.workspace-locked { + &__modal { + .ant-modal-mask { + backdrop-filter: blur(2px); + } + } + + &__tabs { + margin-top: 148px; + + .ant-tabs { + &-nav { + &::before { + border-color: var(--bg-slate-500); + + .#{$light-theme} & { + border-color: var(--bg-vanilla-300); + } + } + } + &-nav-wrap { + justify-content: center; + } + } + } + + &__modal { + &__header { + display: flex; + justify-content: space-between; + align-items: center; + + &__actions { + display: flex; + align-items: center; + gap: 16px; + } + } + .ant-modal-content { + border-radius: 4px; + border: 1px solid var(--bg-slate-400); + background: linear-gradient( + 139deg, + rgba(18, 19, 23, 0.8) 0%, + rgba(18, 19, 23, 0.9) 98.68% + ); + box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(20px); + + .#{$light-theme} & { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-100); + } + } + + .ant-modal-header { + background: transparent; + } + + .ant-list { + &-item { + border-color: var(--bg-slate-500); + + .#{$light-theme} & { + border-color: var(--bg-vanilla-300); + } + + &-meta { + align-items: center !important; + + &-title { + margin-bottom: 0 !important; + } + + &-avatar { + display: flex; + } + } + } + } + &__title { + font-weight: 400; + color: var(--text-vanilla-400); + + .#{$light-theme} & { + color: var(--text-ink-200); + } + } + &__cta { + margin-top: 54px; + } + } + &__container { + padding-top: 64px; + } + &__details { + width: 80%; + margin: 0 auto; + color: var(--text-vanilla-400, #c0c1c3); + text-align: center; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 24px; /* 150% */ + + .#{$light-theme} & { + color: var(--text-ink-200); + } + + &__highlight { + color: var(--text-vanilla-100, #fff); + font-style: normal; + font-weight: 700; + line-height: 24px; + + .#{$light-theme} & { + color: var(--text-ink-100); + } + } + } + &__title { + background: linear-gradient( + 99deg, + #ead8fd 0%, + #7a97fa 33%, + #fd5ab2 66%, + #ead8fd 100% + ); + background-size: 300% 300%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: gradientFlow 24s ease infinite; + margin-bottom: 18px; + } } .contact-us { margin-top: 48px; + color: var(--text-vanilla-400); + + .#{$light-theme} & { + color: var(--text-ink-200); + } } .cta { diff --git a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx index bc6885ae65..e459003665 100644 --- a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx +++ b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx @@ -20,17 +20,17 @@ describe('WorkspaceLocked', () => { }); const workspaceLocked = await screen.findByRole('heading', { - name: /workspace locked/i, + name: /upgrade to continue/i, }); expect(workspaceLocked).toBeInTheDocument(); const gotQuestionText = await screen.findByText(/got question?/i); expect(gotQuestionText).toBeInTheDocument(); - const contactUsLink = await screen.findByRole('link', { - name: /contact us/i, + const contactUsBtn = await screen.findByRole('button', { + name: /Contact Us/i, }); - expect(contactUsLink).toBeInTheDocument(); + expect(contactUsBtn).toBeInTheDocument(); }); test('Render for Admin', async () => { @@ -42,11 +42,11 @@ describe('WorkspaceLocked', () => { render(); const contactAdminMessage = await screen.queryByText( - /please contact your administrator for further help/i, + /contact your admin to proceed with the upgrade./i, ); expect(contactAdminMessage).not.toBeInTheDocument(); const updateCreditCardBtn = await screen.findByRole('button', { - name: /update credit card/i, + name: /continue my journey/i, }); expect(updateCreditCardBtn).toBeInTheDocument(); }); @@ -60,12 +60,12 @@ describe('WorkspaceLocked', () => { render(, {}, 'VIEWER'); const updateCreditCardBtn = await screen.queryByRole('button', { - name: /update credit card/i, + name: /Continue My Journey/i, }); expect(updateCreditCardBtn).not.toBeInTheDocument(); const contactAdminMessage = await screen.findByText( - /please contact your administrator for further help/i, + /contact your admin to proceed with the upgrade./i, ); expect(contactAdminMessage).toBeInTheDocument(); }); diff --git a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx index 0cc3990af7..84d977ae81 100644 --- a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx +++ b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx @@ -1,21 +1,30 @@ /* eslint-disable react/no-unescaped-entities */ import './WorkspaceLocked.styles.scss'; +import type { TabsProps } from 'antd'; import { - CreditCardOutlined, - LockOutlined, - SendOutlined, -} from '@ant-design/icons'; -import { Button, Card, Skeleton, Typography } from 'antd'; + Alert, + Button, + Col, + Collapse, + Flex, + List, + Modal, + Row, + Skeleton, + Space, + Tabs, + Typography, +} from 'antd'; import updateCreditCardApi from 'api/billing/checkout'; import logEvent from 'api/common/logEvent'; -import { SOMETHING_WENT_WRONG } from 'constants/api'; import ROUTES from 'constants/routes'; -import FullScreenHeader from 'container/FullScreenHeader/FullScreenHeader'; import useLicense from 'hooks/useLicense'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; +import { CircleArrowRight } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useMutation } from 'react-query'; import { useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; @@ -23,13 +32,22 @@ import { License } from 'types/api/licenses/def'; import AppReducer from 'types/reducer/app'; import { getFormattedDate } from 'utils/timeUtils'; +import CustomerStoryCard from './CustomerStoryCard'; +import InfoBlocks from './InfoBlocks'; +import { + customerStoriesData, + enterpriseGradeValuesData, + faqData, + infoData, +} from './workspaceLocked.data'; + export default function WorkspaceBlocked(): JSX.Element { const { role } = useSelector((state) => state.app); const isAdmin = role === 'ADMIN'; const [activeLicense, setActiveLicense] = useState(null); - const { notifications } = useNotifications(); + const { t } = useTranslation(['workspaceLocked']); const { isFetching: isFetchingLicenseData, isLoading: isLoadingLicenseData, @@ -67,7 +85,7 @@ export default function WorkspaceBlocked(): JSX.Element { }, onError: () => notifications.error({ - message: SOMETHING_WENT_WRONG, + message: t('somethingWentWrong'), }), }, ); @@ -87,73 +105,248 @@ export default function WorkspaceBlocked(): JSX.Element { logEvent('Workspace Blocked: User Clicked Extend Trial', {}); notifications.info({ - message: 'Extend Trial', + message: t('extendTrial'), + duration: 0, description: ( - If you have a specific reason why you were not able to finish your PoC in - the trial period, please write to us on - cloud-support@signoz.io - with the reason. Sometimes we can extend trial by a few days on a case by - case basis + {t('extendTrialMsgPart1')}{' '} + cloud-support@signoz.io{' '} + {t('extendTrialMsgPart2')} ), }); }; - return ( - <> - - - - {isLoadingLicenseData || !licensesData?.payload?.workSpaceBlock ? ( - - ) : ( - <> - - Workspace Locked - - You have been locked out of your workspace because your trial ended - without an upgrade to a paid plan. Your data will continue to be ingested - till{' '} - {getFormattedDate(licensesData?.payload?.gracePeriodEnd || Date.now())} , - at which point we will drop all the ingested data and terminate the - account. - {!isAdmin && 'Please contact your administrator for further help'} - - -
+ const renderCustomerStories = ( + filterCondition: (index: number) => boolean, + ): JSX.Element[] => + customerStoriesData + .filter((_, index) => filterCondition(index)) + .map((story) => ( + + )); + + const tabItems: TabsProps['items'] = [ + { + key: '1', + label: t('whyChooseSignoz'), + children: ( + +
+ + + + + + + + + {t('enterpriseGradeObservability')} + + {t('observabilityDescription')} + + ( + + } title={item.title} /> + + )} + /> + + {isAdmin && ( + + + + )} + + + + ), + }, + { + key: '2', + label: t('youAreInGoodCompany'), + children: ( + + {/* #FIXME: please suggest if there is any better way to loop in different columns to get the masonry layout */} + {renderCustomerStories((index) => index % 2 === 0)} + {renderCustomerStories((index) => index % 2 !== 0)} + {isAdmin && ( + + + + + )} + + ), + }, + // #TODO: comming soon + // { + // key: '3', + // label: 'Our Pricing', + // children: 'Our Pricing', + // }, + { + key: '4', + label: t('faqs'), + children: ( + + + + + {isAdmin && ( + )} + + + + ), + }, + ]; + return ( +
+ + + {t('trialPlanExpired')} + + + + Got Questions? + -
-
- Got Questions? - - Contact Us - -
- - )} - - + + + } + open + closable={false} + footer={null} + width="65%" + > +
+ {isLoadingLicenseData || !licensesData ? ( + + ) : ( + <> + +
+ + +
Upgrade to Continue
+
+ + {t('upgradeNow')} +
+ {t('yourDataIsSafe')}{' '} + + {getFormattedDate( + licensesData.payload?.gracePeriodEnd || Date.now(), + )} + {' '} + {t('actNow')} +
+
+ + + {!isAdmin && ( + + + + + + )} + {isAdmin && ( + + + + + + + + + )} + + + + + + )} + + + ); } diff --git a/frontend/src/pages/WorkspaceLocked/customerStoryCard.styles.scss b/frontend/src/pages/WorkspaceLocked/customerStoryCard.styles.scss new file mode 100644 index 0000000000..7abddada3a --- /dev/null +++ b/frontend/src/pages/WorkspaceLocked/customerStoryCard.styles.scss @@ -0,0 +1,33 @@ +$component-name: 'customer-story-card'; +$ant-card-override: 'ant-card'; +$light-theme: 'lightMode'; + +.#{$component-name} { + max-width: 385px; + margin: 0 auto; // Center the card within the column + margin-bottom: 24px; + border-radius: 6px; + transition: transform 0.3s ease, box-shadow 0.3s ease; + background-color: var(--bg-ink-400); + border: 1px solid var(--bg-ink-300); + + .#{$light-theme} & { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-100); + } + + .#{$ant-card-override}-meta-title { + margin-bottom: 2px !important; + } + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + background-color: var(--bg-ink-300); + + .#{$light-theme} & { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + background-color: var(--bg-vanilla-100); + } + } +} diff --git a/frontend/src/pages/WorkspaceLocked/workspaceLocked.data.ts b/frontend/src/pages/WorkspaceLocked/workspaceLocked.data.ts new file mode 100644 index 0000000000..0f4d07b96e --- /dev/null +++ b/frontend/src/pages/WorkspaceLocked/workspaceLocked.data.ts @@ -0,0 +1,156 @@ +export const infoData = [ + { + id: 'infoBlock-1', + title: 'Built for scale', + description: + 'Our powerful ingestion engine has a proven track record of handling 10TB+ data ingestion per day.', + }, + { + id: 'infoBlock-2', + title: 'Trusted across the globe', + description: + 'Used by teams in all 5 continents ⎯ across the mountains, rivers, and the high seas.', + }, + { + id: 'infoBlock-3', + title: 'Powering observability for teams of all sizes', + description: + 'Hundreds of companies ⎯from early-stage start-ups to public enterprises use SigNoz to build more reliable products.', + }, +]; + +export const enterpriseGradeValuesData = [ + { + title: 'SSO and SAML support', + }, + { + title: 'Query API keys', + }, + { + title: 'Advanced security with SOC 2 Type I certification', + }, + { + title: 'AWS Private Link', + }, + { + title: 'VPC peering', + }, + { + title: 'Custom integrations', + }, +]; + +export const customerStoriesData = [ + { + key: 'c-story-1', + avatar: 'https://signoz.io/img/users/subomi-oluwalana.webp', + personName: 'Subomi Oluwalana', + role: 'Founder & CEO at Convoy', + customerName: 'Convoy', + message: + "We use OTel with SigNoz to spot redundant database connect calls. For example, we found that our database driver wasn't using the connection pool even though the documentation claimed otherwise.", + link: + 'https://www.linkedin.com/feed/update/urn:li:activity:7212117589068591105/', + }, + { + key: 'c-story-2', + avatar: 'https://signoz.io/img/users/dhruv-garg.webp', + personName: 'Dhruv Garg', + role: 'Tech Lead at Nudge', + customerName: 'Nudge', + message: + 'SigNoz is one of the best observability tools you can self-host hands down. And they are always there to help on their slack channel when needed.', + link: + 'https://www.linkedin.com/posts/dhruv-garg79_signoz-docker-kubernetes-activity-7205163679028240384-Otlb/', + }, + { + key: 'c-story-3', + avatar: 'https://signoz.io/img/users/vivek-bhakta.webp', + personName: 'Vivek Bhakta', + role: 'CTO at Wombo AI', + customerName: 'Wombo AI', + message: + 'We use SigNoz and have been loving it - can definitely handle scale.', + link: 'https://x.com/notorious_VB/status/1701773119696904242', + }, + { + key: 'c-story-4', + avatar: 'https://signoz.io/img/users/pranay-narang.webp', + personName: 'Pranay Narang', + role: 'Engineering at Azodha', + customerName: 'Azodha', + message: + 'Recently moved metrics and logging to SigNoz. Gotta say, absolutely loving the tool.', + link: 'https://x.com/PranayNarang/status/1676247073396752387', + }, + { + key: 'c-story-4', + avatar: 'https://signoz.io/img/users/shey.webp', + personName: 'Sheheryar Sewani', + role: 'Seasoned Rails Dev & Founder', + customerName: '', + message: + "But wow, I'm glad I tried SigNoz. Setting up SigNoz was easy—they provide super helpful instructions along with a docker-compose file.", + link: + 'https://www.linkedin.com/feed/update/urn:li:activity:7181011853915926528/', + }, + { + key: 'c-story-5', + avatar: 'https://signoz.io/img/users/daniel.webp', + personName: 'Daniel Schell', + role: 'Founder & CTO at Airlockdigital', + customerName: 'Airlockdigital', + message: + 'Have been deep diving Signoz. Seems like the new hotness for an "all-in-one".', + link: 'https://x.com/danonit/status/1749256583157284919', + }, + { + key: 'c-story-6', + avatar: 'https://signoz.io/img/users/go-frendi.webp', + personName: 'Go Frendi Gunawan', + role: 'Data Engineer at Ctlyst.id', + customerName: 'Ctlyst.id', + message: + 'Monitoring done. Thanks to SigNoz, I don’t have to deal with Grafana, Loki, Prometheus, and Jaeger separately.', + link: 'https://x.com/gofrendiasgard/status/1680139003658641408', + }, + { + key: 'c-story-7', + avatar: 'https://signoz.io/img/users/anselm.jpg', + personName: 'Anselm Eickhoff', + role: 'Software Architect', + customerName: '', + message: + 'NewRelic: receiving OpenTelemetry at all takes me 1/2 day to grok, docs are a mess. Traces show up after 5min. I burn the free 100GB/mo in 1 day of light testing. @SignozHQ: can run it locally (∞GB), has a special tutorial for OpenTelemetry + Rust! Traces show up immediately.', + link: + 'https://twitter.com/ae_play/status/1572993932094472195?s=20&t=LWWrW5EP_k5q6_mwbFN4jQ', + }, +]; + +export const faqData = [ + { + key: '1', + label: + 'What is the difference between SigNoz Cloud(Teams) and Community Edition?', + children: + 'You can self-host and manage the community edition yourself. You should choose SigNoz Cloud if you don’t want to worry about managing the SigNoz cluster. There are some exclusive features like SSO & SAML support, which come with SigNoz cloud offering. Our team also offers support on the initial configuration of dashboards & alerts and advises on best practices for setting up your observability stack in the SigNoz cloud offering.', + }, + { + key: '2', + label: 'How are number of samples calculated for metrics pricing?', + children: + "If a timeseries sends data every 30s, then it will generate 2 samples per min. So, if you have 10,000 time series sending data every 30s then you will be sending 20,000 samples per min to SigNoz. This will be around 864 mn samples per month and would cost 86.4 USD/month. Here's an explainer video on how metrics pricing is calculated - Link: https://vimeo.com/973012522", + }, + { + key: '3', + label: 'Do you offer enterprise support plans?', + children: + 'Yes, feel free to reach out to us on hello@signoz.io if you need a dedicated support plan or paid support for setting up your initial SigNoz setup.', + }, + { + key: '4', + label: 'Who should use Enterprise plans?', + children: + 'Teams which need enterprise support or features like SSO, Audit logs, etc. may find our enterprise plans valuable.', + }, +]; From afc97511af366bb6f78408a30c420ade573b6610 Mon Sep 17 00:00:00 2001 From: Abhishek Mehandiratta <36722596+abhi12299@users.noreply.github.com> Date: Sat, 7 Sep 2024 02:22:32 +0530 Subject: [PATCH 18/18] feat(dashboard): add widget count to collapsed section rows (#5822) --- .../src/container/GridCardLayout/GridCardLayout.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/src/container/GridCardLayout/GridCardLayout.tsx b/frontend/src/container/GridCardLayout/GridCardLayout.tsx index 4600b46dd1..c4e4279f9f 100644 --- a/frontend/src/container/GridCardLayout/GridCardLayout.tsx +++ b/frontend/src/container/GridCardLayout/GridCardLayout.tsx @@ -472,6 +472,15 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element { if (currentWidget?.panelTypes === PANEL_GROUP_TYPES.ROW) { const rowWidgetProperties = currentPanelMap[id] || {}; + let { title } = currentWidget; + if (rowWidgetProperties.collapsed) { + const widgetCount = rowWidgetProperties.widgets?.length || 0; + const collapsedText = `(${widgetCount} widget${ + widgetCount > 1 ? 's' : '' + })`; + title += ` ${collapsedText}`; + } + return ( )} - - {currentWidget.title} - + {title} {rowWidgetProperties.collapsed ? (