From a8d785beb348634ada38282689e05039cc081228 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 9 Apr 2025 07:27:59 +0200 Subject: [PATCH 01/15] Remove redundant check in auth package --- internal/api/rest.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/api/rest.go b/internal/api/rest.go index fd2f86d..8713976 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -105,6 +105,7 @@ func (api *RestApi) MountConfigApiRoutes(r *mux.Router) { r.StrictSlash(true) if api.Authentication != nil { + log.Debug("Mounting /configuration/ route") r.HandleFunc("/roles/", api.getRoles).Methods(http.MethodGet) r.HandleFunc("/users/", api.createUser).Methods(http.MethodPost, http.MethodPut) r.HandleFunc("/users/", api.getUsers).Methods(http.MethodGet) @@ -229,7 +230,7 @@ func securedCheck(r *http.Request) error { if user.AuthType == schema.AuthToken { // If nothing declared in config: deny all request to this endpoint - if config.Keys.ApiAllowedIPs == nil || len(config.Keys.ApiAllowedIPs) == 0 { + if len(config.Keys.ApiAllowedIPs) == 0 { return fmt.Errorf("missing configuration key ApiAllowedIPs") } From 28cdc1d9e5c6a455a0ca15e624d844b665af3270 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 9 Apr 2025 09:13:21 +0200 Subject: [PATCH 02/15] fix: Update endpoints in Swagger UI --- api/swagger.json | 27 +++++++++++++-------------- api/swagger.yaml | 27 +++++++++++++-------------- internal/api/docs.go | 28 ++++++++++++++-------------- internal/api/rest.go | 34 ++++++++++++++++------------------ 4 files changed, 56 insertions(+), 60 deletions(-) diff --git a/api/swagger.json b/api/swagger.json index 51b22c8..683b520 100644 --- a/api/swagger.json +++ b/api/swagger.json @@ -15,9 +15,8 @@ "version": "1.0.0" }, "host": "localhost:8080", - "basePath": "/api", "paths": { - "/clusters/": { + "/api/clusters/": { "get": { "security": [ { @@ -74,7 +73,7 @@ } } }, - "/jobs/": { + "/api/jobs/": { "get": { "security": [ { @@ -169,7 +168,7 @@ } } }, - "/jobs/delete_job/": { + "/api/jobs/delete_job/": { "delete": { "security": [ { @@ -244,7 +243,7 @@ } } }, - "/jobs/delete_job/{id}": { + "/api/jobs/delete_job/{id}": { "delete": { "security": [ { @@ -314,7 +313,7 @@ } } }, - "/jobs/delete_job_before/{ts}": { + "/api/jobs/delete_job_before/{ts}": { "delete": { "security": [ { @@ -384,7 +383,7 @@ } } }, - "/jobs/edit_meta/{id}": { + "/api/jobs/edit_meta/{id}": { "post": { "security": [ { @@ -454,7 +453,7 @@ } } }, - "/jobs/start_job/": { + "/api/jobs/start_job/": { "post": { "security": [ { @@ -523,7 +522,7 @@ } } }, - "/jobs/stop_job/": { + "/api/jobs/stop_job/": { "post": { "security": [ { @@ -595,7 +594,7 @@ } } }, - "/jobs/tag_job/{id}": { + "/api/jobs/tag_job/{id}": { "post": { "security": [ { @@ -668,7 +667,7 @@ } } }, - "/jobs/{id}": { + "/api/jobs/{id}": { "get": { "security": [ { @@ -827,7 +826,7 @@ } } }, - "/notice/": { + "/config/notice/": { "post": { "security": [ { @@ -893,7 +892,7 @@ } } }, - "/user/{id}": { + "/config/user/{id}": { "post": { "security": [ { @@ -998,7 +997,7 @@ } } }, - "/users/": { + "/config/users/": { "get": { "security": [ { diff --git a/api/swagger.yaml b/api/swagger.yaml index f5f0081..35ec6c4 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -1,4 +1,3 @@ -basePath: /api definitions: api.ApiReturnedUser: properties: @@ -671,7 +670,7 @@ info: title: ClusterCockpit REST API version: 1.0.0 paths: - /clusters/: + /api/clusters/: get: description: Get a list of all cluster configs. Specific cluster can be requested using query parameter. @@ -708,7 +707,7 @@ paths: summary: Lists all cluster configs tags: - Cluster query - /jobs/: + /api/jobs/: get: description: |- Get a list of all jobs. Filters can be applied using query parameters. @@ -773,7 +772,7 @@ paths: summary: Lists all jobs tags: - Job query - /jobs/{id}: + /api/jobs/{id}: get: description: |- Job to get is specified by database ID @@ -882,7 +881,7 @@ paths: summary: Get job meta and configurable metric data tags: - Job query - /jobs/delete_job/: + /api/jobs/delete_job/: delete: consumes: - application/json @@ -932,7 +931,7 @@ paths: summary: Remove a job from the sql database tags: - Job remove - /jobs/delete_job/{id}: + /api/jobs/delete_job/{id}: delete: description: Job to remove is specified by database ID. This will not remove the job from the job archive. @@ -979,7 +978,7 @@ paths: summary: Remove a job from the sql database tags: - Job remove - /jobs/delete_job_before/{ts}: + /api/jobs/delete_job_before/{ts}: delete: description: Remove all jobs with start time before timestamp. The jobs will not be removed from the job archive. @@ -1026,7 +1025,7 @@ paths: summary: Remove a job from the sql database tags: - Job remove - /jobs/edit_meta/{id}: + /api/jobs/edit_meta/{id}: post: consumes: - application/json @@ -1073,7 +1072,7 @@ paths: summary: Edit meta-data json tags: - Job add and modify - /jobs/start_job/: + /api/jobs/start_job/: post: consumes: - application/json @@ -1120,7 +1119,7 @@ paths: summary: Adds a new job as "running" tags: - Job add and modify - /jobs/stop_job/: + /api/jobs/stop_job/: post: description: |- Job to stop is specified by request body. All fields are required in this case. @@ -1168,7 +1167,7 @@ paths: summary: Marks job as completed and triggers archiving tags: - Job add and modify - /jobs/tag_job/{id}: + /api/jobs/tag_job/{id}: post: consumes: - application/json @@ -1218,7 +1217,7 @@ paths: summary: Adds one or more tags to a job tags: - Job add and modify - /notice/: + /config/notice/: post: consumes: - multipart/form-data @@ -1263,7 +1262,7 @@ paths: summary: Updates or empties the notice box content tags: - User - /user/{id}: + /config/user/{id}: post: consumes: - multipart/form-data @@ -1337,7 +1336,7 @@ paths: summary: Updates an existing user tags: - User - /users/: + /config/users/: delete: consumes: - multipart/form-data diff --git a/internal/api/docs.go b/internal/api/docs.go index 642003f..2408f85 100644 --- a/internal/api/docs.go +++ b/internal/api/docs.go @@ -23,7 +23,7 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { - "/clusters/": { + "/api/clusters/": { "get": { "security": [ { @@ -80,7 +80,7 @@ const docTemplate = `{ } } }, - "/jobs/": { + "/api/jobs/": { "get": { "security": [ { @@ -175,7 +175,7 @@ const docTemplate = `{ } } }, - "/jobs/delete_job/": { + "/api/jobs/delete_job/": { "delete": { "security": [ { @@ -250,7 +250,7 @@ const docTemplate = `{ } } }, - "/jobs/delete_job/{id}": { + "/api/jobs/delete_job/{id}": { "delete": { "security": [ { @@ -320,7 +320,7 @@ const docTemplate = `{ } } }, - "/jobs/delete_job_before/{ts}": { + "/api/jobs/delete_job_before/{ts}": { "delete": { "security": [ { @@ -390,7 +390,7 @@ const docTemplate = `{ } } }, - "/jobs/edit_meta/{id}": { + "/api/jobs/edit_meta/{id}": { "post": { "security": [ { @@ -460,7 +460,7 @@ const docTemplate = `{ } } }, - "/jobs/start_job/": { + "/api/jobs/start_job/": { "post": { "security": [ { @@ -529,7 +529,7 @@ const docTemplate = `{ } } }, - "/jobs/stop_job/": { + "/api/jobs/stop_job/": { "post": { "security": [ { @@ -601,7 +601,7 @@ const docTemplate = `{ } } }, - "/jobs/tag_job/{id}": { + "/api/jobs/tag_job/{id}": { "post": { "security": [ { @@ -674,7 +674,7 @@ const docTemplate = `{ } } }, - "/jobs/{id}": { + "/api/jobs/{id}": { "get": { "security": [ { @@ -833,7 +833,7 @@ const docTemplate = `{ } } }, - "/notice/": { + "/config/notice/": { "post": { "security": [ { @@ -899,7 +899,7 @@ const docTemplate = `{ } } }, - "/user/{id}": { + "/config/user/{id}": { "post": { "security": [ { @@ -1004,7 +1004,7 @@ const docTemplate = `{ } } }, - "/users/": { + "/config/users/": { "get": { "security": [ { @@ -2191,7 +2191,7 @@ const docTemplate = `{ var SwaggerInfo = &swag.Spec{ Version: "1.0.0", Host: "localhost:8080", - BasePath: "/api", + BasePath: "", Schemes: []string{}, Title: "ClusterCockpit REST API", Description: "API for batch job control.", diff --git a/internal/api/rest.go b/internal/api/rest.go index db9a860..85b0d13 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -46,7 +46,6 @@ import ( // @license.url https://opensource.org/licenses/MIT // @host localhost:8080 -// @basePath /api // @securityDefinitions.apikey ApiKeyAuth // @in header @@ -105,7 +104,6 @@ func (api *RestApi) MountConfigApiRoutes(r *mux.Router) { r.StrictSlash(true) if api.Authentication != nil { - log.Debug("Mounting /configuration/ route") r.HandleFunc("/roles/", api.getRoles).Methods(http.MethodGet) r.HandleFunc("/users/", api.createUser).Methods(http.MethodPost, http.MethodPut) r.HandleFunc("/users/", api.getUsers).Methods(http.MethodGet) @@ -272,7 +270,7 @@ func securedCheck(r *http.Request) error { // @failure 403 {object} api.ErrorResponse "Forbidden" // @failure 500 {object} api.ErrorResponse "Internal Server Error" // @security ApiKeyAuth -// @router /clusters/ [get] +// @router /api/clusters/ [get] func (api *RestApi) getClusters(rw http.ResponseWriter, r *http.Request) { if user := repository.GetUserFromContext(r.Context()); user != nil && !user.HasRole(schema.RoleApi) { @@ -327,7 +325,7 @@ func (api *RestApi) getClusters(rw http.ResponseWriter, r *http.Request) { // @failure 403 {object} api.ErrorResponse "Forbidden" // @failure 500 {object} api.ErrorResponse "Internal Server Error" // @security ApiKeyAuth -// @router /jobs/ [get] +// @router /api/jobs/ [get] func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) { withMetadata := false filter := &model.JobFilter{} @@ -461,7 +459,7 @@ func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) { // @failure 422 {object} api.ErrorResponse "Unprocessable Entity: finding job failed: sql: no rows in result set" // @failure 500 {object} api.ErrorResponse "Internal Server Error" // @security ApiKeyAuth -// @router /jobs/{id} [get] +// @router /api/jobs/{id} [get] func (api *RestApi) getCompleteJobById(rw http.ResponseWriter, r *http.Request) { // Fetch job from db id, ok := mux.Vars(r)["id"] @@ -554,7 +552,7 @@ func (api *RestApi) getCompleteJobById(rw http.ResponseWriter, r *http.Request) // @failure 422 {object} api.ErrorResponse "Unprocessable Entity: finding job failed: sql: no rows in result set" // @failure 500 {object} api.ErrorResponse "Internal Server Error" // @security ApiKeyAuth -// @router /jobs/{id} [post] +// @router /api/jobs/{id} [post] func (api *RestApi) getJobById(rw http.ResponseWriter, r *http.Request) { // Fetch job from db id, ok := mux.Vars(r)["id"] @@ -658,7 +656,7 @@ func (api *RestApi) getJobById(rw http.ResponseWriter, r *http.Request) { // @failure 404 {object} api.ErrorResponse "Job does not exist" // @failure 500 {object} api.ErrorResponse "Internal Server Error" // @security ApiKeyAuth -// @router /jobs/edit_meta/{id} [post] +// @router /api/jobs/edit_meta/{id} [post] func (api *RestApi) editMeta(rw http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) if err != nil { @@ -704,7 +702,7 @@ func (api *RestApi) editMeta(rw http.ResponseWriter, r *http.Request) { // @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} [post] +// @router /api/jobs/tag_job/{id} [post] func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) if err != nil { @@ -765,7 +763,7 @@ func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) { // @failure 422 {object} api.ErrorResponse "Unprocessable Entity: The combination of jobId, clusterId and startTime does already exist" // @failure 500 {object} api.ErrorResponse "Internal Server Error" // @security ApiKeyAuth -// @router /jobs/start_job/ [post] +// @router /api/jobs/start_job/ [post] func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) { req := schema.JobMeta{BaseJob: schema.JobDefaults} if err := decode(r.Body, &req); err != nil { @@ -838,7 +836,7 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) { // @failure 422 {object} api.ErrorResponse "Unprocessable Entity: job has already been stopped" // @failure 500 {object} api.ErrorResponse "Internal Server Error" // @security ApiKeyAuth -// @router /jobs/stop_job/ [post] +// @router /api/jobs/stop_job/ [post] func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) { // Parse request body req := StopJobApiRequest{} @@ -879,7 +877,7 @@ func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) { // @failure 422 {object} api.ErrorResponse "Unprocessable Entity: finding job failed: sql: no rows in result set" // @failure 500 {object} api.ErrorResponse "Internal Server Error" // @security ApiKeyAuth -// @router /jobs/delete_job/{id} [delete] +// @router /api/jobs/delete_job/{id} [delete] func (api *RestApi) deleteJobById(rw http.ResponseWriter, r *http.Request) { // Fetch job (that will be stopped) from db id, ok := mux.Vars(r)["id"] @@ -922,7 +920,7 @@ func (api *RestApi) deleteJobById(rw http.ResponseWriter, r *http.Request) { // @failure 422 {object} api.ErrorResponse "Unprocessable Entity: finding job failed: sql: no rows in result set" // @failure 500 {object} api.ErrorResponse "Internal Server Error" // @security ApiKeyAuth -// @router /jobs/delete_job/ [delete] +// @router /api/jobs/delete_job/ [delete] func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request) { // Parse request body req := DeleteJobApiRequest{} @@ -972,7 +970,7 @@ func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request) // @failure 422 {object} api.ErrorResponse "Unprocessable Entity: finding job failed: sql: no rows in result set" // @failure 500 {object} api.ErrorResponse "Internal Server Error" // @security ApiKeyAuth -// @router /jobs/delete_job_before/{ts} [delete] +// @router /api/jobs/delete_job_before/{ts} [delete] func (api *RestApi) deleteJobBefore(rw http.ResponseWriter, r *http.Request) { var cnt int // Fetch job (that will be stopped) from db @@ -1110,7 +1108,7 @@ func (api *RestApi) getJobMetrics(rw http.ResponseWriter, r *http.Request) { // @failure 422 {string} string "Unprocessable Entity: creating user failed" // @failure 500 {string} string "Internal Server Error" // @security ApiKeyAuth -// @router /users/ [post] +// @router /config/users/ [post] func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { err := securedCheck(r) if err != nil { @@ -1174,7 +1172,7 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { // @failure 422 {string} string "Unprocessable Entity: deleting user failed" // @failure 500 {string} string "Internal Server Error" // @security ApiKeyAuth -// @router /users/ [delete] +// @router /config/users/ [delete] func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) { err := securedCheck(r) if err != nil { @@ -1210,7 +1208,7 @@ func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) { // @failure 403 {string} string "Forbidden" // @failure 500 {string} string "Internal Server Error" // @security ApiKeyAuth -// @router /users/ [get] +// @router /config/users/ [get] func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { err := securedCheck(r) if err != nil { @@ -1252,7 +1250,7 @@ func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { // @failure 422 {string} string "Unprocessable Entity: The user could not be updated" // @failure 500 {string} string "Internal Server Error" // @security ApiKeyAuth -// @router /user/{id} [post] +// @router /config/user/{id} [post] func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { err := securedCheck(r) if err != nil { @@ -1317,7 +1315,7 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { // @failure 422 {string} string "Unprocessable Entity: The user could not be updated" // @failure 500 {string} string "Internal Server Error" // @security ApiKeyAuth -// @router /notice/ [post] +// @router /config/notice/ [post] func (api *RestApi) editNotice(rw http.ResponseWriter, r *http.Request) { err := securedCheck(r) if err != nil { From 317f80a9846ddda13a5cca68d29bba5ca7619d8f Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 9 Apr 2025 09:40:52 +0200 Subject: [PATCH 03/15] fix: Replace deprecated gqlgen NewDefaultServer call --- cmd/cc-backend/server.go | 21 +++++++++++++++++---- internal/api/rest.go | 2 +- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/cmd/cc-backend/server.go b/cmd/cc-backend/server.go index 1408162..3c19730 100644 --- a/cmd/cc-backend/server.go +++ b/cmd/cc-backend/server.go @@ -18,6 +18,7 @@ import ( "time" "github.com/99designs/gqlgen/graphql/handler" + "github.com/99designs/gqlgen/graphql/handler/transport" "github.com/99designs/gqlgen/graphql/playground" "github.com/ClusterCockpit/cc-backend/internal/api" "github.com/ClusterCockpit/cc-backend/internal/archiver" @@ -31,6 +32,7 @@ import ( "github.com/ClusterCockpit/cc-backend/web" "github.com/gorilla/handlers" "github.com/gorilla/mux" + "github.com/gorilla/websocket" httpSwagger "github.com/swaggo/http-swagger" ) @@ -53,13 +55,24 @@ func serverInit() { // Setup the http.Handler/Router used by the server graph.Init() resolver := graph.GetResolverInstance() - graphQLEndpoint := handler.NewDefaultServer( + graphQLServer := handler.New( generated.NewExecutableSchema(generated.Config{Resolvers: resolver})) + graphQLServer.AddTransport(transport.SSE{}) + graphQLServer.AddTransport(transport.POST{}) + graphQLServer.AddTransport(transport.Websocket{ + KeepAlivePingInterval: 10 * time.Second, + Upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, + }, + }) + if os.Getenv("DEBUG") != "1" { // Having this handler means that a error message is returned via GraphQL instead of the connection simply beeing closed. // The problem with this is that then, no more stacktrace is printed to stderr. - graphQLEndpoint.SetRecoverFunc(func(ctx context.Context, err interface{}) error { + graphQLServer.SetRecoverFunc(func(ctx context.Context, err any) error { switch e := err.(type) { case string: return fmt.Errorf("MAIN > Panic: %s", e) @@ -78,7 +91,7 @@ func serverInit() { router = mux.NewRouter() buildInfo := web.Build{Version: version, Hash: commit, Buildtime: date} - info := map[string]interface{}{} + info := map[string]any{} info["hasOpenIDConnect"] = false if config.Keys.OpenIDConfig != nil { @@ -208,7 +221,7 @@ func serverInit() { router.PathPrefix("/swagger/").Handler(httpSwagger.Handler( httpSwagger.URL("http://" + config.Keys.Addr + "/swagger/doc.json"))).Methods(http.MethodGet) } - secured.Handle("/query", graphQLEndpoint) + secured.Handle("/query", graphQLServer) // Send a searchId and then reply with a redirect to a user, or directly send query to job table for jobid and project. secured.HandleFunc("/search", func(rw http.ResponseWriter, r *http.Request) { diff --git a/internal/api/rest.go b/internal/api/rest.go index 85b0d13..1ebe78e 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -214,7 +214,7 @@ func handleError(err error, statusCode int, rw http.ResponseWriter) { }) } -func decode(r io.Reader, val interface{}) error { +func decode(r io.Reader, val any) error { dec := json.NewDecoder(r) dec.DisallowUnknownFields() return dec.Decode(val) From 29ae2423f845a71f21b1a6f2453e8ad3de7fb3c0 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Wed, 16 Apr 2025 18:36:12 +0200 Subject: [PATCH 04/15] fix metricconfig pointer copy, add disabled metric card in jobView - skips disabled metrics in backend, see cc-backend tries to retrieve "removed" metrics #377 --- internal/metricdata/cc-metric-store.go | 28 ++++++++++++++++++++ pkg/archive/clusterConfig.go | 19 ++++++++++++-- web/frontend/src/Job.root.svelte | 36 ++++++++++++++++++-------- web/frontend/src/job/Metric.svelte | 2 -- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/internal/metricdata/cc-metric-store.go b/internal/metricdata/cc-metric-store.go index 9516e2b..7c84d93 100644 --- a/internal/metricdata/cc-metric-store.go +++ b/internal/metricdata/cc-metric-store.go @@ -302,6 +302,20 @@ func (ccms *CCMetricStore) buildQueries( continue } + // Skip if metric is removed for subcluster + if len(mc.SubClusters) != 0 { + isRemoved := false + for _, scConfig := range mc.SubClusters { + if scConfig.Name == job.SubCluster && scConfig.Remove == true { + isRemoved = true + break + } + } + if isRemoved { + continue + } + } + // Avoid duplicates... handledScopes := make([]schema.MetricScope, 0, 3) @@ -985,6 +999,20 @@ func (ccms *CCMetricStore) buildNodeQueries( continue } + // Skip if metric is removed for subcluster + if mc.SubClusters != nil { + isRemoved := false + for _, scConfig := range mc.SubClusters { + if scConfig.Name == subCluster && scConfig.Remove == true { + isRemoved = true + break + } + } + if isRemoved { + continue + } + } + // Avoid duplicates... handledScopes := make([]schema.MetricScope, 0, 3) diff --git a/pkg/archive/clusterConfig.go b/pkg/archive/clusterConfig.go index 72718d0..d53941b 100644 --- a/pkg/archive/clusterConfig.go +++ b/pkg/archive/clusterConfig.go @@ -68,8 +68,23 @@ func initClusterConfig() error { } for _, sc := range cluster.SubClusters { - newMetric := mc - newMetric.SubClusters = nil + newMetric := &schema.MetricConfig{ + Unit: mc.Unit, + Energy: mc.Energy, + Name: mc.Name, + Scope: mc.Scope, + Aggregation: mc.Aggregation, + Peak: mc.Peak, + Caution: mc.Caution, + Alert: mc.Alert, + Timestep: mc.Timestep, + Normal: mc.Normal, + LowerIsBetter: mc.LowerIsBetter, + } + + if mc.Footprint != "" { + newMetric.Footprint = mc.Footprint + } if cfg, ok := scLookup[sc.Name]; ok { if !cfg.Remove { diff --git a/web/frontend/src/Job.root.svelte b/web/frontend/src/Job.root.svelte index 0a2aa26..92d8bb2 100644 --- a/web/frontend/src/Job.root.svelte +++ b/web/frontend/src/Job.root.svelte @@ -128,14 +128,13 @@ const pendingMetrics = ( ccconfig[`job_view_selectedMetrics:${job.cluster}:${job.subCluster}`] || ccconfig[`job_view_selectedMetrics:${job.cluster}`] - ) || - $initq.data.globalMetrics - .reduce((names, gm) => { - if (gm.availability.find((av) => av.cluster === job.cluster && av.subClusters.includes(job.subCluster))) { - names.push(gm.name); - } - return names; - }, []) + ) || + $initq.data.globalMetrics.reduce((names, gm) => { + if (gm.availability.find((av) => av.cluster === job.cluster && av.subClusters.includes(job.subCluster))) { + names.push(gm.name); + } + return names; + }, []) // Select default Scopes to load: Check before if any metric has accelerator scope by default const accScopeDefault = [...pendingMetrics].some(function (m) { @@ -338,10 +337,25 @@ scopes={item.data.map((x) => x.scope)} isShared={$initq.data.job.exclusive != 1} /> + {:else if item.disabled == true} + + + Disabled Metric + + +

Metric {item.metric} is disabled for subcluster {$initq.data.job.subCluster}.

+

To remove this card, open metric selection and press "Close and Apply".

+
+
{:else} - No dataset returned for {item.metric} + + + Missing Metric + + +

No dataset returned for {item.metric}.

+
+
{/if} {/if} diff --git a/web/frontend/src/job/Metric.svelte b/web/frontend/src/job/Metric.svelte index b68ef47..63a9b80 100644 --- a/web/frontend/src/job/Metric.svelte +++ b/web/frontend/src/job/Metric.svelte @@ -14,7 +14,6 @@ diff --git a/web/frontend/src/systems/nodelist/NodeListRow.svelte b/web/frontend/src/systems/nodelist/NodeListRow.svelte index 5202573..ee8ef49 100644 --- a/web/frontend/src/systems/nodelist/NodeListRow.svelte +++ b/web/frontend/src/systems/nodelist/NodeListRow.svelte @@ -14,7 +14,7 @@ getContextClient, } from "@urql/svelte"; import { Card, CardBody, Spinner } from "@sveltestrap/sveltestrap"; - import { maxScope, checkMetricDisabled } from "../../generic/utils.js"; + import { maxScope, checkMetricDisabled, scramble, scrambleNames } from "../../generic/utils.js"; import MetricPlot from "../../generic/plots/MetricPlot.svelte"; import NodeInfo from "./NodeInfo.svelte"; @@ -110,9 +110,12 @@ extendedLegendData = {} for (const accId of accSet) { const matchJob = $nodeJobsData.data.jobs.items.find((i) => i.resources.find((r) => r.accelerators.includes(accId))) + const matchUser = matchJob?.user ? matchJob.user : null extendedLegendData[accId] = { - user: matchJob?.user ? matchJob?.user : '-', - job: matchJob?.jobId ? matchJob?.jobId : '-', + user: (scrambleNames && matchUser) + ? scramble(matchUser) + : (matchUser ? matchUser : '-'), + job: matchJob?.jobId ? matchJob.jobId : '-', } } // Theoretically extendable for hwthreadIDs From 9bcf7adb67daa30c0c87a1e0dbaa10e87714716d Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Thu, 17 Apr 2025 17:31:59 +0200 Subject: [PATCH 06/15] add api calls for removing tags, initial branch commit --- internal/api/rest.go | 116 ++++++++++++++++++++++++++++++++++++ internal/repository/tags.go | 98 +++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 1 deletion(-) diff --git a/internal/api/rest.go b/internal/api/rest.go index db9a860..89bdd5e 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -750,6 +750,122 @@ 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 + } + + // remainingTags := job.Tags[:0] + // for _, tag := range job.Tags { + // if tag.Type != rtag.Type && + // tag.Name != rtag.Name && + // tag.Scope != rtag.Scope { + // remainingTags = append(remainingTags, tag) + // } + // } + 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 /jobs/tag_job/ [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 of %d", currentCount, targetCount))) +} + // startJob godoc // @summary Adds a new job as "running" // @tags Job add and modify diff --git a/internal/repository/tags.go b/internal/repository/tags.go index 8120364..3a35b34 100644 --- a/internal/repository/tags.go +++ b/internal/repository/tags.go @@ -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 its 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,76 @@ 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, err := r.loadTagIDByInfo(tagName, tagType, tagScope) + if err != nil { + log.Warn("Error while finding tagId with: %s, %s, %s", tagName, tagType, tagScope) + return nil, err + } + + // 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, err := r.loadTagIDByInfo(tagName, tagType, tagScope) + if err != nil { + log.Warn("Error while finding tagId with: %s, %s, %s", tagName, tagType, tagScope) + return err + } + + // 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 @@ -325,3 +395,29 @@ func (r *JobRepository) checkScopeAuth(user *schema.User, operation string, scop return false, fmt.Errorf("error while checking tag operation auth: no user in context") } } + +func (r *JobRepository) loadTagIDByInfo(tagType string, tagName string, tagScope string) (tagID int64, err error) { + // Get Tag ID to delete + getq := sq.Select("id").From("tag"). + Where("tag_type = ?", tagType). + Where("tag_name = ?", tagName). + Where("tag_scope = ?", tagScope) + + rows, err := getq.RunWith(r.stmtCache).Query() + if err != nil { + s, _, _ := getq.ToSql() + log.Errorf("Error get tags for delete with %s: %v", s, err) + return 0, err + } + + dbTags := make([]*schema.Tag, 0) + for rows.Next() { + dbTag := &schema.Tag{} + if err := rows.Scan(&dbTag.ID); err != nil { + log.Warn("Error while scanning rows") + return 0, err + } + } + + return dbTags[0].ID, nil +} From 277f964b30e76a7726fc75bd3673d1f947068627 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Tue, 22 Apr 2025 13:47:25 +0200 Subject: [PATCH 07/15] move taglist a from go tmpl to svelte component --- web/frontend/rollup.config.mjs | 1 + web/frontend/src/Tags.root.svelte | 50 +++++++++++++++++++++++++++ web/frontend/src/tags.entrypoint.js | 12 +++++++ web/templates/monitoring/taglist.tmpl | 46 ++++++------------------ 4 files changed, 74 insertions(+), 35 deletions(-) create mode 100644 web/frontend/src/Tags.root.svelte create mode 100644 web/frontend/src/tags.entrypoint.js diff --git a/web/frontend/rollup.config.mjs b/web/frontend/rollup.config.mjs index 8336287..0e15105 100644 --- a/web/frontend/rollup.config.mjs +++ b/web/frontend/rollup.config.mjs @@ -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'), diff --git a/web/frontend/src/Tags.root.svelte b/web/frontend/src/Tags.root.svelte new file mode 100644 index 0000000..4f7a34e --- /dev/null +++ b/web/frontend/src/Tags.root.svelte @@ -0,0 +1,50 @@ + + + + +
+
+
+ {#each Object.entries(tagmap) as [tagType, tagList]} +
+ Tag Type: {tagType} + + {tagList.length} Tag{(tagList.length != 1)?'s':''} + +
+ {#each tagList as tag (tag.id)} + {#if tag.scope == "global"} + + {tag.name} + {tag.count} Job{(tag.count != 1)?'s':''} + Global + + {:else if tag.scope == "admin"} + + {tag.name} + {tag.count} Job{(tag.count != 1)?'s':''} + Admin + + {:else} + + {tag.name} + {tag.count} Job{(tag.count != 1)?'s':''} + Private + + {/if} + {/each} + {/each} +
+
+
diff --git a/web/frontend/src/tags.entrypoint.js b/web/frontend/src/tags.entrypoint.js new file mode 100644 index 0000000..14df2f9 --- /dev/null +++ b/web/frontend/src/tags.entrypoint.js @@ -0,0 +1,12 @@ +import {} from './header.entrypoint.js' +import Tags from './Tags.root.svelte' + +new Tags({ + target: document.getElementById('svelte-app'), + props: { + // authlevel: authlevel, + tagmap: tagmap, + } +}) + + diff --git a/web/templates/monitoring/taglist.tmpl b/web/templates/monitoring/taglist.tmpl index 7d762c3..4388e94 100644 --- a/web/templates/monitoring/taglist.tmpl +++ b/web/templates/monitoring/taglist.tmpl @@ -1,37 +1,13 @@ {{define "content"}} -
-
-
- {{ range $tagType, $tagList := .Infos.tagmap }} -
- Tag Type: {{ $tagType }} - - {{len $tagList}} Tag{{if ne (len $tagList) 1}}s{{end}} - -
- {{ range $tagList }} - {{if eq .scope "global"}} - - {{ .name }} - {{ .count }} Job{{if ne .count 1}}s{{end}} - Global - - {{else if eq .scope "admin"}} - - {{ .name }} - {{ .count }} Job{{if ne .count 1}}s{{end}} - Admin - - {{else}} - - {{ .name }} - {{ .count }} Job{{if ne .count 1}}s{{end}} - Private - - {{end}} - {{end}} - {{end}} -
-
-
+
+{{end}} +{{define "stylesheets"}} + +{{end}} +{{define "javascript"}} + + {{end}} From a3fb47154627d4b8dcaacf121a29053527388081 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Tue, 22 Apr 2025 17:33:17 +0200 Subject: [PATCH 08/15] adapt and improve svelte taglist component --- web/frontend/src/Tags.root.svelte | 163 ++++++++++++++++++++------ web/frontend/src/tags.entrypoint.js | 8 +- web/templates/monitoring/taglist.tmpl | 4 +- 3 files changed, 137 insertions(+), 38 deletions(-) diff --git a/web/frontend/src/Tags.root.svelte b/web/frontend/src/Tags.root.svelte index 4f7a34e..52288c9 100644 --- a/web/frontend/src/Tags.root.svelte +++ b/web/frontend/src/Tags.root.svelte @@ -2,49 +2,142 @@ @component Tag List Svelte Component. Displays All Tags, Allows deletion. Properties: - - `authlevel Int!`: Current Users Authority Level - - `tagmap Object!`: Map of Appwide Tags + - `username String!`: Users username. + - `isAdmin Bool!`: User has Admin Auth. + - `tagmap Object!`: Map of accessible, appwide tags. Prefiltered in backend. -->
-
-
- {#each Object.entries(tagmap) as [tagType, tagList]} -
- Tag Type: {tagType} - - {tagList.length} Tag{(tagList.length != 1)?'s':''} - -
- {#each tagList as tag (tag.id)} - {#if tag.scope == "global"} - - {tag.name} - {tag.count} Job{(tag.count != 1)?'s':''} - Global - - {:else if tag.scope == "admin"} - - {tag.name} - {tag.count} Job{(tag.count != 1)?'s':''} - Admin - - {:else} - - {tag.name} - {tag.count} Job{(tag.count != 1)?'s':''} - Private - - {/if} - {/each} - {/each} +
+
+ {#each Object.entries(tagmap) as [tagType, tagList]} +
+ Tag Type: {tagType} + {#if pendingChange === tagType} + + {/if} + + {tagList.length} Tag{(tagList.length != 1)?'s':''} +
+
+ {#each tagList as tag (tag.id)} + {#if tag.scope == "global"} + + + {#if isAdmin} + + {/if} + + {:else if tag.scope == "admin"} + + + {#if isAdmin} + + {/if} + + {:else} + + + {#if tag.scope == username} + + {/if} + + {/if} + {/each} +
+ {/each}
+
diff --git a/web/frontend/src/tags.entrypoint.js b/web/frontend/src/tags.entrypoint.js index 14df2f9..024a92d 100644 --- a/web/frontend/src/tags.entrypoint.js +++ b/web/frontend/src/tags.entrypoint.js @@ -4,9 +4,13 @@ import Tags from './Tags.root.svelte' new Tags({ target: document.getElementById('svelte-app'), props: { - // authlevel: authlevel, + username: username, + isAdmin: isAdmin, tagmap: tagmap, - } + }, + context: new Map([ + ['cc-config', clusterCockpitConfig] + ]) }) diff --git a/web/templates/monitoring/taglist.tmpl b/web/templates/monitoring/taglist.tmpl index 4388e94..66122fe 100644 --- a/web/templates/monitoring/taglist.tmpl +++ b/web/templates/monitoring/taglist.tmpl @@ -6,8 +6,10 @@ {{end}} {{define "javascript"}} {{end}} From 543ddf540ea540bf0c3dd5f550593cc096ca224a Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Wed, 23 Apr 2025 14:51:01 +0200 Subject: [PATCH 09/15] implement removeTagFromList mutation, add tag mutation access checks --- api/schema.graphqls | 1 + go.mod | 1 + internal/graph/generated/generated.go | 105 ++++++++++++++++++++++ internal/graph/schema.resolvers.go | 124 +++++++++++++++++++++++--- internal/repository/tags.go | 73 ++++++++------- pkg/schema/user.go | 8 +- web/frontend/src/Tags.root.svelte | 20 ++--- 7 files changed, 272 insertions(+), 60 deletions(-) diff --git a/api/schema.graphqls b/api/schema.graphqls index ed8843c..9092b4f 100644 --- a/api/schema.graphqls +++ b/api/schema.graphqls @@ -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 } diff --git a/go.mod b/go.mod index 2e2aa36..47e3497 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,7 @@ module github.com/ClusterCockpit/cc-backend go 1.23.5 + toolchain go1.24.1 require ( diff --git a/internal/graph/generated/generated.go b/internal/graph/generated/generated.go index e5c9ca2..5dbdfd9 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -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) diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index 029be87..46f485b 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -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.Warn("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.Warn("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.Warn("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.Warn("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.Warn("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.Warn("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.Warn("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 { diff --git a/internal/repository/tags.go b/internal/repository/tags.go index 3a35b34..5712c94 100644 --- a/internal/repository/tags.go +++ b/internal/repository/tags.go @@ -79,10 +79,10 @@ func (r *JobRepository) RemoveTag(user *schema.User, job, tag int64) ([]*schema. // 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, err := r.loadTagIDByInfo(tagName, tagType, tagScope) - if err != nil { - log.Warn("Error while finding tagId with: %s, %s, %s", tagName, tagType, tagScope) - return nil, err + tagID, exists := r.TagId(tagType, tagName, tagScope) + if !exists { + log.Warn("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 @@ -119,12 +119,35 @@ func (r *JobRepository) RemoveJobTagByRequest(user *schema.User, job int64, tagT // 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, err := r.loadTagIDByInfo(tagName, tagType, tagScope) - if err != nil { - log.Warn("Error while finding tagId with: %s, %s, %s", tagName, tagType, tagScope) + tagID, exists := r.TagId(tagType, tagName, tagScope) + if !exists { + log.Warn("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 info +func (r *JobRepository) RemoveTagById(tagID int64) error { // Handle Delete JobTagTable qJobTag := sq.Delete("jobtag").Where("jobtag.tag_id = ?", tagID) @@ -279,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") @@ -395,29 +428,3 @@ func (r *JobRepository) checkScopeAuth(user *schema.User, operation string, scop return false, fmt.Errorf("error while checking tag operation auth: no user in context") } } - -func (r *JobRepository) loadTagIDByInfo(tagType string, tagName string, tagScope string) (tagID int64, err error) { - // Get Tag ID to delete - getq := sq.Select("id").From("tag"). - Where("tag_type = ?", tagType). - Where("tag_name = ?", tagName). - Where("tag_scope = ?", tagScope) - - rows, err := getq.RunWith(r.stmtCache).Query() - if err != nil { - s, _, _ := getq.ToSql() - log.Errorf("Error get tags for delete with %s: %v", s, err) - return 0, err - } - - dbTags := make([]*schema.Tag, 0) - for rows.Next() { - dbTag := &schema.Tag{} - if err := rows.Scan(&dbTag.ID); err != nil { - log.Warn("Error while scanning rows") - return 0, err - } - } - - return dbTags[0].ID, nil -} diff --git a/pkg/schema/user.go b/pkg/schema/user.go index c004254..9b62cfa 100644 --- a/pkg/schema/user.go +++ b/pkg/schema/user.go @@ -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 { diff --git a/web/frontend/src/Tags.root.svelte b/web/frontend/src/Tags.root.svelte index 52288c9..dc156e3 100644 --- a/web/frontend/src/Tags.root.svelte +++ b/web/frontend/src/Tags.root.svelte @@ -37,13 +37,8 @@ return mutationStore({ client: client, query: gql` - mutation ($job: ID!, $tagIds: [ID!]!) { - removeTag(tagIds: $tagIds) { - id - type - name - scope - } + mutation ($tagIds: [ID!]!) { + removeTagFromList(tagIds: $tagIds) } `, variables: { tagIds }, @@ -55,7 +50,13 @@ removeTagMutation({tagIds: [tag.id] }).subscribe( (res) => { if (res.fetching === false && !res.error) { - tagmap = res.data.removeTag; + // console.log('Removed:', res.data.removeTagFromList) + // console.log('Targets:', tagType, tagmap[tagType]) + // console.log('Filter:', tagmap[tagType].filter((t) => !res.data.removeTagFromList.includes(t.id))) + 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; @@ -63,9 +64,6 @@ }, ); } - - $: console.log(username, isAdmin) - $: console.log(pendingChange, tagmap)
From 1b3a12a4dcf1fcff6ea19680abb9f74d61062ce2 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Wed, 23 Apr 2025 15:01:12 +0200 Subject: [PATCH 10/15] feat: add remove functionality to tag view, add confirm alert --- web/frontend/src/Tags.root.svelte | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/web/frontend/src/Tags.root.svelte b/web/frontend/src/Tags.root.svelte index dc156e3..441134a 100644 --- a/web/frontend/src/Tags.root.svelte +++ b/web/frontend/src/Tags.root.svelte @@ -46,23 +46,22 @@ }; function removeTag(tag, tagType) { - pendingChange = tagType; - removeTagMutation({tagIds: [tag.id] }).subscribe( - (res) => { - if (res.fetching === false && !res.error) { - // console.log('Removed:', res.data.removeTagFromList) - // console.log('Targets:', tagType, tagmap[tagType]) - // console.log('Filter:', tagmap[tagType].filter((t) => !res.data.removeTagFromList.includes(t.id))) - tagmap[tagType] = tagmap[tagType].filter((t) => !res.data.removeTagFromList.includes(t.id)); - if (tagmap[tagType].length === 0) { - delete tagmap[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; } - pendingChange = "none"; - } else if (res.fetching === false && res.error) { - throw res.error; - } - }, - ); + }, + ); + } } From 48fa75386c4f1a6c145f34a7a197e7c2a470f88a Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Wed, 23 Apr 2025 16:12:56 +0200 Subject: [PATCH 11/15] feat: add tag removal api endpoints --- internal/api/rest.go | 14 ++++---------- internal/graph/schema.resolvers.go | 14 +++++++------- internal/repository/tags.go | 4 ++-- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/internal/api/rest.go b/internal/api/rest.go index 89bdd5e..78cf276 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -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 != "" { @@ -805,14 +807,6 @@ func (api *RestApi) removeTagJob(rw http.ResponseWriter, r *http.Request) { return } - // remainingTags := job.Tags[:0] - // for _, tag := range job.Tags { - // if tag.Type != rtag.Type && - // tag.Name != rtag.Name && - // tag.Scope != rtag.Scope { - // remainingTags = append(remainingTags, tag) - // } - // } job.Tags = remainingTags } @@ -836,7 +830,7 @@ func (api *RestApi) removeTagJob(rw http.ResponseWriter, r *http.Request) { // @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/ [delete] +// @router /tags/ [delete] func (api *RestApi) removeTags(rw http.ResponseWriter, r *http.Request) { var req TagJobApiRequest if err := decode(r.Body, &req); err != nil { @@ -863,7 +857,7 @@ func (api *RestApi) removeTags(rw http.ResponseWriter, r *http.Request) { } rw.WriteHeader(http.StatusOK) - rw.Write([]byte(fmt.Sprintf("Deleted Tags from DB: %d of %d", currentCount, targetCount))) + rw.Write([]byte(fmt.Sprintf("Deleted Tags from DB: %d successfull of %d requested\n", currentCount, targetCount))) } // startJob godoc diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index 46f485b..10e1b55 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -142,7 +142,7 @@ func (r *mutationResolver) CreateTag(ctx context.Context, typeArg string, name s } return &schema.Tag{ID: id, Type: typeArg, Name: name, Scope: scope}, nil } else { - log.Warn("Not authorized to create tag with scope: %s", scope) + log.Warnf("Not authorized to create tag with scope: %s", scope) return nil, fmt.Errorf("Not authorized to create tag with scope: %s", scope) } } @@ -178,7 +178,7 @@ func (r *mutationResolver) AddTagsToJob(ctx context.Context, job string, tagIds // Test Exists _, _, tscope, exists := r.Repo.TagInfo(tid) if !exists { - log.Warn("Tag does not exist (ID): %d", tid) + log.Warnf("Tag does not exist (ID): %d", tid) return nil, fmt.Errorf("Tag does not exist (ID): %d", tid) } @@ -192,7 +192,7 @@ func (r *mutationResolver) AddTagsToJob(ctx context.Context, job string, tagIds return nil, err } } else { - log.Warn("Not authorized to add tag: %d", tid) + log.Warnf("Not authorized to add tag: %d", tid) return nil, fmt.Errorf("Not authorized to add tag: %d", tid) } } @@ -225,7 +225,7 @@ func (r *mutationResolver) RemoveTagsFromJob(ctx context.Context, job string, ta // Test Exists _, _, tscope, exists := r.Repo.TagInfo(tid) if !exists { - log.Warn("Tag does not exist (ID): %d", tid) + log.Warnf("Tag does not exist (ID): %d", tid) return nil, fmt.Errorf("Tag does not exist (ID): %d", tid) } @@ -239,7 +239,7 @@ func (r *mutationResolver) RemoveTagsFromJob(ctx context.Context, job string, ta return nil, err } } else { - log.Warn("Not authorized to remove tag: %d", tid) + log.Warnf("Not authorized to remove tag: %d", tid) return nil, fmt.Errorf("Not authorized to remove tag: %d", tid) } @@ -268,7 +268,7 @@ func (r *mutationResolver) RemoveTagFromList(ctx context.Context, tagIds []strin // Test Exists _, _, tscope, exists := r.Repo.TagInfo(tid) if !exists { - log.Warn("Tag does not exist (ID): %d", tid) + log.Warnf("Tag does not exist (ID): %d", tid) return nil, fmt.Errorf("Tag does not exist (ID): %d", tid) } @@ -282,7 +282,7 @@ func (r *mutationResolver) RemoveTagFromList(ctx context.Context, tagIds []strin tags = append(tags, int(tid)) } } else { - log.Warn("Not authorized to remove tag: %d", tid) + log.Warnf("Not authorized to remove tag: %d", tid) return nil, fmt.Errorf("Not authorized to remove tag: %d", tid) } } diff --git a/internal/repository/tags.go b/internal/repository/tags.go index 5712c94..db44dbc 100644 --- a/internal/repository/tags.go +++ b/internal/repository/tags.go @@ -81,7 +81,7 @@ func (r *JobRepository) RemoveJobTagByRequest(user *schema.User, job int64, tagT // Get Tag ID to delete tagID, exists := r.TagId(tagType, tagName, tagScope) if !exists { - log.Warn("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope) + 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) } @@ -121,7 +121,7 @@ func (r *JobRepository) RemoveTagByRequest(tagType string, tagName string, tagSc // Get Tag ID to delete tagID, exists := r.TagId(tagType, tagName, tagScope) if !exists { - log.Warn("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope) + 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) } From e3653daea3a792bf50a7d65381b1d511d83d8966 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Wed, 23 Apr 2025 17:59:26 +0200 Subject: [PATCH 12/15] reduce code in tag svelte view --- internal/repository/tags.go | 4 +- web/frontend/src/Tags.root.svelte | 66 +++++++++---------------------- 2 files changed, 20 insertions(+), 50 deletions(-) diff --git a/internal/repository/tags.go b/internal/repository/tags.go index db44dbc..544163e 100644 --- a/internal/repository/tags.go +++ b/internal/repository/tags.go @@ -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 by its ID +// 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 { @@ -146,7 +146,7 @@ func (r *JobRepository) RemoveTagByRequest(tagType string, tagName string, tagSc return nil } -// Removes a tag from db by tag info +// 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) diff --git a/web/frontend/src/Tags.root.svelte b/web/frontend/src/Tags.root.svelte index 441134a..03311b4 100644 --- a/web/frontend/src/Tags.root.svelte +++ b/web/frontend/src/Tags.root.svelte @@ -80,58 +80,28 @@
{#each tagList as tag (tag.id)} - {#if tag.scope == "global"} - - - {#if isAdmin} - - {/if} - - {:else if tag.scope == "admin"} - - - {#if isAdmin} - - {/if} - - {:else} - - - {#if tag.scope == username} - {/if} - - {/if} + + {#if (isAdmin && (tag.scope == "admin" || tag.scope == "global")) || tag.scope == username } + + {/if} + {/each}
{/each} From 94a39fc61f44a021ebfe0e4a288caf5e2fb0ed36 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Thu, 24 Apr 2025 10:53:55 +0200 Subject: [PATCH 13/15] Readd tag endpoints --- internal/api/rest.go | 111 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/internal/api/rest.go b/internal/api/rest.go index 0fa4611..7029d9d 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -82,12 +82,15 @@ 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) + if api.MachineStateDir != "" { r.HandleFunc("/machine_state/{cluster}/{host}", api.getMachineState).Methods(http.MethodGet) r.HandleFunc("/machine_state/{cluster}/{host}", api.putMachineState).Methods(http.MethodPut, http.MethodPost) @@ -713,6 +716,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 From 570eba37947636bcc383ee8aa9213e54c88f9585 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Thu, 24 Apr 2025 11:01:13 +0200 Subject: [PATCH 14/15] Cleanup Swagger docs --- internal/api/rest.go | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/internal/api/rest.go b/internal/api/rest.go index 7029d9d..8a21e68 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -1228,21 +1228,6 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { fmt.Fprintf(rw, "User %v successfully created!\n", username) } -// deleteUser godoc -// @summary Deletes a user -// @tags User -// @description User defined by username in form data will be deleted from database. -// @accept mpfd -// @produce plain -// @param username formData string true "User ID to delete" -// @success 200 "User deleted successfully" -// @failure 400 {string} string "Bad Request" -// @failure 401 {string} string "Unauthorized" -// @failure 403 {string} string "Forbidden" -// @failure 422 {string} string "Unprocessable Entity: deleting user failed" -// @failure 500 {string} string "Internal Server Error" -// @security ApiKeyAuth -// @router /users/ [delete] func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) { // SecuredCheck() only worked with TokenAuth: Removed @@ -1291,26 +1276,6 @@ func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { json.NewEncoder(rw).Encode(users) } -// updateUser godoc -// @summary Updates an existing user -// @tags User -// @description Modifies user defined by username (id) in one of four possible ways. -// @description If more than one formValue is set then only the highest priority field is used. -// @accept mpfd -// @produce plain -// @param id path string true "Database ID of User" -// @param add-role formData string false "Priority 1: Role to add" Enums(admin, support, manager, user, api) -// @param remove-role formData string false "Priority 2: Role to remove" Enums(admin, support, manager, user, api) -// @param add-project formData string false "Priority 3: Project to add" -// @param remove-project formData string false "Priority 4: Project to remove" -// @success 200 {string} string "Success Response Message" -// @failure 400 {string} string "Bad Request" -// @failure 401 {string} string "Unauthorized" -// @failure 403 {string} string "Forbidden" -// @failure 422 {string} string "Unprocessable Entity: The user could not be updated" -// @failure 500 {string} string "Internal Server Error" -// @security ApiKeyAuth -// @router /user/{id} [post] func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { // SecuredCheck() only worked with TokenAuth: Removed From 65df27154c3f26d1748c2ca160af43f6abbc283d Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Thu, 24 Apr 2025 11:14:51 +0200 Subject: [PATCH 15/15] Cleanup and regenerate Swagger docs --- api/swagger.json | 396 +++++++++++-------------------------------- api/swagger.yaml | 279 ++++++++---------------------- internal/api/docs.go | 396 +++++++++++-------------------------------- internal/api/rest.go | 38 +---- 4 files changed, 269 insertions(+), 840 deletions(-) diff --git a/api/swagger.json b/api/swagger.json index 683b520..c05ec77 100644 --- a/api/swagger.json +++ b/api/swagger.json @@ -826,185 +826,14 @@ } } }, - "/config/notice/": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Modifies the content of notice.txt, shown as notice box on the homepage.\nIf more than one formValue is set then only the highest priority field is used.\nOnly accessible from IPs registered with apiAllowedIPs configuration option.", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "text/plain" - ], - "tags": [ - "User" - ], - "summary": "Updates or empties the notice box content", - "parameters": [ - { - "type": "string", - "description": "Priority 1: New content to display", - "name": "new-content", - "in": "formData" - } - ], - "responses": { - "200": { - "description": "Success Response Message", - "schema": { - "type": "string" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "type": "string" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "type": "string" - } - }, - "422": { - "description": "Unprocessable Entity: The user could not be updated", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, - "/config/user/{id}": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Modifies user defined by username (id) in one of four possible ways.\nIf more than one formValue is set then only the highest priority field is used.\nOnly accessible from IPs registered with apiAllowedIPs configuration option.", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "text/plain" - ], - "tags": [ - "User" - ], - "summary": "Updates an existing user", - "parameters": [ - { - "type": "string", - "description": "Database ID of User", - "name": "id", - "in": "path", - "required": true - }, - { - "enum": [ - "admin", - "support", - "manager", - "user", - "api" - ], - "type": "string", - "description": "Priority 1: Role to add", - "name": "add-role", - "in": "formData" - }, - { - "enum": [ - "admin", - "support", - "manager", - "user", - "api" - ], - "type": "string", - "description": "Priority 2: Role to remove", - "name": "remove-role", - "in": "formData" - }, - { - "type": "string", - "description": "Priority 3: Project to add", - "name": "add-project", - "in": "formData" - }, - { - "type": "string", - "description": "Priority 4: Project to remove", - "name": "remove-project", - "in": "formData" - } - ], - "responses": { - "200": { - "description": "Success Response Message", - "schema": { - "type": "string" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "type": "string" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "type": "string" - } - }, - "422": { - "description": "Unprocessable Entity: The user could not be updated", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, - "/config/users/": { + "/api/users/": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a JSON-encoded list of users.\nRequired query-parameter defines if all users or only users with additional special roles are returned.\nOnly accessible from IPs registered with apiAllowedIPs configuration option.", + "description": "Returns a JSON-encoded list of users.\nRequired query-parameter defines if all users or only users with additional special roles are returned.", "produces": [ "application/json" ], @@ -1056,70 +885,111 @@ } } } - }, - "post": { + } + }, + "/jobs/tag_job/{id}": { + "delete": { "security": [ { "ApiKeyAuth": [] } ], - "description": "User specified in form data will be saved to database.\nOnly accessible from IPs registered with apiAllowedIPs configuration option.", + "description": "Removes tag(s) from a job specified by DB ID. Name and Type of Tag(s) must match.\nTag Scope is required for matching, options: \"global\", \"admin\". Private tags can not be deleted via API.\nIf tagged job is already finished: Tag will be removed from respective archive files.", "consumes": [ - "multipart/form-data" + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Job add and modify" + ], + "summary": "Removes one or more tags from a job", + "parameters": [ + { + "type": "integer", + "description": "Job Database ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Array of tag-objects to remove", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.ApiTag" + } + } + } + ], + "responses": { + "200": { + "description": "Updated job resource", + "schema": { + "$ref": "#/definitions/schema.Job" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "Job or tag does not exist", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + } + }, + "/tags/": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Removes tags by type and name. Name and Type of Tag(s) must match.\nTag Scope is required for matching, options: \"global\", \"admin\". Private tags can not be deleted via API.\nTag wills be removed from respective archive files.", + "consumes": [ + "application/json" ], "produces": [ "text/plain" ], "tags": [ - "User" + "Tag remove" ], - "summary": "Adds a new user", + "summary": "Removes all tags and job-relations for type:name tuple", "parameters": [ { - "type": "string", - "description": "Unique user ID", - "name": "username", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "User password", - "name": "password", - "in": "formData", - "required": true - }, - { - "enum": [ - "admin", - "support", - "manager", - "user", - "api" - ], - "type": "string", - "description": "User role", - "name": "role", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Managed project, required for new manager role user", - "name": "project", - "in": "formData" - }, - { - "type": "string", - "description": "Users name", - "name": "name", - "in": "formData" - }, - { - "type": "string", - "description": "Users email", - "name": "email", - "in": "formData" + "description": "Array of tag-objects to remove", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.ApiTag" + } + } } ], "responses": { @@ -1132,93 +1002,25 @@ "400": { "description": "Bad Request", "schema": { - "type": "string" + "$ref": "#/definitions/api.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "type": "string" + "$ref": "#/definitions/api.ErrorResponse" } }, - "403": { - "description": "Forbidden", + "404": { + "description": "Job or tag does not exist", "schema": { - "type": "string" - } - }, - "422": { - "description": "Unprocessable Entity: creating user failed", - "schema": { - "type": "string" + "$ref": "#/definitions/api.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "type": "string" - } - } - } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "User defined by username in form data will be deleted from database.\nOnly accessible from IPs registered with apiAllowedIPs configuration option.", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "text/plain" - ], - "tags": [ - "User" - ], - "summary": "Deletes a user", - "parameters": [ - { - "type": "string", - "description": "User ID to delete", - "name": "username", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "User deleted successfully" - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "type": "string" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "type": "string" - } - }, - "422": { - "description": "Unprocessable Entity: deleting user failed", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" + "$ref": "#/definitions/api.ErrorResponse" } } } diff --git a/api/swagger.yaml b/api/swagger.yaml index 35ec6c4..26210be 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -1217,173 +1217,11 @@ paths: summary: Adds one or more tags to a job tags: - Job add and modify - /config/notice/: - post: - consumes: - - multipart/form-data - description: |- - Modifies the content of notice.txt, shown as notice box on the homepage. - If more than one formValue is set then only the highest priority field is used. - Only accessible from IPs registered with apiAllowedIPs configuration option. - parameters: - - description: 'Priority 1: New content to display' - in: formData - name: new-content - type: string - produces: - - text/plain - responses: - "200": - description: Success Response Message - schema: - type: string - "400": - description: Bad Request - schema: - type: string - "401": - description: Unauthorized - schema: - type: string - "403": - description: Forbidden - schema: - type: string - "422": - description: 'Unprocessable Entity: The user could not be updated' - schema: - type: string - "500": - description: Internal Server Error - schema: - type: string - security: - - ApiKeyAuth: [] - summary: Updates or empties the notice box content - tags: - - User - /config/user/{id}: - post: - consumes: - - multipart/form-data - description: |- - Modifies user defined by username (id) in one of four possible ways. - If more than one formValue is set then only the highest priority field is used. - Only accessible from IPs registered with apiAllowedIPs configuration option. - parameters: - - description: Database ID of User - in: path - name: id - required: true - type: string - - description: 'Priority 1: Role to add' - enum: - - admin - - support - - manager - - user - - api - in: formData - name: add-role - type: string - - description: 'Priority 2: Role to remove' - enum: - - admin - - support - - manager - - user - - api - in: formData - name: remove-role - type: string - - description: 'Priority 3: Project to add' - in: formData - name: add-project - type: string - - description: 'Priority 4: Project to remove' - in: formData - name: remove-project - type: string - produces: - - text/plain - responses: - "200": - description: Success Response Message - schema: - type: string - "400": - description: Bad Request - schema: - type: string - "401": - description: Unauthorized - schema: - type: string - "403": - description: Forbidden - schema: - type: string - "422": - description: 'Unprocessable Entity: The user could not be updated' - schema: - type: string - "500": - description: Internal Server Error - schema: - type: string - security: - - ApiKeyAuth: [] - summary: Updates an existing user - tags: - - User - /config/users/: - delete: - consumes: - - multipart/form-data - description: |- - User defined by username in form data will be deleted from database. - Only accessible from IPs registered with apiAllowedIPs configuration option. - parameters: - - description: User ID to delete - in: formData - name: username - required: true - type: string - produces: - - text/plain - responses: - "200": - description: User deleted successfully - "400": - description: Bad Request - schema: - type: string - "401": - description: Unauthorized - schema: - type: string - "403": - description: Forbidden - schema: - type: string - "422": - description: 'Unprocessable Entity: deleting user failed' - schema: - type: string - "500": - description: Internal Server Error - schema: - type: string - security: - - ApiKeyAuth: [] - summary: Deletes a user - tags: - - User + /api/users/: get: description: |- Returns a JSON-encoded list of users. Required query-parameter defines if all users or only users with additional special roles are returned. - Only accessible from IPs registered with apiAllowedIPs configuration option. parameters: - description: If returned list should contain all users or only users with additional special roles @@ -1421,46 +1259,73 @@ paths: summary: Returns a list of users tags: - User - post: + /jobs/tag_job/{id}: + delete: consumes: - - multipart/form-data + - application/json description: |- - User specified in form data will be saved to database. - Only accessible from IPs registered with apiAllowedIPs configuration option. + Removes tag(s) from a job specified by DB ID. Name and Type of Tag(s) must match. + Tag Scope is required for matching, options: "global", "admin". Private tags can not be deleted via API. + If tagged job is already finished: Tag will be removed from respective archive files. parameters: - - description: Unique user ID - in: formData - name: username + - description: Job Database ID + in: path + name: id required: true - type: string - - description: User password - in: formData - name: password + type: integer + - description: Array of tag-objects to remove + in: body + name: request required: true - type: string - - description: User role - enum: - - admin - - support - - manager - - user - - api - in: formData - name: role + schema: + items: + $ref: '#/definitions/api.ApiTag' + type: array + produces: + - application/json + responses: + "200": + description: Updated job resource + schema: + $ref: '#/definitions/schema.Job' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.ErrorResponse' + "404": + description: Job or tag does not exist + schema: + $ref: '#/definitions/api.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + security: + - ApiKeyAuth: [] + summary: Removes one or more tags from a job + tags: + - Job add and modify + /tags/: + delete: + consumes: + - application/json + description: |- + Removes tags by type and name. Name and Type of Tag(s) must match. + Tag Scope is required for matching, options: "global", "admin". Private tags can not be deleted via API. + Tag wills be removed from respective archive files. + parameters: + - description: Array of tag-objects to remove + in: body + name: request required: true - type: string - - description: Managed project, required for new manager role user - in: formData - name: project - type: string - - description: Users name - in: formData - name: name - type: string - - description: Users email - in: formData - name: email - type: string + schema: + items: + $ref: '#/definitions/api.ApiTag' + type: array produces: - text/plain responses: @@ -1471,28 +1336,24 @@ paths: "400": description: Bad Request schema: - type: string + $ref: '#/definitions/api.ErrorResponse' "401": description: Unauthorized schema: - type: string - "403": - description: Forbidden + $ref: '#/definitions/api.ErrorResponse' + "404": + description: Job or tag does not exist schema: - type: string - "422": - description: 'Unprocessable Entity: creating user failed' - schema: - type: string + $ref: '#/definitions/api.ErrorResponse' "500": description: Internal Server Error schema: - type: string + $ref: '#/definitions/api.ErrorResponse' security: - ApiKeyAuth: [] - summary: Adds a new user + summary: Removes all tags and job-relations for type:name tuple tags: - - User + - Tag remove securityDefinitions: ApiKeyAuth: in: header diff --git a/internal/api/docs.go b/internal/api/docs.go index 2408f85..c1cd391 100644 --- a/internal/api/docs.go +++ b/internal/api/docs.go @@ -833,185 +833,14 @@ const docTemplate = `{ } } }, - "/config/notice/": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Modifies the content of notice.txt, shown as notice box on the homepage.\nIf more than one formValue is set then only the highest priority field is used.\nOnly accessible from IPs registered with apiAllowedIPs configuration option.", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "text/plain" - ], - "tags": [ - "User" - ], - "summary": "Updates or empties the notice box content", - "parameters": [ - { - "type": "string", - "description": "Priority 1: New content to display", - "name": "new-content", - "in": "formData" - } - ], - "responses": { - "200": { - "description": "Success Response Message", - "schema": { - "type": "string" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "type": "string" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "type": "string" - } - }, - "422": { - "description": "Unprocessable Entity: The user could not be updated", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, - "/config/user/{id}": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Modifies user defined by username (id) in one of four possible ways.\nIf more than one formValue is set then only the highest priority field is used.\nOnly accessible from IPs registered with apiAllowedIPs configuration option.", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "text/plain" - ], - "tags": [ - "User" - ], - "summary": "Updates an existing user", - "parameters": [ - { - "type": "string", - "description": "Database ID of User", - "name": "id", - "in": "path", - "required": true - }, - { - "enum": [ - "admin", - "support", - "manager", - "user", - "api" - ], - "type": "string", - "description": "Priority 1: Role to add", - "name": "add-role", - "in": "formData" - }, - { - "enum": [ - "admin", - "support", - "manager", - "user", - "api" - ], - "type": "string", - "description": "Priority 2: Role to remove", - "name": "remove-role", - "in": "formData" - }, - { - "type": "string", - "description": "Priority 3: Project to add", - "name": "add-project", - "in": "formData" - }, - { - "type": "string", - "description": "Priority 4: Project to remove", - "name": "remove-project", - "in": "formData" - } - ], - "responses": { - "200": { - "description": "Success Response Message", - "schema": { - "type": "string" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "type": "string" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "type": "string" - } - }, - "422": { - "description": "Unprocessable Entity: The user could not be updated", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, - "/config/users/": { + "/api/users/": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a JSON-encoded list of users.\nRequired query-parameter defines if all users or only users with additional special roles are returned.\nOnly accessible from IPs registered with apiAllowedIPs configuration option.", + "description": "Returns a JSON-encoded list of users.\nRequired query-parameter defines if all users or only users with additional special roles are returned.", "produces": [ "application/json" ], @@ -1063,70 +892,111 @@ const docTemplate = `{ } } } - }, - "post": { + } + }, + "/jobs/tag_job/{id}": { + "delete": { "security": [ { "ApiKeyAuth": [] } ], - "description": "User specified in form data will be saved to database.\nOnly accessible from IPs registered with apiAllowedIPs configuration option.", + "description": "Removes tag(s) from a job specified by DB ID. Name and Type of Tag(s) must match.\nTag Scope is required for matching, options: \"global\", \"admin\". Private tags can not be deleted via API.\nIf tagged job is already finished: Tag will be removed from respective archive files.", "consumes": [ - "multipart/form-data" + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Job add and modify" + ], + "summary": "Removes one or more tags from a job", + "parameters": [ + { + "type": "integer", + "description": "Job Database ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Array of tag-objects to remove", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.ApiTag" + } + } + } + ], + "responses": { + "200": { + "description": "Updated job resource", + "schema": { + "$ref": "#/definitions/schema.Job" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "Job or tag does not exist", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + } + }, + "/tags/": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Removes tags by type and name. Name and Type of Tag(s) must match.\nTag Scope is required for matching, options: \"global\", \"admin\". Private tags can not be deleted via API.\nTag wills be removed from respective archive files.", + "consumes": [ + "application/json" ], "produces": [ "text/plain" ], "tags": [ - "User" + "Tag remove" ], - "summary": "Adds a new user", + "summary": "Removes all tags and job-relations for type:name tuple", "parameters": [ { - "type": "string", - "description": "Unique user ID", - "name": "username", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "User password", - "name": "password", - "in": "formData", - "required": true - }, - { - "enum": [ - "admin", - "support", - "manager", - "user", - "api" - ], - "type": "string", - "description": "User role", - "name": "role", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Managed project, required for new manager role user", - "name": "project", - "in": "formData" - }, - { - "type": "string", - "description": "Users name", - "name": "name", - "in": "formData" - }, - { - "type": "string", - "description": "Users email", - "name": "email", - "in": "formData" + "description": "Array of tag-objects to remove", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.ApiTag" + } + } } ], "responses": { @@ -1139,93 +1009,25 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "type": "string" + "$ref": "#/definitions/api.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "type": "string" + "$ref": "#/definitions/api.ErrorResponse" } }, - "403": { - "description": "Forbidden", + "404": { + "description": "Job or tag does not exist", "schema": { - "type": "string" - } - }, - "422": { - "description": "Unprocessable Entity: creating user failed", - "schema": { - "type": "string" + "$ref": "#/definitions/api.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "type": "string" - } - } - } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "User defined by username in form data will be deleted from database.\nOnly accessible from IPs registered with apiAllowedIPs configuration option.", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "text/plain" - ], - "tags": [ - "User" - ], - "summary": "Deletes a user", - "parameters": [ - { - "type": "string", - "description": "User ID to delete", - "name": "username", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "User deleted successfully" - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "type": "string" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "type": "string" - } - }, - "422": { - "description": "Unprocessable Entity: deleting user failed", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" + "$ref": "#/definitions/api.ErrorResponse" } } } diff --git a/internal/api/rest.go b/internal/api/rest.go index 2b2a3bd..669768e 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -1163,26 +1163,6 @@ func (api *RestApi) getJobMetrics(rw http.ResponseWriter, r *http.Request) { }) } -// createUser godoc -// @summary Adds a new user -// @tags User -// @description User specified in form data will be saved to database. -// @accept mpfd -// @produce plain -// @param username formData string true "Unique user ID" -// @param password formData string true "User password" -// @param role formData string true "User role" Enums(admin, support, manager, user, api) -// @param project formData string false "Managed project, required for new manager role user" -// @param name formData string false "Users name" -// @param email formData string false "Users email" -// @success 200 {string} string "Success Response" -// @failure 400 {string} string "Bad Request" -// @failure 401 {string} string "Unauthorized" -// @failure 403 {string} string "Forbidden" -// @failure 422 {string} string "Unprocessable Entity: creating user failed" -// @failure 500 {string} string "Internal Server Error" -// @security ApiKeyAuth -// @router /config/users/ [post] func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { // SecuredCheck() only worked with TokenAuth: Removed @@ -1257,7 +1237,7 @@ func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) { // @failure 403 {string} string "Forbidden" // @failure 500 {string} string "Internal Server Error" // @security ApiKeyAuth -// @router /config/users/ [get] +// @router /api/users/ [get] func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { // SecuredCheck() only worked with TokenAuth: Removed @@ -1319,22 +1299,6 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { } } -// editNotice godoc -// @summary Updates or empties the notice box content -// @tags User -// @description Modifies the content of notice.txt, shown as notice box on the homepage. -// @description If more than one formValue is set then only the highest priority field is used. -// @accept mpfd -// @produce plain -// @param new-content formData string false "Priority 1: New content to display" -// @success 200 {string} string "Success Response Message" -// @failure 400 {string} string "Bad Request" -// @failure 401 {string} string "Unauthorized" -// @failure 403 {string} string "Forbidden" -// @failure 422 {string} string "Unprocessable Entity: The user could not be updated" -// @failure 500 {string} string "Internal Server Error" -// @security ApiKeyAuth -// @router /notice/ [post] func (api *RestApi) editNotice(rw http.ResponseWriter, r *http.Request) { // SecuredCheck() only worked with TokenAuth: Removed