diff --git a/api/schema.graphqls b/api/schema.graphqls index 957a9f5..13b2887 100644 --- a/api/schema.graphqls +++ b/api/schema.graphqls @@ -236,6 +236,7 @@ input StringInput { contains: String startsWith: String endsWith: String + in: [String!] } input IntRange { from: Int!, to: Int! } @@ -256,6 +257,7 @@ type HistoPoint { type JobsStatistics { id: ID! # If `groupBy` was used, ID of the user/project/cluster + name: String # if User-Statistics: Given Name of Account (ID) Owner totalJobs: Int! # Number of jobs that matched shortJobs: Int! # Number of jobs with a duration of less than 2 minutes totalWalltime: Int! # Sum of the duration of all matched jobs in hours diff --git a/docs/searchbar.md b/docs/searchbar.md index 77d5f75..c80620c 100644 --- a/docs/searchbar.md +++ b/docs/searchbar.md @@ -3,14 +3,31 @@ ### Usage * Searchtags are implemented as `type:` search-string - * Types `jobId, jobName, projectId, username` for roles `admin` and `support` + * Types `jobId, jobName, projectId, username, name` for roles `admin` and `support` + * `jobName` is jobName as persisted in `job.meta_data` table-column + * `username` is actual account identifier as persisted in `job.user` table-column + * `name` is account owners name as persisted in `user.name` table-column * Types `jobId, jobName` for role `user` * Examples: * `jobName:myJob12` * `jobId:123456` * `username:abcd100` + * `name:Paul` * If no searchTag used: Best guess search with the following hierarchy - * `jobId -> username -> projectId -> jobName` + * `jobId -> username -> name -> projectId -> jobName` +* Destinations: + * JobId: Always Job-Table (Allows multiple identical matches, e.g. JobIds from different clusters) + * JobName: Always Job-Table (Allows multiple identical matches, e.g. JobNames from different clusters) + * ProjectId: Always Job-Table + * Username + * If *one* match found: Opens detailed user-view (`/monitoring/user/$USER`) + * If *multiple* matches found: Opens user-table with matches listed (`/monitoring/users/`) + * **Please Note**: Only users with jobs will be shown in table! I.e., "multiple matches" can still be only one entry in table. + * Name + * If *one* matching username found: Opens detailed user-view (`/monitoring/user/$USER`) + * If *multiple* usernames found: Opens user-table with matches listed (`/monitoring/users/`) + * **Please Note**: Only users with jobs will be shown in table! I.e., "multiple matches" can still be only one entry in table. + * Best guess search always redirects to Job-Table or `/monitoring/user/$USER` (first username match) * Simple HTML Error if ... * Best guess search fails -> 'Not Found' * Query `type` is unknown @@ -18,7 +35,8 @@ * Spaces trimmed (both for searchTag and queryString) * ` job12` == `job12` * `projectID : abcd ` == `projectId:abcd` -* jobId-Query now redirects to table - * Allows multiple jobs from different systems, but with identical job-id to be found -* jobName-Query works with a part of the jobName-String (e.g. jobName:myjob for jobName myjob_cluster1) - * JobName GQL Query is resolved as matching the query as a part of the whole metaData-JSON in the SQL DB. +* `jobName`- and `name-`queries work with a part of the target-string + * `jobName:myjob` for jobName "myjob_cluster1" + * `name:Paul` for name "Paul Atreides" + +* JobName GQL Query is resolved as matching the query as a part of the whole metaData-JSON in the SQL DB. diff --git a/go.mod b/go.mod index 799ed31..e6a1877 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,8 @@ require ( github.com/influxdata/influxdb-client-go/v2 v2.10.0 github.com/jmoiron/sqlx v1.3.5 github.com/mattn/go-sqlite3 v1.14.15 + github.com/prometheus/client_golang v1.14.0 + github.com/prometheus/common v0.37.0 github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 github.com/swaggo/http-swagger v1.3.3 github.com/swaggo/swag v1.8.5 @@ -58,9 +60,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/prometheus/client_golang v1.14.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect - github.com/prometheus/common v0.37.0 // indirect github.com/prometheus/procfs v0.8.0 // indirect github.com/qustavo/sqlhooks/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect diff --git a/go.sum b/go.sum index b1ec1f8..c74fed0 100644 --- a/go.sum +++ b/go.sum @@ -622,6 +622,8 @@ github.com/google/go-containerregistry v0.5.1/go.mod h1:Ct15B4yir3PLOP5jsy0GNeYV github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -1503,6 +1505,7 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180224232135-f6cff0780e54/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1743,6 +1746,7 @@ golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20= +golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/graph/generated/generated.go b/internal/graph/generated/generated.go index edae306..c4a098f 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -131,6 +131,7 @@ type ComplexityRoot struct { HistDuration func(childComplexity int) int HistNumNodes func(childComplexity int) int ID func(childComplexity int) int + Name func(childComplexity int) int ShortJobs func(childComplexity int) int TotalCoreHours func(childComplexity int) int TotalJobs func(childComplexity int) int @@ -671,6 +672,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.JobsStatistics.ID(childComplexity), true + case "JobsStatistics.name": + if e.complexity.JobsStatistics.Name == nil { + break + } + + return e.complexity.JobsStatistics.Name(childComplexity), true + case "JobsStatistics.shortJobs": if e.complexity.JobsStatistics.ShortJobs == nil { break @@ -1619,6 +1627,7 @@ input StringInput { contains: String startsWith: String endsWith: String + in: [String!] } input IntRange { from: Int!, to: Int! } @@ -1639,6 +1648,7 @@ type HistoPoint { type JobsStatistics { id: ID! # If ` + "`" + `groupBy` + "`" + ` was used, ID of the user/project/cluster + name: String # if User-Statistics: Given Name of Account (ID) Owner totalJobs: Int! # Number of jobs that matched shortJobs: Int! # Number of jobs with a duration of less than 2 minutes totalWalltime: Int! # Sum of the duration of all matched jobs in hours @@ -4485,6 +4495,47 @@ func (ec *executionContext) fieldContext_JobsStatistics_id(ctx context.Context, return fc, nil } +func (ec *executionContext) _JobsStatistics_name(ctx context.Context, field graphql.CollectedField, obj *model.JobsStatistics) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_JobsStatistics_name(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.Name, nil + }) + 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_JobsStatistics_name(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "JobsStatistics", + Field: field, + IsMethod: false, + IsResolver: false, + 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) _JobsStatistics_totalJobs(ctx context.Context, field graphql.CollectedField, obj *model.JobsStatistics) (ret graphql.Marshaler) { fc, err := ec.fieldContext_JobsStatistics_totalJobs(ctx, field) if err != nil { @@ -6401,6 +6452,8 @@ func (ec *executionContext) fieldContext_Query_jobsStatistics(ctx context.Contex switch field.Name { case "id": return ec.fieldContext_JobsStatistics_id(ctx, field) + case "name": + return ec.fieldContext_JobsStatistics_name(ctx, field) case "totalJobs": return ec.fieldContext_JobsStatistics_totalJobs(ctx, field) case "shortJobs": @@ -10640,7 +10693,7 @@ func (ec *executionContext) unmarshalInputStringInput(ctx context.Context, obj i asMap[k] = v } - fieldsInOrder := [...]string{"eq", "contains", "startsWith", "endsWith"} + fieldsInOrder := [...]string{"eq", "contains", "startsWith", "endsWith", "in"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -10679,6 +10732,14 @@ func (ec *executionContext) unmarshalInputStringInput(ctx context.Context, obj i if err != nil { return it, err } + case "in": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("in")) + it.In, err = ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } } } @@ -11340,6 +11401,10 @@ func (ec *executionContext) _JobsStatistics(ctx context.Context, sel ast.Selecti if out.Values[i] == graphql.Null { invalids++ } + case "name": + + out.Values[i] = ec._JobsStatistics_name(ctx, field, obj) + case "totalJobs": out.Values[i] = ec._JobsStatistics_totalJobs(ctx, field, obj) diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index 9d2f9ea..0828b88 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -72,6 +72,7 @@ type JobResultList struct { type JobsStatistics struct { ID string `json:"id"` + Name *string `json:"name"` TotalJobs int `json:"totalJobs"` ShortJobs int `json:"shortJobs"` TotalWalltime int `json:"totalWalltime"` @@ -102,10 +103,11 @@ type PageRequest struct { } type StringInput struct { - Eq *string `json:"eq"` - Contains *string `json:"contains"` - StartsWith *string `json:"startsWith"` - EndsWith *string `json:"endsWith"` + Eq *string `json:"eq"` + Contains *string `json:"contains"` + StartsWith *string `json:"startsWith"` + EndsWith *string `json:"endsWith"` + In []string `json:"in"` } type TimeRangeOutput struct { diff --git a/internal/repository/job.go b/internal/repository/job.go index de21176..68d15c3 100644 --- a/internal/repository/job.go +++ b/internal/repository/job.go @@ -49,6 +49,7 @@ func GetJobRepository() *JobRepository { jobRepoInstance = &JobRepository{ DB: db.DB, driver: db.Driver, + stmtCache: sq.NewStmtCache(db.DB), cache: lrucache.New(1024 * 1024), archiveChannel: make(chan *schema.Job, 128), @@ -501,6 +502,17 @@ func (r *JobRepository) FindJobnameOrUserOrProject(ctx context.Context, searchte } else if err == nil { return "", username, "", nil } + + if username == "" { // Try with Name2Username query + errtwo := sq.Select("user.username").Distinct().From("user"). + Where("user.name LIKE ?", fmt.Sprint("%"+searchterm+"%")). + RunWith(r.stmtCache).QueryRow().Scan(&username) + if errtwo != nil && errtwo != sql.ErrNoRows { + return "", "", "", errtwo + } else if errtwo == nil { + return "", username, "", nil + } + } } if user == nil || user.HasRole(auth.RoleAdmin) || user.HasRole(auth.RoleSupport) { @@ -542,7 +554,105 @@ func (r *JobRepository) FindUser(ctx context.Context, searchterm string) (userna return "", ErrNotFound } else { - log.Infof("Non-Admin User %s : Requested Query Username -> %s: Forbidden", user.Name, username) + log.Infof("Non-Admin User %s : Requested Query Username -> %s: Forbidden", user.Name, searchterm) + return "", ErrForbidden + } +} + +func (r *JobRepository) FindUserByName(ctx context.Context, searchterm string) (username string, err error) { + user := auth.GetUser(ctx) + if user == nil || user.HasRole(auth.RoleAdmin) || user.HasRole(auth.RoleSupport) { + err := sq.Select("user.username").Distinct().From("user"). + Where("user.name = ?", searchterm). + RunWith(r.stmtCache).QueryRow().Scan(&username) + if err != nil && err != sql.ErrNoRows { + return "", err + } else if err == nil { + return username, nil + } + return "", ErrNotFound + + } else { + log.Infof("Non-Admin User %s : Requested Query Name -> %s: Forbidden", user.Name, searchterm) + return "", ErrForbidden + } +} + +func (r *JobRepository) FindUsers(ctx context.Context, searchterm string) (usernames []string, err error) { + user := auth.GetUser(ctx) + emptyResult := make([]string, 0) + if user == nil || user.HasRole(auth.RoleAdmin) || user.HasRole(auth.RoleSupport) { + rows, err := sq.Select("job.user").Distinct().From("job"). + Where("job.user LIKE ?", fmt.Sprint("%", searchterm, "%")). + RunWith(r.stmtCache).Query() + if err != nil && err != sql.ErrNoRows { + return emptyResult, err + } else if err == nil { + for rows.Next() { + var name string + err := rows.Scan(&name) + if err != nil { + rows.Close() + log.Warnf("Error while scanning rows: %v", err) + return emptyResult, err + } + usernames = append(usernames, name) + } + return usernames, nil + } + return emptyResult, ErrNotFound + + } else { + log.Infof("Non-Admin User %s : Requested Query Usernames -> %s: Forbidden", user.Name, searchterm) + return emptyResult, ErrForbidden + } +} + +func (r *JobRepository) FindUsersByName(ctx context.Context, searchterm string) (usernames []string, err error) { + user := auth.GetUser(ctx) + emptyResult := make([]string, 0) + if user == nil || user.HasRole(auth.RoleAdmin) || user.HasRole(auth.RoleSupport) { + rows, err := sq.Select("user.username").Distinct().From("user"). + Where("user.name LIKE ?", fmt.Sprint("%", searchterm, "%")). + RunWith(r.stmtCache).Query() + if err != nil && err != sql.ErrNoRows { + return emptyResult, err + } else if err == nil { + for rows.Next() { + var username string + err := rows.Scan(&username) + if err != nil { + rows.Close() + log.Warnf("Error while scanning rows: %v", err) + return emptyResult, err + } + usernames = append(usernames, username) + } + return usernames, nil + } + return emptyResult, ErrNotFound + + } else { + log.Infof("Non-Admin User %s : Requested Query name -> %s: Forbidden", user.Name, searchterm) + return emptyResult, ErrForbidden + } +} + +func (r *JobRepository) FindNameByUser(ctx context.Context, searchterm string) (name string, err error) { + user := auth.GetUser(ctx) + if user == nil || user.HasRole(auth.RoleAdmin) || user.HasRole(auth.RoleSupport) { + err := sq.Select("user.name").Distinct().From("user"). + Where("user.username = ?", searchterm). + RunWith(r.stmtCache).QueryRow().Scan(&name) + if err != nil && err != sql.ErrNoRows { + return "", err + } else if err == nil { + return name, nil + } + return "", ErrNotFound + + } else { + log.Infof("Non-Admin User %s : Requested Query Name -> %s: Forbidden", user.Name, searchterm) return "", ErrForbidden } } diff --git a/internal/repository/query.go b/internal/repository/query.go index 7fa34b6..183b89b 100644 --- a/internal/repository/query.go +++ b/internal/repository/query.go @@ -206,6 +206,13 @@ func buildStringCondition(field string, cond *model.StringInput, query sq.Select if cond.Contains != nil { return query.Where(field+" LIKE ?", fmt.Sprint("%", *cond.Contains, "%")) } + if cond.In != nil { + queryUsers := make([]string, len(cond.In)) + for i, val := range cond.In { + queryUsers[i] = val + } + return query.Where(sq.Or{sq.Eq{"job.user": queryUsers}}) + } return query } diff --git a/internal/routerConfig/routes.go b/internal/routerConfig/routes.go index 417ced3..4f2d85c 100644 --- a/internal/routerConfig/routes.go +++ b/internal/routerConfig/routes.go @@ -183,9 +183,14 @@ func buildFilterPresets(query url.Values) map[string]interface{} { if query.Get("jobName") != "" { filterPresets["jobName"] = query.Get("jobName") } - if query.Get("user") != "" { - filterPresets["user"] = query.Get("user") - filterPresets["userMatch"] = "eq" + if len(query["user"]) != 0 { + if len(query["user"]) == 1 { + filterPresets["user"] = query.Get("user") + filterPresets["userMatch"] = "contains" + } else { + filterPresets["user"] = query["user"] + filterPresets["userMatch"] = "in" + } } if len(query["state"]) != 0 { filterPresets["state"] = query["state"] @@ -319,12 +324,27 @@ func HandleSearchBar(rw http.ResponseWriter, r *http.Request, api *api.RestApi) http.Redirect(rw, r, "/monitoring/jobs/?jobId=NotFound", http.StatusTemporaryRedirect) // Workaround to display correctly empty table } case "username": - username, _ := api.JobRepository.FindUser(r.Context(), strings.Trim(splitSearch[1], " ")) // Restricted: username - if username != "" { - http.Redirect(rw, r, "/monitoring/user/"+username, http.StatusTemporaryRedirect) + usernames, _ := api.JobRepository.FindUsers(r.Context(), strings.Trim(splitSearch[1], " ")) // Restricted: usernames + if len(usernames) == 1 { + http.Redirect(rw, r, "/monitoring/user/"+usernames[0], http.StatusTemporaryRedirect) // One Match: Redirect to User View + return + } else if len(usernames) > 1 { + http.Redirect(rw, r, "/monitoring/users/?user="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusTemporaryRedirect) // > 1 Matches: Redirect to user table return } else { - http.Redirect(rw, r, "/monitoring/jobs/?jobId=NotFound", http.StatusTemporaryRedirect) // Workaround to display correctly empty table + http.Redirect(rw, r, "/monitoring/users/?user=NotFound", http.StatusTemporaryRedirect) // Workaround to display correctly empty table + } + case "name": + usernames, _ := api.JobRepository.FindUsersByName(r.Context(), strings.Trim(splitSearch[1], " ")) // Restricted: usernames queried by name + if len(usernames) == 1 { + http.Redirect(rw, r, "/monitoring/user/"+usernames[0], http.StatusTemporaryRedirect) + return + } else if len(usernames) > 1 { + joinedNames := strings.Join(usernames, "&user=") + http.Redirect(rw, r, "/monitoring/users/?user="+joinedNames, http.StatusTemporaryRedirect) // > 1 Matches: Redirect to user table + return + } else { + http.Redirect(rw, r, "/monitoring/users/?user=NotFound", http.StatusTemporaryRedirect) // Workaround to display correctly empty table } default: http.Error(rw, "'searchId' type parameter unknown", http.StatusBadRequest) diff --git a/web/frontend/src/Header.svelte b/web/frontend/src/Header.svelte index 96a5f06..af32a23 100644 --- a/web/frontend/src/Header.svelte +++ b/web/frontend/src/Header.svelte @@ -57,7 +57,7 @@ - + {#if username} diff --git a/web/frontend/src/List.root.svelte b/web/frontend/src/List.root.svelte index 7d973e4..d80e330 100644 --- a/web/frontend/src/List.root.svelte +++ b/web/frontend/src/List.root.svelte @@ -20,6 +20,7 @@ const stats = operationStore(`query($filter: [JobFilter!]!) { rows: jobsStatistics(filter: $filter, groupBy: ${type}) { id + name totalJobs totalWalltime totalCoreHours @@ -93,6 +94,15 @@ + {#if type == 'USER'} + + Name + + + {/if} Total Jobs