Merge branch '275_tag_scope_jobview_rework' into dev

This commit is contained in:
Christoph Kluge 2024-09-24 17:25:20 +02:00
commit 827f6daabc
31 changed files with 1643 additions and 577 deletions

View File

@ -113,6 +113,7 @@ type Tag {
id: ID! id: ID!
type: String! type: String!
name: String! name: String!
scope: String!
} }
type Resource { type Resource {
@ -235,7 +236,7 @@ type Query {
} }
type Mutation { type Mutation {
createTag(type: String!, name: String!): Tag! createTag(type: String!, name: String!, scope: String!): Tag!
deleteTag(id: ID!): ID! deleteTag(id: ID!): ID!
addTagsToJob(job: ID!, tagIds: [ID!]!): [Tag!]! addTagsToJob(job: ID!, tagIds: [ID!]!): [Tag!]!
removeTagsFromJob(job: ID!, tagIds: [ID!]!): [Tag!]! removeTagsFromJob(job: ID!, tagIds: [ID!]!): [Tag!]!

View File

@ -215,7 +215,7 @@ func TestRestApi(t *testing.T) {
"exclusive": 1, "exclusive": 1,
"monitoringStatus": 1, "monitoringStatus": 1,
"smt": 1, "smt": 1,
"tags": [{ "type": "testTagType", "name": "testTagName" }], "tags": [{ "type": "testTagType", "name": "testTagName", "scope": "testuser" }],
"resources": [ "resources": [
{ {
"hostname": "host123", "hostname": "host123",
@ -283,7 +283,7 @@ func TestRestApi(t *testing.T) {
t.Fatalf("unexpected job properties: %#v", job) t.Fatalf("unexpected job properties: %#v", job)
} }
if len(job.Tags) != 1 || job.Tags[0].Type != "testTagType" || job.Tags[0].Name != "testTagName" { if len(job.Tags) != 1 || job.Tags[0].Type != "testTagType" || job.Tags[0].Name != "testTagName" || job.Tags[0].Scope != "testuser" {
t.Fatalf("unexpected tags: %#v", job.Tags) t.Fatalf("unexpected tags: %#v", job.Tags)
} }

View File

@ -177,8 +177,9 @@ type ErrorResponse struct {
// ApiTag model // ApiTag model
type ApiTag struct { type ApiTag struct {
// Tag Type // Tag Type
Type string `json:"type" example:"Debug"` Type string `json:"type" example:"Debug"`
Name string `json:"name" example:"Testjob"` // Tag Name Name string `json:"name" example:"Testjob"` // Tag Name
Scope string `json:"scope" example:"global"` // Tag Scope for Frontend Display
} }
// ApiMeta model // ApiMeta model
@ -420,7 +421,7 @@ func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) {
StartTime: job.StartTime.Unix(), StartTime: job.StartTime.Unix(),
} }
res.Tags, err = api.JobRepository.GetTags(&job.ID) res.Tags, err = api.JobRepository.GetTags(r.Context(), &job.ID)
if err != nil { if err != nil {
handleError(err, http.StatusInternalServerError, rw) handleError(err, http.StatusInternalServerError, rw)
return return
@ -493,7 +494,7 @@ func (api *RestApi) getCompleteJobById(rw http.ResponseWriter, r *http.Request)
return return
} }
job.Tags, err = api.JobRepository.GetTags(&job.ID) job.Tags, err = api.JobRepository.GetTags(r.Context(), &job.ID)
if err != nil { if err != nil {
handleError(err, http.StatusInternalServerError, rw) handleError(err, http.StatusInternalServerError, rw)
return return
@ -579,7 +580,7 @@ func (api *RestApi) getJobById(rw http.ResponseWriter, r *http.Request) {
return return
} }
job.Tags, err = api.JobRepository.GetTags(&job.ID) job.Tags, err = api.JobRepository.GetTags(r.Context(), &job.ID)
if err != nil { if err != nil {
handleError(err, http.StatusInternalServerError, rw) handleError(err, http.StatusInternalServerError, rw)
return return
@ -687,6 +688,7 @@ func (api *RestApi) editMeta(rw http.ResponseWriter, r *http.Request) {
// @summary Adds one or more tags to a job // @summary Adds one or more tags to a job
// @tags Job add and modify // @tags Job add and modify
// @description Adds tag(s) to a job specified by DB ID. Name and Type of Tag(s) can be chosen freely. // @description Adds tag(s) to a job specified by DB ID. Name and Type of Tag(s) can be chosen freely.
// @description Tag Scope for frontend visibility will default to "global" if none entered, other options: "admin" or specific username.
// @description If tagged job is already finished: Tag will be written directly to respective archive files. // @description If tagged job is already finished: Tag will be written directly to respective archive files.
// @accept json // @accept json
// @produce json // @produce json
@ -712,7 +714,7 @@ func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) {
return return
} }
job.Tags, err = api.JobRepository.GetTags(&job.ID) job.Tags, err = api.JobRepository.GetTags(r.Context(), &job.ID)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) http.Error(rw, err.Error(), http.StatusInternalServerError)
return return
@ -725,16 +727,17 @@ func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) {
} }
for _, tag := range req { for _, tag := range req {
tagId, err := api.JobRepository.AddTagOrCreate(job.ID, tag.Type, tag.Name) tagId, err := api.JobRepository.AddTagOrCreate(r.Context(), job.ID, tag.Type, tag.Name, tag.Scope)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) http.Error(rw, err.Error(), http.StatusInternalServerError)
return return
} }
job.Tags = append(job.Tags, &schema.Tag{ job.Tags = append(job.Tags, &schema.Tag{
ID: tagId, ID: tagId,
Type: tag.Type, Type: tag.Type,
Name: tag.Name, Name: tag.Name,
Scope: tag.Scope,
}) })
} }
@ -802,7 +805,7 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) {
unlockOnce.Do(api.RepositoryMutex.Unlock) unlockOnce.Do(api.RepositoryMutex.Unlock)
for _, tag := range req.Tags { for _, tag := range req.Tags {
if _, err := api.JobRepository.AddTagOrCreate(id, tag.Type, tag.Name); err != nil { if _, err := api.JobRepository.AddTagOrCreate(r.Context(), id, tag.Type, tag.Name, tag.Scope); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) http.Error(rw, err.Error(), http.StatusInternalServerError)
handleError(fmt.Errorf("adding tag to new job %d failed: %w", id, err), http.StatusInternalServerError, rw) handleError(fmt.Errorf("adding tag to new job %d failed: %w", id, err), http.StatusInternalServerError, rw)
return return

View File

@ -229,7 +229,7 @@ type ComplexityRoot struct {
Mutation struct { Mutation struct {
AddTagsToJob func(childComplexity int, job string, tagIds []string) int AddTagsToJob func(childComplexity int, job string, tagIds []string) int
CreateTag func(childComplexity int, typeArg string, name string) int CreateTag func(childComplexity int, typeArg string, name string, scope string) int
DeleteTag func(childComplexity int, id string) int DeleteTag func(childComplexity int, id string) int
RemoveTagsFromJob func(childComplexity int, job string, tagIds []string) int RemoveTagsFromJob func(childComplexity int, job string, tagIds []string) int
UpdateConfiguration func(childComplexity int, name string, value string) int UpdateConfiguration func(childComplexity int, name string, value string) int
@ -303,9 +303,10 @@ type ComplexityRoot struct {
} }
Tag struct { Tag struct {
ID func(childComplexity int) int ID func(childComplexity int) int
Name func(childComplexity int) int Name func(childComplexity int) int
Type func(childComplexity int) int Scope func(childComplexity int) int
Type func(childComplexity int) int
} }
TimeRangeOutput struct { TimeRangeOutput struct {
@ -355,7 +356,7 @@ type MetricValueResolver interface {
Name(ctx context.Context, obj *schema.MetricValue) (*string, error) Name(ctx context.Context, obj *schema.MetricValue) (*string, error)
} }
type MutationResolver interface { type MutationResolver interface {
CreateTag(ctx context.Context, typeArg string, name string) (*schema.Tag, error) CreateTag(ctx context.Context, typeArg string, name string, scope string) (*schema.Tag, error)
DeleteTag(ctx context.Context, id string) (string, error) DeleteTag(ctx context.Context, id string) (string, error)
AddTagsToJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error) AddTagsToJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error)
RemoveTagsFromJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error) RemoveTagsFromJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error)
@ -1183,7 +1184,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return 0, false return 0, false
} }
return e.complexity.Mutation.CreateTag(childComplexity, args["type"].(string), args["name"].(string)), true return e.complexity.Mutation.CreateTag(childComplexity, args["type"].(string), args["name"].(string), args["scope"].(string)), true
case "Mutation.deleteTag": case "Mutation.deleteTag":
if e.complexity.Mutation.DeleteTag == nil { if e.complexity.Mutation.DeleteTag == nil {
@ -1602,6 +1603,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Tag.Name(childComplexity), true return e.complexity.Tag.Name(childComplexity), true
case "Tag.scope":
if e.complexity.Tag.Scope == nil {
break
}
return e.complexity.Tag.Scope(childComplexity), true
case "Tag.type": case "Tag.type":
if e.complexity.Tag.Type == nil { if e.complexity.Tag.Type == nil {
break break
@ -1949,6 +1957,7 @@ type Tag {
id: ID! id: ID!
type: String! type: String!
name: String! name: String!
scope: String!
} }
type Resource { type Resource {
@ -2071,7 +2080,7 @@ type Query {
} }
type Mutation { type Mutation {
createTag(type: String!, name: String!): Tag! createTag(type: String!, name: String!, scope: String!): Tag!
deleteTag(id: ID!): ID! deleteTag(id: ID!): ID!
addTagsToJob(job: ID!, tagIds: [ID!]!): [Tag!]! addTagsToJob(job: ID!, tagIds: [ID!]!): [Tag!]!
removeTagsFromJob(job: ID!, tagIds: [ID!]!): [Tag!]! removeTagsFromJob(job: ID!, tagIds: [ID!]!): [Tag!]!
@ -2244,6 +2253,15 @@ func (ec *executionContext) field_Mutation_createTag_args(ctx context.Context, r
} }
} }
args["name"] = arg1 args["name"] = arg1
var arg2 string
if tmp, ok := rawArgs["scope"]; ok {
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("scope"))
arg2, err = ec.unmarshalNString2string(ctx, tmp)
if err != nil {
return nil, err
}
}
args["scope"] = arg2
return args, nil return args, nil
} }
@ -4622,6 +4640,8 @@ func (ec *executionContext) fieldContext_Job_tags(_ context.Context, field graph
return ec.fieldContext_Tag_type(ctx, field) return ec.fieldContext_Tag_type(ctx, field)
case "name": case "name":
return ec.fieldContext_Tag_name(ctx, field) return ec.fieldContext_Tag_name(ctx, field)
case "scope":
return ec.fieldContext_Tag_scope(ctx, field)
} }
return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name) return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name)
}, },
@ -7680,7 +7700,7 @@ func (ec *executionContext) _Mutation_createTag(ctx context.Context, field graph
}() }()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().CreateTag(rctx, fc.Args["type"].(string), fc.Args["name"].(string)) return ec.resolvers.Mutation().CreateTag(rctx, fc.Args["type"].(string), fc.Args["name"].(string), fc.Args["scope"].(string))
}) })
if err != nil { if err != nil {
ec.Error(ctx, err) ec.Error(ctx, err)
@ -7711,6 +7731,8 @@ func (ec *executionContext) fieldContext_Mutation_createTag(ctx context.Context,
return ec.fieldContext_Tag_type(ctx, field) return ec.fieldContext_Tag_type(ctx, field)
case "name": case "name":
return ec.fieldContext_Tag_name(ctx, field) return ec.fieldContext_Tag_name(ctx, field)
case "scope":
return ec.fieldContext_Tag_scope(ctx, field)
} }
return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name) return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name)
}, },
@ -7829,6 +7851,8 @@ func (ec *executionContext) fieldContext_Mutation_addTagsToJob(ctx context.Conte
return ec.fieldContext_Tag_type(ctx, field) return ec.fieldContext_Tag_type(ctx, field)
case "name": case "name":
return ec.fieldContext_Tag_name(ctx, field) return ec.fieldContext_Tag_name(ctx, field)
case "scope":
return ec.fieldContext_Tag_scope(ctx, field)
} }
return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name) return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name)
}, },
@ -7892,6 +7916,8 @@ func (ec *executionContext) fieldContext_Mutation_removeTagsFromJob(ctx context.
return ec.fieldContext_Tag_type(ctx, field) return ec.fieldContext_Tag_type(ctx, field)
case "name": case "name":
return ec.fieldContext_Tag_name(ctx, field) return ec.fieldContext_Tag_name(ctx, field)
case "scope":
return ec.fieldContext_Tag_scope(ctx, field)
} }
return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name) return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name)
}, },
@ -8199,6 +8225,8 @@ func (ec *executionContext) fieldContext_Query_tags(_ context.Context, field gra
return ec.fieldContext_Tag_type(ctx, field) return ec.fieldContext_Tag_type(ctx, field)
case "name": case "name":
return ec.fieldContext_Tag_name(ctx, field) return ec.fieldContext_Tag_name(ctx, field)
case "scope":
return ec.fieldContext_Tag_scope(ctx, field)
} }
return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name) return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name)
}, },
@ -10547,6 +10575,50 @@ func (ec *executionContext) fieldContext_Tag_name(_ context.Context, field graph
return fc, nil return fc, nil
} }
func (ec *executionContext) _Tag_scope(ctx context.Context, field graphql.CollectedField, obj *schema.Tag) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Tag_scope(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.Scope, 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.(string)
fc.Result = res
return ec.marshalNString2string(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_Tag_scope(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Tag",
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) _TimeRangeOutput_from(ctx context.Context, field graphql.CollectedField, obj *model.TimeRangeOutput) (ret graphql.Marshaler) { func (ec *executionContext) _TimeRangeOutput_from(ctx context.Context, field graphql.CollectedField, obj *model.TimeRangeOutput) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_TimeRangeOutput_from(ctx, field) fc, err := ec.fieldContext_TimeRangeOutput_from(ctx, field)
if err != nil { if err != nil {
@ -15666,6 +15738,11 @@ func (ec *executionContext) _Tag(ctx context.Context, sel ast.SelectionSet, obj
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
out.Invalids++ out.Invalids++
} }
case "scope":
out.Values[i] = ec._Tag_scope(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default: default:
panic("unknown field " + strconv.Quote(field.Name)) panic("unknown field " + strconv.Quote(field.Name))
} }

View File

@ -29,7 +29,7 @@ func (r *clusterResolver) Partitions(ctx context.Context, obj *schema.Cluster) (
// Tags is the resolver for the tags field. // Tags is the resolver for the tags field.
func (r *jobResolver) Tags(ctx context.Context, obj *schema.Job) ([]*schema.Tag, error) { func (r *jobResolver) Tags(ctx context.Context, obj *schema.Job) ([]*schema.Tag, error) {
return r.Repo.GetTags(&obj.ID) return r.Repo.GetTags(ctx, &obj.ID)
} }
// ConcurrentJobs is the resolver for the concurrentJobs field. // ConcurrentJobs is the resolver for the concurrentJobs field.
@ -86,14 +86,14 @@ func (r *metricValueResolver) Name(ctx context.Context, obj *schema.MetricValue)
} }
// CreateTag is the resolver for the createTag field. // CreateTag is the resolver for the createTag field.
func (r *mutationResolver) CreateTag(ctx context.Context, typeArg string, name string) (*schema.Tag, error) { func (r *mutationResolver) CreateTag(ctx context.Context, typeArg string, name string, scope string) (*schema.Tag, error) {
id, err := r.Repo.CreateTag(typeArg, name) id, err := r.Repo.CreateTag(typeArg, name, scope)
if err != nil { if err != nil {
log.Warn("Error while creating tag") log.Warn("Error while creating tag")
return nil, err return nil, err
} }
return &schema.Tag{ID: id, Type: typeArg, Name: name}, nil return &schema.Tag{ID: id, Type: typeArg, Name: name, Scope: scope}, nil
} }
// DeleteTag is the resolver for the deleteTag field. // DeleteTag is the resolver for the deleteTag field.
@ -103,6 +103,7 @@ func (r *mutationResolver) DeleteTag(ctx context.Context, id string) (string, er
// AddTagsToJob is the resolver for the addTagsToJob field. // AddTagsToJob is the resolver for the addTagsToJob field.
func (r *mutationResolver) AddTagsToJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error) { func (r *mutationResolver) AddTagsToJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error) {
// Selectable Tags Pre-Filtered by Scope in Frontend: No backend check required
jid, err := strconv.ParseInt(job, 10, 64) jid, err := strconv.ParseInt(job, 10, 64)
if err != nil { if err != nil {
log.Warn("Error while adding tag to job") log.Warn("Error while adding tag to job")
@ -117,7 +118,7 @@ func (r *mutationResolver) AddTagsToJob(ctx context.Context, job string, tagIds
return nil, err return nil, err
} }
if tags, err = r.Repo.AddTag(jid, tid); err != nil { if tags, err = r.Repo.AddTag(ctx, jid, tid); err != nil {
log.Warn("Error while adding tag") log.Warn("Error while adding tag")
return nil, err return nil, err
} }
@ -128,6 +129,7 @@ func (r *mutationResolver) AddTagsToJob(ctx context.Context, job string, tagIds
// RemoveTagsFromJob is the resolver for the removeTagsFromJob field. // RemoveTagsFromJob is the resolver for the removeTagsFromJob field.
func (r *mutationResolver) RemoveTagsFromJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error) { func (r *mutationResolver) RemoveTagsFromJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error) {
// Removable Tags Pre-Filtered by Scope in Frontend: No backend check required
jid, err := strconv.ParseInt(job, 10, 64) jid, err := strconv.ParseInt(job, 10, 64)
if err != nil { if err != nil {
log.Warn("Error while parsing job id") log.Warn("Error while parsing job id")
@ -142,7 +144,7 @@ func (r *mutationResolver) RemoveTagsFromJob(ctx context.Context, job string, ta
return nil, err return nil, err
} }
if tags, err = r.Repo.RemoveTag(jid, tid); err != nil { if tags, err = r.Repo.RemoveTag(ctx, jid, tid); err != nil {
log.Warn("Error while removing tag") log.Warn("Error while removing tag")
return nil, err return nil, err
} }
@ -168,7 +170,7 @@ func (r *queryResolver) Clusters(ctx context.Context) ([]*schema.Cluster, error)
// Tags is the resolver for the tags field. // Tags is the resolver for the tags field.
func (r *queryResolver) Tags(ctx context.Context) ([]*schema.Tag, error) { func (r *queryResolver) Tags(ctx context.Context) ([]*schema.Tag, error) {
return r.Repo.GetTags(nil) return r.Repo.GetTags(ctx, nil)
} }
// GlobalMetrics is the resolver for the globalMetrics field. // GlobalMetrics is the resolver for the globalMetrics field.

View File

@ -120,8 +120,8 @@ func HandleImportFlag(flag string) error {
} }
for _, tag := range job.Tags { for _, tag := range job.Tags {
if _, err := r.AddTagOrCreate(id, tag.Type, tag.Name); err != nil { if err := r.ImportTag(id, tag.Type, tag.Name, tag.Scope); err != nil {
log.Error("Error while adding or creating tag") log.Error("Error while adding or creating tag on import")
return err return err
} }
} }

View File

@ -89,7 +89,8 @@ func (r *JobRepository) CountJobs(
ctx context.Context, ctx context.Context,
filters []*model.JobFilter, filters []*model.JobFilter,
) (int, error) { ) (int, error) {
query, qerr := SecurityCheck(ctx, sq.Select("count(*)").From("job")) // DISTICT count for tags filters, does not affect other queries
query, qerr := SecurityCheck(ctx, sq.Select("count(DISTINCT job.id)").From("job"))
if qerr != nil { if qerr != nil {
return 0, qerr return 0, qerr
} }
@ -136,7 +137,8 @@ func SecurityCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilde
// Build a sq.SelectBuilder out of a schema.JobFilter. // Build a sq.SelectBuilder out of a schema.JobFilter.
func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.SelectBuilder { func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.SelectBuilder {
if filter.Tags != nil { if filter.Tags != nil {
query = query.Join("jobtag ON jobtag.job_id = job.id").Where(sq.Eq{"jobtag.tag_id": filter.Tags}) // This is an OR-Logic query: Returns all distinct jobs with at least one of the requested tags; TODO: AND-Logic query?
query = query.Join("jobtag ON jobtag.job_id = job.id").Where(sq.Eq{"jobtag.tag_id": filter.Tags}).Distinct()
} }
if filter.JobID != nil { if filter.JobID != nil {
query = buildStringCondition("job.job_id", filter.JobID, query) query = buildStringCondition("job.job_id", filter.JobID, query)

View File

@ -5,9 +5,11 @@
package repository package repository
import ( import (
"context"
"fmt" "fmt"
"testing" "testing"
"github.com/ClusterCockpit/cc-backend/pkg/schema"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@ -45,7 +47,19 @@ func TestFindById(t *testing.T) {
func TestGetTags(t *testing.T) { func TestGetTags(t *testing.T) {
r := setup(t) r := setup(t)
tags, counts, err := r.CountTags(nil) const contextUserKey ContextKey = "user"
contextUserValue := &schema.User{
Username: "testuser",
Projects: make([]string, 0),
Roles: []string{"user"},
AuthType: 0,
AuthSource: 2,
}
ctx := context.WithValue(getContext(t), contextUserKey, contextUserValue)
// Test Tag has Scope "global"
tags, counts, err := r.CountTags(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -11,8 +11,9 @@ ALTER TABLE job ADD COLUMN energy REAL NOT NULL DEFAULT 0.0;
ALTER TABLE job ADD COLUMN energy_footprint TEXT DEFAULT NULL; ALTER TABLE job ADD COLUMN energy_footprint TEXT DEFAULT NULL;
ALTER TABLE job ADD COLUMN footprint TEXT DEFAULT NULL; ALTER TABLE job ADD COLUMN footprint TEXT DEFAULT NULL;
UPDATE job SET footprint = '{"flops_any_avg": 0.0}'; ALTER TABLE tag ADD COLUMN tag_scope TEXT NOT NULL DEFAULT 'global';
UPDATE job SET footprint = '{"flops_any_avg": 0.0}';
UPDATE job SET footprint = json_replace(footprint, '$.flops_any_avg', job.flops_any_avg); UPDATE job SET footprint = json_replace(footprint, '$.flops_any_avg', job.flops_any_avg);
UPDATE job SET footprint = json_insert(footprint, '$.mem_bw_avg', job.mem_bw_avg); UPDATE job SET footprint = json_insert(footprint, '$.mem_bw_avg', job.mem_bw_avg);
UPDATE job SET footprint = json_insert(footprint, '$.mem_used_max', job.mem_used_max); UPDATE job SET footprint = json_insert(footprint, '$.mem_used_max', job.mem_used_max);

View File

@ -5,6 +5,8 @@
package repository package repository
import ( import (
"context"
"fmt"
"strings" "strings"
"github.com/ClusterCockpit/cc-backend/pkg/archive" "github.com/ClusterCockpit/cc-backend/pkg/archive"
@ -14,7 +16,14 @@ import (
) )
// Add the tag with id `tagId` to the job with the database id `jobId`. // Add the tag with id `tagId` to the job with the database id `jobId`.
func (r *JobRepository) AddTag(job int64, tag int64) ([]*schema.Tag, error) { func (r *JobRepository) AddTag(ctx context.Context, job int64, tag int64) ([]*schema.Tag, error) {
j, err := r.FindById(ctx, job)
if err != nil {
log.Warn("Error while finding job by id")
return nil, err
}
q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(job, tag) q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(job, tag)
if _, err := q.RunWith(r.stmtCache).Exec(); err != nil { if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
@ -23,49 +32,62 @@ func (r *JobRepository) AddTag(job int64, tag int64) ([]*schema.Tag, error) {
return nil, err return nil, err
} }
j, err := r.FindByIdDirect(job) tags, err := r.GetTags(ctx, &job)
if err != nil {
log.Warn("Error while finding job by id")
return nil, err
}
tags, err := r.GetTags(&job)
if err != nil { if err != nil {
log.Warn("Error while getting tags for job") log.Warn("Error while getting tags for job")
return nil, err return nil, err
} }
return tags, archive.UpdateTags(j, tags) archiveTags, err := r.getArchiveTags(&job)
if err != nil {
log.Warn("Error while getting tags for job")
return nil, err
}
return tags, archive.UpdateTags(j, archiveTags)
} }
// Removes a tag from a job // Removes a tag from a job
func (r *JobRepository) RemoveTag(job, tag int64) ([]*schema.Tag, error) { func (r *JobRepository) RemoveTag(ctx context.Context, job, tag int64) ([]*schema.Tag, error) {
j, err := r.FindById(ctx, job)
if err != nil {
log.Warn("Error while finding job by id")
return nil, err
}
q := sq.Delete("jobtag").Where("jobtag.job_id = ?", job).Where("jobtag.tag_id = ?", tag) q := sq.Delete("jobtag").Where("jobtag.job_id = ?", job).Where("jobtag.tag_id = ?", tag)
if _, err := q.RunWith(r.stmtCache).Exec(); err != nil { if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
s, _, _ := q.ToSql() s, _, _ := q.ToSql()
log.Errorf("Error adding tag with %s: %v", s, err) log.Errorf("Error removing tag with %s: %v", s, err)
return nil, err return nil, err
} }
j, err := r.FindByIdDirect(job) tags, err := r.GetTags(ctx, &job)
if err != nil {
log.Warn("Error while finding job by id")
return nil, err
}
tags, err := r.GetTags(&job)
if err != nil { if err != nil {
log.Warn("Error while getting tags for job") log.Warn("Error while getting tags for job")
return nil, err return nil, err
} }
return tags, archive.UpdateTags(j, tags) archiveTags, err := r.getArchiveTags(&job)
if err != nil {
log.Warn("Error while getting tags for job")
return nil, err
}
return tags, archive.UpdateTags(j, archiveTags)
} }
// CreateTag creates a new tag with the specified type and name and returns its database id. // CreateTag creates a new tag with the specified type and name and returns its database id.
func (r *JobRepository) CreateTag(tagType string, tagName string) (tagId int64, err error) { func (r *JobRepository) CreateTag(tagType string, tagName string, tagScope string) (tagId int64, err error) {
q := sq.Insert("tag").Columns("tag_type", "tag_name").Values(tagType, tagName)
// Default to "Global" scope if none defined
if tagScope == "" {
tagScope = "global"
}
q := sq.Insert("tag").Columns("tag_type", "tag_name", "tag_scope").Values(tagType, tagName, tagScope)
res, err := q.RunWith(r.stmtCache).Exec() res, err := q.RunWith(r.stmtCache).Exec()
if err != nil { if err != nil {
@ -77,9 +99,10 @@ func (r *JobRepository) CreateTag(tagType string, tagName string) (tagId int64,
return res.LastInsertId() return res.LastInsertId()
} }
func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts map[string]int, err error) { func (r *JobRepository) CountTags(ctx context.Context) (tags []schema.Tag, counts map[string]int, err error) {
// Fetch all Tags in DB for Display in Frontend Tag-View
tags = make([]schema.Tag, 0, 100) tags = make([]schema.Tag, 0, 100)
xrows, err := r.DB.Queryx("SELECT id, tag_type, tag_name FROM tag") xrows, err := r.DB.Queryx("SELECT id, tag_type, tag_name, tag_scope FROM tag")
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -89,16 +112,38 @@ func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts
if err = xrows.StructScan(&t); err != nil { if err = xrows.StructScan(&t); err != nil {
return nil, nil, err return nil, nil, err
} }
tags = append(tags, t)
// Handle Scope Filtering: Tag Scope is Global, Private (== Username) or User is auth'd to view Admin Tags
readable, err := r.checkScopeAuth(ctx, "read", t.Scope)
if err != nil {
return nil, nil, err
}
if readable {
tags = append(tags, t)
}
} }
q := sq.Select("t.tag_name, count(jt.tag_id)"). user := GetUserFromContext(ctx)
// Query and Count Jobs with attached Tags
q := sq.Select("t.tag_name, t.id, count(jt.tag_id)").
From("tag t"). From("tag t").
LeftJoin("jobtag jt ON t.id = jt.tag_id"). LeftJoin("jobtag jt ON t.id = jt.tag_id").
GroupBy("t.tag_name") GroupBy("t.tag_name")
// Handle Scope Filtering
scopeList := "\"global\""
if user != nil {
scopeList += ",\"" + user.Username + "\""
}
if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
scopeList += ",\"admin\""
}
q = q.Where("t.tag_scope IN (" + scopeList + ")")
// Handle Job Ownership
if user != nil && user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs if user != nil && user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs
log.Debug("CountTags: User Admin or Support -> Count all Jobs for Tags") // log.Debug("CountTags: User Admin or Support -> Count all Jobs for Tags")
// Unchanged: Needs to be own case still, due to UserRole/NoRole compatibility handling in else case // Unchanged: Needs to be own case still, due to UserRole/NoRole compatibility handling in else case
} else if user != nil && user.HasRole(schema.RoleManager) { // MANAGER: Count own jobs plus project's jobs } else if user != nil && user.HasRole(schema.RoleManager) { // MANAGER: Count own jobs plus project's jobs
// Build ("project1", "project2", ...) list of variable length directly in SQL string // Build ("project1", "project2", ...) list of variable length directly in SQL string
@ -115,29 +160,45 @@ func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts
counts = make(map[string]int) counts = make(map[string]int)
for rows.Next() { for rows.Next() {
var tagName string var tagName string
var tagId int
var count int var count int
if err = rows.Scan(&tagName, &count); err != nil { if err = rows.Scan(&tagName, &tagId, &count); err != nil {
return nil, nil, err return nil, nil, err
} }
counts[tagName] = count // Use tagId as second Map-Key component to differentiate tags with identical names
counts[fmt.Sprint(tagName, tagId)] = count
} }
err = rows.Err() err = rows.Err()
return return tags, counts, err
} }
// AddTagOrCreate adds the tag with the specified type and name to the job with the database id `jobId`. // AddTagOrCreate adds the tag with the specified type and name to the job with the database id `jobId`.
// If such a tag does not yet exist, it is created. // If such a tag does not yet exist, it is created.
func (r *JobRepository) AddTagOrCreate(jobId int64, tagType string, tagName string) (tagId int64, err error) { func (r *JobRepository) AddTagOrCreate(ctx context.Context, jobId int64, tagType string, tagName string, tagScope string) (tagId int64, err error) {
tagId, exists := r.TagId(tagType, tagName)
// Default to "Global" scope if none defined
if tagScope == "" {
tagScope = "global"
}
writable, err := r.checkScopeAuth(ctx, "write", tagScope)
if err != nil {
return 0, err
}
if !writable {
return 0, fmt.Errorf("cannot write tag scope with current authorization")
}
tagId, exists := r.TagId(tagType, tagName, tagScope)
if !exists { if !exists {
tagId, err = r.CreateTag(tagType, tagName) tagId, err = r.CreateTag(tagType, tagName, tagScope)
if err != nil { if err != nil {
return 0, err return 0, err
} }
} }
if _, err := r.AddTag(jobId, tagId); err != nil { if _, err := r.AddTag(ctx, jobId, tagId); err != nil {
return 0, err return 0, err
} }
@ -145,19 +206,19 @@ func (r *JobRepository) AddTagOrCreate(jobId int64, tagType string, tagName stri
} }
// TagId returns the database id of the tag with the specified type and name. // TagId returns the database id of the tag with the specified type and name.
func (r *JobRepository) TagId(tagType string, tagName string) (tagId int64, exists bool) { func (r *JobRepository) TagId(tagType string, tagName string, tagScope string) (tagId int64, exists bool) {
exists = true exists = true
if err := sq.Select("id").From("tag"). if err := sq.Select("id").From("tag").
Where("tag.tag_type = ?", tagType).Where("tag.tag_name = ?", tagName). Where("tag.tag_type = ?", tagType).Where("tag.tag_name = ?", tagName).Where("tag.tag_scope = ?", tagScope).
RunWith(r.stmtCache).QueryRow().Scan(&tagId); err != nil { RunWith(r.stmtCache).QueryRow().Scan(&tagId); err != nil {
exists = false exists = false
} }
return return
} }
// GetTags returns a list of all tags if job is nil or of the tags that the job with that database ID has. // GetTags returns a list of all scoped tags if job is nil or of the tags that the job with that database ID has.
func (r *JobRepository) GetTags(job *int64) ([]*schema.Tag, error) { func (r *JobRepository) GetTags(ctx context.Context, job *int64) ([]*schema.Tag, error) {
q := sq.Select("id", "tag_type", "tag_name").From("tag") q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag")
if job != nil { if job != nil {
q = q.Join("jobtag ON jobtag.tag_id = tag.id").Where("jobtag.job_id = ?", *job) q = q.Join("jobtag ON jobtag.tag_id = tag.id").Where("jobtag.job_id = ?", *job)
} }
@ -172,7 +233,41 @@ func (r *JobRepository) GetTags(job *int64) ([]*schema.Tag, error) {
tags := make([]*schema.Tag, 0) tags := make([]*schema.Tag, 0)
for rows.Next() { for rows.Next() {
tag := &schema.Tag{} tag := &schema.Tag{}
if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name); err != nil { if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
log.Warn("Error while scanning rows")
return nil, err
}
// Handle Scope Filtering: Tag Scope is Global, Private (== Username) or User is auth'd to view Admin Tags
readable, err := r.checkScopeAuth(ctx, "read", tag.Scope)
if err != nil {
return nil, err
}
if readable {
tags = append(tags, tag)
}
}
return tags, nil
}
// GetArchiveTags returns a list of all tags *regardless of scope* for archiving if job is nil or of the tags that the job with that database ID has.
func (r *JobRepository) getArchiveTags(job *int64) ([]*schema.Tag, error) {
q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag")
if job != nil {
q = q.Join("jobtag ON jobtag.tag_id = tag.id").Where("jobtag.job_id = ?", *job)
}
rows, err := q.RunWith(r.stmtCache).Query()
if err != nil {
s, _, _ := q.ToSql()
log.Errorf("Error get tags with %s: %v", s, err)
return nil, err
}
tags := make([]*schema.Tag, 0)
for rows.Next() {
tag := &schema.Tag{}
if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
log.Warn("Error while scanning rows") log.Warn("Error while scanning rows")
return nil, err return nil, err
} }
@ -181,3 +276,60 @@ func (r *JobRepository) GetTags(job *int64) ([]*schema.Tag, error) {
return tags, nil return tags, nil
} }
func (r *JobRepository) ImportTag(jobId int64, tagType string, tagName string, tagScope string) (err error) {
// Import has no scope ctx, only import from metafile to DB (No recursive archive update required), only returns err
tagId, exists := r.TagId(tagType, tagName, tagScope)
if !exists {
tagId, err = r.CreateTag(tagType, tagName, tagScope)
if err != nil {
return err
}
}
q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(jobId, tagId)
if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
s, _, _ := q.ToSql()
log.Errorf("Error adding tag on import with %s: %v", s, err)
return err
}
return nil
}
func (r *JobRepository) checkScopeAuth(ctx context.Context, operation string, scope string) (pass bool, err error) {
user := GetUserFromContext(ctx)
if user != nil {
switch {
case operation == "write" && scope == "admin":
if user.HasRole(schema.RoleAdmin) || (len(user.Roles) == 1 && user.HasRole(schema.RoleApi)) {
return true, nil
}
return false, nil
case operation == "write" && scope == "global":
if user.HasRole(schema.RoleAdmin) || (len(user.Roles) == 1 && user.HasRole(schema.RoleApi)) {
return true, nil
}
return false, nil
case operation == "write" && scope == user.Username:
return true, nil
case operation == "read" && scope == "admin":
return user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}), nil
case operation == "read" && scope == "global":
return true, nil
case operation == "read" && scope == user.Username:
return true, nil
default:
if operation == "read" || operation == "write" {
// No acceptable scope: deny tag
return false, nil
} else {
return false, fmt.Errorf("error while checking tag operation auth: unknown operation (%s)", operation)
}
}
} else {
return false, fmt.Errorf("error while checking tag operation auth: no user in context")
}
}

Binary file not shown.

View File

@ -124,9 +124,8 @@ func setupAnalysisRoute(i InfoType, r *http.Request) InfoType {
func setupTaglistRoute(i InfoType, r *http.Request) InfoType { func setupTaglistRoute(i InfoType, r *http.Request) InfoType {
jobRepo := repository.GetJobRepository() jobRepo := repository.GetJobRepository()
user := repository.GetUserFromContext(r.Context())
tags, counts, err := jobRepo.CountTags(user) tags, counts, err := jobRepo.CountTags(r.Context())
tagMap := make(map[string][]map[string]interface{}) tagMap := make(map[string][]map[string]interface{})
if err != nil { if err != nil {
log.Warnf("GetTags failed: %s", err.Error()) log.Warnf("GetTags failed: %s", err.Error())
@ -134,11 +133,13 @@ func setupTaglistRoute(i InfoType, r *http.Request) InfoType {
return i return i
} }
// Uses tag.ID as second Map-Key component to differentiate tags with identical names
for _, tag := range tags { for _, tag := range tags {
tagItem := map[string]interface{}{ tagItem := map[string]interface{}{
"id": tag.ID, "id": tag.ID,
"name": tag.Name, "name": tag.Name,
"count": counts[tag.Name], "scope": tag.Scope,
"count": counts[fmt.Sprint(tag.Name, tag.ID)],
} }
tagMap[tag.Type] = append(tagMap[tag.Type], tagItem) tagMap[tag.Type] = append(tagMap[tag.Type], tagItem)
} }

View File

@ -171,8 +171,9 @@ func UpdateTags(job *schema.Job, tags []*schema.Tag) error {
jobMeta.Tags = make([]*schema.Tag, 0) jobMeta.Tags = make([]*schema.Tag, 0)
for _, tag := range tags { for _, tag := range tags {
jobMeta.Tags = append(jobMeta.Tags, &schema.Tag{ jobMeta.Tags = append(jobMeta.Tags, &schema.Tag{
Name: tag.Name, Name: tag.Name,
Type: tag.Type, Type: tag.Type,
Scope: tag.Scope,
}) })
} }

View File

@ -117,9 +117,10 @@ type JobStatistics struct {
// Tag model // Tag model
// @Description Defines a tag using name and type. // @Description Defines a tag using name and type.
type Tag struct { type Tag struct {
Type string `json:"type" db:"tag_type" example:"Debug"` Type string `json:"type" db:"tag_type" example:"Debug"`
Name string `json:"name" db:"tag_name" example:"Testjob"` Name string `json:"name" db:"tag_name" example:"Testjob"`
ID int64 `json:"id" db:"id"` Scope string `json:"scope" db:"tag_scope" example:"global"`
ID int64 `json:"id" db:"id"`
} }
// Resource model // Resource model

View File

@ -2,9 +2,9 @@
@component Main single job display component; displays plots for every metric as well as various information @component Main single job display component; displays plots for every metric as well as various information
Properties: Properties:
- `dbid Number`: The jobs DB ID
- `username String`: Empty string if auth. is disabled, otherwise the username as string - `username String`: Empty string if auth. is disabled, otherwise the username as string
- `authlevel Number`: The current users authentication level - `authlevel Number`: The current users authentication level
- `clusters [String]`: List of cluster names
- `roles [Number]`: Enum containing available roles - `roles [Number]`: Enum containing available roles
--> -->
@ -25,7 +25,6 @@
CardHeader, CardHeader,
CardTitle, CardTitle,
Button, Button,
Icon,
} from "@sveltestrap/sveltestrap"; } from "@sveltestrap/sveltestrap";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { import {
@ -35,16 +34,16 @@
transformDataForRoofline, transformDataForRoofline,
} from "./generic/utils.js"; } from "./generic/utils.js";
import Metric from "./job/Metric.svelte"; import Metric from "./job/Metric.svelte";
import TagManagement from "./job/TagManagement.svelte";
import StatsTable from "./job/StatsTable.svelte"; import StatsTable from "./job/StatsTable.svelte";
import JobFootprint from "./generic/helper/JobFootprint.svelte"; import JobSummary from "./job/JobSummary.svelte";
import ConcurrentJobs from "./generic/helper/ConcurrentJobs.svelte";
import PlotTable from "./generic/PlotTable.svelte"; import PlotTable from "./generic/PlotTable.svelte";
import Polar from "./generic/plots/Polar.svelte";
import Roofline from "./generic/plots/Roofline.svelte"; import Roofline from "./generic/plots/Roofline.svelte";
import JobInfo from "./generic/joblist/JobInfo.svelte"; import JobInfo from "./generic/joblist/JobInfo.svelte";
import MetricSelection from "./generic/select/MetricSelection.svelte"; import MetricSelection from "./generic/select/MetricSelection.svelte";
export let dbid; export let dbid;
export let username;
export let authlevel; export let authlevel;
export let roles; export let roles;
@ -53,13 +52,11 @@
const ccconfig = getContext("cc-config") const ccconfig = getContext("cc-config")
let isMetricsSelectionOpen = false, let isMetricsSelectionOpen = false,
showFootprint = !!ccconfig[`job_view_showFootprint`],
selectedMetrics = [], selectedMetrics = [],
selectedScopes = []; selectedScopes = [];
let plots = {}, let plots = {},
jobTags, roofWidth
statsTable
let missingMetrics = [], let missingMetrics = [],
missingHosts = [], missingHosts = [],
@ -75,7 +72,7 @@
duration, numNodes, numHWThreads, numAcc, duration, numNodes, numHWThreads, numAcc,
SMT, exclusive, partition, subCluster, arrayJobId, SMT, exclusive, partition, subCluster, arrayJobId,
monitoringStatus, state, walltime, monitoringStatus, state, walltime,
tags { id, type, name }, tags { id, type, scope, name },
resources { hostname, hwthreads, accelerators }, resources { hostname, hwthreads, accelerators },
metaData, metaData,
userData { name, email }, userData { name, email },
@ -231,221 +228,224 @@
})); }));
</script> </script>
<Row> <Row class="mb-3">
<Col> <!-- Column 1: Job Info, Job Tags, Concurrent Jobs, Admin Message if found-->
<Col xs={12} md={6} xl={3} class="mb-3 mb-xxl-0">
{#if $initq.error} {#if $initq.error}
<Card body color="danger">{$initq.error.message}</Card> <Card body color="danger">{$initq.error.message}</Card>
{:else if $initq.data} {:else if $initq.data}
<JobInfo job={$initq.data.job} {jobTags} /> <Card class="overflow-auto" style="height: 400px;">
<TabContent> <!-- on:tab={(e) => (status = e.detail)} -->
{#if $initq.data?.job?.metaData?.message}
<TabPane tabId="admin-msg" tab="Admin Note" active>
<CardBody>
<Card body class="mb-2" color="warning">
<h5>Job {$initq.data?.job?.jobId} ({$initq.data?.job?.cluster})</h5>
The following note was added by administrators:
</Card>
<Card body>
{@html $initq.data.job.metaData.message}
</Card>
</CardBody>
</TabPane>
{/if}
<TabPane tabId="meta-info" tab="Job Info" active={$initq.data?.job?.metaData?.message?false:true}>
<CardBody class="pb-2">
<JobInfo job={$initq.data.job} {username} {authlevel} {roles} showTags={false} showTagedit/>
</CardBody>
</TabPane>
{#if $initq.data.job.concurrentJobs != null && $initq.data.job.concurrentJobs.items.length != 0}
<TabPane tabId="shared-jobs">
<span slot="tab">
{$initq.data.job.concurrentJobs.items.length} Concurrent Jobs
</span>
<CardBody>
<ConcurrentJobs cJobs={$initq.data.job.concurrentJobs} showLinks={(authlevel > roles.manager)}/>
</CardBody>
</TabPane>
{/if}
</TabContent>
</Card>
{:else} {:else}
<Spinner secondary /> <Spinner secondary />
{/if} {/if}
</Col> </Col>
{#if $initq.data && showFootprint}
<Col> <!-- Column 2: Job Footprint, Polar Representation, Heuristic Summary -->
<JobFootprint <Col xs={12} md={6} xl={4} xxl={3} class="mb-3 mb-xxl-0">
job={$initq.data.job} {#if $initq.error}
/> <Card body color="danger">{$initq.error.message}</Card>
</Col> {:else if $initq?.data && $jobMetrics?.data}
{/if} <JobSummary job={$initq.data.job} jobMetrics={$jobMetrics.data.jobMetrics}/>
{#if $initq?.data && $jobMetrics?.data?.jobMetrics} {:else}
{#if $initq.data.job.concurrentJobs != null && $initq.data.job.concurrentJobs.items.length != 0}
{#if authlevel > roles.manager}
<Col>
<h5>
Concurrent Jobs <Icon
name="info-circle"
style="cursor:help;"
title="Shared jobs running on the same node with overlapping runtimes"
/>
</h5>
<ul>
<li>
<a
href="/monitoring/jobs/?{$initq.data.job.concurrentJobs
.listQuery}"
target="_blank">See All</a
>
</li>
{#each $initq.data.job.concurrentJobs.items as pjob, index}
<li>
<a href="/monitoring/job/{pjob.id}" target="_blank"
>{pjob.jobId}</a
>
</li>
{/each}
</ul>
</Col>
{:else}
<Col>
<h5>
{$initq.data.job.concurrentJobs.items.length} Concurrent Jobs
</h5>
<p>
Number of shared jobs on the same node with overlapping runtimes.
</p>
</Col>
{/if}
{/if}
<Col>
<Polar
metrics={ccconfig[
`job_view_polarPlotMetrics:${$initq.data.job.cluster}`
] || ccconfig[`job_view_polarPlotMetrics`]}
cluster={$initq.data.job.cluster}
subCluster={$initq.data.job.subCluster}
jobMetrics={$jobMetrics.data.jobMetrics}
/>
</Col>
<Col>
<Roofline
renderTime={true}
subCluster={$initq.data.clusters
.find((c) => c.name == $initq.data.job.cluster)
.subClusters.find((sc) => sc.name == $initq.data.job.subCluster)}
data={transformDataForRoofline(
$jobMetrics.data?.jobMetrics?.find(
(m) => m.name == "flops_any" && m.scope == "node",
)?.metric,
$jobMetrics.data?.jobMetrics?.find(
(m) => m.name == "mem_bw" && m.scope == "node",
)?.metric,
)}
/>
</Col>
{:else}
<Col />
<Spinner secondary /> <Spinner secondary />
<Col />
{/if}
</Row>
<Row class="mb-3">
<Col xs="auto">
{#if $initq.data}
<TagManagement job={$initq.data.job} bind:jobTags />
{/if} {/if}
</Col> </Col>
<Col xs="auto">
{#if $initq.data} <!-- Column 3: Job Roofline; If footprint Enabled: full width, else half width -->
<Button outline on:click={() => (isMetricsSelectionOpen = true)}> <Col xs={12} md={12} xl={5} xxl={6}>
<Icon name="graph-up" /> Metrics {#if $initq.error || $jobMetrics.error}
</Button> <Card body color="danger">
{/if} <p>Initq Error: {$initq.error?.message}</p>
</Col> <p>jobMetrics Error: {$jobMetrics.error?.message}</p>
</Row> </Card>
<Row> {:else if $initq?.data && $jobMetrics?.data}
<Col> <Card style="height: 400px;">
{#if $jobMetrics.error} <div bind:clientWidth={roofWidth}>
{#if $initq.data.job.monitoringStatus == 0 || $initq.data.job.monitoringStatus == 2} <Roofline
<Card body color="warning">Not monitored or archiving failed</Card> allowSizeChange={true}
<br /> width={roofWidth}
{/if} renderTime={true}
<Card body color="danger">{$jobMetrics.error.message}</Card> subCluster={$initq.data.clusters
{:else if $jobMetrics.fetching} .find((c) => c.name == $initq.data.job.cluster)
<Spinner secondary /> .subClusters.find((sc) => sc.name == $initq.data.job.subCluster)}
{:else if $initq?.data && $jobMetrics?.data?.jobMetrics} data={transformDataForRoofline(
<PlotTable $jobMetrics.data?.jobMetrics?.find(
let:item (m) => m.name == "flops_any" && m.scope == "node",
let:width )?.metric,
renderFor="job" $jobMetrics.data?.jobMetrics?.find(
items={orderAndMap( (m) => m.name == "mem_bw" && m.scope == "node",
groupByScope($jobMetrics.data.jobMetrics), )?.metric,
selectedMetrics, )}
)}
itemsPerRow={ccconfig.plot_view_plotsPerRow}
>
{#if item.data}
<Metric
bind:this={plots[item.metric]}
on:load-all={loadAllScopes}
job={$initq.data.job}
metricName={item.metric}
metricUnit={$initq.data.globalMetrics.find((gm) => gm.name == item.metric)?.unit}
nativeScope={$initq.data.globalMetrics.find((gm) => gm.name == item.metric)?.scope}
rawData={item.data.map((x) => x.metric)}
scopes={item.data.map((x) => x.scope)}
{width}
isShared={$initq.data.job.exclusive != 1}
/> />
{:else} </div>
<Card body color="warning" </Card>
>No dataset returned for <code>{item.metric}</code></Card {:else}
> <Spinner secondary />
{/if}
</PlotTable>
{/if} {/if}
</Col> </Col>
</Row> </Row>
<Row class="mt-2">
<Col> <Card class="mb-3">
{#if $initq.data} <CardBody>
<TabContent> <Row class="mb-2">
{#if somethingMissing} {#if $initq.data}
<TabPane tabId="resources" tab="Resources" active={somethingMissing}> <Col xs="auto">
<div style="margin: 10px;"> <Button outline on:click={() => (isMetricsSelectionOpen = true)} color="primary">
<Card color="warning"> Select Metrics
<CardHeader> </Button>
<CardTitle>Missing Metrics/Resources</CardTitle> </Col>
</CardHeader> {/if}
<CardBody> </Row>
{#if missingMetrics.length > 0} <hr/>
<p> <Row>
No data at all is available for the metrics: {missingMetrics.join( <Col>
", ", {#if $jobMetrics.error}
)} {#if $initq.data.job.monitoringStatus == 0 || $initq.data.job.monitoringStatus == 2}
</p> <Card body color="warning">Not monitored or archiving failed</Card>
{/if} <br />
{#if missingHosts.length > 0}
<p>Some metrics are missing for the following hosts:</p>
<ul>
{#each missingHosts as missing}
<li>
{missing.hostname}: {missing.metrics.join(", ")}
</li>
{/each}
</ul>
{/if}
</CardBody>
</Card>
</div>
</TabPane>
{/if}
<TabPane
tabId="stats"
tab="Statistics Table"
active={!somethingMissing}
>
{#if $jobMetrics?.data?.jobMetrics}
{#key $jobMetrics.data.jobMetrics}
<StatsTable
bind:this={statsTable}
job={$initq.data.job}
jobMetrics={$jobMetrics.data.jobMetrics}
/>
{/key}
{/if} {/if}
</TabPane> <Card body color="danger">{$jobMetrics.error.message}</Card>
<TabPane tabId="job-script" tab="Job Script"> {:else if $jobMetrics.fetching}
<div class="pre-wrapper"> <Spinner secondary />
{#if $initq.data.job.metaData?.jobScript} {:else if $initq?.data && $jobMetrics?.data?.jobMetrics}
<pre><code>{$initq.data.job.metaData?.jobScript}</code></pre> <PlotTable
{:else} let:item
<Card body color="warning">No job script available</Card> let:width
{/if} renderFor="job"
</div> items={orderAndMap(
</TabPane> groupByScope($jobMetrics.data.jobMetrics),
<TabPane tabId="slurm-info" tab="Slurm Info"> selectedMetrics,
<div class="pre-wrapper"> )}
{#if $initq.data.job.metaData?.slurmInfo} itemsPerRow={ccconfig.plot_view_plotsPerRow}
<pre><code>{$initq.data.job.metaData?.slurmInfo}</code></pre> >
{#if item.data}
<Metric
bind:this={plots[item.metric]}
on:load-all={loadAllScopes}
job={$initq.data.job}
metricName={item.metric}
metricUnit={$initq.data.globalMetrics.find((gm) => gm.name == item.metric)?.unit}
nativeScope={$initq.data.globalMetrics.find((gm) => gm.name == item.metric)?.scope}
rawData={item.data.map((x) => x.metric)}
scopes={item.data.map((x) => x.scope)}
{width}
isShared={$initq.data.job.exclusive != 1}
/>
{:else} {:else}
<Card body color="warning" <Card body color="warning"
>No additional slurm information available</Card >No dataset returned for <code>{item.metric}</code></Card
> >
{/if} {/if}
</div> </PlotTable>
</TabPane> {/if}
</TabContent> </Col>
</Row>
</CardBody>
</Card>
<Row class="mb-3">
<Col>
{#if $initq.data}
<Card>
<TabContent>
{#if somethingMissing}
<TabPane tabId="resources" tab="Resources" active={somethingMissing}>
<div style="margin: 10px;">
<Card color="warning">
<CardHeader>
<CardTitle>Missing Metrics/Resources</CardTitle>
</CardHeader>
<CardBody>
{#if missingMetrics.length > 0}
<p>
No data at all is available for the metrics: {missingMetrics.join(
", ",
)}
</p>
{/if}
{#if missingHosts.length > 0}
<p>Some metrics are missing for the following hosts:</p>
<ul>
{#each missingHosts as missing}
<li>
{missing.hostname}: {missing.metrics.join(", ")}
</li>
{/each}
</ul>
{/if}
</CardBody>
</Card>
</div>
</TabPane>
{/if}
<TabPane
tabId="stats"
tab="Statistics Table"
class="overflow-x-auto"
active={!somethingMissing}
>
{#if $jobMetrics?.data?.jobMetrics}
{#key $jobMetrics.data.jobMetrics}
<StatsTable
job={$initq.data.job}
jobMetrics={$jobMetrics.data.jobMetrics}
/>
{/key}
{/if}
</TabPane>
<TabPane tabId="job-script" tab="Job Script">
<div class="pre-wrapper">
{#if $initq.data.job.metaData?.jobScript}
<pre><code>{$initq.data.job.metaData?.jobScript}</code></pre>
{:else}
<Card body color="warning">No job script available</Card>
{/if}
</div>
</TabPane>
<TabPane tabId="slurm-info" tab="Slurm Info">
<div class="pre-wrapper">
{#if $initq.data.job.metaData?.slurmInfo}
<pre><code>{$initq.data.job.metaData?.slurmInfo}</code></pre>
{:else}
<Card body color="warning"
>No additional slurm information available</Card
>
{/if}
</div>
</TabPane>
</TabContent>
</Card>
{/if} {/if}
</Col> </Col>
</Row> </Row>

View File

@ -322,7 +322,9 @@
{#if filters.tags.length != 0} {#if filters.tags.length != 0}
<Info icon="tags" on:click={() => (isTagsOpen = true)}> <Info icon="tags" on:click={() => (isTagsOpen = true)}>
{#each filters.tags as tagId} {#each filters.tags as tagId}
<Tag id={tagId} clickable={false} /> {#key tagId}
<Tag id={tagId} clickable={false} />
{/key}
{/each} {/each}
</Info> </Info>
{/if} {/if}

View File

@ -81,6 +81,7 @@
id id
type type
name name
scope
} }
userData { userData {
name name

View File

@ -0,0 +1,101 @@
<!--
@component Concurrent Jobs Component; Lists all concurrent jobs in one scrollable card.
Properties:
- `cJobs JobLinkResultList`: List of concurrent Jobs
- `showLinks Bool?`: Show list as clickable links [Default: false]
- `renderCard Bool?`: If to render component as content only or with card wrapping [Default: true]
- `width String?`: Width of the card [Default: 'auto']
- `height String?`: Height of the card [Default: '310px']
-->
<script>
import {
Card,
CardHeader,
CardBody,
Icon
} from "@sveltestrap/sveltestrap";
export let cJobs;
export let showLinks = false;
export let renderCard = false;
export let width = "auto";
export let height = "400px";
</script>
{#if renderCard}
<Card class="overflow-auto" style="width: {width}; height: {height}">
<CardHeader class="mb-0 d-flex justify-content-center">
{cJobs.items.length} Concurrent Jobs
<Icon
style="cursor:help; margin-left:0.5rem;"
name="info-circle"
title="Jobs running on the same node with overlapping runtimes using shared resources"
/>
</CardHeader>
<CardBody>
{#if showLinks}
<ul>
<li>
<a
href="/monitoring/jobs/?{cJobs.listQuery}"
target="_blank">See All</a
>
</li>
{#each cJobs.items as cJob}
<li>
<a href="/monitoring/job/{cJob.id}" target="_blank"
>{cJob.jobId}</a
>
</li>
{/each}
</ul>
{:else}
<ul>
{#each cJobs.items as cJob}
<li>
{cJob.jobId}
</li>
{/each}
</ul>
{/if}
</CardBody>
</Card>
{:else}
<p>
{cJobs.items.length} Jobs running on the same node with overlapping runtimes using shared resources.
( <a
href="/monitoring/jobs/?{cJobs.listQuery}"
target="_blank">See All</a
> )
</p>
<hr/>
{#if showLinks}
<ul>
{#each cJobs.items as cJob}
<li>
<a href="/monitoring/job/{cJob.id}" target="_blank"
>{cJob.jobId}</a
>
</li>
{/each}
</ul>
{:else}
<ul>
{#each cJobs.items as cJob}
<li>
{cJob.jobId}
</li>
{/each}
</ul>
{/if}
{/if}
<style>
ul {
columns: 3;
-webkit-columns: 3;
-moz-columns: 3;
}
</style>

View File

@ -208,7 +208,7 @@
<Tooltip <Tooltip
target={`footprint-${job.jobId}-${index}`} target={`footprint-${job.jobId}-${index}`}
placement="right" placement="right"
offset={[0, 20]}>{fpd.message}</Tooltip >{fpd.message}</Tooltip
> >
</div> </div>
<Row cols={12} class="{(footprintData.length == (index + 1)) ? 'mb-0' : 'mb-2'}"> <Row cols={12} class="{(footprintData.length == (index + 1)) ? 'mb-0' : 'mb-2'}">
@ -246,7 +246,7 @@
<Tooltip <Tooltip
target={`footprint-${job.jobId}-${index}`} target={`footprint-${job.jobId}-${index}`}
placement="right" placement="right"
offset={[0, 20]}>{fpd.message}</Tooltip >{fpd.message}</Tooltip
> >
{/if} {/if}
{/each} {/each}

View File

@ -23,12 +23,22 @@
if ($initialized && tag == null) if ($initialized && tag == null)
tag = allTags.find(tag => tag.id == id) tag = allTags.find(tag => tag.id == id)
} }
function getScopeColor(scope) {
switch (scope) {
case "admin":
return "#19e5e6";
case "global":
return "#c85fc8";
default:
return "#ffc107";
}
}
</script> </script>
<style> <style>
a { a {
margin-left: 0.5rem; margin-right: 0.5rem;
line-height: 2;
} }
span { span {
font-size: 0.9rem; font-size: 0.9rem;
@ -37,7 +47,7 @@
<a target={clickable ? "_blank" : null} href={clickable ? `/monitoring/jobs/?tag=${id}` : null}> <a target={clickable ? "_blank" : null} href={clickable ? `/monitoring/jobs/?tag=${id}` : null}>
{#if tag} {#if tag}
<span class="badge bg-warning text-dark">{tag.type}: {tag.name}</span> <span style="background-color:{getScopeColor(tag?.scope)};" class="my-1 badge text-dark">{tag.type}: {tag.name}</span>
{:else} {:else}
Loading... Loading...
{/if} {/if}

View File

@ -0,0 +1,510 @@
<!--
@component Job Info Subcomponent; allows management of job tags by deletion or new entries
Properties:
- `job Object`: The job object
- `jobTags [Number]`: The array of currently designated tags
- `username String`: Empty string if auth. is disabled, otherwise the username as string
- `authlevel Number`: The current users authentication level
- `roles [Number]`: Enum containing available roles
- `renderModal Bool?`: If component is rendered as bootstrap modal button [Default: true]
-->
<script>
import { getContext } from "svelte";
import { gql, getContextClient, mutationStore } from "@urql/svelte";
import {
Row,
Col,
Icon,
Button,
ListGroup,
ListGroupItem,
Input,
InputGroup,
InputGroupText,
Spinner,
Modal,
ModalBody,
ModalHeader,
ModalFooter,
Alert,
Tooltip,
} from "@sveltestrap/sveltestrap";
import { fuzzySearchTags } from "../utils.js";
import Tag from "./Tag.svelte";
export let job;
export let jobTags = job.tags;
export let username;
export let authlevel;
export let roles;
export let renderModal = true;
let allTags = getContext("tags"),
initialized = getContext("initialized");
let newTagType = "",
newTagName = "",
newTagScope = username;
let filterTerm = "";
let pendingChange = false;
let isOpen = false;
const isAdmin = (roles && authlevel >= roles.admin);
const client = getContextClient();
const createTagMutation = ({ type, name, scope }) => {
return mutationStore({
client: client,
query: gql`
mutation ($type: String!, $name: String!, $scope: String!) {
createTag(type: $type, name: $name, scope: $scope) {
id
type
name
scope
}
}
`,
variables: { type, name, scope },
});
};
const addTagsToJobMutation = ({ job, tagIds }) => {
return mutationStore({
client: client,
query: gql`
mutation ($job: ID!, $tagIds: [ID!]!) {
addTagsToJob(job: $job, tagIds: $tagIds) {
id
type
name
scope
}
}
`,
variables: { job, tagIds },
});
};
const removeTagsFromJobMutation = ({ job, tagIds }) => {
return mutationStore({
client: client,
query: gql`
mutation ($job: ID!, $tagIds: [ID!]!) {
removeTagsFromJob(job: $job, tagIds: $tagIds) {
id
type
name
scope
}
}
`,
variables: { job, tagIds },
});
};
$: allTagsFiltered = ($initialized, fuzzySearchTags(filterTerm, allTags));
$: usedTagsFiltered = matchJobTags(jobTags, allTagsFiltered, 'used', isAdmin);
$: unusedTagsFiltered = matchJobTags(jobTags, allTagsFiltered, 'unused', isAdmin);
$: {
newTagType = "";
newTagName = "";
let parts = filterTerm.split(":").map((s) => s.trim());
if (parts.length == 2 && parts.every((s) => s.length > 0)) {
newTagType = parts[0];
newTagName = parts[1];
}
}
function matchJobTags(tags, availableTags, type, isAdmin) {
const jobTagIds = tags.map((t) => t.id)
if (type == 'used') {
return availableTags.filter((at) => jobTagIds.includes(at.id))
} else if (type == 'unused' && isAdmin) {
return availableTags.filter((at) => !jobTagIds.includes(at.id))
} else if (type == 'unused' && !isAdmin) { // Normal Users should not see unused global tags here
return availableTags.filter((at) => !jobTagIds.includes(at.id) && at.scope !== "global")
}
return []
}
function isNewTag(type, name) {
for (let tag of allTagsFiltered)
if (tag.type == type && tag.name == name) return false;
return true;
}
function createTag(type, name, scope) {
pendingChange = true;
createTagMutation({ type: type, name: name, scope: scope }).subscribe((res) => {
if (res.fetching === false && !res.error) {
pendingChange = false;
allTags = [...allTags, res.data.createTag];
newTagType = "";
newTagName = "";
addTagToJob(res.data.createTag);
} else if (res.fetching === false && res.error) {
throw res.error;
}
});
}
function addTagToJob(tag) {
pendingChange = tag.id;
addTagsToJobMutation({ job: job.id, tagIds: [tag.id] }).subscribe((res) => {
if (res.fetching === false && !res.error) {
jobTags = job.tags = res.data.addTagsToJob;
pendingChange = false;
} else if (res.fetching === false && res.error) {
throw res.error;
}
});
}
function removeTagFromJob(tag) {
pendingChange = tag.id;
removeTagsFromJobMutation({ job: job.id, tagIds: [tag.id] }).subscribe(
(res) => {
if (res.fetching === false && !res.error) {
jobTags = job.tags = res.data.removeTagsFromJob;
pendingChange = false;
} else if (res.fetching === false && res.error) {
throw res.error;
}
},
);
}
</script>
{#if renderModal}
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
<ModalHeader>
Manage Tags <Icon name="tags"/>
</ModalHeader>
<ModalBody>
<InputGroup class="mb-3">
<Input
type="text"
placeholder="Search Tags"
bind:value={filterTerm}
/>
<InputGroupText id={`tag-management-info-modal`} style="cursor:help; font-size:larger;align-content:center;">
<Icon name=info-circle/>
</InputGroupText>
<Tooltip
target={`tag-management-info-modal`}
placement="right">
Search using "type: name". If no tag matches your search, a
button for creating a new one will appear.
</Tooltip>
</InputGroup>
<div class="mb-3 scroll-group">
{#if usedTagsFiltered.length > 0}
<ListGroup class="mb-3">
{#each usedTagsFiltered as utag}
<ListGroupItem color="light">
<Tag tag={utag} />
<span style="float: right;">
{#if pendingChange === utag.id}
<Spinner size="sm" secondary />
{:else}
{#if utag.scope === 'global' || utag.scope === 'admin'}
{#if isAdmin}
<Button
size="sm"
color="danger"
on:click={() => removeTagFromJob(utag)}
>
<Icon name="x" />
</Button>
{:else}
<Button
size="sm"
color="dark"
outline
disabled
>
Global Tag
</Button>
{/if}
{:else}
<Button
size="sm"
color="danger"
on:click={() => removeTagFromJob(utag)}
>
<Icon name="x" />
</Button>
{/if}
{/if}
</span>
</ListGroupItem>
{/each}
</ListGroup>
{:else if filterTerm !== ""}
<ListGroup class="mb-3">
<ListGroupItem disabled>
<i>No attached tags matching.</i>
</ListGroupItem>
</ListGroup>
{:else}
<ListGroup class="mb-3">
<ListGroupItem disabled>
<i>Job has no attached tags.</i>
</ListGroupItem>
</ListGroup>
{/if}
{#if unusedTagsFiltered.length > 0}
<ListGroup class="">
{#each unusedTagsFiltered as uutag}
<ListGroupItem color="secondary">
<Tag tag={uutag} />
<span style="float: right;">
{#if pendingChange === uutag.id}
<Spinner size="sm" secondary />
{:else}
{#if uutag.scope === 'global' || uutag.scope === 'admin'}
{#if isAdmin}
<Button
size="sm"
color="success"
on:click={() => addTagToJob(uutag)}
>
<Icon name="plus" />
</Button>
{/if}
{:else}
<Button
size="sm"
color="success"
on:click={() => addTagToJob(uutag)}
>
<Icon name="plus" />
</Button>
{/if}
{/if}
</span>
</ListGroupItem>
{/each}
</ListGroup>
{:else if filterTerm !== ""}
<ListGroup class="">
<ListGroupItem disabled>
<i>No unused tags matching.</i>
</ListGroupItem>
</ListGroup>
{:else}
<ListGroup class="">
<ListGroupItem disabled>
<i>No unused tags available.</i>
</ListGroupItem>
</ListGroup>
{/if}
</div>
{#if newTagType && newTagName && isNewTag(newTagType, newTagName)}
<Row>
<Col xs={isAdmin ? 7 : 12} md={12} lg={isAdmin ? 7 : 12} xl={12} xxl={isAdmin ? 7 : 12} class="mb-2">
<Button
outline
style="width:100%;"
color="success"
on:click={(e) => (
e.preventDefault(), createTag(newTagType, newTagName, newTagScope)
)}
>
Add new tag:
<Tag tag={{ type: newTagType, name: newTagName, scope: newTagScope }} clickable={false}/>
</Button>
</Col>
{#if isAdmin}
<Col xs={5} md={12} lg={5} xl={12} xxl={5} class="mb-2" style="align-content:center;">
<Input type="select" bind:value={newTagScope}>
<option value={username}>Scope: Private</option>
<option value={"global"}>Scope: Global</option>
<option value={"admin"}>Scope: Admin</option>
</Input>
</Col>
{/if}
</Row>
{:else if filterTerm !== "" && allTagsFiltered.length == 0}
<Alert color="info">
Search Term is not a valid Tag (<code>type: name</code>)
</Alert>
{:else if filterTerm == "" && unusedTagsFiltered.length == 0}
<Alert color="info">
Type "<code>type: name</code>" into the search field to create a new tag.
</Alert>
{/if}
</ModalBody>
<ModalFooter>
<Button color="primary" on:click={() => (isOpen = false)}>Close</Button>
</ModalFooter>
</Modal>
<Button outline on:click={() => (isOpen = true)} size="sm" color="primary">
Manage {jobTags?.length ? jobTags.length : ''} Tags
</Button>
{:else}
<InputGroup class="mb-3">
<Input
type="text"
placeholder="Search Tags"
bind:value={filterTerm}
/>
<InputGroupText id={`tag-management-info`} style="cursor:help; font-size:larger;align-content:center;">
<Icon name=info-circle/>
</InputGroupText>
<Tooltip
target={`tag-management-info`}
placement="right">
Search using "type: name". If no tag matches your search, a
button for creating a new one will appear.
</Tooltip>
</InputGroup>
{#if usedTagsFiltered.length > 0}
<ListGroup class="mb-3">
{#each usedTagsFiltered as utag}
<ListGroupItem color="light">
<Tag tag={utag} />
<span style="float: right;">
{#if pendingChange === utag.id}
<Spinner size="sm" secondary />
{:else}
{#if utag.scope === 'global' || utag.scope === 'admin'}
{#if isAdmin}
<Button
size="sm"
color="danger"
on:click={() => removeTagFromJob(utag)}
>
<Icon name="x" />
</Button>
{/if}
{:else}
<Button
size="sm"
color="danger"
on:click={() => removeTagFromJob(utag)}
>
<Icon name="x" />
</Button>
{/if}
{/if}
</span>
</ListGroupItem>
{/each}
</ListGroup>
{:else if filterTerm !== ""}
<ListGroup class="mb-3">
<ListGroupItem disabled>
<i>No attached tags matching.</i>
</ListGroupItem>
</ListGroup>
{:else}
<ListGroup class="mb-3">
<ListGroupItem disabled>
<i>Job has no attached tags.</i>
</ListGroupItem>
</ListGroup>
{/if}
{#if unusedTagsFiltered.length > 0}
<ListGroup class="mb-3">
{#each unusedTagsFiltered as uutag}
<ListGroupItem color="secondary">
<Tag tag={uutag} />
<span style="float: right;">
{#if pendingChange === uutag.id}
<Spinner size="sm" secondary />
{:else}
{#if uutag.scope === 'global' || uutag.scope === 'admin'}
{#if isAdmin}
<Button
size="sm"
color="success"
on:click={() => addTagToJob(uutag)}
>
<Icon name="plus" />
</Button>
{/if}
{:else}
<Button
size="sm"
color="success"
on:click={() => addTagToJob(uutag)}
>
<Icon name="plus" />
</Button>
{/if}
{/if}
</span>
</ListGroupItem>
{/each}
</ListGroup>
{:else if filterTerm !== ""}
<ListGroup class="mb-3">
<ListGroupItem disabled>
<i>No unused tags matching.</i>
</ListGroupItem>
</ListGroup>
{:else}
<ListGroup class="mb-3">
<ListGroupItem disabled>
<i>No unused tags available.</i>
</ListGroupItem>
</ListGroup>
{/if}
{#if newTagType && newTagName && isNewTag(newTagType, newTagName)}
<Row>
<Col xs={isAdmin ? 7 : 12} md={12} lg={isAdmin ? 7 : 12} xl={12} xxl={isAdmin ? 7 : 12} class="mb-2">
<Button
outline
style="width:100%;"
color="success"
on:click={(e) => (
e.preventDefault(), createTag(newTagType, newTagName, newTagScope)
)}
>
Add new tag:
<Tag tag={{ type: newTagType, name: newTagName, scope: newTagScope }} clickable={false}/>
</Button>
</Col>
{#if isAdmin}
<Col xs={5} md={12} lg={5} xl={12} xxl={5} class="mb-2" style="align-content:center;">
<Input type="select" bind:value={newTagScope}>
<option value={username}>Scope: Private</option>
<option value={"global"}>Scope: Global</option>
<option value={"admin"}>Scope: Admin</option>
</Input>
</Col>
{/if}
</Row>
{:else if filterTerm !== "" && allTagsFiltered.length == 0}
<Alert color="info">
Search Term is not a valid Tag (<code>type: name</code>)
</Alert>
{:else if filterTerm == "" && unusedTagsFiltered.length == 0}
<Alert color="info">
Type "<code>type: name</code>" into the search field to create a new tag.
</Alert>
{/if}
{/if}
<style>
.scroll-group {
max-height: 500px;
overflow-y: auto;
}
</style>

View File

@ -10,9 +10,14 @@
import { Badge, Icon } from "@sveltestrap/sveltestrap"; import { Badge, Icon } from "@sveltestrap/sveltestrap";
import { scrambleNames, scramble } from "../utils.js"; import { scrambleNames, scramble } from "../utils.js";
import Tag from "../helper/Tag.svelte"; import Tag from "../helper/Tag.svelte";
import TagManagement from "../helper/TagManagement.svelte";
export let job; export let job;
export let jobTags = job.tags; export let jobTags = job.tags;
export let showTagedit = false;
export let username = null;
export let authlevel= null;
export let roles = null;
function formatDuration(duration) { function formatDuration(duration) {
const hours = Math.floor(duration / 3600); const hours = Math.floor(duration / 3600);
@ -36,7 +41,7 @@
</script> </script>
<div> <div>
<p> <p class="mb-2">
<span class="fw-bold" <span class="fw-bold"
><a href="/monitoring/job/{job.id}" target="_blank">{job.jobId}</a> ><a href="/monitoring/job/{job.id}" target="_blank">{job.jobId}</a>
({job.cluster})</span ({job.cluster})</span
@ -63,7 +68,7 @@
{/if} {/if}
</p> </p>
<p> <p class="mb-2">
<Icon name="person-fill" /> <Icon name="person-fill" />
<a class="fst-italic" href="/monitoring/user/{job.user}" target="_blank"> <a class="fst-italic" href="/monitoring/user/{job.user}" target="_blank">
{scrambleNames ? scramble(job.user) : job.user} {scrambleNames ? scramble(job.user) : job.user}
@ -84,7 +89,7 @@
{/if} {/if}
</p> </p>
<p> <p class="mb-2">
{#if job.numNodes == 1} {#if job.numNodes == 1}
{job.resources[0].hostname} {job.resources[0].hostname}
{:else} {:else}
@ -104,7 +109,7 @@
{job.subCluster} {job.subCluster}
</p> </p>
<p> <p class="mb-2">
Start: <span class="fw-bold" Start: <span class="fw-bold"
>{new Date(job.startTime).toLocaleString()}</span >{new Date(job.startTime).toLocaleString()}</span
> >
@ -117,11 +122,25 @@
{/if} {/if}
</p> </p>
<p> {#if showTagedit}
{#each jobTags as tag} <hr class="mt-0 mb-2"/>
<Tag {tag} /> <p class="mb-1">
{/each} <TagManagement bind:jobTags {job} {username} {authlevel} {roles} renderModal/> :
</p> {#if jobTags?.length > 0}
{#each jobTags as tag}
<Tag {tag}/>
{/each}
{:else}
<span style="font-size: 0.9rem; background-color: lightgray;" class="my-1 badge text-dark">No Tags</span>
{/if}
</p>
{:else}
<p class="mb-1">
{#each jobTags as tag}
<Tag {tag} />
{/each}
</p>
{/if}
</div> </div>
<style> <style>

View File

@ -2,10 +2,11 @@
@component Polar Plot based on chartJS Radar @component Polar Plot based on chartJS Radar
Properties: Properties:
- `metrics [String]`: Metric names to display as polar plot - `footprintData [Object]?`: job.footprint content, evaluated in regards to peak config in jobSummary.svelte [Default: null]
- `cluster GraphQL.Cluster`: Cluster Object of the parent job - `metrics [String]?`: Metric names to display as polar plot [Default: null]
- `subCluster GraphQL.SubCluster`: SubCluster Object of the parent job - `cluster GraphQL.Cluster?`: Cluster Object of the parent job [Default: null]
- `jobMetrics [GraphQL.JobMetricWithName]`: Metric data - `subCluster GraphQL.SubCluster?`: SubCluster Object of the parent job [Default: null]
- `jobMetrics [GraphQL.JobMetricWithName]?`: Metric data [Default: null]
- `height Number?`: Plot height [Default: 365] - `height Number?`: Plot height [Default: 365]
--> -->
@ -33,24 +34,52 @@
LineElement LineElement
); );
export let metrics export let footprintData = null;
export let cluster export let metrics = null;
export let subCluster export let cluster = null;
export let jobMetrics export let subCluster = null;
export let height = 365 export let jobMetrics = null;
export let height = 350;
const getMetricConfig = getContext("getMetricConfig") function getLabels() {
if (footprintData) {
const labels = metrics.filter(name => { return footprintData.filter(fpd => {
if (!jobMetrics.find(m => m.name == name && m.scope == "node")) { if (!jobMetrics.find(m => m.name == fpd.name && m.scope == "node" || fpd.impact == 4)) {
console.warn(`PolarPlot: No metric data for '${name}'`) console.warn(`PolarPlot: No metric data (or config) for '${fpd.name}'`)
return false return false
}
return true
})
.map(filtered => filtered.name)
.sort(function (a, b) {
return ((a > b) ? 1 : ((b > a) ? -1 : 0));
});
} else {
return metrics.filter(name => {
if (!jobMetrics.find(m => m.name == name && m.scope == "node")) {
console.warn(`PolarPlot: No metric data for '${name}'`)
return false
}
return true
})
.sort(function (a, b) {
return ((a > b) ? 1 : ((b > a) ? -1 : 0));
});
} }
return true }
const labels = getLabels();
const getMetricConfig = getContext("getMetricConfig");
const getValuesForStatGeneric = (getStat) => labels.map(name => {
const peak = getMetricConfig(cluster, subCluster, name).peak
const metric = jobMetrics.find(m => m.name == name && m.scope == "node")
const value = getStat(metric.metric) / peak
return value <= 1. ? value : 1.
}) })
const getValuesForStat = (getStat) => labels.map(name => { const getValuesForStatFootprint = (getStat) => labels.map(name => {
const peak = getMetricConfig(cluster, subCluster, name).peak const peak = footprintData.find(fpd => fpd.name === name).peak
const metric = jobMetrics.find(m => m.name == name && m.scope == "node") const metric = jobMetrics.find(m => m.name == name && m.scope == "node")
const value = getStat(metric.metric) / peak const value = getStat(metric.metric) / peak
return value <= 1. ? value : 1. return value <= 1. ? value : 1.
@ -70,12 +99,32 @@
return avg / metric.series.length return avg / metric.series.length
} }
function loadDataGeneric(type) {
if (type === 'avg') {
return getValuesForStatGeneric(getAvg)
} else if (type === 'max') {
return getValuesForStatGeneric(getMax)
}
console.log('Unknown Type For Polar Data')
return []
}
function loadDataForFootprint(type) {
if (type === 'avg') {
return getValuesForStatFootprint(getAvg)
} else if (type === 'max') {
return getValuesForStatFootprint(getMax)
}
console.log('Unknown Type For Polar Data')
return []
}
const data = { const data = {
labels: labels, labels: labels,
datasets: [ datasets: [
{ {
label: 'Max', label: 'Max',
data: getValuesForStat(getMax), data: footprintData ? loadDataForFootprint('max') : loadDataGeneric('max'), //
fill: 1, fill: 1,
backgroundColor: 'rgba(0, 102, 255, 0.25)', backgroundColor: 'rgba(0, 102, 255, 0.25)',
borderColor: 'rgb(0, 102, 255)', borderColor: 'rgb(0, 102, 255)',
@ -86,7 +135,7 @@
}, },
{ {
label: 'Avg', label: 'Avg',
data: getValuesForStat(getAvg), data: footprintData ? loadDataForFootprint('avg') : loadDataGeneric('avg'), // getValuesForStat(getAvg)
fill: true, fill: true,
backgroundColor: 'rgba(255, 153, 0, 0.25)', backgroundColor: 'rgba(255, 153, 0, 0.25)',
borderColor: 'rgb(255, 153, 0)', borderColor: 'rgb(255, 153, 0)',
@ -100,7 +149,7 @@
// No custom defined options but keep for clarity // No custom defined options but keep for clarity
const options = { const options = {
maintainAspectRatio: false, maintainAspectRatio: true,
animation: false, animation: false,
scales: { // fix scale scales: { // fix scale
r: { r: {

View File

@ -7,7 +7,7 @@
- `allowSizeChange Bool?`: If dimensions of rendered plot can change [Default: false] - `allowSizeChange Bool?`: If dimensions of rendered plot can change [Default: false]
- `subCluster GraphQL.SubCluster?`: SubCluster Object; contains required topology information [Default: null] - `subCluster GraphQL.SubCluster?`: SubCluster Object; contains required topology information [Default: null]
- `width Number?`: Plot width (reactively adaptive) [Default: 600] - `width Number?`: Plot width (reactively adaptive) [Default: 600]
- `height Number?`: Plot height (reactively adaptive) [Default: 350] - `height Number?`: Plot height (reactively adaptive) [Default: 380]
Data Format: Data Format:
- `data = [null, [], []]` - `data = [null, [], []]`
@ -33,7 +33,7 @@
export let allowSizeChange = false; export let allowSizeChange = false;
export let subCluster = null; export let subCluster = null;
export let width = 600; export let width = 600;
export let height = 350; export let height = 380;
let plotWrapper = null; let plotWrapper = null;
let uplot = null; let uplot = null;
@ -41,8 +41,6 @@
const lineWidth = clusterCockpitConfig.plot_general_lineWidth; const lineWidth = clusterCockpitConfig.plot_general_lineWidth;
// Helpers // Helpers
function getGradientR(x) { function getGradientR(x) {
if (x < 0.5) return 0; if (x < 0.5) return 0;
@ -317,7 +315,7 @@
// The Color Scale For Time Information // The Color Scale For Time Information
const posX = u.valToPos(0.1, "x", true) const posX = u.valToPos(0.1, "x", true)
const posXLimit = u.valToPos(100, "x", true) const posXLimit = u.valToPos(100, "x", true)
const posY = u.valToPos(15000.0, "y", true) const posY = u.valToPos(14000.0, "y", true)
u.ctx.fillStyle = 'black' u.ctx.fillStyle = 'black'
u.ctx.fillText('Start', posX, posY) u.ctx.fillText('Start', posX, posY)
const start = posX + 10 const start = posX + 10
@ -364,7 +362,7 @@
</script> </script>
{#if data != null} {#if data != null}
<div bind:this={plotWrapper} /> <div bind:this={plotWrapper} class="p-2"/>
{:else} {:else}
<Card class="mx-4" body color="warning">Cannot render roofline: No data!</Card <Card class="mx-4" body color="warning">Cannot render roofline: No data!</Card
> >

View File

@ -77,7 +77,7 @@ export function init(extraInitQuery = "") {
footprint footprint
} }
} }
tags { id, name, type } tags { id, name, type, scope }
globalMetrics { globalMetrics {
name name
scope scope

View File

@ -5,6 +5,7 @@ new Job({
target: document.getElementById('svelte-app'), target: document.getElementById('svelte-app'),
props: { props: {
dbid: jobInfos.id, dbid: jobInfos.id,
username: username,
authlevel: authlevel, authlevel: authlevel,
roles: roles roles: roles
}, },

View File

@ -0,0 +1,347 @@
<!--
@component Job Summary component; Displays job.footprint data as bars in relation to thresholds, as polar plot, and summariziong comment
Properties:
- `job Object`: The GQL job object
- `displayTitle Bool?`: If to display cardHeader with title [Default: true]
- `width String?`: Width of the card [Default: 'auto']
- `height String?`: Height of the card [Default: '310px']
-->
<script context="module">
function findJobThresholds(job, metricConfig) {
if (!job || !metricConfig) {
console.warn("Argument missing for findJobThresholds!");
return null;
}
// metricConfig is on subCluster-Level
const defaultThresholds = {
peak: metricConfig.peak,
normal: metricConfig.normal,
caution: metricConfig.caution,
alert: metricConfig.alert
};
// Job_Exclusivity does not matter, only aggregation
if (metricConfig.aggregation === "avg") {
return defaultThresholds;
} else if (metricConfig.aggregation === "sum") {
const topol = getContext("getHardwareTopology")(job.cluster, job.subCluster)
const jobFraction = job.numHWThreads / topol.node.length;
return {
peak: round(defaultThresholds.peak * jobFraction, 0),
normal: round(defaultThresholds.normal * jobFraction, 0),
caution: round(defaultThresholds.caution * jobFraction, 0),
alert: round(defaultThresholds.alert * jobFraction, 0),
};
} else {
console.warn(
"Missing or unkown aggregation mode (sum/avg) for metric:",
metricConfig,
);
return defaultThresholds;
}
}
</script>
<script>
import { getContext } from "svelte";
import {
Card,
CardBody,
Progress,
Icon,
Tooltip,
Row,
Col,
TabContent,
TabPane
} from "@sveltestrap/sveltestrap";
import Polar from "../generic/plots/Polar.svelte";
import { round } from "mathjs";
export let job;
export let jobMetrics;
export let width = "auto";
export let height = "400px";
const ccconfig = getContext("cc-config")
const showFootprint = !!ccconfig[`job_view_showFootprint`];
const footprintData = job?.footprint?.map((jf) => {
const fmc = getContext("getMetricConfig")(job.cluster, job.subCluster, jf.name);
if (fmc) {
// Unit
const unit = (fmc?.unit?.prefix ? fmc.unit.prefix : "") + (fmc?.unit?.base ? fmc.unit.base : "")
// Threshold / -Differences
const fmt = findJobThresholds(job, fmc);
if (jf.name === "flops_any") fmt.peak = round(fmt.peak * 0.85, 0);
// Define basic data -> Value: Use as Provided
const fmBase = {
name: jf.name,
stat: jf.stat,
value: jf.value,
unit: unit,
peak: fmt.peak,
dir: fmc.lowerIsBetter
};
if (evalFootprint(jf.value, fmt, fmc.lowerIsBetter, "alert")) {
return {
...fmBase,
color: "danger",
message: `Metric average way ${fmc.lowerIsBetter ? "above" : "below"} expected normal thresholds.`,
impact: 3
};
} else if (evalFootprint(jf.value, fmt, fmc.lowerIsBetter, "caution")) {
return {
...fmBase,
color: "warning",
message: `Metric average ${fmc.lowerIsBetter ? "above" : "below"} expected normal thresholds.`,
impact: 2,
};
} else if (evalFootprint(jf.value, fmt, fmc.lowerIsBetter, "normal")) {
return {
...fmBase,
color: "success",
message: "Metric average within expected thresholds.",
impact: 1,
};
} else if (evalFootprint(jf.value, fmt, fmc.lowerIsBetter, "peak")) {
return {
...fmBase,
color: "info",
message:
"Metric average above expected normal thresholds: Check for artifacts recommended.",
impact: 0,
};
} else {
return {
...fmBase,
color: "secondary",
message:
"Metric average above expected peak threshold: Check for artifacts!",
impact: -1,
};
}
} else { // No matching metric config: display as single value
return {
name: jf.name,
stat: jf.stat,
value: jf.value,
message:
`No config for metric ${jf.name} found.`,
impact: 4,
};
}
}).sort(function (a, b) { // Sort by impact value primarily, within impact sort name alphabetically
return a.impact - b.impact || ((a.name > b.name) ? 1 : ((b.name > a.name) ? -1 : 0));
});;
function evalFootprint(mean, thresholds, lowerIsBetter, level) {
// Handle Metrics in which less value is better
switch (level) {
case "peak":
if (lowerIsBetter)
return false; // metric over peak -> return false to trigger impact -1
else return mean <= thresholds.peak && mean > thresholds.normal;
case "alert":
if (lowerIsBetter)
return mean <= thresholds.peak && mean >= thresholds.alert;
else return mean <= thresholds.alert && mean >= 0;
case "caution":
if (lowerIsBetter)
return mean < thresholds.alert && mean >= thresholds.caution;
else return mean <= thresholds.caution && mean > thresholds.alert;
case "normal":
if (lowerIsBetter)
return mean < thresholds.caution && mean >= 0;
else return mean <= thresholds.normal && mean > thresholds.caution;
default:
return false;
}
}
/*
function writeSummary(fpd) {
// Hardcoded! Needs to be retrieved from globalMetrics
const performanceMetrics = ['flops_any', 'mem_bw'];
const utilizationMetrics = ['cpu_load', 'acc_utilization'];
const energyMetrics = ['cpu_power'];
let performanceScore = 0;
let utilizationScore = 0;
let energyScore = 0;
let performanceMetricsCounted = 0;
let utilizationMetricsCounted = 0;
let energyMetricsCounted = 0;
fpd.forEach(metric => {
console.log('Metric, Impact', metric.name, metric.impact)
if (performanceMetrics.includes(metric.name)) {
performanceScore += metric.impact
performanceMetricsCounted += 1
} else if (utilizationMetrics.includes(metric.name)) {
utilizationScore += metric.impact
utilizationMetricsCounted += 1
} else if (energyMetrics.includes(metric.name)) {
energyScore += metric.impact
energyMetricsCounted += 1
}
});
performanceScore = (performanceMetricsCounted == 0) ? performanceScore : (performanceScore / performanceMetricsCounted);
utilizationScore = (utilizationMetricsCounted == 0) ? utilizationScore : (utilizationScore / utilizationMetricsCounted);
energyScore = (energyMetricsCounted == 0) ? energyScore : (energyScore / energyMetricsCounted);
let res = [];
console.log('Perf', performanceScore, performanceMetricsCounted)
console.log('Util', utilizationScore, utilizationMetricsCounted)
console.log('Energy', energyScore, energyMetricsCounted)
if (performanceScore == 1) {
res.push('<b>Performance:</b> Your job performs well.')
} else if (performanceScore != 0) {
res.push('<b>Performance:</b> Your job performs suboptimal.')
}
if (utilizationScore == 1) {
res.push('<b>Utilization:</b> Your job utilizes resources well.')
} else if (utilizationScore != 0) {
res.push('<b>Utilization:</b> Your job utilizes resources suboptimal.')
}
if (energyScore == 1) {
res.push('<b>Energy:</b> Your job has good energy values.')
} else if (energyScore != 0) {
res.push('<b>Energy:</b> Your job consumes more energy than necessary.')
}
return res;
};
$: summaryMessages = writeSummary(footprintData)
*/
</script>
<Card class="overflow-auto" style="width: {width}; height: {height}">
<TabContent> <!-- on:tab={(e) => (status = e.detail)} -->
{#if showFootprint}
<TabPane tabId="foot" tab="Footprint" active>
<CardBody>
{#each footprintData as fpd, index}
{#if fpd.impact !== 4}
<div class="mb-1 d-flex justify-content-between">
<div>&nbsp;<b>{fpd.name} ({fpd.stat})</b></div>
<div
class="cursor-help d-inline-flex"
id={`footprint-${job.jobId}-${index}`}
>
<div class="mx-1">
{#if fpd.impact === 3 || fpd.impact === -1}
<Icon name="exclamation-triangle-fill" class="text-danger" />
{:else if fpd.impact === 2}
<Icon name="exclamation-triangle" class="text-warning" />
{/if}
{#if fpd.impact === 3}
<Icon name="emoji-frown" class="text-danger" />
{:else if fpd.impact === 2}
<Icon name="emoji-neutral" class="text-warning" />
{:else if fpd.impact === 1}
<Icon name="emoji-smile" class="text-success" />
{:else if fpd.impact === 0}
<Icon name="emoji-laughing" class="text-info" />
{:else if fpd.impact === -1}
<Icon name="emoji-dizzy" class="text-danger" />
{/if}
</div>
<div>
{fpd.value} / {fpd.peak}
{fpd.unit} &nbsp;
</div>
</div>
<Tooltip
target={`footprint-${job.jobId}-${index}`}
placement="right"
>{fpd.message}</Tooltip
>
</div>
<Row cols={12} class="{(footprintData.length == (index + 1)) ? 'mb-0' : 'mb-2'}">
{#if fpd.dir}
<Col xs="1">
<Icon name="caret-left-fill" />
</Col>
{/if}
<Col xs="11" class="align-content-center">
<Progress value={fpd.value} max={fpd.peak} color={fpd.color} />
</Col>
{#if !fpd.dir}
<Col xs="1">
<Icon name="caret-right-fill" />
</Col>
{/if}
</Row>
{:else}
<div class="mb-1 d-flex justify-content-between">
<div>
&nbsp;<b>{fpd.name} ({fpd.stat})</b>
</div>
<div
class="cursor-help d-inline-flex"
id={`footprint-${job.jobId}-${index}`}
>
<div class="mx-1">
<Icon name="info-circle"/>
</div>
<div>
{fpd.value}&nbsp;
</div>
</div>
</div>
<Tooltip
target={`footprint-${job.jobId}-${index}`}
placement="right"
>{fpd.message}</Tooltip
>
{/if}
{/each}
</CardBody>
</TabPane>
{/if}
<TabPane tabId="polar" tab="Polar" active={!showFootprint}>
<CardBody>
<Polar
{footprintData}
{jobMetrics}
/>
</CardBody>
</TabPane>
<!--
<TabPane tabId="summary" tab="Summary">
<CardBody>
<p>Based on footprint data, this job performs as follows:</p>
<hr/>
<ul>
{#each summaryMessages as sm}
<li>
{@html sm}
</li>
{/each}
</ul>
</CardBody>
</TabPane>
-->
</TabContent>
</Card>
<style>
.cursor-help {
cursor: help;
}
</style>

View File

@ -11,6 +11,7 @@
import { import {
Button, Button,
Table, Table,
Input,
InputGroup, InputGroup,
InputGroupText, InputGroupText,
Icon, Icon,
@ -84,29 +85,32 @@
} }
</script> </script>
<Table> <Table class="mb-0">
<thead> <thead>
<!-- Header Row 1: Selectors -->
<tr> <tr>
<th> <th>
<Button outline on:click={() => (isMetricSelectionOpen = true)}> <Button outline on:click={() => (isMetricSelectionOpen = true)} class="w-100 px-2" color="primary">
Metrics Select Metrics
</Button> </Button>
</th> </th>
{#each selectedMetrics as metric} {#each selectedMetrics as metric}
<!-- To Match Row-2 Header Field Count-->
<th colspan={selectedScopes[metric] == "node" ? 3 : 4}> <th colspan={selectedScopes[metric] == "node" ? 3 : 4}>
<InputGroup> <InputGroup>
<InputGroupText> <InputGroupText>
{metric} {metric}
</InputGroupText> </InputGroupText>
<select class="form-select" bind:value={selectedScopes[metric]}> <Input type="select" bind:value={selectedScopes[metric]}>
{#each scopesForMetric(metric, jobMetrics) as scope} {#each scopesForMetric(metric, jobMetrics) as scope}
<option value={scope}>{scope}</option> <option value={scope}>{scope}</option>
{/each} {/each}
</select> </Input>
</InputGroup> </InputGroup>
</th> </th>
{/each} {/each}
</tr> </tr>
<!-- Header Row 2: Fields -->
<tr> <tr>
<th>Node</th> <th>Node</th>
{#each selectedMetrics as metric} {#each selectedMetrics as metric}
@ -146,8 +150,6 @@
</tbody> </tbody>
</Table> </Table>
<br />
<MetricSelection <MetricSelection
cluster={job.cluster} cluster={job.cluster}
configName="job_view_nodestats_selectedMetrics" configName="job_view_nodestats_selectedMetrics"

View File

@ -1,238 +0,0 @@
<!--
@component Job View Subcomponent; allows management of job tags by deletion or new entries
Properties:
- `job Object`: The job object
- `jobTags [Number]`: The array of currently designated tags
-->
<script>
import { getContext } from "svelte";
import { gql, getContextClient, mutationStore } from "@urql/svelte";
import {
Icon,
Button,
ListGroupItem,
Spinner,
Modal,
Input,
ModalBody,
ModalHeader,
ModalFooter,
Alert,
} from "@sveltestrap/sveltestrap";
import { fuzzySearchTags } from "../generic/utils.js";
import Tag from "../generic/helper/Tag.svelte";
export let job;
export let jobTags = job.tags;
let allTags = getContext("tags"),
initialized = getContext("initialized");
let newTagType = "",
newTagName = "";
let filterTerm = "";
let pendingChange = false;
let isOpen = false;
const client = getContextClient();
const createTagMutation = ({ type, name }) => {
return mutationStore({
client: client,
query: gql`
mutation ($type: String!, $name: String!) {
createTag(type: $type, name: $name) {
id
type
name
}
}
`,
variables: { type, name },
});
};
const addTagsToJobMutation = ({ job, tagIds }) => {
return mutationStore({
client: client,
query: gql`
mutation ($job: ID!, $tagIds: [ID!]!) {
addTagsToJob(job: $job, tagIds: $tagIds) {
id
type
name
}
}
`,
variables: { job, tagIds },
});
};
const removeTagsFromJobMutation = ({ job, tagIds }) => {
return mutationStore({
client: client,
query: gql`
mutation ($job: ID!, $tagIds: [ID!]!) {
removeTagsFromJob(job: $job, tagIds: $tagIds) {
id
type
name
}
}
`,
variables: { job, tagIds },
});
};
let allTagsFiltered; // $initialized is in there because when it becomes true, allTags is initailzed.
$: allTagsFiltered = ($initialized, fuzzySearchTags(filterTerm, allTags));
$: {
newTagType = "";
newTagName = "";
let parts = filterTerm.split(":").map((s) => s.trim());
if (parts.length == 2 && parts.every((s) => s.length > 0)) {
newTagType = parts[0];
newTagName = parts[1];
}
}
function isNewTag(type, name) {
for (let tag of allTagsFiltered)
if (tag.type == type && tag.name == name) return false;
return true;
}
function createTag(type, name) {
pendingChange = true;
createTagMutation({ type: type, name: name }).subscribe((res) => {
if (res.fetching === false && !res.error) {
pendingChange = false;
allTags = [...allTags, res.data.createTag];
newTagType = "";
newTagName = "";
addTagToJob(res.data.createTag);
} else if (res.fetching === false && res.error) {
throw res.error;
}
});
}
function addTagToJob(tag) {
pendingChange = tag.id;
addTagsToJobMutation({ job: job.id, tagIds: [tag.id] }).subscribe((res) => {
if (res.fetching === false && !res.error) {
jobTags = job.tags = res.data.addTagsToJob;
pendingChange = false;
} else if (res.fetching === false && res.error) {
throw res.error;
}
});
}
function removeTagFromJob(tag) {
pendingChange = tag.id;
removeTagsFromJobMutation({ job: job.id, tagIds: [tag.id] }).subscribe(
(res) => {
if (res.fetching === false && !res.error) {
jobTags = job.tags = res.data.removeTagsFromJob;
pendingChange = false;
} else if (res.fetching === false && res.error) {
throw res.error;
}
},
);
}
</script>
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
<ModalHeader>
Manage Tags
{#if pendingChange !== false}
<Spinner size="sm" secondary />
{:else}
<Icon name="tags" />
{/if}
</ModalHeader>
<ModalBody>
<Input
style="width: 100%;"
type="text"
placeholder="Search Tags"
bind:value={filterTerm}
/>
<br />
<Alert color="info">
Search using "<code>type: name</code>". If no tag matches your search, a
button for creating a new one will appear.
</Alert>
<ul class="list-group">
{#each allTagsFiltered as tag}
<ListGroupItem>
<Tag {tag} />
<span style="float: right;">
{#if pendingChange === tag.id}
<Spinner size="sm" secondary />
{:else if job.tags.find((t) => t.id == tag.id)}
<Button
size="sm"
outline
color="danger"
on:click={() => removeTagFromJob(tag)}
>
<Icon name="x" />
</Button>
{:else}
<Button
size="sm"
outline
color="success"
on:click={() => addTagToJob(tag)}
>
<Icon name="plus" />
</Button>
{/if}
</span>
</ListGroupItem>
{:else}
<ListGroupItem disabled>
<i>No tags matching</i>
</ListGroupItem>
{/each}
</ul>
<br />
{#if newTagType && newTagName && isNewTag(newTagType, newTagName)}
<Button
outline
color="success"
on:click={(e) => (
e.preventDefault(), createTag(newTagType, newTagName)
)}
>
Create & Add Tag:
<Tag tag={{ type: newTagType, name: newTagName }} clickable={false} />
</Button>
{:else if allTagsFiltered.length == 0}
<Alert>Search Term is not a valid Tag (<code>type: name</code>)</Alert>
{/if}
</ModalBody>
<ModalFooter>
<Button color="primary" on:click={() => (isOpen = false)}>Close</Button>
</ModalFooter>
</Modal>
<Button outline on:click={() => (isOpen = true)}>
Manage Tags <Icon name="tags" />
</Button>
<style>
ul.list-group {
max-height: 450px;
margin-bottom: 10px;
overflow: scroll;
}
</style>

View File

@ -11,6 +11,7 @@
id: "{{ .Infos.id }}", id: "{{ .Infos.id }}",
}; };
const clusterCockpitConfig = {{ .Config }}; const clusterCockpitConfig = {{ .Config }};
const username = {{ .User.Username }};
const authlevel = {{ .User.GetAuthLevel }}; const authlevel = {{ .User.GetAuthLevel }};
const roles = {{ .Roles }}; const roles = {{ .Roles }};
</script> </script>

View File

@ -7,8 +7,16 @@
{{ $tagType }} {{ $tagType }}
</div> </div>
{{ range $tagList }} {{ range $tagList }}
<a class="btn btn-lg btn-warning" href="/monitoring/jobs/?tag={{ .id }}" role="button"> {{if eq .scope "global"}}
{{ .name }} <span class="badge bg-light text-dark">{{ .count }}</span> </a> <a style="background-color:#c85fc8;" class="btn btn-lg" href="/monitoring/jobs/?tag={{ .id }}" role="button">
{{ .name }} <span class="badge bg-light text-dark">{{ .count }}</span> </a>
{{else if eq .scope "admin"}}
<a style="background-color:#19e5e6;" class="btn btn-lg" href="/monitoring/jobs/?tag={{ .id }}" role="button">
{{ .name }} <span class="badge bg-light text-dark">{{ .count }}</span> </a>
{{else}}
<a class="btn btn-lg btn-warning" href="/monitoring/jobs/?tag={{ .id }}" role="button">
{{ .name }} <span class="badge bg-light text-dark">{{ .count }}</span> </a>
{{end}}
{{end}} {{end}}
{{end}} {{end}}
</div> </div>