diff --git a/alert/feedback.go b/alert/feedback.go new file mode 100644 index 0000000000..b1aac279c9 --- /dev/null +++ b/alert/feedback.go @@ -0,0 +1,7 @@ +package alert + +// Feedback represents user provided information about a given alert +type Feedback struct { + AlertID int + NoiseReason string +} diff --git a/alert/queries.sql b/alert/queries.sql index cf2e1b2bf7..0f0875da10 100644 --- a/alert/queries.sql +++ b/alert/queries.sql @@ -1,23 +1,50 @@ -- name: LockOneAlertService :one -SELECT maintenance_expires_at notnull::bool AS is_maint_mode, +SELECT + maintenance_expires_at NOTNULL::bool AS is_maint_mode, alerts.status -FROM services svc +FROM + services svc JOIN alerts ON alerts.service_id = svc.id -WHERE alerts.id = $1 FOR -UPDATE; +WHERE + alerts.id = $1 +FOR UPDATE; -- name: RequestAlertEscalationByTime :one -UPDATE escalation_policy_state -SET force_escalation = TRUE -WHERE alert_id = $1 - AND ( - last_escalation <= $2::timestamptz - OR last_escalation IS NULL - ) RETURNING TRUE; +UPDATE + escalation_policy_state +SET + force_escalation = TRUE +WHERE + alert_id = $1 + AND (last_escalation <= $2::timestamptz + OR last_escalation IS NULL) +RETURNING + TRUE; -- name: AlertHasEPState :one -SELECT EXISTS ( - SELECT 1 - FROM escalation_policy_state - WHERE alert_id = $1 - ) AS has_ep_state; +SELECT + EXISTS ( + SELECT + 1 + FROM + escalation_policy_state + WHERE + alert_id = $1) AS has_ep_state; + +-- name: AlertFeedback :one +SELECT + alert_id, + noise_reason +FROM + alert_feedback +WHERE + alert_id = $1; + +-- name: SetAlertFeedback :exec +INSERT INTO alert_feedback(alert_id, noise_reason) + VALUES ($1, $2) +ON CONFLICT (alert_id) + DO UPDATE SET + noise_reason = $2 + WHERE + alert_feedback.alert_id = $1; diff --git a/alert/store.go b/alert/store.go index e86455de32..2b152b001f 100644 --- a/alert/store.go +++ b/alert/store.go @@ -791,3 +791,47 @@ func (s *Store) State(ctx context.Context, alertIDs []int) ([]State, error) { return list, nil } + +func (s *Store) Feedback(ctx context.Context, alertID int) (*Feedback, error) { + err := permission.LimitCheckAny(ctx, permission.System, permission.User) + if err != nil { + return nil, err + } + + row, err := gadb.New(s.db).AlertFeedback(ctx, int64(alertID)) + if errors.Is(err, sql.ErrNoRows) { + return &Feedback{ + AlertID: alertID, + }, nil + } + if err != nil { + return nil, err + } + + return &Feedback{ + AlertID: int(row.AlertID), + NoiseReason: row.NoiseReason, + }, err +} + +func (s Store) UpdateFeedback(ctx context.Context, feedback *Feedback) error { + err := permission.LimitCheckAny(ctx, permission.System, permission.User) + if err != nil { + return err + } + + err = validate.Text("NoiseReason", feedback.NoiseReason, 1, 255) + if err != nil { + return err + } + + err = gadb.New(s.db).SetAlertFeedback(ctx, gadb.SetAlertFeedbackParams{ + AlertID: int64(feedback.AlertID), + NoiseReason: feedback.NoiseReason, + }) + if err != nil { + return err + } + + return nil +} diff --git a/gadb/models.go b/gadb/models.go index 9b1f842593..9591b41cb4 100644 --- a/gadb/models.go +++ b/gadb/models.go @@ -760,6 +760,11 @@ type Alert struct { Details string } +type AlertFeedback struct { + AlertID int64 + NoiseReason string +} + type AlertLog struct { ID int64 AlertID sql.NullInt64 diff --git a/gadb/queries.sql.go b/gadb/queries.sql.go index d295540398..bf16169206 100644 --- a/gadb/queries.sql.go +++ b/gadb/queries.sql.go @@ -15,12 +15,32 @@ import ( "github.com/lib/pq" ) +const alertFeedback = `-- name: AlertFeedback :one +SELECT + alert_id, + noise_reason +FROM + alert_feedback +WHERE + alert_id = $1 +` + +func (q *Queries) AlertFeedback(ctx context.Context, alertID int64) (AlertFeedback, error) { + row := q.db.QueryRowContext(ctx, alertFeedback, alertID) + var i AlertFeedback + err := row.Scan(&i.AlertID, &i.NoiseReason) + return i, err +} + const alertHasEPState = `-- name: AlertHasEPState :one -SELECT EXISTS ( - SELECT 1 - FROM escalation_policy_state - WHERE alert_id = $1 - ) AS has_ep_state +SELECT + EXISTS ( + SELECT + 1 + FROM + escalation_policy_state + WHERE + alert_id = $1) AS has_ep_state ` func (q *Queries) AlertHasEPState(ctx context.Context, alertID int64) (bool, error) { @@ -324,12 +344,15 @@ func (q *Queries) FindOneCalSubForUpdate(ctx context.Context, id uuid.UUID) (Fin } const lockOneAlertService = `-- name: LockOneAlertService :one -SELECT maintenance_expires_at notnull::bool AS is_maint_mode, +SELECT + maintenance_expires_at NOTNULL::bool AS is_maint_mode, alerts.status -FROM services svc +FROM + services svc JOIN alerts ON alerts.service_id = svc.id -WHERE alerts.id = $1 FOR -UPDATE +WHERE + alerts.id = $1 +FOR UPDATE ` type LockOneAlertServiceRow struct { @@ -386,13 +409,16 @@ func (q *Queries) Now(ctx context.Context) (time.Time, error) { } const requestAlertEscalationByTime = `-- name: RequestAlertEscalationByTime :one -UPDATE escalation_policy_state -SET force_escalation = TRUE -WHERE alert_id = $1 - AND ( - last_escalation <= $2::timestamptz - OR last_escalation IS NULL - ) RETURNING TRUE +UPDATE + escalation_policy_state +SET + force_escalation = TRUE +WHERE + alert_id = $1 + AND (last_escalation <= $2::timestamptz + OR last_escalation IS NULL) +RETURNING + TRUE ` type RequestAlertEscalationByTimeParams struct { @@ -407,6 +433,26 @@ func (q *Queries) RequestAlertEscalationByTime(ctx context.Context, arg RequestA return column_1, err } +const setAlertFeedback = `-- name: SetAlertFeedback :exec +INSERT INTO alert_feedback(alert_id, noise_reason) + VALUES ($1, $2) +ON CONFLICT (alert_id) + DO UPDATE SET + noise_reason = $2 + WHERE + alert_feedback.alert_id = $1 +` + +type SetAlertFeedbackParams struct { + AlertID int64 + NoiseReason string +} + +func (q *Queries) SetAlertFeedback(ctx context.Context, arg SetAlertFeedbackParams) error { + _, err := q.db.ExecContext(ctx, setAlertFeedback, arg.AlertID, arg.NoiseReason) + return err +} + const statusMgrCMInfo = `-- name: StatusMgrCMInfo :one SELECT user_id, diff --git a/graphql2/generated.go b/graphql2/generated.go index 0d8b851e71..215d983e30 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -98,6 +98,7 @@ type ComplexityRoot struct { Details func(childComplexity int) int ID func(childComplexity int) int Metrics func(childComplexity int) int + NoiseReason func(childComplexity int) int PendingNotifications func(childComplexity int) int RecentEvents func(childComplexity int, input *AlertRecentEventsOptions) int Service func(childComplexity int) int @@ -304,6 +305,7 @@ type ComplexityRoot struct { EscalateAlerts func(childComplexity int, input []int) int LinkAccount func(childComplexity int, token string) int SendContactMethodVerification func(childComplexity int, input SendContactMethodVerificationInput) int + SetAlertNoiseReason func(childComplexity int, input SetAlertNoiseReasonInput) int SetConfig func(childComplexity int, input []ConfigValueInput) int SetFavorite func(childComplexity int, input SetFavoriteInput) int SetLabel func(childComplexity int, input SetLabelInput) int @@ -666,6 +668,7 @@ type AlertResolver interface { RecentEvents(ctx context.Context, obj *alert.Alert, input *AlertRecentEventsOptions) (*AlertLogEntryConnection, error) PendingNotifications(ctx context.Context, obj *alert.Alert) ([]AlertPendingNotification, error) Metrics(ctx context.Context, obj *alert.Alert) (*alertmetrics.Metric, error) + NoiseReason(ctx context.Context, obj *alert.Alert) (*string, error) } type AlertLogEntryResolver interface { Message(ctx context.Context, obj *alertlog.Entry) (string, error) @@ -720,6 +723,7 @@ type MutationResolver interface { UpdateEscalationPolicyStep(ctx context.Context, input UpdateEscalationPolicyStepInput) (bool, error) DeleteAll(ctx context.Context, input []assignment.RawTarget) (bool, error) CreateAlert(ctx context.Context, input CreateAlertInput) (*alert.Alert, error) + SetAlertNoiseReason(ctx context.Context, input SetAlertNoiseReasonInput) (bool, error) CreateService(ctx context.Context, input CreateServiceInput) (*service.Service, error) CreateEscalationPolicy(ctx context.Context, input CreateEscalationPolicyInput) (*escalation.Policy, error) CreateEscalationPolicyStep(ctx context.Context, input CreateEscalationPolicyStepInput) (*escalation.Step, error) @@ -923,6 +927,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Alert.Metrics(childComplexity), true + case "Alert.noiseReason": + if e.complexity.Alert.NoiseReason == nil { + break + } + + return e.complexity.Alert.NoiseReason(childComplexity), true + case "Alert.pendingNotifications": if e.complexity.Alert.PendingNotifications == nil { break @@ -1923,6 +1934,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.SendContactMethodVerification(childComplexity, args["input"].(SendContactMethodVerificationInput)), true + case "Mutation.setAlertNoiseReason": + if e.complexity.Mutation.SetAlertNoiseReason == nil { + break + } + + args, err := ec.field_Mutation_setAlertNoiseReason_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.SetAlertNoiseReason(childComplexity, args["input"].(SetAlertNoiseReasonInput)), true + case "Mutation.setConfig": if e.complexity.Mutation.SetConfig == nil { break @@ -3919,6 +3942,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputScheduleTargetInput, ec.unmarshalInputSendContactMethodVerificationInput, ec.unmarshalInputServiceSearchOptions, + ec.unmarshalInputSetAlertNoiseReasonInput, ec.unmarshalInputSetFavoriteInput, ec.unmarshalInputSetLabelInput, ec.unmarshalInputSetScheduleOnCallNotificationRulesInput, @@ -4437,6 +4461,21 @@ func (ec *executionContext) field_Mutation_sendContactMethodVerification_args(ct return args, nil } +func (ec *executionContext) field_Mutation_setAlertNoiseReason_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 SetAlertNoiseReasonInput + if tmp, ok := rawArgs["input"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + arg0, err = ec.unmarshalNSetAlertNoiseReasonInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐSetAlertNoiseReasonInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_setConfig_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -6043,6 +6082,47 @@ func (ec *executionContext) fieldContext_Alert_metrics(ctx context.Context, fiel return fc, nil } +func (ec *executionContext) _Alert_noiseReason(ctx context.Context, field graphql.CollectedField, obj *alert.Alert) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Alert_noiseReason(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Alert().NoiseReason(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Alert_noiseReason(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Alert", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _AlertConnection_nodes(ctx context.Context, field graphql.CollectedField, obj *AlertConnection) (ret graphql.Marshaler) { fc, err := ec.fieldContext_AlertConnection_nodes(ctx, field) if err != nil { @@ -6106,6 +6186,8 @@ func (ec *executionContext) fieldContext_AlertConnection_nodes(ctx context.Conte return ec.fieldContext_Alert_pendingNotifications(ctx, field) case "metrics": return ec.fieldContext_Alert_metrics(ctx, field) + case "noiseReason": + return ec.fieldContext_Alert_noiseReason(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Alert", field.Name) }, @@ -11111,6 +11193,8 @@ func (ec *executionContext) fieldContext_Mutation_updateAlerts(ctx context.Conte return ec.fieldContext_Alert_pendingNotifications(ctx, field) case "metrics": return ec.fieldContext_Alert_metrics(ctx, field) + case "noiseReason": + return ec.fieldContext_Alert_noiseReason(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Alert", field.Name) }, @@ -11244,6 +11328,8 @@ func (ec *executionContext) fieldContext_Mutation_escalateAlerts(ctx context.Con return ec.fieldContext_Alert_pendingNotifications(ctx, field) case "metrics": return ec.fieldContext_Alert_metrics(ctx, field) + case "noiseReason": + return ec.fieldContext_Alert_noiseReason(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Alert", field.Name) }, @@ -11597,6 +11683,8 @@ func (ec *executionContext) fieldContext_Mutation_createAlert(ctx context.Contex return ec.fieldContext_Alert_pendingNotifications(ctx, field) case "metrics": return ec.fieldContext_Alert_metrics(ctx, field) + case "noiseReason": + return ec.fieldContext_Alert_noiseReason(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Alert", field.Name) }, @@ -11615,6 +11703,61 @@ func (ec *executionContext) fieldContext_Mutation_createAlert(ctx context.Contex return fc, nil } +func (ec *executionContext) _Mutation_setAlertNoiseReason(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_setAlertNoiseReason(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().SetAlertNoiseReason(rctx, fc.Args["input"].(SetAlertNoiseReasonInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_setAlertNoiseReason(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_setAlertNoiseReason_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_createService(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_createService(ctx, field) if err != nil { @@ -14732,6 +14875,8 @@ func (ec *executionContext) fieldContext_Query_alert(ctx context.Context, field return ec.fieldContext_Alert_pendingNotifications(ctx, field) case "metrics": return ec.fieldContext_Alert_metrics(ctx, field) + case "noiseReason": + return ec.fieldContext_Alert_noiseReason(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Alert", field.Name) }, @@ -28313,6 +28458,44 @@ func (ec *executionContext) unmarshalInputServiceSearchOptions(ctx context.Conte return it, nil } +func (ec *executionContext) unmarshalInputSetAlertNoiseReasonInput(ctx context.Context, obj interface{}) (SetAlertNoiseReasonInput, error) { + var it SetAlertNoiseReasonInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"alertID", "noiseReason"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "alertID": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("alertID")) + data, err := ec.unmarshalNInt2int(ctx, v) + if err != nil { + return it, err + } + it.AlertID = data + case "noiseReason": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("noiseReason")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.NoiseReason = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputSetFavoriteInput(ctx context.Context, obj interface{}) (SetFavoriteInput, error) { var it SetFavoriteInput asMap := map[string]interface{}{} @@ -30189,6 +30372,39 @@ func (ec *executionContext) _Alert(ctx context.Context, sel ast.SelectionSet, ob continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "noiseReason": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Alert_noiseReason(ctx, field, obj) + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) default: panic("unknown field " + strconv.Quote(field.Name)) @@ -32147,6 +32363,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_createAlert(ctx, field) }) + case "setAlertNoiseReason": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_setAlertNoiseReason(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "createService": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_createService(ctx, field) @@ -38790,6 +39013,11 @@ func (ec *executionContext) marshalNServiceOnCallUser2ᚕgithubᚗcomᚋtarget return ret } +func (ec *executionContext) unmarshalNSetAlertNoiseReasonInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐSetAlertNoiseReasonInput(ctx context.Context, v interface{}) (SetAlertNoiseReasonInput, error) { + res, err := ec.unmarshalInputSetAlertNoiseReasonInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalNSetFavoriteInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐSetFavoriteInput(ctx context.Context, v interface{}) (SetFavoriteInput, error) { res, err := ec.unmarshalInputSetFavoriteInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/graphql2/graphqlapp/alert.go b/graphql2/graphqlapp/alert.go index 233b25df2f..af0788dd0a 100644 --- a/graphql2/graphqlapp/alert.go +++ b/graphql2/graphqlapp/alert.go @@ -32,9 +32,8 @@ type ( AlertLogEntryState App ) -func (a *App) Alert() graphql2.AlertResolver { return (*Alert)(a) } -func (a *App) AlertMetric() graphql2.AlertMetricResolver { return (*AlertMetric)(a) } - +func (a *App) Alert() graphql2.AlertResolver { return (*Alert)(a) } +func (a *App) AlertMetric() graphql2.AlertMetricResolver { return (*AlertMetric)(a) } func (a *App) AlertLogEntry() graphql2.AlertLogEntryResolver { return (*AlertLogEntry)(a) } func (a *AlertLogEntry) ID(ctx context.Context, obj *alertlog.Entry) (int, error) { @@ -361,6 +360,28 @@ func (m *Mutation) CreateAlert(ctx context.Context, input graphql2.CreateAlertIn return m.AlertStore.Create(ctx, a) } +func (a *Alert) NoiseReason(ctx context.Context, raw *alert.Alert) (*string, error) { + am, err := a.AlertStore.Feedback(ctx, raw.ID) + if err != nil { + return nil, err + } + if am.NoiseReason == "" { + return nil, nil + } + return &am.NoiseReason, nil +} + +func (m *Mutation) SetAlertNoiseReason(ctx context.Context, input graphql2.SetAlertNoiseReasonInput) (bool, error) { + err := m.AlertStore.UpdateFeedback(ctx, &alert.Feedback{ + AlertID: input.AlertID, + NoiseReason: input.NoiseReason, + }) + if err != nil { + return false, err + } + return true, nil +} + func (a *Alert) RecentEvents(ctx context.Context, obj *alert.Alert, opts *graphql2.AlertRecentEventsOptions) (*graphql2.AlertLogEntryConnection, error) { if opts == nil { opts = new(graphql2.AlertRecentEventsOptions) diff --git a/graphql2/models_gen.go b/graphql2/models_gen.go index 99d0e3a10a..c7fec2d5c3 100644 --- a/graphql2/models_gen.go +++ b/graphql2/models_gen.go @@ -456,6 +456,11 @@ type ServiceSearchOptions struct { FavoritesFirst *bool `json:"favoritesFirst,omitempty"` } +type SetAlertNoiseReasonInput struct { + AlertID int `json:"alertID"` + NoiseReason string `json:"noiseReason"` +} + type SetFavoriteInput struct { Target *assignment.RawTarget `json:"target"` Favorite bool `json:"favorite"` diff --git a/graphql2/schema.graphql b/graphql2/schema.graphql index c8dc1639c9..b150c0fc59 100644 --- a/graphql2/schema.graphql +++ b/graphql2/schema.graphql @@ -514,6 +514,7 @@ type Mutation { deleteAll(input: [TargetInput!]): Boolean! createAlert(input: CreateAlertInput!): Alert + setAlertNoiseReason(input: SetAlertNoiseReasonInput!): Boolean! createService(input: CreateServiceInput!): Service createEscalationPolicy(input: CreateEscalationPolicyInput!): EscalationPolicy @@ -591,6 +592,11 @@ input CreateAlertInput { sanitize: Boolean } +input SetAlertNoiseReasonInput { + alertID: Int! + noiseReason: String! +} + input CreateUserInput { username: String! password: String! @@ -1067,6 +1073,8 @@ type Alert { # metrics are only available for closed alerts metrics: AlertMetric + + noiseReason: String } type AlertMetric { diff --git a/migrate/migrations/20230616110941-add-alerts-feedback.sql b/migrate/migrations/20230616110941-add-alerts-feedback.sql new file mode 100644 index 0000000000..5f2cbbe443 --- /dev/null +++ b/migrate/migrations/20230616110941-add-alerts-feedback.sql @@ -0,0 +1,9 @@ +-- +migrate Up +CREATE TABLE alert_feedback +( + alert_id BIGINT PRIMARY KEY REFERENCES alerts (id) ON DELETE CASCADE, + noise_reason TEXT NOT NULL +); + +-- +migrate Down +DROP TABLE alert_feedback; diff --git a/migrate/schema.sql b/migrate/schema.sql index 7b1ca83ce1..d255c0f645 100644 --- a/migrate/schema.sql +++ b/migrate/schema.sql @@ -1,13 +1,13 @@ -- This file is auto-generated by "make db-schema"; DO NOT EDIT --- DATA=f8b8653517556c6064814fa4778fdf024dde96156ce34f9063a28fb49dd6e5f1 - --- DISK=d2225dfa0730854683a85d12e1b83a1c1b5670b5a756c1d52909eb7ada6bb418 - --- PSQL=d2225dfa0730854683a85d12e1b83a1c1b5670b5a756c1d52909eb7ada6bb418 - +-- DATA=834c3a25163544ad7cf2fe079cc7da7119b95359609b20c584f0561411ff5200 - +-- DISK=334f8f372a31ab442509645785948cbcd3fde9edeb632064547bd7d75e8d7754 - +-- PSQL=334f8f372a31ab442509645785948cbcd3fde9edeb632064547bd7d75e8d7754 - -- -- PostgreSQL database dump -- --- Dumped from database version 13.5 --- Dumped by pg_dump version 13.6 (Ubuntu 13.6-0ubuntu0.21.10.1) +-- Dumped from database version 12.13 +-- Dumped by pg_dump version 15.1 SET statement_timeout = 0; SET lock_timeout = 0; @@ -20,6 +20,13 @@ SET xmloption = content; SET client_min_messages = warning; SET row_security = off; +-- +-- Name: public; Type: SCHEMA; Schema: -; Owner: - +-- + +-- *not* creating schema, since initdb creates it + + -- -- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: - -- @@ -1511,6 +1518,16 @@ SET default_tablespace = ''; SET default_table_access_method = heap; +-- +-- Name: alert_feedback; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.alert_feedback ( + alert_id bigint NOT NULL, + noise_reason text NOT NULL +); + + -- -- Name: alert_logs; Type: TABLE; Schema: public; Owner: - -- @@ -1847,7 +1864,7 @@ ALTER SEQUENCE public.ep_step_on_call_users_id_seq OWNED BY public.ep_step_on_ca -- CREATE TABLE public.escalation_policies ( - id uuid DEFAULT gen_random_uuid() NOT NULL, + id uuid DEFAULT public.gen_random_uuid() NOT NULL, name text NOT NULL, description text DEFAULT ''::text NOT NULL, repeat integer DEFAULT 0 NOT NULL, @@ -1860,7 +1877,7 @@ CREATE TABLE public.escalation_policies ( -- CREATE TABLE public.escalation_policy_actions ( - id uuid DEFAULT gen_random_uuid() NOT NULL, + id uuid DEFAULT public.gen_random_uuid() NOT NULL, escalation_policy_step_id uuid NOT NULL, user_id uuid, schedule_id uuid, @@ -1929,7 +1946,7 @@ ALTER SEQUENCE public.escalation_policy_state_id_seq OWNED BY public.escalation_ -- CREATE TABLE public.escalation_policy_steps ( - id uuid DEFAULT gen_random_uuid() NOT NULL, + id uuid DEFAULT public.gen_random_uuid() NOT NULL, delay integer DEFAULT 1 NOT NULL, step_number integer DEFAULT '-1'::integer NOT NULL, escalation_policy_id uuid NOT NULL @@ -1977,7 +1994,7 @@ CREATE SEQUENCE public.incident_number_seq -- CREATE TABLE public.integration_keys ( - id uuid DEFAULT gen_random_uuid() NOT NULL, + id uuid DEFAULT public.gen_random_uuid() NOT NULL, name text NOT NULL, type public.enum_integration_keys_type NOT NULL, service_id uuid NOT NULL @@ -2048,7 +2065,7 @@ CREATE TABLE public.notification_channels ( -- CREATE TABLE public.notification_policy_cycles ( - id uuid DEFAULT gen_random_uuid() NOT NULL, + id uuid DEFAULT public.gen_random_uuid() NOT NULL, user_id uuid NOT NULL, alert_id integer NOT NULL, repeat_count integer DEFAULT 0 NOT NULL, @@ -2064,7 +2081,7 @@ WITH (fillfactor='65'); -- CREATE TABLE public.outgoing_messages ( - id uuid DEFAULT gen_random_uuid() NOT NULL, + id uuid DEFAULT public.gen_random_uuid() NOT NULL, message_type public.enum_outgoing_messages_type NOT NULL, contact_method_id uuid, created_at timestamp with time zone DEFAULT now() NOT NULL, @@ -2138,7 +2155,7 @@ ALTER SEQUENCE public.region_ids_id_seq OWNED BY public.region_ids.id; -- CREATE TABLE public.rotation_participants ( - id uuid DEFAULT gen_random_uuid() NOT NULL, + id uuid DEFAULT public.gen_random_uuid() NOT NULL, rotation_id uuid NOT NULL, "position" integer NOT NULL, user_id uuid NOT NULL @@ -2183,7 +2200,7 @@ ALTER SEQUENCE public.rotation_state_id_seq OWNED BY public.rotation_state.id; -- CREATE TABLE public.rotations ( - id uuid DEFAULT gen_random_uuid() NOT NULL, + id uuid DEFAULT public.gen_random_uuid() NOT NULL, name text NOT NULL, description text DEFAULT ''::text NOT NULL, type public.enum_rotation_type NOT NULL, @@ -2265,7 +2282,7 @@ ALTER SEQUENCE public.schedule_on_call_users_id_seq OWNED BY public.schedule_on_ -- CREATE TABLE public.schedule_rules ( - id uuid DEFAULT gen_random_uuid() NOT NULL, + id uuid DEFAULT public.gen_random_uuid() NOT NULL, schedule_id uuid NOT NULL, sunday boolean DEFAULT true NOT NULL, monday boolean DEFAULT true NOT NULL, @@ -2289,7 +2306,7 @@ CREATE TABLE public.schedule_rules ( -- CREATE TABLE public.schedules ( - id uuid DEFAULT gen_random_uuid() NOT NULL, + id uuid DEFAULT public.gen_random_uuid() NOT NULL, name text NOT NULL, description text DEFAULT ''::text NOT NULL, time_zone text NOT NULL, @@ -2302,7 +2319,7 @@ CREATE TABLE public.schedules ( -- CREATE TABLE public.services ( - id uuid DEFAULT gen_random_uuid() NOT NULL, + id uuid DEFAULT public.gen_random_uuid() NOT NULL, name text NOT NULL, description text DEFAULT ''::text NOT NULL, escalation_policy_id uuid NOT NULL, @@ -2328,7 +2345,7 @@ CREATE TABLE public.switchover_log ( CREATE TABLE public.switchover_state ( ok boolean NOT NULL, current_state public.enum_switchover_state NOT NULL, - db_id uuid DEFAULT gen_random_uuid() NOT NULL, + db_id uuid DEFAULT public.gen_random_uuid() NOT NULL, CONSTRAINT switchover_state_ok_check CHECK (ok) ); @@ -2453,7 +2470,7 @@ CREATE TABLE public.user_calendar_subscriptions ( -- CREATE TABLE public.user_contact_methods ( - id uuid DEFAULT gen_random_uuid() NOT NULL, + id uuid DEFAULT public.gen_random_uuid() NOT NULL, name text NOT NULL, type public.enum_user_contact_method_type NOT NULL, value text NOT NULL, @@ -2505,7 +2522,7 @@ ALTER SEQUENCE public.user_favorites_id_seq OWNED BY public.user_favorites.id; -- CREATE TABLE public.user_notification_rules ( - id uuid DEFAULT gen_random_uuid() NOT NULL, + id uuid DEFAULT public.gen_random_uuid() NOT NULL, delay_minutes integer DEFAULT 0 NOT NULL, contact_method_id uuid NOT NULL, user_id uuid NOT NULL, @@ -2694,6 +2711,14 @@ ALTER TABLE ONLY public.twilio_voice_errors ALTER COLUMN id SET DEFAULT nextval( ALTER TABLE ONLY public.user_favorites ALTER COLUMN id SET DEFAULT nextval('public.user_favorites_id_seq'::regclass); +-- +-- Name: alert_feedback alert_feedback_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.alert_feedback + ADD CONSTRAINT alert_feedback_pkey PRIMARY KEY (alert_id); + + -- -- Name: alert_logs alert_logs_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -4067,6 +4092,14 @@ CREATE TRIGGER trg_set_rot_state_pos_on_part_reorder BEFORE UPDATE ON public.rot CREATE TRIGGER trg_start_rotation_on_first_part_add AFTER INSERT ON public.rotation_participants FOR EACH ROW EXECUTE FUNCTION public.fn_start_rotation_on_first_part_add(); +-- +-- Name: alert_feedback alert_feedback_alert_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.alert_feedback + ADD CONSTRAINT alert_feedback_alert_id_fkey FOREIGN KEY (alert_id) REFERENCES public.alerts(id) ON DELETE CASCADE; + + -- -- Name: alert_metrics alert_metrics_alert_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -4603,6 +4636,14 @@ ALTER TABLE ONLY public.users ADD CONSTRAINT users_alert_status_log_contact_method_id_fkey FOREIGN KEY (alert_status_log_contact_method_id) REFERENCES public.user_contact_methods(id) ON DELETE SET NULL DEFERRABLE; +-- +-- Name: SCHEMA public; Type: ACL; Schema: -; Owner: - +-- + +REVOKE USAGE ON SCHEMA public FROM PUBLIC; +GRANT ALL ON SCHEMA public TO PUBLIC; + + -- -- PostgreSQL database dump complete -- diff --git a/web/src/app/alerts/components/AlertDetails.tsx b/web/src/app/alerts/components/AlertDetails.tsx index f723c049f5..e2f8d06a59 100644 --- a/web/src/app/alerts/components/AlertDetails.tsx +++ b/web/src/app/alerts/components/AlertDetails.tsx @@ -1,5 +1,7 @@ import React, { ReactElement, useState, ReactNode } from 'react' import p from 'prop-types' +import ButtonGroup from '@mui/material/ButtonGroup' +import Button from '@mui/material/Button' import Card from '@mui/material/Card' import CardContent from '@mui/material/CardContent' import FormControlLabel from '@mui/material/FormControlLabel' @@ -32,7 +34,7 @@ import { styles as globalStyles } from '../../styles/materialStyles' import Markdown from '../../util/Markdown' import AlertDetailLogs from '../AlertDetailLogs' import AppLink from '../../util/AppLink' -import CardActions, { Action } from '../../details/CardActions' +import CardActions from '../../details/CardActions' import { Alert, Target, @@ -41,6 +43,12 @@ import { } from '../../../schema' import ServiceNotices from '../../services/ServiceNotices' import { Time } from '../../util/Time' +import AlertFeedback, { + mutation as undoFeedbackMutation, +} from './AlertFeedback' +import LoadingButton from '../../loading/components/LoadingButton' +import { Notice } from '../../details/Notices' +import { useIsWidthDown } from '../../util/useWidth' interface AlertDetailsProps { data: Alert @@ -79,6 +87,16 @@ const updateStatusMutation = gql` export default function AlertDetails(props: AlertDetailsProps): JSX.Element { const classes = useStyles() + const isMobile = useIsWidthDown('sm') + + const [undoFeedback, undoFeedbackStatus] = useMutation(undoFeedbackMutation, { + variables: { + input: { + alertID: props.data.id, + noiseReason: '', + }, + }, + }) const [ack] = useMutation(updateStatusMutation, { variables: { @@ -317,60 +335,90 @@ export default function AlertDetails(props: AlertDetailsProps): JSX.Element { /* * Options to show for alert details menu */ - function getMenuOptions(): Action[] { + function getMenuOptions(): Array { const { status } = props.data - let options: Action[] = [] - - if (status === 'StatusClosed') return options - if (status === 'StatusUnacknowledged') { - options = [ - { - icon: , - label: 'Acknowledge', - handleOnClick: () => ack(), - }, - ] - } - + if (status === 'StatusClosed') return [] const isMaintMode = Boolean(props.data?.service?.maintenanceExpiresAt) - // only remaining status is acknowledged, show remaining buttons return [ - ...options, - { - icon: , - label: 'Close', - handleOnClick: () => close(), - }, + + {status === 'StatusUnacknowledged' && ( + + )} + + + , + ] + } + + const { data: alert } = props + + let extraNotices: Notice[] = alert.pendingNotifications.map((n) => ({ + type: 'WARNING', + message: `Notification Pending for ${n.destination}`, + details: + 'This could be due to rate-limiting, processing, or network delays.', + })) + + const noiseReason = alert?.noiseReason ?? '' + if (noiseReason !== '') { + const nrArr = noiseReason.split('|') + const reasons = nrArr.join(', ') + extraNotices = [ + ...extraNotices, { - icon: , - label: isMaintMode - ? 'Escalate disabled. In maintenance mode.' - : 'Escalate', - handleOnClick: () => escalate(), - ButtonProps: { - disabled: isMaintMode, - }, + type: 'INFO', + message: 'This alert has been marked as noise', + details: `Reason${nrArr.length > 1 ? 's' : ''}: ${reasons}`, + action: ( + undoFeedback()} + /> + ), }, ] } - const { data: alert } = props return ( ({ - type: 'WARNING', - message: `Notification Pending for ${n.destination}`, - details: - 'This could be due to rate-limiting, processing, or network delays.', - }))} + extraNotices={extraNotices as Notice[]} /> {/* Main Alert Info */} - - + + {alert.service && ( @@ -392,9 +440,14 @@ export default function AlertDetails(props: AlertDetailsProps): JSX.Element { - + + {!noiseReason && ( + + + + )} {renderAlertDetails()} {/* Escalation Policy Info */} diff --git a/web/src/app/alerts/components/AlertFeedback.tsx b/web/src/app/alerts/components/AlertFeedback.tsx new file mode 100644 index 0000000000..0fb1fc8014 --- /dev/null +++ b/web/src/app/alerts/components/AlertFeedback.tsx @@ -0,0 +1,163 @@ +import React, { useState, useEffect } from 'react' +import { useQuery, useMutation, gql } from 'urql' +import Button from '@mui/material/Button' +import Checkbox from '@mui/material/Checkbox' +import FormGroup from '@mui/material/FormGroup' +import FormControlLabel from '@mui/material/FormControlLabel' +import TextField from '@mui/material/TextField' +import { Card, CardContent, CardHeader, Typography } from '@mui/material' +import CardActions from '../../details/CardActions' + +const query = gql` + query AlertFeedbackQuery($id: Int!) { + alert(id: $id) { + id + noiseReason + } + } +` + +export const mutation = gql` + mutation SetAlertNoiseReasonMutation($input: SetAlertNoiseReasonInput!) { + setAlertNoiseReason(input: $input) + } +` + +interface AlertFeedbackProps { + alertID: number +} + +export default function AlertFeedback(props: AlertFeedbackProps): JSX.Element { + const { alertID } = props + + const [{ data }] = useQuery({ + query, + variables: { + id: alertID, + }, + }) + + const options = ['False positive', 'Not actionable', 'Poor details'] + + const dataNoiseReason = data?.alert?.noiseReason ?? '' + + const getDefaults = (): [Array, string] => { + const vals = dataNoiseReason !== '' ? dataNoiseReason.split('|') : [] + let defaultValue: Array = [] + let defaultOther = '' + vals.forEach((val: string) => { + if (!options.includes(val)) { + defaultOther = val + } else { + defaultValue = [...defaultValue, val] + } + }) + + return [defaultValue, defaultOther] + } + + const defaults = getDefaults() + const [noiseReasons, setNoiseReasons] = useState>(defaults[0]) + const [other, setOther] = useState(defaults[1]) + const [otherChecked, setOtherChecked] = useState(Boolean(defaults[1])) + const [mutationStatus, commit] = useMutation(mutation) + const { error } = mutationStatus + + useEffect(() => { + const v = getDefaults() + setNoiseReasons(v[0]) + setOther(v[1]) + setOtherChecked(Boolean(v[1])) + }, [dataNoiseReason]) + + function handleSubmit(): void { + let n = noiseReasons.slice() + if (other !== '' && otherChecked) n = [...n, other] + commit({ + input: { + alertID, + noiseReason: n.join('|'), + }, + }) + } + + function handleCheck( + e: React.ChangeEvent, + noiseReason: string, + ): void { + if (e.target.checked) { + setNoiseReasons([...noiseReasons, noiseReason]) + } else { + setNoiseReasons(noiseReasons.filter((n) => n !== noiseReason)) + } + } + + return ( + + + + + {options.map((option) => ( + handleCheck(e, option)} + /> + } + /> + ))} + + setOtherChecked(true)} + onChange={(e) => { + setOther(e.target.value) + }} + /> + } + control={ + { + setOtherChecked(e.target.checked) + if (!e.target.checked) { + setOther('') + } + }} + /> + } + disableTypography + /> + + {error?.message && ( + + {error?.message} + + )} + + + Submit + , + ]} + /> + + ) +} diff --git a/web/src/app/alerts/pages/AlertDetailPage.tsx b/web/src/app/alerts/pages/AlertDetailPage.tsx index cfc393310e..ef81d2ce45 100644 --- a/web/src/app/alerts/pages/AlertDetailPage.tsx +++ b/web/src/app/alerts/pages/AlertDetailPage.tsx @@ -13,6 +13,7 @@ const query = gql` summary details createdAt + noiseReason service { id name diff --git a/web/src/app/details/CardActions.tsx b/web/src/app/details/CardActions.tsx index 5cdca8058c..2b851afab8 100644 --- a/web/src/app/details/CardActions.tsx +++ b/web/src/app/details/CardActions.tsx @@ -28,6 +28,7 @@ const useStyles = makeStyles({ }, primaryActionsContainer: { padding: 8, + width: '100%', }, autoExpandWidth: { margin: '0 auto', diff --git a/web/src/app/loading/components/LoadingButton.tsx b/web/src/app/loading/components/LoadingButton.tsx index 54056f9dba..7cdf804613 100644 --- a/web/src/app/loading/components/LoadingButton.tsx +++ b/web/src/app/loading/components/LoadingButton.tsx @@ -26,9 +26,9 @@ const LoadingButton = (props: LoadingButtonProps): JSX.Element => { return (