diff --git a/graphql2/generated.go b/graphql2/generated.go index 0f1657274b..1d43b50eb2 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -244,6 +244,11 @@ type ComplexityRoot struct { Type func(childComplexity int) int } + IntegrationKeyConnection struct { + Nodes func(childComplexity int) int + PageInfo func(childComplexity int) int + } + Label struct { Key func(childComplexity int) int Value func(childComplexity int) int @@ -362,6 +367,7 @@ type ComplexityRoot struct { GenerateSlackAppManifest func(childComplexity int) int HeartbeatMonitor func(childComplexity int, id string) int IntegrationKey func(childComplexity int, id string) int + IntegrationKeys func(childComplexity int, input *IntegrationKeySearchOptions) int LabelKeys func(childComplexity int, input *LabelKeySearchOptions) int LabelValues func(childComplexity int, input *LabelValueSearchOptions) int Labels func(childComplexity int, input *LabelSearchOptions) int @@ -697,6 +703,7 @@ type QueryResolver interface { Labels(ctx context.Context, input *LabelSearchOptions) (*LabelConnection, error) LabelKeys(ctx context.Context, input *LabelKeySearchOptions) (*StringConnection, error) LabelValues(ctx context.Context, input *LabelValueSearchOptions) (*StringConnection, error) + IntegrationKeys(ctx context.Context, input *IntegrationKeySearchOptions) (*IntegrationKeyConnection, error) UserOverrides(ctx context.Context, input *UserOverrideSearchOptions) (*UserOverrideConnection, error) UserOverride(ctx context.Context, id string) (*override.UserOverride, error) Config(ctx context.Context, all *bool) ([]ConfigValue, error) @@ -1442,6 +1449,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.IntegrationKey.Type(childComplexity), true + case "IntegrationKeyConnection.nodes": + if e.complexity.IntegrationKeyConnection.Nodes == nil { + break + } + + return e.complexity.IntegrationKeyConnection.Nodes(childComplexity), true + + case "IntegrationKeyConnection.pageInfo": + if e.complexity.IntegrationKeyConnection.PageInfo == nil { + break + } + + return e.complexity.IntegrationKeyConnection.PageInfo(childComplexity), true + case "Label.key": if e.complexity.Label.Key == nil { break @@ -2321,6 +2342,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.IntegrationKey(childComplexity, args["id"].(string)), true + case "Query.integrationKeys": + if e.complexity.Query.IntegrationKeys == nil { + break + } + + args, err := ec.field_Query_integrationKeys_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.IntegrationKeys(childComplexity, args["input"].(*IntegrationKeySearchOptions)), true + case "Query.labelKeys": if e.complexity.Query.LabelKeys == nil { break @@ -3463,6 +3496,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputDebugMessagesInput, ec.unmarshalInputDebugSendSMSInput, ec.unmarshalInputEscalationPolicySearchOptions, + ec.unmarshalInputIntegrationKeySearchOptions, ec.unmarshalInputLabelKeySearchOptions, ec.unmarshalInputLabelSearchOptions, ec.unmarshalInputLabelValueSearchOptions, @@ -4434,6 +4468,21 @@ func (ec *executionContext) field_Query_integrationKey_args(ctx context.Context, return args, nil } +func (ec *executionContext) field_Query_integrationKeys_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 *IntegrationKeySearchOptions + if tmp, ok := rawArgs["input"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + arg0, err = ec.unmarshalOIntegrationKeySearchOptions2ᚖgithub.comᚋtargetᚋgoalertᚋgraphql2ᚐIntegrationKeySearchOptions(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Query_labelKeys_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -9037,6 +9086,112 @@ func (ec *executionContext) fieldContext_IntegrationKey_href(ctx context.Context return fc, nil } +func (ec *executionContext) _IntegrationKeyConnection_nodes(ctx context.Context, field graphql.CollectedField, obj *IntegrationKeyConnection) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_IntegrationKeyConnection_nodes(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 obj.Nodes, nil + }) + 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.([]integrationkey.IntegrationKey) + fc.Result = res + return ec.marshalNIntegrationKey2ᚕgithub.comᚋtargetᚋgoalertᚋintegrationkeyᚐIntegrationKeyᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_IntegrationKeyConnection_nodes(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "IntegrationKeyConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_IntegrationKey_id(ctx, field) + case "serviceID": + return ec.fieldContext_IntegrationKey_serviceID(ctx, field) + case "type": + return ec.fieldContext_IntegrationKey_type(ctx, field) + case "name": + return ec.fieldContext_IntegrationKey_name(ctx, field) + case "href": + return ec.fieldContext_IntegrationKey_href(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type IntegrationKey", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _IntegrationKeyConnection_pageInfo(ctx context.Context, field graphql.CollectedField, obj *IntegrationKeyConnection) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_IntegrationKeyConnection_pageInfo(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 obj.PageInfo, nil + }) + 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.(*PageInfo) + fc.Result = res + return ec.marshalNPageInfo2ᚖgithub.comᚋtargetᚋgoalertᚋgraphql2ᚐPageInfo(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_IntegrationKeyConnection_pageInfo(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "IntegrationKeyConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "endCursor": + return ec.fieldContext_PageInfo_endCursor(ctx, field) + case "hasNextPage": + return ec.fieldContext_PageInfo_hasNextPage(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type PageInfo", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _Label_key(ctx context.Context, field graphql.CollectedField, obj *label.Label) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Label_key(ctx, field) if err != nil { @@ -14599,6 +14754,67 @@ func (ec *executionContext) fieldContext_Query_labelValues(ctx context.Context, return fc, nil } +func (ec *executionContext) _Query_integrationKeys(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_integrationKeys(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.Query().IntegrationKeys(rctx, fc.Args["input"].(*IntegrationKeySearchOptions)) + }) + 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.(*IntegrationKeyConnection) + fc.Result = res + return ec.marshalNIntegrationKeyConnection2ᚖgithub.comᚋtargetᚋgoalertᚋgraphql2ᚐIntegrationKeyConnection(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_integrationKeys(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "nodes": + return ec.fieldContext_IntegrationKeyConnection_nodes(ctx, field) + case "pageInfo": + return ec.fieldContext_IntegrationKeyConnection_pageInfo(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type IntegrationKeyConnection", field.Name) + }, + } + 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_Query_integrationKeys_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return + } + return fc, nil +} + func (ec *executionContext) _Query_userOverrides(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Query_userOverrides(ctx, field) if err != nil { @@ -24345,6 +24561,63 @@ func (ec *executionContext) unmarshalInputEscalationPolicySearchOptions(ctx cont return it, nil } +func (ec *executionContext) unmarshalInputIntegrationKeySearchOptions(ctx context.Context, obj interface{}) (IntegrationKeySearchOptions, error) { + var it IntegrationKeySearchOptions + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + if _, present := asMap["first"]; !present { + asMap["first"] = 15 + } + if _, present := asMap["after"]; !present { + asMap["after"] = "" + } + if _, present := asMap["search"]; !present { + asMap["search"] = "" + } + + for k, v := range asMap { + switch k { + case "first": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("first")) + it.First, err = ec.unmarshalOInt2ᚖint(ctx, v) + if err != nil { + return it, err + } + case "after": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after")) + it.After, err = ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + case "search": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("search")) + it.Search, err = ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + case "omit": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("omit")) + it.Omit, err = ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputLabelKeySearchOptions(ctx context.Context, obj interface{}) (LabelKeySearchOptions, error) { var it LabelKeySearchOptions asMap := map[string]interface{}{} @@ -27451,6 +27724,41 @@ func (ec *executionContext) _IntegrationKey(ctx context.Context, sel ast.Selecti return out } +var integrationKeyConnectionImplementors = []string{"IntegrationKeyConnection"} + +func (ec *executionContext) _IntegrationKeyConnection(ctx context.Context, sel ast.SelectionSet, obj *IntegrationKeyConnection) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, integrationKeyConnectionImplementors) + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("IntegrationKeyConnection") + case "nodes": + + out.Values[i] = ec._IntegrationKeyConnection_nodes(ctx, field, obj) + + if out.Values[i] == graphql.Null { + invalids++ + } + case "pageInfo": + + out.Values[i] = ec._IntegrationKeyConnection_pageInfo(ctx, field, obj) + + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var labelImplementors = []string{"Label"} func (ec *executionContext) _Label(ctx context.Context, sel ast.SelectionSet, obj *label.Label) graphql.Marshaler { @@ -28751,6 +29059,29 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr return ec.OperationContext.RootResolverMiddleware(ctx, innerFunc) } + out.Concurrently(i, func() graphql.Marshaler { + return rrm(innerCtx) + }) + case "integrationKeys": + field := field + + innerFunc := func(ctx context.Context) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_integrationKeys(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, innerFunc) + } + out.Concurrently(i, func() graphql.Marshaler { return rrm(innerCtx) }) @@ -32201,6 +32532,20 @@ func (ec *executionContext) marshalNIntegrationKey2ᚕgithub.comᚋtargetᚋgo return ret } +func (ec *executionContext) marshalNIntegrationKeyConnection2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐIntegrationKeyConnection(ctx context.Context, sel ast.SelectionSet, v IntegrationKeyConnection) graphql.Marshaler { + return ec._IntegrationKeyConnection(ctx, sel, &v) +} + +func (ec *executionContext) marshalNIntegrationKeyConnection2ᚖgithub.comᚋtargetᚋgoalertᚋgraphql2ᚐIntegrationKeyConnection(ctx context.Context, sel ast.SelectionSet, v *IntegrationKeyConnection) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._IntegrationKeyConnection(ctx, sel, v) +} + func (ec *executionContext) unmarshalNIntegrationKeyType2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐIntegrationKeyType(ctx context.Context, v interface{}) (IntegrationKeyType, error) { var res IntegrationKeyType err := res.UnmarshalGQL(v) @@ -34530,6 +34875,14 @@ func (ec *executionContext) marshalOIntegrationKey2ᚖgithub.comᚋtargetᚋgo return ec._IntegrationKey(ctx, sel, v) } +func (ec *executionContext) unmarshalOIntegrationKeySearchOptions2ᚖgithub.comᚋtargetᚋgoalertᚋgraphql2ᚐIntegrationKeySearchOptions(ctx context.Context, v interface{}) (*IntegrationKeySearchOptions, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputIntegrationKeySearchOptions(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalOLabelKeySearchOptions2ᚖgithub.comᚋtargetᚋgoalertᚋgraphql2ᚐLabelKeySearchOptions(ctx context.Context, v interface{}) (*LabelKeySearchOptions, error) { if v == nil { return nil, nil diff --git a/graphql2/graphqlapp/integrationkey.go b/graphql2/graphqlapp/integrationkey.go index aa5c6a2af9..36010a9128 100644 --- a/graphql2/graphqlapp/integrationkey.go +++ b/graphql2/graphqlapp/integrationkey.go @@ -8,6 +8,7 @@ import ( "github.com/target/goalert/config" "github.com/target/goalert/graphql2" "github.com/target/goalert/integrationkey" + "github.com/target/goalert/search" ) type IntegrationKey App @@ -58,3 +59,51 @@ func (key *IntegrationKey) Href(ctx context.Context, raw *integrationkey.Integra return "", nil } + +func (q *Query) IntegrationKeys(ctx context.Context, input *graphql2.IntegrationKeySearchOptions) (conn *graphql2.IntegrationKeyConnection, err error) { + if input == nil { + input = &graphql2.IntegrationKeySearchOptions{} + } + + var opts integrationkey.InKeySearchOptions + if input.Search != nil { + opts.Search = *input.Search + } + opts.Omit = input.Omit + if input.After != nil && *input.After != "" { + err = search.ParseCursor(*input.After, &opts) + if err != nil { + return conn, err + } + } + if input.First != nil { + opts.Limit = *input.First + } + if opts.Limit == 0 { + opts.Limit = 15 + } + + opts.Limit++ + intKeys, err := q.IntKeyStore.Search(ctx, &opts) + if err != nil { + return nil, err + } + conn = new(graphql2.IntegrationKeyConnection) + conn.PageInfo = &graphql2.PageInfo{} + if len(intKeys) == opts.Limit { + intKeys = intKeys[:len(intKeys)-1] + conn.PageInfo.HasNextPage = true + } + if len(intKeys) > 0 { + lastKey := intKeys[len(intKeys)-1] + opts.After = lastKey.Name + + cur, err := search.Cursor(opts) + if err != nil { + return nil, err + } + conn.PageInfo.EndCursor = &cur + } + conn.Nodes = intKeys + return conn, err +} diff --git a/graphql2/models_gen.go b/graphql2/models_gen.go index 833bb14246..a800512f41 100644 --- a/graphql2/models_gen.go +++ b/graphql2/models_gen.go @@ -12,6 +12,7 @@ import ( "github.com/target/goalert/alert/alertlog" "github.com/target/goalert/assignment" "github.com/target/goalert/escalation" + "github.com/target/goalert/integrationkey" "github.com/target/goalert/label" "github.com/target/goalert/limit" "github.com/target/goalert/notification/slack" @@ -272,6 +273,18 @@ type EscalationPolicySearchOptions struct { FavoritesFirst *bool `json:"favoritesFirst"` } +type IntegrationKeyConnection struct { + Nodes []integrationkey.IntegrationKey `json:"nodes"` + PageInfo *PageInfo `json:"pageInfo"` +} + +type IntegrationKeySearchOptions struct { + First *int `json:"first"` + After *string `json:"after"` + Search *string `json:"search"` + Omit []string `json:"omit"` +} + type LabelConnection struct { Nodes []label.Label `json:"nodes"` PageInfo *PageInfo `json:"pageInfo"` diff --git a/graphql2/schema.graphql b/graphql2/schema.graphql index 131d28a7ce..9b09d552a5 100644 --- a/graphql2/schema.graphql +++ b/graphql2/schema.graphql @@ -80,6 +80,9 @@ type Query { # Allows searching for label values. labelValues(input: LabelValueSearchOptions): StringConnection! + # Allows searching for integration keys. + integrationKeys(input: IntegrationKeySearchOptions): IntegrationKeyConnection! + # Allows searching for user overrides. userOverrides(input: UserOverrideSearchOptions): UserOverrideConnection! @@ -231,6 +234,12 @@ type UserOverrideConnection { nodes: [UserOverride!]! pageInfo: PageInfo! } + +type IntegrationKeyConnection { + nodes: [IntegrationKey!]! + pageInfo: PageInfo! +} + type UserOverride { id: ID! @@ -268,6 +277,13 @@ input LabelValueSearchOptions { omit: [String!] } +input IntegrationKeySearchOptions { + first: Int = 15 + after: String = "" + search: String = "" + omit: [String!] +} + type LabelConnection { nodes: [Label!]! pageInfo: PageInfo! diff --git a/integrationkey/search.go b/integrationkey/search.go new file mode 100644 index 0000000000..ee7fd3e38c --- /dev/null +++ b/integrationkey/search.go @@ -0,0 +1,118 @@ +package integrationkey + +import ( + "context" + "database/sql" + "text/template" + + "github.com/pkg/errors" + "github.com/target/goalert/permission" + "github.com/target/goalert/search" + "github.com/target/goalert/util/sqlutil" + "github.com/target/goalert/validation/validate" +) + +// InKeySearchOptions allow filtering and paginating the list of rotations. +type InKeySearchOptions struct { + Search string `json:"s,omitempty"` + After string `json:"a,omitempty"` + + // Omit specifies a list of key ids to exclude from the results. + Omit []string `json:"o,omitempty"` + + Limit int `json:"-"` +} + +var intKeySearchTemplate = template.Must(template.New("integration-key-search").Parse(` + SELECT DISTINCT + key.id, key.name, key.type, key.service_id + FROM integration_keys key + WHERE true + {{if .Omit}} + AND not key.id = any(:omit) + {{end}} + {{if .Search}} + AND (key.id::text ILIKE :search) + {{end}} + {{if .After}} + lower(key.name) > lower(:after) + {{end}} + LIMIT {{.Limit}} +`)) + +type intKeyRenderData InKeySearchOptions + +func (opts intKeyRenderData) Normalize() (*intKeyRenderData, error) { + if opts.Limit == 0 { + opts.Limit = search.DefaultMaxResults + } + + err := validate.Many( + validate.Search("Search", opts.Search), + validate.Range("Limit", opts.Limit, 0, search.MaxResults), + validate.Range("Omit", len(opts.Omit), 0, 50), + ) + + if err != nil { + return nil, err + } + + return &opts, err +} + +func (opts intKeyRenderData) SearchStr() string { + if opts.Search == "" { + return "" + } + + return search.Escape(opts.Search) + "%" +} + +func (opts intKeyRenderData) QueryArgs() []sql.NamedArg { + + return []sql.NamedArg{ + sql.Named("search", opts.SearchStr()), + sql.Named("after", opts.After), + sql.Named("omit", sqlutil.StringArray(opts.Omit)), + } +} + +func (s *Store) Search(ctx context.Context, opts *InKeySearchOptions) ([]IntegrationKey, error) { + err := permission.LimitCheckAny(ctx, permission.User) + if err != nil { + return nil, err + } + if opts == nil { + opts = &InKeySearchOptions{} + } + data, err := (*intKeyRenderData)(opts).Normalize() + if err != nil { + return nil, err + } + query, args, err := search.RenderQuery(ctx, intKeySearchTemplate, data) + if err != nil { + return nil, errors.Wrap(err, "render query") + } + + rows, err := s.db.QueryContext(ctx, query, args...) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + defer rows.Close() + + var result []IntegrationKey + for rows.Next() { + var intKey IntegrationKey + err = rows.Scan(&intKey.ID, &intKey.Name, &intKey.Type, &intKey.ServiceID) + if err != nil { + return nil, errors.Wrap(err, "scan row") + } + + result = append(result, intKey) + } + + return result, nil +} diff --git a/service/search.go b/service/search.go index 8d45507202..387fb1e8e4 100644 --- a/service/search.go +++ b/service/search.go @@ -51,6 +51,11 @@ var searchTemplate = template.Must(template.New("search").Funcs(search.Helpers() fav IS DISTINCT FROM NULL FROM services svc {{if not .FavoritesOnly }}LEFT {{end}}JOIN user_favorites fav ON svc.id = fav.tgt_service_id AND {{if .FavoritesUserID}}fav.user_id = :favUserID{{else}}false{{end}} + {{if and .IntegrationKey}} + JOIN integration_keys intKey ON + intKey.service_id = svc.id AND + intKey.id = :integrationKey + {{end}} {{if and .LabelKey (not .LabelNegate)}} JOIN labels l ON l.tgt_service_id = svc.id AND @@ -71,7 +76,7 @@ var searchTemplate = template.Must(template.New("search").Funcs(search.Helpers() {{if ne .LabelValue "*"}} AND value = :labelValue{{end}} ) {{end}} - {{- if and .Search (not .LabelKey)}} + {{- if and .Search (not .LabelKey) (not .IntegrationKey)}} AND {{prefixSearch "search" "svc.name"}} {{- end}} {{- if .After.Name}} @@ -98,19 +103,38 @@ func (opts renderData) OrderBy() string { return "lower(svc.name)" } +func (opts renderData) IntegrationKey() string { + if !strings.Contains(opts.Search, "token=") { + return "" + } + return opts.Search[6:42] +} + func (opts renderData) LabelKey() string { - idx := strings.IndexByte(opts.Search, '=') + searchStr := opts.Search + if strings.Contains(opts.Search, "token=") { + // strip token string + searchStr = opts.Search[42:] + searchStr = strings.TrimSpace(searchStr) + } + idx := strings.IndexByte(searchStr, '=') if idx == -1 { return "" } - return strings.TrimSuffix(opts.Search[:idx], "!") // if `!=`` is used + return strings.TrimSuffix(searchStr[:idx], "!") // if `!=`` is used } func (opts renderData) LabelValue() string { - idx := strings.IndexByte(opts.Search, '=') + searchStr := opts.Search + if strings.Contains(opts.Search, "token=") { + // strip token string + searchStr = opts.Search[42:] + searchStr = strings.TrimSpace(searchStr) + } + idx := strings.IndexByte(searchStr, '=') if idx == -1 { return "" } - val := opts.Search[idx+1:] + val := searchStr[idx+1:] if val == "" { return "*" } @@ -144,6 +168,9 @@ func (opts renderData) Normalize() (*renderData, error) { if err != nil { return nil, err } + if opts.IntegrationKey() != "" { + err = validate.Search("IntegrationKey", opts.IntegrationKey()) + } if opts.LabelKey() != "" { err = validate.Search("LabelKey", opts.LabelKey()) if opts.LabelValue() != "*" { @@ -162,6 +189,7 @@ func (opts renderData) Normalize() (*renderData, error) { func (opts renderData) QueryArgs() []sql.NamedArg { return []sql.NamedArg{ sql.Named("favUserID", opts.FavoritesUserID), + sql.Named("integrationKey", opts.IntegrationKey()), sql.Named("labelKey", opts.LabelKey()), sql.Named("labelValue", opts.LabelValue()), sql.Named("labelNegate", opts.LabelNegate()), diff --git a/web/src/app/alerts/CreateAlertDialog/StepContent/CreateAlertServiceSelect.js b/web/src/app/alerts/CreateAlertDialog/StepContent/CreateAlertServiceSelect.js index b4a93065ba..42f0dc280c 100644 --- a/web/src/app/alerts/CreateAlertDialog/StepContent/CreateAlertServiceSelect.js +++ b/web/src/app/alerts/CreateAlertDialog/StepContent/CreateAlertServiceSelect.js @@ -17,13 +17,13 @@ import { Box, } from '@mui/material' import makeStyles from '@mui/styles/makeStyles' -import ServiceLabelFilterContainer from '../../../services/ServiceLabelFilterContainer' +import ServiceLabelFilterContainer from '../../../services/ServiceFilterContainer' import { Search as SearchIcon } from '@mui/icons-material' import { FavoriteIcon } from '../../../util/SetFavoriteButton' import { ServiceChip } from '../../../util/Chips' import AddIcon from '@mui/icons-material/Add' import _ from 'lodash' -import getServiceLabel from '../../../util/getServiceLabel' +import getServiceFilters from '../../../util/getServiceFilters' import { CREATE_ALERT_LIMIT, DEBOUNCE_DELAY } from '../../../config' import { allErrors } from '../../../util/errutil' @@ -116,7 +116,7 @@ export function CreateAlertServiceSelect(props) { return () => clearTimeout(t) }, [searchUserInput]) - const { labelKey, labelValue } = getServiceLabel(searchUserInput) + const { labelKey, labelValue } = getServiceFilters(searchUserInput) const addAll = (e) => { e.stopPropagation() diff --git a/web/src/app/selection/IntegrationKeySelect.tsx b/web/src/app/selection/IntegrationKeySelect.tsx new file mode 100644 index 0000000000..353cd011e4 --- /dev/null +++ b/web/src/app/selection/IntegrationKeySelect.tsx @@ -0,0 +1,25 @@ +import { gql } from '@apollo/client' +import { IntegrationKey } from '../../schema' +import { makeQuerySelect } from './QuerySelect' + +const query = gql` + query ($input: IntegrationKeySearchOptions) { + integrationKeys(input: $input) { + nodes { + id + name + type + serviceID + } + } + } +` + +export const IntegrationKeySelect = makeQuerySelect('IntegrationKeySelect', { + query, + mapDataNode: (key: IntegrationKey) => ({ + label: key.id, + subText: key.name, + value: key.id, + }), +}) diff --git a/web/src/app/selection/LabelKeySelect.js b/web/src/app/selection/LabelKeySelect.tsx similarity index 68% rename from web/src/app/selection/LabelKeySelect.js rename to web/src/app/selection/LabelKeySelect.tsx index cc2249dc62..829d960666 100644 --- a/web/src/app/selection/LabelKeySelect.js +++ b/web/src/app/selection/LabelKeySelect.tsx @@ -1,6 +1,5 @@ import { gql } from '@apollo/client' import { makeQuerySelect } from './QuerySelect' -import p from 'prop-types' const query = gql` query ($input: LabelKeySearchOptions) { @@ -12,8 +11,5 @@ const query = gql` export const LabelKeySelect = makeQuerySelect('LabelKeySelect', { query, - mapDataNode: (key) => ({ label: key, value: key }), + mapDataNode: (key: string) => ({ label: key, value: key }), }) -LabelKeySelect.propTypes = { - value: p.string, -} diff --git a/web/src/app/selection/LabelValueSelect.js b/web/src/app/selection/LabelValueSelect.tsx similarity index 50% rename from web/src/app/selection/LabelValueSelect.js rename to web/src/app/selection/LabelValueSelect.tsx index 5606eb72f9..aa2f976cc0 100644 --- a/web/src/app/selection/LabelValueSelect.js +++ b/web/src/app/selection/LabelValueSelect.tsx @@ -1,5 +1,4 @@ import { gql } from '@apollo/client' -import p from 'prop-types' import { makeQuerySelect } from './QuerySelect' const query = gql` @@ -9,12 +8,20 @@ const query = gql` } } ` +interface LabelValueSearchProps { + label: string + disabled: boolean + name: string +} export const LabelValueSelect = makeQuerySelect('LabelValueSelect', { query, - extraVariablesFunc: ({ labelKey: key, ...props }) => [props, { key }], - mapDataNode: (value) => ({ label: value, value }), + extraVariablesFunc: ({ + labelKey: key, + ...props + }: { + labelKey: string + props: LabelValueSearchProps + }) => [props, { key }], + mapDataNode: (value: string) => ({ label: value, value }), }) -LabelValueSelect.propTypes = { - labelKey: p.string, -} diff --git a/web/src/app/selection/MaterialSelect.tsx b/web/src/app/selection/MaterialSelect.tsx index 7eb15ec704..8beeee4596 100644 --- a/web/src/app/selection/MaterialSelect.tsx +++ b/web/src/app/selection/MaterialSelect.tsx @@ -67,6 +67,7 @@ interface CommonSelectProps { noOptionsError?: Error name?: string required?: boolean + formatInputOnChange?: (value: string) => string onInputChange?: (value: string) => void options: SelectOption[] placeholder?: string @@ -98,6 +99,7 @@ export default function MaterialSelect( noOptionsText, noOptionsError, onChange, + formatInputOnChange = (val) => val, onInputChange = () => {}, options: _options, placeholder, @@ -126,8 +128,9 @@ export default function MaterialSelect( const [inputValue, _setInputValue] = useState(getInputLabel()) const setInputValue = (input: string): void => { - _setInputValue(input) - onInputChange(input) + const formattedInput = formatInputOnChange(input) + _setInputValue(formattedInput) + onInputChange(formattedInput) } useEffect(() => { diff --git a/web/src/app/selection/QuerySelect.js b/web/src/app/selection/QuerySelect.js index 0b16e2dcb4..06bfeb18d2 100644 --- a/web/src/app/selection/QuerySelect.js +++ b/web/src/app/selection/QuerySelect.js @@ -125,12 +125,15 @@ export const querySelectPropTypes = { onCreate: p.func, error: p.bool, + formatInputOnChange: p.func, onChange: p.func, value: valueCheck, label: p.string, multiple: p.bool, name: p.string, placeholder: p.string, + disabled: p.bool, + labelKey: p.string, } // makeQuerySelect will return a new React component that can be used @@ -177,6 +180,8 @@ export function makeQuerySelect(displayName, options) { onCreate: _onCreate, onChange = () => {}, + formatInputOnChange = (val) => val, + ..._otherProps } = props @@ -244,6 +249,7 @@ export function makeQuerySelect(displayName, options) { placeholder || (defaultQueryVariables && !searchInput ? 'Start typing...' : null) } + formatInputOnChange={(val) => formatInputOnChange(val)} onChange={(val) => handleChange(val)} {...otherProps} /> diff --git a/web/src/app/services/ServiceLabelFilterContainer.js b/web/src/app/services/ServiceFilterContainer.tsx similarity index 54% rename from web/src/app/services/ServiceLabelFilterContainer.js rename to web/src/app/services/ServiceFilterContainer.tsx index 1cdd56c962..91cf2165e6 100644 --- a/web/src/app/services/ServiceLabelFilterContainer.js +++ b/web/src/app/services/ServiceFilterContainer.tsx @@ -1,19 +1,36 @@ -import React from 'react' -import p from 'prop-types' +import React, { Ref } from 'react' import Grid from '@mui/material/Grid' import Typography from '@mui/material/Typography' import { Filter as LabelFilterIcon } from 'mdi-material-ui' import { LabelKeySelect } from '../selection/LabelKeySelect' import { LabelValueSelect } from '../selection/LabelValueSelect' +import { IntegrationKeySelect } from '../selection/IntegrationKeySelect' import FilterContainer from '../util/FilterContainer' -export default function ServiceLabelFilterContainer(props) { - const { labelKey, labelValue } = props.value +interface Value { + labelKey: string + labelValue: string + integrationKey: string +} + +interface ServiceFilterContainerProps { + value: Value + onChange: (val: Value) => void + onReset: () => void + + // optionally anchors the popover to a specified element's ref + anchorRef?: Ref +} + +export default function ServiceFilterContainer( + props: ServiceFilterContainerProps, +): JSX.Element { + const { labelKey, labelValue, integrationKey } = props.value return ( } - title='Search by Labels' + title='Search Services by Filters' iconButtonProps={{ 'data-cy': 'services-filter-button', color: 'default', @@ -23,6 +40,27 @@ export default function ServiceLabelFilterContainer(props) { onReset={props.onReset} anchorRef={props.anchorRef} > + + + Search by Integration Key + + + + { + if (input.indexOf('token=') > -1) { + input = input.substring(input.indexOf('token=') + 6) + } + return input + }} + onChange={(integrationKey) => + props.onChange({ ...props.value, integrationKey }) + } + /> + Search by Label @@ -51,12 +89,3 @@ export default function ServiceLabelFilterContainer(props) { ) } - -ServiceLabelFilterContainer.propTypes = { - value: p.shape({ labelKey: p.string, labelValue: p.string }), - onChange: p.func, - onReset: p.func, - - // optionally anchors the popover to a specified element's ref - anchorRef: p.object, -} diff --git a/web/src/app/services/ServiceList.tsx b/web/src/app/services/ServiceList.tsx index d8b37ab617..598ec730b7 100644 --- a/web/src/app/services/ServiceList.tsx +++ b/web/src/app/services/ServiceList.tsx @@ -2,9 +2,9 @@ import React from 'react' import { gql } from 'urql' import { useURLParam } from '../actions' import SimpleListPage from '../lists/SimpleListPage' -import getServiceLabel from '../util/getServiceLabel' +import getServiceFilters from '../util/getServiceFilters' import ServiceCreateDialog from './ServiceCreateDialog' -import ServiceLabelFilterContainer from './ServiceLabelFilterContainer' +import ServiceFilterContainer from './ServiceFilterContainer' const query = gql` query servicesQuery($input: ServiceSearchOptions) { @@ -25,7 +25,8 @@ const query = gql` export default function ServiceList(): JSX.Element { const [searchParam, setSearchParam] = useURLParam('search', '') - const { labelKey, labelValue } = getServiceLabel(searchParam) + const { labelKey, labelValue, integrationKey } = + getServiceFilters(searchParam) return ( } createLabel='Service' searchAdornment={ - - setSearchParam(labelKey ? labelKey + '=' + labelValue : '') - } + { + const labelSearch = labelKey ? labelKey + '=' + labelValue : '' + const intKeySearch = integrationKey ? 'token=' + integrationKey : '' + const searchStr = + intKeySearch && labelSearch + ? intKeySearch + ' ' + labelSearch + : intKeySearch + labelSearch + setSearchParam(searchStr) + }} onReset={() => setSearchParam('')} /> } diff --git a/web/src/app/util/getServiceFilters.test.ts b/web/src/app/util/getServiceFilters.test.ts new file mode 100644 index 0000000000..262b1c1498 --- /dev/null +++ b/web/src/app/util/getServiceFilters.test.ts @@ -0,0 +1,52 @@ +import getServiceFilters from './getServiceFilters' + +test('it should split service labels correctly', () => { + const check = ( + search: string, + labelKey: string, + labelValue: string, + integrationKey: string, + ): void => + expect(getServiceFilters(search)).toEqual({ + labelKey, + labelValue, + integrationKey, + }) + + check( + 'token=00000000-0000-0000-0000-000000000001 wcbn.fm/rfaa=88.3 ann arbor', + 'wcbn.fm/rfaa', + '88.3 ann arbor', + '00000000-0000-0000-0000-000000000001', + ) + check( + 'token=00000000-0000-0000-0000-000000000001 foo=bar', + 'foo', + 'bar', + '00000000-0000-0000-0000-000000000001', + ) + check( + 'token=00000000-0000-0000-0000-000000000001 foo!=bar', + 'foo', + 'bar', + '00000000-0000-0000-0000-000000000001', + ) + check( + 'token=00000000-0000-0000-0000-000000000001 foo=bar=baz', + 'foo', + 'bar=baz', + '00000000-0000-0000-0000-000000000001', + ) + check( + 'token=00000000-0000-0000-0000-000000000001 foo=bar!=baz', + 'foo', + 'bar!=baz', + '00000000-0000-0000-0000-000000000001', + ) + check( + 'token=00000000-0000-0000-0000-000000000001 foo=bar===!==', + 'foo', + 'bar===!==', + '00000000-0000-0000-0000-000000000001', + ) +}) diff --git a/web/src/app/util/getServiceLabel.ts b/web/src/app/util/getServiceFilters.ts similarity index 52% rename from web/src/app/util/getServiceLabel.ts rename to web/src/app/util/getServiceFilters.ts index d981f792d3..e2bac761c9 100644 --- a/web/src/app/util/getServiceLabel.ts +++ b/web/src/app/util/getServiceFilters.ts @@ -1,10 +1,17 @@ -export default function getServiceLabel(input: string): { +export default function getServiceFilters(input: string): { labelKey: string labelValue: string + integrationKey: string } { // grab key and value from the input param, if at all let labelKey = '' let labelValue = '' + let integrationKey = '' + if (input.includes('token=')) { + const tokenStr = input.substring(0, 42) + integrationKey = tokenStr.slice(6) + input = input.replace(tokenStr, '').trim() // remove token string from input + } if (input.includes('=')) { const searchSplit = input.split(/(!=|=)/) labelKey = searchSplit[0] @@ -12,5 +19,5 @@ export default function getServiceLabel(input: string): { labelValue = searchSplit.slice(2).join('') } - return { labelKey, labelValue } + return { labelKey, labelValue, integrationKey } } diff --git a/web/src/app/util/getServiceLabel.test.ts b/web/src/app/util/getServiceLabel.test.ts deleted file mode 100644 index d0f1625af9..0000000000 --- a/web/src/app/util/getServiceLabel.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import getServiceLabel from './getServiceLabel' - -test('it should split service labels correctly', () => { - const check = (search: string, labelKey: string, labelValue: string): void => - expect(getServiceLabel(search)).toEqual({ labelKey, labelValue }) - - check('wcbn.fm/rfaa=88.3 ann arbor', 'wcbn.fm/rfaa', '88.3 ann arbor') - check('foo=bar', 'foo', 'bar') - check('foo!=bar', 'foo', 'bar') - check('foo=bar=baz', 'foo', 'bar=baz') - check('foo=bar!=baz', 'foo', 'bar!=baz') - check('foo=bar===!==', 'foo', 'bar===!==') -}) diff --git a/web/src/cypress/integration/services.ts b/web/src/cypress/integration/services.ts index 420d395fbf..fad6129a34 100644 --- a/web/src/cypress/integration/services.ts +++ b/web/src/cypress/integration/services.ts @@ -59,9 +59,10 @@ function testServices(screen: ScreenFormat): void { cy.url().should('eq', Cypress.config().baseUrl + `/services/${svc.id}`) }) - describe('Filtering', () => { + describe.only('Filtering', () => { let label1: Label let label2: Label // uses key/value from label1 + let intKey: IntegrationKey beforeEach(() => { cy.createLabel().then((l: Label) => { label1 = l @@ -72,6 +73,9 @@ function testServices(screen: ScreenFormat): void { label2 = l }) }) + cy.createIntKey().then((i: IntegrationKey) => { + intKey = i + }) }) it('should open and close the filter popover', () => { @@ -161,6 +165,23 @@ function testServices(screen: ScreenFormat): void { .should('not.contain', label2.svc.description) }) + it('should filter by integration key', () => { + // open filter + if (screen === 'mobile') { + cy.get('[data-cy=app-bar] button[data-cy=open-search]').click() + } + cy.get('button[data-cy="services-filter-button"]').click() + + cy.get('input[name="integration-key"]').selectByLabel(intKey.id) + + // close filter + cy.get('button[data-cy="filter-done"]').click() + + cy.get('body') + .should('contain', intKey.svc.name) + .should('contain', intKey.svc.description) + }) + it('should reset label filters', () => { // open filter if (screen === 'mobile') { diff --git a/web/src/cypress/support/service.ts b/web/src/cypress/support/service.ts index 26f8910fe0..2f04fa497a 100644 --- a/web/src/cypress/support/service.ts +++ b/web/src/cypress/support/service.ts @@ -1,4 +1,5 @@ import { Chance } from 'chance' +import { IntegrationKeyType } from '../../schema' const c = new Chance() declare global { @@ -18,6 +19,9 @@ declare global { /** Creates a label for a given service */ createLabel: typeof createLabel + /** Creates an integration key for a given service */ + createIntKey: typeof createIntKey + /** Creates a label for a given service */ createHeartbeatMonitor: typeof createHeartbeatMonitor } @@ -58,6 +62,22 @@ declare global { value?: string } + interface IntegrationKey { + svcID: string + svc: Service + id: string + name: string + type: IntegrationKeyType + } + + interface IntKeyOptions { + svcID?: string + svc?: ServiceOptions + id?: string + name?: string + type?: IntegrationKeyType + } + interface HeartbeatMonitor { svcID: string svc: Service @@ -181,6 +201,56 @@ function createLabel(label?: LabelOptions): Cypress.Chainable