mirror of
				https://github.com/ClusterCockpit/cc-backend
				synced 2025-10-30 23:45:06 +01:00 
			
		
		
		
	Merge pull request #379 from ClusterCockpit/add_tag_delete
Add Tag Deletion: API and Frontend
This commit is contained in:
		| @@ -277,6 +277,7 @@ type Mutation { | ||||
|   deleteTag(id: ID!): ID! | ||||
|   addTagsToJob(job: ID!, tagIds: [ID!]!): [Tag!]! | ||||
|   removeTagsFromJob(job: ID!, tagIds: [ID!]!): [Tag!]! | ||||
|   removeTagFromList(tagIds: [ID!]!): [Int!]! | ||||
|  | ||||
|   updateConfiguration(name: String!, value: String!): String | ||||
| } | ||||
|   | ||||
							
								
								
									
										1
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								go.mod
									
									
									
									
									
								
							| @@ -1,6 +1,7 @@ | ||||
| module github.com/ClusterCockpit/cc-backend | ||||
|  | ||||
| go 1.23.5 | ||||
|  | ||||
| toolchain go1.24.1 | ||||
|  | ||||
| require ( | ||||
|   | ||||
| @@ -78,12 +78,14 @@ func (api *RestApi) MountApiRoutes(r *mux.Router) { | ||||
| 	r.HandleFunc("/jobs/{id}", api.getJobById).Methods(http.MethodPost) | ||||
| 	r.HandleFunc("/jobs/{id}", api.getCompleteJobById).Methods(http.MethodGet) | ||||
| 	r.HandleFunc("/jobs/tag_job/{id}", api.tagJob).Methods(http.MethodPost, http.MethodPatch) | ||||
| 	r.HandleFunc("/jobs/tag_job/{id}", api.removeTagJob).Methods(http.MethodDelete) | ||||
| 	r.HandleFunc("/jobs/edit_meta/{id}", api.editMeta).Methods(http.MethodPost, http.MethodPatch) | ||||
| 	r.HandleFunc("/jobs/metrics/{id}", api.getJobMetrics).Methods(http.MethodGet) | ||||
| 	r.HandleFunc("/jobs/delete_job/", api.deleteJobByRequest).Methods(http.MethodDelete) | ||||
| 	r.HandleFunc("/jobs/delete_job/{id}", api.deleteJobById).Methods(http.MethodDelete) | ||||
| 	r.HandleFunc("/jobs/delete_job_before/{ts}", api.deleteJobBefore).Methods(http.MethodDelete) | ||||
|  | ||||
| 	r.HandleFunc("/tags/", api.removeTags).Methods(http.MethodDelete) | ||||
| 	r.HandleFunc("/clusters/", api.getClusters).Methods(http.MethodGet) | ||||
|  | ||||
| 	if api.MachineStateDir != "" { | ||||
| @@ -750,6 +752,114 @@ func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) { | ||||
| 	json.NewEncoder(rw).Encode(job) | ||||
| } | ||||
|  | ||||
| // removeTagJob godoc | ||||
| // @summary     Removes one or more tags from a job | ||||
| // @tags Job add and modify | ||||
| // @description Removes tag(s) from a job specified by DB ID. Name and Type of Tag(s) must match. | ||||
| // @description Tag Scope is required for matching, options: "global", "admin". Private tags can not be deleted via API. | ||||
| // @description If tagged job is already finished: Tag will be removed from respective archive files. | ||||
| // @accept      json | ||||
| // @produce     json | ||||
| // @param       id      path     int                  true "Job Database ID" | ||||
| // @param       request body     api.TagJobApiRequest true "Array of tag-objects to remove" | ||||
| // @success     200     {object} schema.Job                "Updated job resource" | ||||
| // @failure     400     {object} api.ErrorResponse         "Bad Request" | ||||
| // @failure     401     {object} api.ErrorResponse         "Unauthorized" | ||||
| // @failure     404     {object} api.ErrorResponse         "Job or tag does not exist" | ||||
| // @failure     500     {object} api.ErrorResponse         "Internal Server Error" | ||||
| // @security    ApiKeyAuth | ||||
| // @router      /jobs/tag_job/{id} [delete] | ||||
| func (api *RestApi) removeTagJob(rw http.ResponseWriter, r *http.Request) { | ||||
| 	id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) | ||||
| 	if err != nil { | ||||
| 		http.Error(rw, err.Error(), http.StatusBadRequest) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	job, err := api.JobRepository.FindById(r.Context(), id) | ||||
| 	if err != nil { | ||||
| 		http.Error(rw, err.Error(), http.StatusNotFound) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	job.Tags, err = api.JobRepository.GetTags(repository.GetUserFromContext(r.Context()), &job.ID) | ||||
| 	if err != nil { | ||||
| 		http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	var req TagJobApiRequest | ||||
| 	if err := decode(r.Body, &req); err != nil { | ||||
| 		http.Error(rw, err.Error(), http.StatusBadRequest) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	for _, rtag := range req { | ||||
| 		// Only Global and Admin Tags | ||||
| 		if rtag.Scope != "global" && rtag.Scope != "admin" { | ||||
| 			log.Warnf("Cannot delete private tag for job %d: Skip", job.JobID) | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		remainingTags, err := api.JobRepository.RemoveJobTagByRequest(repository.GetUserFromContext(r.Context()), job.ID, rtag.Type, rtag.Name, rtag.Scope) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		job.Tags = remainingTags | ||||
| 	} | ||||
|  | ||||
| 	rw.Header().Add("Content-Type", "application/json") | ||||
| 	rw.WriteHeader(http.StatusOK) | ||||
| 	json.NewEncoder(rw).Encode(job) | ||||
| } | ||||
|  | ||||
| // removeTags godoc | ||||
| // @summary     Removes all tags and job-relations for type:name tuple | ||||
| // @tags Tag remove | ||||
| // @description Removes tags by type and name. Name and Type of Tag(s) must match. | ||||
| // @description Tag Scope is required for matching, options: "global", "admin". Private tags can not be deleted via API. | ||||
| // @description Tag wills be removed from respective archive files. | ||||
| // @accept      json | ||||
| // @produce     plain | ||||
| // @param       request body     api.TagJobApiRequest true "Array of tag-objects to remove" | ||||
| // @success     200     {string} string                    "Success Response" | ||||
| // @failure     400     {object} api.ErrorResponse         "Bad Request" | ||||
| // @failure     401     {object} api.ErrorResponse         "Unauthorized" | ||||
| // @failure     404     {object} api.ErrorResponse         "Job or tag does not exist" | ||||
| // @failure     500     {object} api.ErrorResponse         "Internal Server Error" | ||||
| // @security    ApiKeyAuth | ||||
| // @router      /tags/ [delete] | ||||
| func (api *RestApi) removeTags(rw http.ResponseWriter, r *http.Request) { | ||||
| 	var req TagJobApiRequest | ||||
| 	if err := decode(r.Body, &req); err != nil { | ||||
| 		http.Error(rw, err.Error(), http.StatusBadRequest) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	targetCount := len(req) | ||||
| 	currentCount := 0 | ||||
| 	for _, rtag := range req { | ||||
| 		// Only Global and Admin Tags | ||||
| 		if rtag.Scope != "global" && rtag.Scope != "admin" { | ||||
| 			log.Warn("Cannot delete private tag: Skip") | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		err := api.JobRepository.RemoveTagByRequest(rtag.Type, rtag.Name, rtag.Scope) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} else { | ||||
| 			currentCount++ | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	rw.WriteHeader(http.StatusOK) | ||||
| 	rw.Write([]byte(fmt.Sprintf("Deleted Tags from DB: %d successfull of %d requested\n", currentCount, targetCount))) | ||||
| } | ||||
|  | ||||
| // startJob godoc | ||||
| // @summary     Adds a new job as "running" | ||||
| // @tags Job add and modify | ||||
|   | ||||
| @@ -250,6 +250,7 @@ type ComplexityRoot struct { | ||||
| 		AddTagsToJob        func(childComplexity int, job string, tagIds []string) int | ||||
| 		CreateTag           func(childComplexity int, typeArg string, name string, scope string) int | ||||
| 		DeleteTag           func(childComplexity int, id string) int | ||||
| 		RemoveTagFromList   func(childComplexity int, tagIds []string) int | ||||
| 		RemoveTagsFromJob   func(childComplexity int, job string, tagIds []string) int | ||||
| 		UpdateConfiguration func(childComplexity int, name string, value string) int | ||||
| 	} | ||||
| @@ -399,6 +400,7 @@ type MutationResolver interface { | ||||
| 	DeleteTag(ctx context.Context, id string) (string, error) | ||||
| 	AddTagsToJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error) | ||||
| 	RemoveTagsFromJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error) | ||||
| 	RemoveTagFromList(ctx context.Context, tagIds []string) ([]int, error) | ||||
| 	UpdateConfiguration(ctx context.Context, name string, value string) (*string, error) | ||||
| } | ||||
| type QueryResolver interface { | ||||
| @@ -1310,6 +1312,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in | ||||
|  | ||||
| 		return e.complexity.Mutation.DeleteTag(childComplexity, args["id"].(string)), true | ||||
|  | ||||
| 	case "Mutation.removeTagFromList": | ||||
| 		if e.complexity.Mutation.RemoveTagFromList == nil { | ||||
| 			break | ||||
| 		} | ||||
|  | ||||
| 		args, err := ec.field_Mutation_removeTagFromList_args(context.TODO(), rawArgs) | ||||
| 		if err != nil { | ||||
| 			return 0, false | ||||
| 		} | ||||
|  | ||||
| 		return e.complexity.Mutation.RemoveTagFromList(childComplexity, args["tagIds"].([]string)), true | ||||
|  | ||||
| 	case "Mutation.removeTagsFromJob": | ||||
| 		if e.complexity.Mutation.RemoveTagsFromJob == nil { | ||||
| 			break | ||||
| @@ -2339,6 +2353,7 @@ type Mutation { | ||||
|   deleteTag(id: ID!): ID! | ||||
|   addTagsToJob(job: ID!, tagIds: [ID!]!): [Tag!]! | ||||
|   removeTagsFromJob(job: ID!, tagIds: [ID!]!): [Tag!]! | ||||
|   removeTagFromList(tagIds: [ID!]!): [Int!]! | ||||
|  | ||||
|   updateConfiguration(name: String!, value: String!): String | ||||
| } | ||||
| @@ -2617,6 +2632,34 @@ func (ec *executionContext) field_Mutation_deleteTag_argsID( | ||||
| 	return zeroVal, nil | ||||
| } | ||||
|  | ||||
| func (ec *executionContext) field_Mutation_removeTagFromList_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { | ||||
| 	var err error | ||||
| 	args := map[string]any{} | ||||
| 	arg0, err := ec.field_Mutation_removeTagFromList_argsTagIds(ctx, rawArgs) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	args["tagIds"] = arg0 | ||||
| 	return args, nil | ||||
| } | ||||
| func (ec *executionContext) field_Mutation_removeTagFromList_argsTagIds( | ||||
| 	ctx context.Context, | ||||
| 	rawArgs map[string]any, | ||||
| ) ([]string, error) { | ||||
| 	if _, ok := rawArgs["tagIds"]; !ok { | ||||
| 		var zeroVal []string | ||||
| 		return zeroVal, nil | ||||
| 	} | ||||
|  | ||||
| 	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("tagIds")) | ||||
| 	if tmp, ok := rawArgs["tagIds"]; ok { | ||||
| 		return ec.unmarshalNID2ᚕstringᚄ(ctx, tmp) | ||||
| 	} | ||||
|  | ||||
| 	var zeroVal []string | ||||
| 	return zeroVal, nil | ||||
| } | ||||
|  | ||||
| func (ec *executionContext) field_Mutation_removeTagsFromJob_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { | ||||
| 	var err error | ||||
| 	args := map[string]any{} | ||||
| @@ -9690,6 +9733,61 @@ func (ec *executionContext) fieldContext_Mutation_removeTagsFromJob(ctx context. | ||||
| 	return fc, nil | ||||
| } | ||||
|  | ||||
| func (ec *executionContext) _Mutation_removeTagFromList(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { | ||||
| 	fc, err := ec.fieldContext_Mutation_removeTagFromList(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) (any, error) { | ||||
| 		ctx = rctx // use context from middleware stack in children | ||||
| 		return ec.resolvers.Mutation().RemoveTagFromList(rctx, fc.Args["tagIds"].([]string)) | ||||
| 	}) | ||||
| 	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.([]int) | ||||
| 	fc.Result = res | ||||
| 	return ec.marshalNInt2ᚕintᚄ(ctx, field.Selections, res) | ||||
| } | ||||
|  | ||||
| func (ec *executionContext) fieldContext_Mutation_removeTagFromList(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { | ||||
| 	fc = &graphql.FieldContext{ | ||||
| 		Object:     "Mutation", | ||||
| 		Field:      field, | ||||
| 		IsMethod:   true, | ||||
| 		IsResolver: true, | ||||
| 		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { | ||||
| 			return nil, errors.New("field of type Int does not have child fields") | ||||
| 		}, | ||||
| 	} | ||||
| 	defer func() { | ||||
| 		if r := recover(); r != nil { | ||||
| 			err = ec.Recover(ctx, r) | ||||
| 			ec.Error(ctx, err) | ||||
| 		} | ||||
| 	}() | ||||
| 	ctx = graphql.WithFieldContext(ctx, fc) | ||||
| 	if fc.Args, err = ec.field_Mutation_removeTagFromList_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { | ||||
| 		ec.Error(ctx, err) | ||||
| 		return fc, err | ||||
| 	} | ||||
| 	return fc, nil | ||||
| } | ||||
|  | ||||
| func (ec *executionContext) _Mutation_updateConfiguration(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { | ||||
| 	fc, err := ec.fieldContext_Mutation_updateConfiguration(ctx, field) | ||||
| 	if err != nil { | ||||
| @@ -17765,6 +17863,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) | ||||
| 			if out.Values[i] == graphql.Null { | ||||
| 				out.Invalids++ | ||||
| 			} | ||||
| 		case "removeTagFromList": | ||||
| 			out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { | ||||
| 				return ec._Mutation_removeTagFromList(ctx, field) | ||||
| 			}) | ||||
| 			if out.Values[i] == graphql.Null { | ||||
| 				out.Invalids++ | ||||
| 			} | ||||
| 		case "updateConfiguration": | ||||
| 			out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { | ||||
| 				return ec._Mutation_updateConfiguration(ctx, field) | ||||
|   | ||||
| @@ -125,23 +125,41 @@ func (r *metricValueResolver) Name(ctx context.Context, obj *schema.MetricValue) | ||||
|  | ||||
| // CreateTag is the resolver for the createTag field. | ||||
| func (r *mutationResolver) CreateTag(ctx context.Context, typeArg string, name string, scope string) (*schema.Tag, error) { | ||||
| 	id, err := r.Repo.CreateTag(typeArg, name, scope) | ||||
| 	if err != nil { | ||||
| 		log.Warn("Error while creating tag") | ||||
| 		return nil, err | ||||
| 	user := repository.GetUserFromContext(ctx) | ||||
| 	if user == nil { | ||||
| 		return nil, fmt.Errorf("no user in context") | ||||
| 	} | ||||
|  | ||||
| 	return &schema.Tag{ID: id, Type: typeArg, Name: name, Scope: scope}, nil | ||||
| 	// Test Access: Admins && Admin Tag OR Support/Admin and Global Tag OR Everyone && Private Tag | ||||
| 	if user.HasRole(schema.RoleAdmin) && scope == "admin" || | ||||
| 		user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) && scope == "global" || | ||||
| 		user.Username == scope { | ||||
| 		// Create in DB | ||||
| 		id, err := r.Repo.CreateTag(typeArg, name, scope) | ||||
| 		if err != nil { | ||||
| 			log.Warn("Error while creating tag") | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		return &schema.Tag{ID: id, Type: typeArg, Name: name, Scope: scope}, nil | ||||
| 	} else { | ||||
| 		log.Warnf("Not authorized to create tag with scope: %s", scope) | ||||
| 		return nil, fmt.Errorf("Not authorized to create tag with scope: %s", scope) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // DeleteTag is the resolver for the deleteTag field. | ||||
| func (r *mutationResolver) DeleteTag(ctx context.Context, id string) (string, error) { | ||||
| 	// This Uses ID string <-> ID string, removeTagFromList uses []string <-> []int | ||||
| 	panic(fmt.Errorf("not implemented: DeleteTag - deleteTag")) | ||||
| } | ||||
|  | ||||
| // AddTagsToJob is the resolver for the addTagsToJob field. | ||||
| 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 | ||||
| 	user := repository.GetUserFromContext(ctx) | ||||
| 	if user == nil { | ||||
| 		return nil, fmt.Errorf("no user in context") | ||||
| 	} | ||||
|  | ||||
| 	jid, err := strconv.ParseInt(job, 10, 64) | ||||
| 	if err != nil { | ||||
| 		log.Warn("Error while adding tag to job") | ||||
| @@ -150,15 +168,32 @@ func (r *mutationResolver) AddTagsToJob(ctx context.Context, job string, tagIds | ||||
|  | ||||
| 	tags := []*schema.Tag{} | ||||
| 	for _, tagId := range tagIds { | ||||
| 		// Get ID | ||||
| 		tid, err := strconv.ParseInt(tagId, 10, 64) | ||||
| 		if err != nil { | ||||
| 			log.Warn("Error while parsing tag id") | ||||
| 			return nil, err | ||||
| 		} | ||||
|  | ||||
| 		if tags, err = r.Repo.AddTag(repository.GetUserFromContext(ctx), jid, tid); err != nil { | ||||
| 			log.Warn("Error while adding tag") | ||||
| 			return nil, err | ||||
| 		// Test Exists | ||||
| 		_, _, tscope, exists := r.Repo.TagInfo(tid) | ||||
| 		if !exists { | ||||
| 			log.Warnf("Tag does not exist (ID): %d", tid) | ||||
| 			return nil, fmt.Errorf("Tag does not exist (ID): %d", tid) | ||||
| 		} | ||||
|  | ||||
| 		// Test Access: Admins && Admin Tag OR Support/Admin and Global Tag OR Everyone && Private Tag | ||||
| 		if user.HasRole(schema.RoleAdmin) && tscope == "admin" || | ||||
| 			user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) && tscope == "global" || | ||||
| 			user.Username == tscope { | ||||
| 			// Add to Job | ||||
| 			if tags, err = r.Repo.AddTag(user, jid, tid); err != nil { | ||||
| 				log.Warn("Error while adding tag") | ||||
| 				return nil, err | ||||
| 			} | ||||
| 		} else { | ||||
| 			log.Warnf("Not authorized to add tag: %d", tid) | ||||
| 			return nil, fmt.Errorf("Not authorized to add tag: %d", tid) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @@ -167,7 +202,11 @@ func (r *mutationResolver) AddTagsToJob(ctx context.Context, job string, tagIds | ||||
|  | ||||
| // RemoveTagsFromJob is the resolver for the removeTagsFromJob field. | ||||
| 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 | ||||
| 	user := repository.GetUserFromContext(ctx) | ||||
| 	if user == nil { | ||||
| 		return nil, fmt.Errorf("no user in context") | ||||
| 	} | ||||
|  | ||||
| 	jid, err := strconv.ParseInt(job, 10, 64) | ||||
| 	if err != nil { | ||||
| 		log.Warn("Error while parsing job id") | ||||
| @@ -176,21 +215,80 @@ func (r *mutationResolver) RemoveTagsFromJob(ctx context.Context, job string, ta | ||||
|  | ||||
| 	tags := []*schema.Tag{} | ||||
| 	for _, tagId := range tagIds { | ||||
| 		// Get ID | ||||
| 		tid, err := strconv.ParseInt(tagId, 10, 64) | ||||
| 		if err != nil { | ||||
| 			log.Warn("Error while parsing tag id") | ||||
| 			return nil, err | ||||
| 		} | ||||
|  | ||||
| 		if tags, err = r.Repo.RemoveTag(repository.GetUserFromContext(ctx), jid, tid); err != nil { | ||||
| 			log.Warn("Error while removing tag") | ||||
| 			return nil, err | ||||
| 		// Test Exists | ||||
| 		_, _, tscope, exists := r.Repo.TagInfo(tid) | ||||
| 		if !exists { | ||||
| 			log.Warnf("Tag does not exist (ID): %d", tid) | ||||
| 			return nil, fmt.Errorf("Tag does not exist (ID): %d", tid) | ||||
| 		} | ||||
|  | ||||
| 		// Test Access: Admins && Admin Tag OR Support/Admin and Global Tag OR Everyone && Private Tag | ||||
| 		if user.HasRole(schema.RoleAdmin) && tscope == "admin" || | ||||
| 			user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) && tscope == "global" || | ||||
| 			user.Username == tscope { | ||||
| 			// Remove from Job | ||||
| 			if tags, err = r.Repo.RemoveTag(user, jid, tid); err != nil { | ||||
| 				log.Warn("Error while removing tag") | ||||
| 				return nil, err | ||||
| 			} | ||||
| 		} else { | ||||
| 			log.Warnf("Not authorized to remove tag: %d", tid) | ||||
| 			return nil, fmt.Errorf("Not authorized to remove tag: %d", tid) | ||||
| 		} | ||||
|  | ||||
| 	} | ||||
|  | ||||
| 	return tags, nil | ||||
| } | ||||
|  | ||||
| // RemoveTagFromList is the resolver for the removeTagFromList field. | ||||
| func (r *mutationResolver) RemoveTagFromList(ctx context.Context, tagIds []string) ([]int, error) { | ||||
| 	// Needs Contextuser | ||||
| 	user := repository.GetUserFromContext(ctx) | ||||
| 	if user == nil { | ||||
| 		return nil, fmt.Errorf("no user in context") | ||||
| 	} | ||||
|  | ||||
| 	tags := []int{} | ||||
| 	for _, tagId := range tagIds { | ||||
| 		// Get ID | ||||
| 		tid, err := strconv.ParseInt(tagId, 10, 64) | ||||
| 		if err != nil { | ||||
| 			log.Warn("Error while parsing tag id for removal") | ||||
| 			return nil, err | ||||
| 		} | ||||
|  | ||||
| 		// Test Exists | ||||
| 		_, _, tscope, exists := r.Repo.TagInfo(tid) | ||||
| 		if !exists { | ||||
| 			log.Warnf("Tag does not exist (ID): %d", tid) | ||||
| 			return nil, fmt.Errorf("Tag does not exist (ID): %d", tid) | ||||
| 		} | ||||
|  | ||||
| 		// Test Access: Admins && Admin Tag OR Everyone && Private Tag | ||||
| 		if user.HasRole(schema.RoleAdmin) && (tscope == "global" || tscope == "admin") || user.Username == tscope { | ||||
| 			// Remove from DB | ||||
| 			if err = r.Repo.RemoveTagById(tid); err != nil { | ||||
| 				log.Warn("Error while removing tag") | ||||
| 				return nil, err | ||||
| 			} else { | ||||
| 				tags = append(tags, int(tid)) | ||||
| 			} | ||||
| 		} else { | ||||
| 			log.Warnf("Not authorized to remove tag: %d", tid) | ||||
| 			return nil, fmt.Errorf("Not authorized to remove tag: %d", tid) | ||||
| 		} | ||||
| 	} | ||||
| 	return tags, nil | ||||
| } | ||||
|  | ||||
| // UpdateConfiguration is the resolver for the updateConfiguration field. | ||||
| func (r *mutationResolver) UpdateConfiguration(ctx context.Context, name string, value string) (*string, error) { | ||||
| 	if err := repository.GetUserCfgRepo().UpdateConfig(name, value, repository.GetUserFromContext(ctx)); err != nil { | ||||
|   | ||||
| @@ -45,7 +45,7 @@ func (r *JobRepository) AddTag(user *schema.User, job int64, tag int64) ([]*sche | ||||
| 	return tags, archive.UpdateTags(j, archiveTags) | ||||
| } | ||||
|  | ||||
| // Removes a tag from a job | ||||
| // Removes a tag from a job by tag id | ||||
| func (r *JobRepository) RemoveTag(user *schema.User, job, tag int64) ([]*schema.Tag, error) { | ||||
| 	j, err := r.FindByIdWithUser(user, job) | ||||
| 	if err != nil { | ||||
| @@ -76,6 +76,99 @@ func (r *JobRepository) RemoveTag(user *schema.User, job, tag int64) ([]*schema. | ||||
| 	return tags, archive.UpdateTags(j, archiveTags) | ||||
| } | ||||
|  | ||||
| // Removes a tag from a job by tag info | ||||
| func (r *JobRepository) RemoveJobTagByRequest(user *schema.User, job int64, tagType string, tagName string, tagScope string) ([]*schema.Tag, error) { | ||||
| 	// Get Tag ID to delete | ||||
| 	tagID, exists := r.TagId(tagType, tagName, tagScope) | ||||
| 	if !exists { | ||||
| 		log.Warnf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope) | ||||
| 		return nil, fmt.Errorf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope) | ||||
| 	} | ||||
|  | ||||
| 	// Get Job | ||||
| 	j, err := r.FindByIdWithUser(user, job) | ||||
| 	if err != nil { | ||||
| 		log.Warn("Error while finding job by id") | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	// Handle Delete | ||||
| 	q := sq.Delete("jobtag").Where("jobtag.job_id = ?", job).Where("jobtag.tag_id = ?", tagID) | ||||
|  | ||||
| 	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil { | ||||
| 		s, _, _ := q.ToSql() | ||||
| 		log.Errorf("Error removing tag from table 'jobTag' with %s: %v", s, err) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	tags, err := r.GetTags(user, &job) | ||||
| 	if err != nil { | ||||
| 		log.Warn("Error while getting tags for job") | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	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 db by tag info | ||||
| func (r *JobRepository) RemoveTagByRequest(tagType string, tagName string, tagScope string) error { | ||||
| 	// Get Tag ID to delete | ||||
| 	tagID, exists := r.TagId(tagType, tagName, tagScope) | ||||
| 	if !exists { | ||||
| 		log.Warnf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope) | ||||
| 		return fmt.Errorf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope) | ||||
| 	} | ||||
|  | ||||
| 	// Handle Delete JobTagTable | ||||
| 	qJobTag := sq.Delete("jobtag").Where("jobtag.tag_id = ?", tagID) | ||||
|  | ||||
| 	if _, err := qJobTag.RunWith(r.stmtCache).Exec(); err != nil { | ||||
| 		s, _, _ := qJobTag.ToSql() | ||||
| 		log.Errorf("Error removing tag from table 'jobTag' with %s: %v", s, err) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	// Handle Delete TagTable | ||||
| 	qTag := sq.Delete("tag").Where("tag.id = ?", tagID) | ||||
|  | ||||
| 	if _, err := qTag.RunWith(r.stmtCache).Exec(); err != nil { | ||||
| 		s, _, _ := qTag.ToSql() | ||||
| 		log.Errorf("Error removing tag from table 'tag' with %s: %v", s, err) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // Removes a tag from db by tag id | ||||
| func (r *JobRepository) RemoveTagById(tagID int64) error { | ||||
| 	// Handle Delete JobTagTable | ||||
| 	qJobTag := sq.Delete("jobtag").Where("jobtag.tag_id = ?", tagID) | ||||
|  | ||||
| 	if _, err := qJobTag.RunWith(r.stmtCache).Exec(); err != nil { | ||||
| 		s, _, _ := qJobTag.ToSql() | ||||
| 		log.Errorf("Error removing tag from table 'jobTag' with %s: %v", s, err) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	// Handle Delete TagTable | ||||
| 	qTag := sq.Delete("tag").Where("tag.id = ?", tagID) | ||||
|  | ||||
| 	if _, err := qTag.RunWith(r.stmtCache).Exec(); err != nil { | ||||
| 		s, _, _ := qTag.ToSql() | ||||
| 		log.Errorf("Error removing tag from table 'tag' with %s: %v", s, err) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // CreateTag creates a new tag with the specified type and name and returns its database id. | ||||
| func (r *JobRepository) CreateTag(tagType string, tagName string, tagScope string) (tagId int64, err error) { | ||||
| 	// Default to "Global" scope if none defined | ||||
| @@ -209,6 +302,16 @@ func (r *JobRepository) TagId(tagType string, tagName string, tagScope string) ( | ||||
| 	return | ||||
| } | ||||
|  | ||||
| // TagInfo returns the database infos of the tag with the specified id. | ||||
| func (r *JobRepository) TagInfo(tagId int64) (tagType string, tagName string, tagScope string, exists bool) { | ||||
| 	exists = true | ||||
| 	if err := sq.Select("tag.tag_type", "tag.tag_name", "tag.tag_scope").From("tag").Where("tag.id = ?", tagId). | ||||
| 		RunWith(r.stmtCache).QueryRow().Scan(&tagType, &tagName, &tagScope); err != nil { | ||||
| 		exists = false | ||||
| 	} | ||||
| 	return | ||||
| } | ||||
|  | ||||
| // 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(user *schema.User, job *int64) ([]*schema.Tag, error) { | ||||
| 	q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag") | ||||
|   | ||||
| @@ -85,6 +85,7 @@ func IsValidRole(role string) bool { | ||||
| 	return getRoleEnum(role) != RoleError | ||||
| } | ||||
|  | ||||
| // Check if User has SPECIFIED role AND role is VALID | ||||
| func (u *User) HasValidRole(role string) (hasRole bool, isValid bool) { | ||||
| 	if IsValidRole(role) { | ||||
| 		for _, r := range u.Roles { | ||||
| @@ -97,6 +98,7 @@ func (u *User) HasValidRole(role string) (hasRole bool, isValid bool) { | ||||
| 	return false, false | ||||
| } | ||||
|  | ||||
| // Check if User has SPECIFIED role | ||||
| func (u *User) HasRole(role Role) bool { | ||||
| 	for _, r := range u.Roles { | ||||
| 		if r == GetRoleString(role) { | ||||
| @@ -106,7 +108,7 @@ func (u *User) HasRole(role Role) bool { | ||||
| 	return false | ||||
| } | ||||
|  | ||||
| // Role-Arrays are short: performance not impacted by nested loop | ||||
| // Check if User has ANY of the listed roles | ||||
| func (u *User) HasAnyRole(queryroles []Role) bool { | ||||
| 	for _, ur := range u.Roles { | ||||
| 		for _, qr := range queryroles { | ||||
| @@ -118,7 +120,7 @@ func (u *User) HasAnyRole(queryroles []Role) bool { | ||||
| 	return false | ||||
| } | ||||
|  | ||||
| // Role-Arrays are short: performance not impacted by nested loop | ||||
| // Check if User has ALL of the listed roles | ||||
| func (u *User) HasAllRoles(queryroles []Role) bool { | ||||
| 	target := len(queryroles) | ||||
| 	matches := 0 | ||||
| @@ -138,7 +140,7 @@ func (u *User) HasAllRoles(queryroles []Role) bool { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Role-Arrays are short: performance not impacted by nested loop | ||||
| // Check if User has NONE of the listed roles | ||||
| func (u *User) HasNotRoles(queryroles []Role) bool { | ||||
| 	matches := 0 | ||||
| 	for _, ur := range u.Roles { | ||||
|   | ||||
| @@ -62,6 +62,7 @@ export default [ | ||||
|     entrypoint('jobs', 'src/jobs.entrypoint.js'), | ||||
|     entrypoint('user', 'src/user.entrypoint.js'), | ||||
|     entrypoint('list', 'src/list.entrypoint.js'), | ||||
|     entrypoint('taglist', 'src/tags.entrypoint.js'), | ||||
|     entrypoint('job', 'src/job.entrypoint.js'), | ||||
|     entrypoint('systems', 'src/systems.entrypoint.js'), | ||||
|     entrypoint('node', 'src/node.entrypoint.js'), | ||||
|   | ||||
							
								
								
									
										110
									
								
								web/frontend/src/Tags.root.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								web/frontend/src/Tags.root.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | ||||
| <!-- | ||||
|     @component Tag List Svelte Component. Displays All Tags, Allows deletion. | ||||
|  | ||||
|     Properties: | ||||
|     - `username String!`: Users username. | ||||
|     - `isAdmin Bool!`: User has Admin Auth. | ||||
|     - `tagmap Object!`: Map of accessible, appwide tags. Prefiltered in backend. | ||||
|  --> | ||||
|  | ||||
| <script> | ||||
|   import { | ||||
|     gql, | ||||
|     getContextClient, | ||||
|     mutationStore, | ||||
|   } from "@urql/svelte"; | ||||
|   import { | ||||
|     Badge, | ||||
|     InputGroup, | ||||
|     Icon, | ||||
|     Button, | ||||
|     Spinner, | ||||
|   } from "@sveltestrap/sveltestrap"; | ||||
|   import { | ||||
|     init, | ||||
|   } from "./generic/utils.js"; | ||||
|  | ||||
|   export let username; | ||||
|   export let isAdmin; | ||||
|   export let tagmap; | ||||
|  | ||||
|   const {} = init(); | ||||
|   const client = getContextClient(); | ||||
|  | ||||
|   let pendingChange = "none"; | ||||
|  | ||||
|   const removeTagMutation = ({ tagIds }) => { | ||||
|     return mutationStore({ | ||||
|       client: client, | ||||
|       query: gql` | ||||
|         mutation ($tagIds: [ID!]!) { | ||||
|           removeTagFromList(tagIds: $tagIds) | ||||
|         } | ||||
|       `, | ||||
|       variables: { tagIds }, | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   function removeTag(tag, tagType) { | ||||
|     if (confirm("Are you sure you want to completely remove this tag?\n\n" + tagType + ':' + tag.name)) { | ||||
|       pendingChange = tagType; | ||||
|       removeTagMutation({tagIds: [tag.id] }).subscribe( | ||||
|         (res) => { | ||||
|           if (res.fetching === false && !res.error) { | ||||
|             tagmap[tagType] = tagmap[tagType].filter((t) => !res.data.removeTagFromList.includes(t.id)); | ||||
|             if (tagmap[tagType].length === 0) { | ||||
|               delete tagmap[tagType] | ||||
|             } | ||||
|             pendingChange = "none"; | ||||
|           } else if (res.fetching === false && res.error) { | ||||
|             throw res.error; | ||||
|           } | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <div class="container"> | ||||
|   <div class="row justify-content-center"> | ||||
|     <div class="col-10"> | ||||
|       {#each Object.entries(tagmap) as [tagType, tagList]} | ||||
|         <div class="my-3 p-2 bg-secondary rounded text-white"> <!-- text-capitalize --> | ||||
|           Tag Type: <b>{tagType}</b> | ||||
|           {#if pendingChange === tagType} | ||||
|             <Spinner size="sm" secondary /> | ||||
|           {/if} | ||||
|           <span style="float: right; padding-bottom: 0.4rem; padding-top: 0.4rem;" class="badge bg-light text-secondary"> | ||||
|             {tagList.length} Tag{(tagList.length != 1)?'s':''} | ||||
|           </span> | ||||
|         </div> | ||||
|         <div class="d-inline-flex flex-wrap"> | ||||
|           {#each tagList as tag (tag.id)} | ||||
|             <InputGroup class="w-auto flex-nowrap" style="margin-right: 0.5rem; margin-bottom: 0.5rem;"> | ||||
|               <Button outline color="secondary" href="/monitoring/jobs/?tag={tag.id}" target="_blank"> | ||||
|                 <Badge color="light" style="font-size:medium;" border>{tag.name}</Badge> :  | ||||
|                 <Badge color="primary" pill>{tag.count} Job{(tag.count != 1)?'s':''}</Badge> | ||||
|                 {#if tag.scope == "global"} | ||||
|                   <Badge style="background-color:#c85fc8 !important;" pill>Global</Badge> | ||||
|                 {:else if tag.scope == "admin"} | ||||
|                   <Badge style="background-color:#19e5e6 !important;" pill>Admin</Badge> | ||||
|                 {:else} | ||||
|                   <Badge color="warning" pill>Private</Badge> | ||||
|                 {/if} | ||||
|               </Button> | ||||
|               {#if (isAdmin && (tag.scope == "admin" || tag.scope == "global")) || tag.scope == username } | ||||
|                 <Button | ||||
|                   size="sm" | ||||
|                   color="danger" | ||||
|                   on:click={() => removeTag(tag, tagType)} | ||||
|                 > | ||||
|                   <Icon name="x" /> | ||||
|                 </Button> | ||||
|               {/if} | ||||
|             </InputGroup> | ||||
|           {/each} | ||||
|         </div> | ||||
|       {/each} | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
							
								
								
									
										16
									
								
								web/frontend/src/tags.entrypoint.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								web/frontend/src/tags.entrypoint.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| import {} from './header.entrypoint.js' | ||||
| import Tags from './Tags.root.svelte' | ||||
|  | ||||
| new Tags({ | ||||
|     target: document.getElementById('svelte-app'), | ||||
|     props: { | ||||
|         username: username, | ||||
|         isAdmin: isAdmin, | ||||
|         tagmap: tagmap, | ||||
|     }, | ||||
|     context: new Map([ | ||||
|         ['cc-config', clusterCockpitConfig] | ||||
|     ]) | ||||
| }) | ||||
|  | ||||
|  | ||||
| @@ -1,37 +1,15 @@ | ||||
| {{define "content"}} | ||||
|     <div class="container"> | ||||
|         <div class="row  justify-content-center"> | ||||
|             <div class="col-10"> | ||||
|             {{ range $tagType, $tagList := .Infos.tagmap }} | ||||
|                 <div class="my-3 p-2 bg-secondary rounded text-white"> <!-- text-capitalize --> | ||||
|                     Tag Type: <b>{{ $tagType }}</b> | ||||
|                     <span style="float: right; padding-bottom: 0.4rem; padding-top: 0.4rem;" class="badge bg-light text-secondary"> | ||||
|                         {{len $tagList}} Tag{{if ne (len $tagList) 1}}s{{end}} | ||||
|                     </span> | ||||
|                 </div> | ||||
|                 {{ range $tagList }} | ||||
|                     {{if eq .scope "global"}} | ||||
|                         <a class="btn btn-outline-secondary" href="/monitoring/jobs/?tag={{ .id }}" role="button"> | ||||
|                             {{ .name }} | ||||
|                             <span class="badge bg-primary mr-1">{{ .count }} Job{{if ne .count 1}}s{{end}}</span> | ||||
|                             <span style="background-color:#c85fc8;" class="badge text-dark">Global</span> | ||||
|                         </a> | ||||
|                     {{else if eq .scope "admin"}} | ||||
|                         <a class="btn btn-outline-secondary" href="/monitoring/jobs/?tag={{ .id }}" role="button"> | ||||
|                             {{ .name }} | ||||
|                             <span class="badge bg-primary mr-1">{{ .count }} Job{{if ne .count 1}}s{{end}}</span> | ||||
|                             <span style="background-color:#19e5e6;" class="badge text-dark">Admin</span> | ||||
|                         </a> | ||||
|                     {{else}} | ||||
|                         <a class="btn btn-outline-secondary" href="/monitoring/jobs/?tag={{ .id }}" role="button"> | ||||
|                             {{ .name }} | ||||
|                             <span class="badge bg-primary mr-1">{{ .count }} Job{{if ne .count 1}}s{{end}}</span> | ||||
|                             <span class="badge bg-warning text-dark">Private</span> | ||||
|                         </a> | ||||
|                     {{end}} | ||||
|                 {{end}} | ||||
|             {{end}} | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|     <div id="svelte-app"></div> | ||||
| {{end}} | ||||
| {{define "stylesheets"}} | ||||
|     <link rel='stylesheet' href='/build/taglist.css'> | ||||
| {{end}} | ||||
| {{define "javascript"}} | ||||
|     <script> | ||||
|         const username = {{ .User.Username }}; | ||||
|         const isAdmin = {{ .User.HasRole .Roles.admin }}; | ||||
|         const tagmap = {{ .Infos.tagmap }}; | ||||
|         const clusterCockpitConfig = {{ .Config }}; | ||||
|     </script> | ||||
|     <script src='/build/taglist.js'></script> | ||||
| {{end}} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user