From d32b0f8b7ea831e0abed2bb6dcfe4f310017ca62 Mon Sep 17 00:00:00 2001 From: Steven Kang Date: Fri, 10 Jan 2025 13:21:27 +1300 Subject: [PATCH] feat(kubernetes): support for jobs and cron jobs - r8s-182 (#260) Co-authored-by: James Carppe <85850129+jamescarppe@users.noreply.github.com> Co-authored-by: Anthony Lapenna Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com> Co-authored-by: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com> Co-authored-by: Yajith Dayarathna Co-authored-by: LP B Co-authored-by: oscarzhou Co-authored-by: testA113 --- api/http/handler/kubernetes/cron_job.go | 78 ++++++ api/http/handler/kubernetes/handler.go | 4 + api/http/handler/kubernetes/job.go | 85 +++++++ api/http/models/kubernetes/cron_jobs.go | 36 +++ api/http/models/kubernetes/jobs.go | 44 ++++ api/kubernetes/cli/cronjob.go | 123 ++++++++++ api/kubernetes/cli/cronjob_test.go | 66 +++++ api/kubernetes/cli/job.go | 227 ++++++++++++++++++ api/kubernetes/cli/job_test.go | 64 +++++ api/kubernetes/cli/pod.go | 19 ++ app/kubernetes/__module.js | 14 ++ app/kubernetes/react/views/index.ts | 5 + .../kubernetes/applications/useCronJobs.ts | 4 +- app/react/kubernetes/applications/useJobs.ts | 4 +- .../CronJobsDatatable/CronJobsDatatable.tsx | 202 ++++++++++++++++ .../CronJobsExecutionsInnerDatatable.tsx | 47 ++++ .../CronJobsDatatable/columns/command.tsx | 7 + .../CronJobsDatatable/columns/expand.tsx | 10 + .../CronJobsDatatable/columns/helper.ts | 5 + .../CronJobsDatatable/columns/index.tsx | 17 ++ .../CronJobsDatatable/columns/name.tsx | 23 ++ .../CronJobsDatatable/columns/namespace.tsx | 32 +++ .../CronJobsDatatable/columns/schedule.tsx | 7 + .../CronJobsDatatable/columns/suspend.tsx | 10 + .../CronJobsDatatable/columns/timezone.tsx | 7 + .../JobsView/CronJobsDatatable/index.tsx | 1 + .../CronJobsDatatable/queries/query-keys.ts | 6 + .../CronJobsDatatable/queries/useCronJobs.ts | 38 +++ .../queries/useDeleteCronJobsMutation.ts | 34 +++ .../JobsView/CronJobsDatatable/types.ts | 13 + .../JobsView/JobsDatatable/JobsDatatable.tsx | 181 ++++++++++++++ .../JobsDatatable/columns/actions.tsx | 30 +++ .../JobsDatatable/columns/command.tsx | 7 + .../JobsDatatable/columns/duration.tsx | 7 + .../JobsDatatable/columns/finished.tsx | 7 + .../JobsView/JobsDatatable/columns/helper.ts | 5 + .../JobsView/JobsDatatable/columns/index.tsx | 19 ++ .../JobsView/JobsDatatable/columns/name.tsx | 23 ++ .../JobsDatatable/columns/namespace.tsx | 32 +++ .../JobsDatatable/columns/started.tsx | 12 + .../JobsDatatable/columns/status.module.css | 13 + .../JobsView/JobsDatatable/columns/status.tsx | 48 ++++ .../JobsView/JobsDatatable/index.tsx | 1 + .../JobsDatatable/queries/query-keys.ts | 6 + .../queries/useDeleteJobsMutation.ts | 31 +++ .../JobsView/JobsDatatable/queries/useJobs.ts | 38 +++ .../JobsView/JobsDatatable/types.ts | 18 ++ .../more-resources/JobsView/JobsView.tsx | 51 ++++ .../more-resources/JobsView/index.ts | 1 + .../KubernetesSidebar/KubernetesSidebar.tsx | 44 ++-- .../SidebarItem/useSidebarSrefActive.tsx | 2 + 51 files changed, 1786 insertions(+), 22 deletions(-) create mode 100644 api/http/handler/kubernetes/cron_job.go create mode 100644 api/http/handler/kubernetes/job.go create mode 100644 api/http/models/kubernetes/cron_jobs.go create mode 100644 api/http/models/kubernetes/jobs.go create mode 100644 api/kubernetes/cli/cronjob.go create mode 100644 api/kubernetes/cli/cronjob_test.go create mode 100644 api/kubernetes/cli/job.go create mode 100644 api/kubernetes/cli/job_test.go create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/CronJobsDatatable.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/CronJobsExecutionsInnerDatatable.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/expand.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/helper.ts create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/index.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/name.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/namespace.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/suspend.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/index.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/queries/query-keys.ts create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/queries/useCronJobs.ts create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/queries/useDeleteCronJobsMutation.ts create mode 100644 app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/types.ts create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/JobsDatatable.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/actions.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/helper.ts create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/index.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/name.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/namespace.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.module.css create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/index.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/queries/query-keys.ts create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/queries/useDeleteJobsMutation.ts create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/queries/useJobs.ts create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsDatatable/types.ts create mode 100644 app/react/kubernetes/more-resources/JobsView/JobsView.tsx create mode 100644 app/react/kubernetes/more-resources/JobsView/index.ts diff --git a/api/http/handler/kubernetes/cron_job.go b/api/http/handler/kubernetes/cron_job.go new file mode 100644 index 000000000..cad65bf9f --- /dev/null +++ b/api/http/handler/kubernetes/cron_job.go @@ -0,0 +1,78 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetKubernetesCronJobs +// @summary Get a list of kubernetes Cron Jobs +// @description Get a list of kubernetes Cron Jobs that the user has access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {array} models.K8sCronJob "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of Cron Jobs." +// @router /kubernetes/{id}/cron_jobs [get] +func (handler *Handler) getAllKubernetesCronJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesCronJobs").Msg("Unable to prepare kube client") + return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr) + } + + cronJobs, err := cli.GetCronJobs("") + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesCronJobs").Msg("Unable to fetch Cron Jobs across all namespaces") + return httperror.InternalServerError("unable to fetch Cron Jobs. Error: ", err) + } + + return response.JSON(w, cronJobs) +} + +// @id DeleteCronJobs +// @summary Delete Cron Jobs +// @description Delete the provided list of Cron Jobs. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @param id path int true "Environment identifier" +// @param payload body models.K8sCronJobDeleteRequests true "A map where the key is the namespace and the value is an array of Cron Jobs to delete" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific service account." +// @failure 500 "Server error occurred while attempting to delete Cron Jobs." +// @router /kubernetes/{id}/cron_jobs/delete [POST] +func (handler *Handler) deleteKubernetesCronJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sCronJobDeleteRequests + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + err = cli.DeleteCronJobs(payload) + if err != nil { + return httperror.InternalServerError("Unable to delete Cron Jobs", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index 2ea77e589..a47bd6ed9 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -55,6 +55,10 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza endpointRouter.Handle("/applications/count", httperror.LoggerHandler(h.getAllKubernetesApplicationsCount)).Methods(http.MethodGet) endpointRouter.Handle("/configmaps", httperror.LoggerHandler(h.GetAllKubernetesConfigMaps)).Methods(http.MethodGet) endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet) + endpointRouter.Handle("/cron_jobs", httperror.LoggerHandler(h.getAllKubernetesCronJobs)).Methods(http.MethodGet) + endpointRouter.Handle("/cron_jobs/delete", httperror.LoggerHandler(h.deleteKubernetesCronJobs)).Methods(http.MethodPost) + endpointRouter.Handle("/jobs", httperror.LoggerHandler(h.getAllKubernetesJobs)).Methods(http.MethodGet) + endpointRouter.Handle("/jobs/delete", httperror.LoggerHandler(h.deleteKubernetesJobs)).Methods(http.MethodPost) endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet) endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost) endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet) diff --git a/api/http/handler/kubernetes/job.go b/api/http/handler/kubernetes/job.go new file mode 100644 index 000000000..be1d67b81 --- /dev/null +++ b/api/http/handler/kubernetes/job.go @@ -0,0 +1,85 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetKubernetesJobs +// @summary Get a list of kubernetes Jobs +// @description Get a list of kubernetes Jobs that the user has access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param includeCronJobChildren query bool false "Whether to include Jobs that have a cronjob owner" +// @success 200 {array} models.K8sJob "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of Jobs." +// @router /kubernetes/{id}/jobs [get] +func (handler *Handler) getAllKubernetesJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + includeCronJobChildren, err := request.RetrieveBooleanQueryParameter(r, "includeCronJobChildren", true) + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesJobs").Msg("Invalid query parameter includeCronJobChildren") + return httperror.BadRequest("an error occurred during the GetAllKubernetesJobs operation, invalid query parameter includeCronJobChildren. Error: ", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesJobs").Msg("Unable to prepare kube client") + return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr) + } + + jobs, err := cli.GetJobs("", includeCronJobChildren) + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesJobs").Msg("Unable to fetch Jobs across all namespaces") + return httperror.InternalServerError("unable to fetch Jobs. Error: ", err) + } + + return response.JSON(w, jobs) +} + +// @id DeleteJobs +// @summary Delete Jobs +// @description Delete the provided list of Jobs. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @param id path int true "Environment identifier" +// @param payload body models.K8sJobDeleteRequests true "A map where the key is the namespace and the value is an array of Jobs to delete" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific service account." +// @failure 500 "Server error occurred while attempting to delete Jobs." +// @router /kubernetes/{id}/jobs/delete [POST] +func (handler *Handler) deleteKubernetesJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sJobDeleteRequests + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + err = cli.DeleteJobs(payload) + if err != nil { + return httperror.InternalServerError("Unable to delete Jobs", err) + } + + return response.Empty(w) +} diff --git a/api/http/models/kubernetes/cron_jobs.go b/api/http/models/kubernetes/cron_jobs.go new file mode 100644 index 000000000..3923e1a55 --- /dev/null +++ b/api/http/models/kubernetes/cron_jobs.go @@ -0,0 +1,36 @@ +package kubernetes + +import ( + "errors" + "net/http" +) + +type K8sCronJob struct { + Id string `json:"Id"` + Name string `json:"Name"` + Namespace string `json:"Namespace"` + Command string `json:"Command"` + Schedule string `json:"Schedule"` + Timezone string `json:"Timezone"` + Suspend bool `json:"Suspend"` + Jobs []K8sJob `json:"Jobs"` + IsSystem bool `json:"IsSystem"` +} + +type ( + K8sCronJobDeleteRequests map[string][]string +) + +func (r K8sCronJobDeleteRequests) Validate(request *http.Request) error { + if len(r) == 0 { + return errors.New("missing deletion request list in payload") + } + + for ns := range r { + if len(ns) == 0 { + return errors.New("deletion given with empty namespace") + } + } + + return nil +} diff --git a/api/http/models/kubernetes/jobs.go b/api/http/models/kubernetes/jobs.go new file mode 100644 index 000000000..e0aeae2af --- /dev/null +++ b/api/http/models/kubernetes/jobs.go @@ -0,0 +1,44 @@ +package kubernetes + +import ( + "errors" + "net/http" + + corev1 "k8s.io/api/core/v1" +) + +// K8sJob struct +type K8sJob struct { + ID string `json:"Id"` + Namespace string `json:"Namespace"` + Name string `json:"Name"` + PodName string `json:"PodName"` + Container corev1.Container `json:"Container,omitempty"` + Command string `json:"Command,omitempty"` + BackoffLimit int32 `json:"BackoffLimit,omitempty"` + Completions int32 `json:"Completions,omitempty"` + StartTime string `json:"StartTime"` + FinishTime string `json:"FinishTime"` + Duration string `json:"Duration"` + Status string `json:"Status"` + FailedReason string `json:"FailedReason"` + IsSystem bool `json:"IsSystem"` +} + +type ( + K8sJobDeleteRequests map[string][]string +) + +func (r K8sJobDeleteRequests) Validate(request *http.Request) error { + if len(r) == 0 { + return errors.New("missing deletion request list in payload") + } + + for ns := range r { + if len(ns) == 0 { + return errors.New("deletion given with empty namespace") + } + } + + return nil +} diff --git a/api/kubernetes/cli/cronjob.go b/api/kubernetes/cli/cronjob.go new file mode 100644 index 000000000..f00be6dc7 --- /dev/null +++ b/api/kubernetes/cli/cronjob.go @@ -0,0 +1,123 @@ +package cli + +import ( + "context" + "strings" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/portainer/portainer/api/internal/errorlist" + batchv1 "k8s.io/api/batch/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetCronJobs returns all cronjobs in the given namespace +// If the user is a kube admin, it returns all cronjobs in the namespace +// Otherwise, it returns only the cronjobs in the non-admin namespaces +func (kcl *KubeClient) GetCronJobs(namespace string) ([]models.K8sCronJob, error) { + if kcl.IsKubeAdmin { + return kcl.fetchCronJobs(namespace) + } + + return kcl.fetchCronJobsForNonAdmin(namespace) +} + +// fetchCronJobsForNonAdmin returns all cronjobs in the given namespace +// It returns only the cronjobs in the non-admin namespaces +func (kcl *KubeClient) fetchCronJobsForNonAdmin(namespace string) ([]models.K8sCronJob, error) { + cronJobs, err := kcl.fetchCronJobs(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sCronJob, 0) + for _, cronJob := range cronJobs { + if _, ok := nonAdminNamespaceSet[cronJob.Namespace]; ok { + results = append(results, cronJob) + } + } + + return results, nil +} + +// fetchCronJobs returns all cronjobs in the given namespace +// It returns all cronjobs in the namespace +func (kcl *KubeClient) fetchCronJobs(namespace string) ([]models.K8sCronJob, error) { + cronJobs, err := kcl.cli.BatchV1().CronJobs(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + jobs, err := kcl.cli.BatchV1().Jobs(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sCronJob, 0) + for _, cronJob := range cronJobs.Items { + results = append(results, kcl.parseCronJob(cronJob, jobs)) + } + + return results, nil +} + +// parseCronJob converts a batchv1.CronJob object to a models.K8sCronJob object. +func (kcl *KubeClient) parseCronJob(cronJob batchv1.CronJob, jobsList *batchv1.JobList) models.K8sCronJob { + jobs, err := kcl.getCronJobExecutions(cronJob.Name, jobsList) + if err != nil { + return models.K8sCronJob{} + } + + timezone := "" + if cronJob.Spec.TimeZone != nil { + timezone = *cronJob.Spec.TimeZone + } + + suspend := false + if cronJob.Spec.Suspend != nil { + suspend = *cronJob.Spec.Suspend + } + + return models.K8sCronJob{ + Id: string(cronJob.UID), + Name: cronJob.Name, + Namespace: cronJob.Namespace, + Command: strings.Join(cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Command, " "), + Schedule: cronJob.Spec.Schedule, + Timezone: timezone, + Suspend: suspend, + Jobs: jobs, + IsSystem: kcl.isSystemCronJob(cronJob.Namespace), + } +} + +func (kcl *KubeClient) isSystemCronJob(namespace string) bool { + return kcl.isSystemNamespace(namespace) +} + +// DeleteCronJobs deletes the provided list of cronjobs in its namespace +// it returns an error if any of the cronjobs are not found or if there is an error deleting the cronjobs +func (kcl *KubeClient) DeleteCronJobs(payload models.K8sCronJobDeleteRequests) error { + var errors []error + for namespace := range payload { + for _, cronJobName := range payload[namespace] { + client := kcl.cli.BatchV1().CronJobs(namespace) + + _, err := client.Get(context.Background(), cronJobName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + continue + } + + errors = append(errors, err) + } + + if err := client.Delete(context.Background(), cronJobName, metav1.DeleteOptions{}); err != nil { + errors = append(errors, err) + } + } + } + + return errorlist.Combine(errors) +} diff --git a/api/kubernetes/cli/cronjob_test.go b/api/kubernetes/cli/cronjob_test.go new file mode 100644 index 000000000..af67c65ba --- /dev/null +++ b/api/kubernetes/cli/cronjob_test.go @@ -0,0 +1,66 @@ +package cli + +import ( + "context" + "testing" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +// TestFetchCronJobs tests the fetchCronJobs method for both admin and non-admin clients +// It creates a fake Kubernetes client and passes it to the fetchCronJobs method +// It then logs the fetched Cron Jobs +// non-admin client will have access to the default namespace only +func (kcl *KubeClient) TestFetchCronJobs(t *testing.T) { + t.Run("admin client can fetch Cron Jobs from all namespaces", func(t *testing.T) { + kcl.cli = kfake.NewSimpleClientset() + kcl.instanceID = "test" + kcl.IsKubeAdmin = true + + cronJobs, err := kcl.GetCronJobs("") + if err != nil { + t.Fatalf("Failed to fetch Cron Jobs: %v", err) + } + + t.Logf("Fetched Cron Jobs: %v", cronJobs) + }) + + t.Run("non-admin client can fetch Cron Jobs from the default namespace only", func(t *testing.T) { + kcl.cli = kfake.NewSimpleClientset() + kcl.instanceID = "test" + kcl.IsKubeAdmin = false + kcl.NonAdminNamespaces = []string{"default"} + + cronJobs, err := kcl.GetCronJobs("") + if err != nil { + t.Fatalf("Failed to fetch Cron Jobs: %v", err) + } + + t.Logf("Fetched Cron Jobs: %v", cronJobs) + }) + + t.Run("delete Cron Jobs", func(t *testing.T) { + kcl.cli = kfake.NewSimpleClientset() + kcl.instanceID = "test" + + _, err := kcl.cli.BatchV1().CronJobs("default").Create(context.Background(), &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cronjob"}, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create cron job: %v", err) + } + + err = kcl.DeleteCronJobs(models.K8sCronJobDeleteRequests{ + "default": []string{"test-cronjob"}, + }) + + if err != nil { + t.Fatalf("Failed to delete Cron Jobs: %v", err) + } + + t.Logf("Deleted Cron Jobs") + }) +} diff --git a/api/kubernetes/cli/job.go b/api/kubernetes/cli/job.go new file mode 100644 index 000000000..78db1e1e1 --- /dev/null +++ b/api/kubernetes/cli/job.go @@ -0,0 +1,227 @@ +package cli + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/portainer/portainer/api/internal/errorlist" + "github.com/rs/zerolog/log" + batchv1 "k8s.io/api/batch/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetJobs returns all jobs in the given namespace +// If the user is a kube admin, it returns all jobs in the namespace +// Otherwise, it returns only the jobs in the non-admin namespaces +func (kcl *KubeClient) GetJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) { + if kcl.IsKubeAdmin { + return kcl.fetchJobs(namespace, includeCronJobChildren) + } + + return kcl.fetchJobsForNonAdmin(namespace, includeCronJobChildren) +} + +// fetchJobsForNonAdmin returns all jobs in the given namespace +// It returns only the jobs in the non-admin namespaces +func (kcl *KubeClient) fetchJobsForNonAdmin(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) { + jobs, err := kcl.fetchJobs(namespace, includeCronJobChildren) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sJob, 0) + for _, job := range jobs { + if _, ok := nonAdminNamespaceSet[job.Namespace]; ok { + results = append(results, job) + } + } + + return results, nil +} + +// fetchJobs returns all jobs in the given namespace +// It returns all jobs in the namespace +func (kcl *KubeClient) fetchJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) { + jobs, err := kcl.cli.BatchV1().Jobs(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sJob, 0) + for _, job := range jobs.Items { + if !includeCronJobChildren && checkCronJobOwner(job) { + continue + } + + results = append(results, kcl.parseJob(job)) + } + + return results, nil +} + +// checkCronJobOwner checks if the job has a cronjob owner +// it returns true if the job has a cronjob owner +// otherwise, it returns false +func checkCronJobOwner(job batchv1.Job) bool { + for _, owner := range job.OwnerReferences { + if owner.Kind == "CronJob" { + return true + } + } + + return false +} + +// parseJob converts a batchv1.Job object to a models.K8sJob object. +func (kcl *KubeClient) parseJob(job batchv1.Job) models.K8sJob { + times := parseJobTimes(job) + status, failedReason := determineJobStatus(job) + podName := getJobPodName(kcl, job) + + return models.K8sJob{ + ID: string(job.UID), + Namespace: job.Namespace, + Name: job.Name, + PodName: podName, + Command: strings.Join(job.Spec.Template.Spec.Containers[0].Command, " "), + Container: job.Spec.Template.Spec.Containers[0], + BackoffLimit: *job.Spec.BackoffLimit, + Completions: *job.Spec.Completions, + StartTime: times.start, + FinishTime: times.finish, + Duration: times.duration, + Status: status, + FailedReason: failedReason, + IsSystem: kcl.isSystemJob(job.Namespace), + } +} + +func (kcl *KubeClient) isSystemJob(namespace string) bool { + return kcl.isSystemNamespace(namespace) +} + +type jobTimes struct { + start string + finish string + duration string +} + +func parseJobTimes(job batchv1.Job) jobTimes { + times := jobTimes{ + start: "N/A", + finish: "N/A", + duration: "N/A", + } + + if st := job.Status.StartTime; st != nil { + times.start = st.Time.Format(time.RFC3339) + times.duration = time.Since(st.Time).Truncate(time.Minute).String() + + if ct := job.Status.CompletionTime; ct != nil { + times.finish = ct.Time.Format(time.RFC3339) + times.duration = ct.Time.Sub(st.Time).String() + } + } + + return times +} + +func determineJobStatus(job batchv1.Job) (status, failedReason string) { + failedReason = "N/A" + + switch { + case job.Status.Failed > 0: + return "Failed", getLatestJobCondition(job.Status.Conditions) + case job.Status.Succeeded > 0: + return "Succeeded", failedReason + case job.Status.Active == 0: + return "Completed", failedReason + default: + return "Running", failedReason + } +} + +func getJobPodName(kcl *KubeClient, job batchv1.Job) string { + pod, err := kcl.getLatestJobPod(job.Namespace, job.Name) + if err != nil { + log.Warn().Err(err). + Str("job", job.Name). + Str("namespace", job.Namespace). + Msg("Failed to get latest job pod") + return "" + } + + if pod != nil { + return pod.Name + } + return "" +} + +// getCronJobExecutions returns the jobs for a given cronjob +// it returns the jobs for the cronjob +func (kcl *KubeClient) getCronJobExecutions(cronJobName string, jobs *batchv1.JobList) ([]models.K8sJob, error) { + maxItems := 5 + + results := make([]models.K8sJob, 0) + for _, job := range jobs.Items { + for _, owner := range job.OwnerReferences { + if owner.Kind == "CronJob" && owner.Name == cronJobName { + results = append(results, kcl.parseJob(job)) + + if len(results) >= maxItems { + return results, nil + } + } + } + } + + return results, nil +} + +// DeleteJobs deletes the provided list of jobs +// it returns an error if any of the jobs are not found or if there is an error deleting the jobs +func (kcl *KubeClient) DeleteJobs(payload models.K8sJobDeleteRequests) error { + var errors []error + for namespace := range payload { + for _, jobName := range payload[namespace] { + client := kcl.cli.BatchV1().Jobs(namespace) + + _, err := client.Get(context.Background(), jobName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + continue + } + + errors = append(errors, err) + } + + if err := client.Delete(context.Background(), jobName, metav1.DeleteOptions{}); err != nil { + errors = append(errors, err) + } + } + } + + return errorlist.Combine(errors) +} + +// getLatestJobCondition returns the latest condition of the job +// it returns the latest condition of the job +// this is only used for the failed reason +func getLatestJobCondition(conditions []batchv1.JobCondition) string { + if len(conditions) == 0 { + return "No conditions" + } + + sort.Slice(conditions, func(i, j int) bool { + return conditions[i].LastTransitionTime.After(conditions[j].LastTransitionTime.Time) + }) + + latest := conditions[0] + return fmt.Sprintf("%s: %s", latest.Type, latest.Message) +} diff --git a/api/kubernetes/cli/job_test.go b/api/kubernetes/cli/job_test.go new file mode 100644 index 000000000..c23411657 --- /dev/null +++ b/api/kubernetes/cli/job_test.go @@ -0,0 +1,64 @@ +package cli + +import ( + "context" + "testing" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +// TestFetchJobs tests the fetchJobs method for both admin and non-admin clients +// It creates a fake Kubernetes client and passes it to the fetchJobs method +// It then logs the fetched jobs +// non-admin client will have access to the default namespace only +func (kcl *KubeClient) TestFetchJobs(t *testing.T) { + t.Run("admin client can fetch jobs from all namespaces", func(t *testing.T) { + kcl.cli = kfake.NewSimpleClientset() + kcl.instanceID = "test" + kcl.IsKubeAdmin = true + + jobs, err := kcl.GetJobs("", false) + if err != nil { + t.Fatalf("Failed to fetch jobs: %v", err) + } + + t.Logf("Fetched jobs: %v", jobs) + }) + + t.Run("non-admin client can fetch jobs from the default namespace only", func(t *testing.T) { + kcl.cli = kfake.NewSimpleClientset() + kcl.instanceID = "test" + kcl.IsKubeAdmin = false + kcl.NonAdminNamespaces = []string{"default"} + + jobs, err := kcl.GetJobs("", false) + if err != nil { + t.Fatalf("Failed to fetch jobs: %v", err) + } + + t.Logf("Fetched jobs: %v", jobs) + }) + + t.Run("delete jobs", func(t *testing.T) { + kcl.cli = kfake.NewSimpleClientset() + kcl.instanceID = "test" + + _, err := kcl.cli.BatchV1().Jobs("default").Create(context.Background(), &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create job: %v", err) + } + + err = kcl.DeleteJobs(models.K8sJobDeleteRequests{ + "default": []string{"test-job"}, + }) + + if err != nil { + t.Fatalf("Failed to delete jobs: %v", err) + } + }) +} diff --git a/api/kubernetes/cli/pod.go b/api/kubernetes/cli/pod.go index 7e6e74bad..8d22a20db 100644 --- a/api/kubernetes/cli/pod.go +++ b/api/kubernetes/cli/pod.go @@ -275,3 +275,22 @@ func isPodUsingSecret(pod *corev1.Pod, secretName string) bool { return false } + +// getLatestJobPod returns the pods that are owned by a job +// it returns an error if there is an error fetching the pods +func (kcl *KubeClient) getLatestJobPod(namespace string, jobName string) (*corev1.Pod, error) { + pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + for _, pod := range pods.Items { + for _, owner := range pod.OwnerReferences { + if owner.Kind == "Job" && owner.Name == jobName { + return &pod, nil + } + } + } + + return nil, nil +} diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index 07e1cf389..98607e569 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -581,6 +581,19 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo abstract: true, }; + const jobs = { + name: 'kubernetes.moreResources.jobs', + url: '/jobs?tab', + views: { + 'content@': { + component: 'jobsView', + }, + }, + data: { + docs: '/user/kubernetes/more-resources/jobs', + }, + }; + const serviceAccounts = { name: 'kubernetes.moreResources.serviceAccounts', url: '/serviceAccounts', @@ -661,6 +674,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo $stateRegistryProvider.register(ingressesEdit); $stateRegistryProvider.register(moreResources); + $stateRegistryProvider.register(jobs); $stateRegistryProvider.register(serviceAccounts); $stateRegistryProvider.register(clusterRoles); $stateRegistryProvider.register(roles); diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts index a9f7bae59..a6c440de3 100644 --- a/app/kubernetes/react/views/index.ts +++ b/app/kubernetes/react/views/index.ts @@ -21,6 +21,7 @@ import { RolesView } from '@/react/kubernetes/more-resources/RolesView'; import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView'; import { NamespaceView } from '@/react/kubernetes/namespaces/ItemView/NamespaceView'; import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView'; +import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView'; export const viewsModule = angular .module('portainer.kubernetes.react.views', []) @@ -89,6 +90,10 @@ export const viewsModule = angular 'kubernetesConsoleView', r2a(withUIRouter(withReactQuery(withCurrentUser(ConsoleView))), []) ) + .component( + 'jobsView', + r2a(withUIRouter(withReactQuery(withCurrentUser(JobsView))), []) + ) .component( 'serviceAccountsView', r2a(withUIRouter(withReactQuery(withCurrentUser(ServiceAccountsView))), []) diff --git a/app/react/kubernetes/applications/useCronJobs.ts b/app/react/kubernetes/applications/useCronJobs.ts index 69a8e2362..be6d371f8 100644 --- a/app/react/kubernetes/applications/useCronJobs.ts +++ b/app/react/kubernetes/applications/useCronJobs.ts @@ -2,7 +2,7 @@ import { CronJob, CronJobList } from 'kubernetes-types/batch/v1'; import { useQuery } from '@tanstack/react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { withError } from '@/react-tools/react-query'; +import { withGlobalError } from '@/react-tools/react-query'; import axios from '@/portainer/services/axios'; import { parseKubernetesAxiosError } from '../axiosError'; @@ -24,7 +24,7 @@ export function useCronJobs( queryKeys.cronJobsForCluster(environmentId), () => getCronJobsForCluster(environmentId, namespaces), { - ...withError('Unable to retrieve CronJobs'), + ...withGlobalError('Unable to retrieve CronJobs'), enabled: !!namespaces?.length, } ); diff --git a/app/react/kubernetes/applications/useJobs.ts b/app/react/kubernetes/applications/useJobs.ts index 2fe98f0ec..eacd7615d 100644 --- a/app/react/kubernetes/applications/useJobs.ts +++ b/app/react/kubernetes/applications/useJobs.ts @@ -2,7 +2,7 @@ import { Job, JobList } from 'kubernetes-types/batch/v1'; import { useQuery } from '@tanstack/react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { withError } from '@/react-tools/react-query'; +import { withGlobalError } from '@/react-tools/react-query'; import axios from '@/portainer/services/axios'; import { parseKubernetesAxiosError } from '../axiosError'; @@ -21,7 +21,7 @@ export function useJobs(environmentId: EnvironmentId, namespaces?: string[]) { queryKeys.jobsForCluster(environmentId), () => getJobsForCluster(environmentId, namespaces), { - ...withError('Unable to retrieve Jobs'), + ...withGlobalError('Unable to retrieve Jobs'), enabled: !!namespaces?.length, } ); diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/CronJobsDatatable.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/CronJobsDatatable.tsx new file mode 100644 index 000000000..cecdafccb --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/CronJobsDatatable.tsx @@ -0,0 +1,202 @@ +import { useMemo } from 'react'; +import { Trash2, CalendarSync } from 'lucide-react'; +import { useRouter } from '@uirouter/react'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { Authorized, useAuthorizations } from '@/react/hooks/useUser'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; +import { + DefaultDatatableSettings, + TableSettings as KubeTableSettings, +} from '@/react/kubernetes/datatables/DefaultDatatableSettings'; +import { useKubeStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; +import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton'; + +import { confirmDelete } from '@@/modals/confirm'; +import { TableSettingsMenu } from '@@/datatables'; +import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable'; +import { LoadingButton } from '@@/buttons'; +import { + type FilteredColumnsTableSettings, + filteredColumnsSettings, +} from '@@/datatables/types'; +import { mergeOptions } from '@@/datatables/extend-options/mergeOptions'; +import { withColumnFilters } from '@@/datatables/extend-options/withColumnFilters'; + +import { Job } from '../JobsDatatable/types'; + +import { useCronJobs } from './queries/useCronJobs'; +import { columns } from './columns'; +import { CronJob } from './types'; +import { useDeleteCronJobsMutation } from './queries/useDeleteCronJobsMutation'; +import { CronJobsExecutionsInnerDatatable } from './CronJobsExecutionsInnerDatatable'; + +const storageKey = 'cronJobs'; + +interface TableSettings + extends KubeTableSettings, + FilteredColumnsTableSettings {} + +interface CronJobsExecutionsProps { + item: Job[]; + tableState: TableSettings; +} + +export function CronJobsDatatable() { + const environmentId = useEnvironmentId(); + const tableState = useKubeStore( + storageKey, + undefined, + (set) => ({ + ...filteredColumnsSettings(set), + }) + ); + + const cronJobsQuery = useCronJobs(environmentId, { + refetchInterval: tableState.autoRefreshRate * 1000, + }); + const cronJobsRowData = cronJobsQuery.data; + + const { authorized: canAccessSystemResources } = useAuthorizations( + 'K8sAccessSystemNamespaces' + ); + const filteredCronJobs = useMemo( + () => + tableState.showSystemResources + ? cronJobsRowData + : cronJobsRowData?.filter( + (cronJob) => + (canAccessSystemResources && tableState.showSystemResources) || + !cronJob.IsSystem + ), + [cronJobsRowData, tableState.showSystemResources, canAccessSystemResources] + ); + + return ( + row.Id} + isRowSelectable={(row) => !row.original.IsSystem} + renderTableActions={(selectedRows) => ( + + )} + renderTableSettings={() => ( + + + + )} + description={ + + } + data-cy="k8s-cronJobs-datatable" + extendTableOptions={mergeOptions( + withColumnFilters(tableState.columnFilters, tableState.setColumnFilters) + )} + getRowCanExpand={(row) => (row.original.Jobs ?? []).length > 0} + renderSubRow={(row) => ( + + )} + /> + ); +} + +function SubRow({ item, tableState }: CronJobsExecutionsProps) { + return ( + + + + + + ); +} + +interface SelectedCronJob { + Namespace: string; + Name: string; +} + +type TableActionsProps = { + selectedItems: CronJob[]; +}; + +function TableActions({ selectedItems }: TableActionsProps) { + const environmentId = useEnvironmentId(); + const deleteCronJobsMutation = useDeleteCronJobsMutation(environmentId); + const router = useRouter(); + + return ( + + handleRemoveClick(selectedItems)} + icon={Trash2} + isLoading={deleteCronJobsMutation.isLoading} + loadingText="Removing Cron Jobs..." + data-cy="k8s-cronJobs-removeCronJobButton" + > + Remove + + + + + ); + + async function handleRemoveClick(cronJobs: SelectedCronJob[]) { + const confirmed = await confirmDelete( + <> +

Are you sure you want to delete the selected Cron Jobs?

+
    + {cronJobs.map((s, index) => ( +
  • + {s.Namespace}/{s.Name} +
  • + ))} +
+ + ); + if (!confirmed) { + return null; + } + + const payload: Record = {}; + cronJobs.forEach((r) => { + payload[r.Namespace] = payload[r.Namespace] || []; + payload[r.Namespace].push(r.Name); + }); + + deleteCronJobsMutation.mutate( + { environmentId, data: payload }, + { + onSuccess: () => { + notifySuccess( + 'Cron Jobs successfully removed', + cronJobs.map((r) => `${r.Namespace}/${r.Name}`).join(', ') + ); + router.stateService.reload(); + }, + onError: (error) => { + notifyError( + 'Unable to delete Cron Jobs', + error as Error, + cronJobs.map((r) => `${r.Namespace}/${r.Name}`).join(', ') + ); + }, + } + ); + + return cronJobs; + } +} diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/CronJobsExecutionsInnerDatatable.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/CronJobsExecutionsInnerDatatable.tsx new file mode 100644 index 000000000..27fe67ad7 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/CronJobsExecutionsInnerDatatable.tsx @@ -0,0 +1,47 @@ +import { CalendarCheck2 } from 'lucide-react'; + +import { + DefaultDatatableSettings, + TableSettings as KubeTableSettings, +} from '@/react/kubernetes/datatables/DefaultDatatableSettings'; + +import { Datatable, TableSettingsMenu } from '@@/datatables'; +import { + type FilteredColumnsTableSettings, + BasicTableSettings, +} from '@@/datatables/types'; +import { TableState } from '@@/datatables/useTableState'; + +import { columns } from '../JobsDatatable/columns'; +import { Job } from '../JobsDatatable/types'; + +interface TableSettings + extends KubeTableSettings, + FilteredColumnsTableSettings {} + +interface CronJobsExecutionsProps { + item: Job[]; + tableState: TableSettings; +} + +export function CronJobsExecutionsInnerDatatable({ + item, + tableState, +}: CronJobsExecutionsProps) { + return ( + row.Id} + title="Executions" + titleIcon={CalendarCheck2} + data-cy="k8s-cronJobs-executions-datatable" + renderTableSettings={() => ( + + + + )} + settingsManager={tableState as unknown as TableState} + /> + ); +} diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx new file mode 100644 index 000000000..04e6e155f --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx @@ -0,0 +1,7 @@ +import { columnHelper } from './helper'; + +export const command = columnHelper.accessor((row) => row.Command, { + header: 'Command', + id: 'command', + cell: ({ getValue }) => getValue(), +}); diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/expand.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/expand.tsx new file mode 100644 index 000000000..1911be6f0 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/expand.tsx @@ -0,0 +1,10 @@ +import { buildExpandColumn } from '@@/datatables/expand-column'; + +import { CronJob } from '../types'; + +import { columnHelper } from './helper'; + +export const expand = columnHelper.display({ + ...buildExpandColumn(), + id: 'expand', +}); diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/helper.ts b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/helper.ts new file mode 100644 index 000000000..5d4021ac7 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/helper.ts @@ -0,0 +1,5 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { CronJob } from '../types'; + +export const columnHelper = createColumnHelper(); diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/index.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/index.tsx new file mode 100644 index 000000000..8e5d55f6f --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/index.tsx @@ -0,0 +1,17 @@ +import { expand } from './expand'; +import { name } from './name'; +import { namespace } from './namespace'; +import { schedule } from './schedule'; +import { suspend } from './suspend'; +import { timezone } from './timezone'; +import { command } from './command'; + +export const columns = [ + expand, + name, + namespace, + command, + schedule, + suspend, + timezone, +]; diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/name.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/name.tsx new file mode 100644 index 000000000..a3394f57e --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/name.tsx @@ -0,0 +1,23 @@ +import { SystemBadge } from '@@/Badge/SystemBadge'; + +import { columnHelper } from './helper'; + +export const name = columnHelper.accessor( + (row) => { + let result = row.Name; + if (row.IsSystem) { + result += ' system'; + } + return result; + }, + { + header: 'Name', + id: 'name', + cell: ({ row }) => ( +
+ {row.original.Name} + {row.original.IsSystem && } +
+ ), + } +); diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/namespace.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/namespace.tsx new file mode 100644 index 000000000..26eb33b1e --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/namespace.tsx @@ -0,0 +1,32 @@ +import { Row } from '@tanstack/react-table'; + +import { filterHOC } from '@@/datatables/Filter'; +import { Link } from '@@/Link'; + +import { CronJob } from '../types'; + +import { columnHelper } from './helper'; + +export const namespace = columnHelper.accessor((row) => row.Namespace, { + header: 'Namespace', + id: 'namespace', + cell: ({ getValue, row }) => ( + + {getValue()} + + ), + meta: { + filter: filterHOC('Filter by namespace'), + }, + enableColumnFilter: true, + filterFn: (row: Row, _columnId: string, filterValue: string[]) => + filterValue.length === 0 || + filterValue.includes(row.original.Namespace ?? ''), +}); diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx new file mode 100644 index 000000000..60a673eb0 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx @@ -0,0 +1,7 @@ +import { columnHelper } from './helper'; + +export const schedule = columnHelper.accessor((row) => row.Schedule, { + header: 'Schedule', + id: 'schedule', + cell: ({ getValue }) => getValue(), +}); diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/suspend.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/suspend.tsx new file mode 100644 index 000000000..8ae455b80 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/suspend.tsx @@ -0,0 +1,10 @@ +import { columnHelper } from './helper'; + +export const suspend = columnHelper.accessor((row) => row.Suspend, { + header: 'Suspend', + id: 'suspend', + cell: ({ getValue }) => { + const suspended = getValue(); + return suspended ? 'Yes' : 'No'; + }, +}); diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx new file mode 100644 index 000000000..54bb35a79 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx @@ -0,0 +1,7 @@ +import { columnHelper } from './helper'; + +export const timezone = columnHelper.accessor((row) => row.Timezone, { + header: 'Timezone', + id: 'timezone', + cell: ({ getValue }) => getValue(), +}); diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/index.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/index.tsx new file mode 100644 index 000000000..941075061 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/index.tsx @@ -0,0 +1 @@ +export { CronJobsDatatable } from './CronJobsDatatable'; diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/queries/query-keys.ts b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/queries/query-keys.ts new file mode 100644 index 000000000..2695ab17d --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/queries/query-keys.ts @@ -0,0 +1,6 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export const queryKeys = { + list: (environmentId: EnvironmentId) => + ['environments', environmentId, 'kubernetes', 'cronJobs'] as const, +}; diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/queries/useCronJobs.ts b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/queries/useCronJobs.ts new file mode 100644 index 000000000..3b790e20c --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/queries/useCronJobs.ts @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; + +import { withGlobalError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { CronJob } from '../types'; + +import { queryKeys } from './query-keys'; + +export function useCronJobs( + environmentId: EnvironmentId, + options?: { refetchInterval?: number; enabled?: boolean } +) { + return useQuery( + queryKeys.list(environmentId), + async () => getAllCronJobs(environmentId), + { + ...withGlobalError('Unable to get cron jobs'), + refetchInterval() { + return options?.refetchInterval ?? false; + }, + enabled: options?.enabled, + } + ); +} + +async function getAllCronJobs(environmentId: EnvironmentId) { + try { + const { data: cronJobs } = await axios.get( + `kubernetes/${environmentId}/cron_jobs` + ); + + return cronJobs; + } catch (e) { + throw parseAxiosError(e, 'Unable to get cron jobs'); + } +} diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/queries/useDeleteCronJobsMutation.ts b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/queries/useDeleteCronJobsMutation.ts new file mode 100644 index 000000000..6e442482e --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/queries/useDeleteCronJobsMutation.ts @@ -0,0 +1,34 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { withGlobalError, withInvalidate } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { queryKeys } from './query-keys'; + +export function useDeleteCronJobsMutation(environmentId: EnvironmentId) { + const queryClient = useQueryClient(); + return useMutation(deleteCronJob, { + ...withInvalidate(queryClient, [queryKeys.list(environmentId)]), + ...withGlobalError('Unable to delete Cron Jobs'), + }); +} + +type NamespaceCronJobsMap = Record; + +export async function deleteCronJob({ + environmentId, + data, +}: { + environmentId: EnvironmentId; + data: NamespaceCronJobsMap; +}) { + try { + return await axios.post( + `kubernetes/${environmentId}/cron_jobs/delete`, + data + ); + } catch (e) { + throw parseAxiosError(e, `Unable to delete Cron Jobs`); + } +} diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/types.ts b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/types.ts new file mode 100644 index 000000000..72765b630 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/types.ts @@ -0,0 +1,13 @@ +import { Job } from '../JobsDatatable/types'; + +export type CronJob = { + Id: string; + Name: string; + Namespace: string; + Command: string; + Schedule: string; + Timezone: string; + Suspend: boolean; + IsSystem?: boolean; + Jobs?: Job[]; +}; diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/JobsDatatable.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/JobsDatatable.tsx new file mode 100644 index 000000000..a149bd5e7 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/JobsDatatable.tsx @@ -0,0 +1,181 @@ +import { useMemo } from 'react'; +import { Trash2, CalendarCheck2 } from 'lucide-react'; +import { useRouter } from '@uirouter/react'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { Authorized, useAuthorizations } from '@/react/hooks/useUser'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; +import { + DefaultDatatableSettings, + TableSettings as KubeTableSettings, +} from '@/react/kubernetes/datatables/DefaultDatatableSettings'; +import { useKubeStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; +import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton'; + +import { confirmDelete } from '@@/modals/confirm'; +import { Datatable, TableSettingsMenu } from '@@/datatables'; +import { LoadingButton } from '@@/buttons'; +import { + type FilteredColumnsTableSettings, + filteredColumnsSettings, +} from '@@/datatables/types'; +import { mergeOptions } from '@@/datatables/extend-options/mergeOptions'; +import { withColumnFilters } from '@@/datatables/extend-options/withColumnFilters'; + +import { useJobs } from './queries/useJobs'; +import { columns } from './columns'; +import { Job } from './types'; +import { useDeleteJobsMutation } from './queries/useDeleteJobsMutation'; + +const storageKey = 'jobs'; + +interface TableSettings + extends KubeTableSettings, + FilteredColumnsTableSettings {} + +export function JobsDatatable() { + const environmentId = useEnvironmentId(); + const tableState = useKubeStore( + storageKey, + undefined, + (set) => ({ + ...filteredColumnsSettings(set), + }) + ); + + const jobsQuery = useJobs(environmentId, { + refetchInterval: tableState.autoRefreshRate * 1000, + }); + const jobsRowData = jobsQuery.data; + + const { authorized: canAccessSystemResources } = useAuthorizations( + 'K8sAccessSystemNamespaces' + ); + const filteredJobs = useMemo( + () => + tableState.showSystemResources + ? jobsRowData + : jobsRowData?.filter( + (job) => + // show everything if we can access system resources and the table is set to show them + (canAccessSystemResources && tableState.showSystemResources) || + // otherwise, only show non-system resources + !job.IsSystem + ), + [jobsRowData, tableState.showSystemResources, canAccessSystemResources] + ); + + return ( + row.Id} + isRowSelectable={(row) => !row.original.IsSystem} + renderTableActions={(selectedRows) => ( + + )} + renderTableSettings={() => ( + + + + )} + description={ + + } + data-cy="k8s-jobs-datatable" + extendTableOptions={mergeOptions( + withColumnFilters(tableState.columnFilters, tableState.setColumnFilters) + )} + /> + ); +} + +interface SelectedJob { + Namespace: string; + Name: string; +} + +type TableActionsProps = { + selectedItems: Job[]; +}; + +function TableActions({ selectedItems }: TableActionsProps) { + const environmentId = useEnvironmentId(); + const deleteJobsMutation = useDeleteJobsMutation(environmentId); + const router = useRouter(); + + return ( + + handleRemoveClick(selectedItems)} + icon={Trash2} + isLoading={deleteJobsMutation.isLoading} + loadingText="Removing jobs..." + data-cy="k8s-jobs-removeJobButton" + > + Remove + + + + + ); + + async function handleRemoveClick(jobs: SelectedJob[]) { + const confirmed = await confirmDelete( + <> +

Are you sure you want to delete the selected job(s)?

+
    + {jobs.map((s, index) => ( +
  • + {s.Namespace}/{s.Name} +
  • + ))} +
+ + ); + if (!confirmed) { + return null; + } + + const payload: Record = {}; + jobs.forEach((r) => { + payload[r.Namespace] = payload[r.Namespace] || []; + payload[r.Namespace].push(r.Name); + }); + + deleteJobsMutation.mutate( + { environmentId, data: payload }, + { + onSuccess: () => { + notifySuccess( + 'Jobs successfully removed', + jobs.map((r) => `${r.Namespace}/${r.Name}`).join(', ') + ); + router.stateService.reload(); + }, + onError: (error) => { + notifyError( + 'Unable to delete jobs', + error as Error, + jobs.map((r) => `${r.Namespace}/${r.Name}`).join(', ') + ); + }, + } + ); + + return jobs; + } +} diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/actions.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/actions.tsx new file mode 100644 index 000000000..1945d6fa7 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/actions.tsx @@ -0,0 +1,30 @@ +import { FileText } from 'lucide-react'; + +import { Link } from '@@/Link'; +import { Icon } from '@@/Icon'; + +import { columnHelper } from './helper'; + +export const actions = columnHelper.accessor(() => '', { + header: 'Actions', + id: 'actions', + enableSorting: false, + cell: ({ row: { original: job } }) => ( +
+ + + Logs + +
+ ), +}); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx new file mode 100644 index 000000000..04e6e155f --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx @@ -0,0 +1,7 @@ +import { columnHelper } from './helper'; + +export const command = columnHelper.accessor((row) => row.Command, { + header: 'Command', + id: 'command', + cell: ({ getValue }) => getValue(), +}); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx new file mode 100644 index 000000000..23cfcaf34 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx @@ -0,0 +1,7 @@ +import { columnHelper } from './helper'; + +export const duration = columnHelper.accessor((row) => row.Duration, { + header: 'Duration', + id: 'duration', + cell: ({ getValue }) => getValue(), +}); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx new file mode 100644 index 000000000..b3bcb4e0f --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx @@ -0,0 +1,7 @@ +import { columnHelper } from './helper'; + +export const finished = columnHelper.accessor((row) => row.FinishTime, { + header: 'Finished', + id: 'finished', + cell: ({ getValue }) => getValue(), +}); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/helper.ts b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/helper.ts new file mode 100644 index 000000000..c89105a8f --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/helper.ts @@ -0,0 +1,5 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { Job } from '../types'; + +export const columnHelper = createColumnHelper(); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/index.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/index.tsx new file mode 100644 index 000000000..9b0167ece --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/index.tsx @@ -0,0 +1,19 @@ +import { name } from './name'; +import { namespace } from './namespace'; +import { started } from './started'; +import { finished } from './finished'; +import { duration } from './duration'; +import { status } from './status'; +import { actions } from './actions'; +import { command } from './command'; + +export const columns = [ + name, + namespace, + command, + status, + started, + finished, + duration, + actions, +]; diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/name.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/name.tsx new file mode 100644 index 000000000..a3394f57e --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/name.tsx @@ -0,0 +1,23 @@ +import { SystemBadge } from '@@/Badge/SystemBadge'; + +import { columnHelper } from './helper'; + +export const name = columnHelper.accessor( + (row) => { + let result = row.Name; + if (row.IsSystem) { + result += ' system'; + } + return result; + }, + { + header: 'Name', + id: 'name', + cell: ({ row }) => ( +
+ {row.original.Name} + {row.original.IsSystem && } +
+ ), + } +); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/namespace.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/namespace.tsx new file mode 100644 index 000000000..2ef3f4ffe --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/namespace.tsx @@ -0,0 +1,32 @@ +import { Row } from '@tanstack/react-table'; + +import { filterHOC } from '@@/datatables/Filter'; +import { Link } from '@@/Link'; + +import { Job } from '../types'; + +import { columnHelper } from './helper'; + +export const namespace = columnHelper.accessor((row) => row.Namespace, { + header: 'Namespace', + id: 'namespace', + cell: ({ getValue, row }) => ( + + {getValue()} + + ), + meta: { + filter: filterHOC('Filter by namespace'), + }, + enableColumnFilter: true, + filterFn: (row: Row, _columnId: string, filterValue: string[]) => + filterValue.length === 0 || + filterValue.includes(row.original.Namespace ?? ''), +}); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx new file mode 100644 index 000000000..eff7f2465 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx @@ -0,0 +1,12 @@ +import { formatDate } from '@/portainer/filters/filters'; + +import { columnHelper } from './helper'; + +export const started = columnHelper.accessor( + (row) => formatDate(row.StartTime), + { + header: 'Started', + id: 'started', + cell: ({ getValue }) => getValue(), + } +); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.module.css b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.module.css new file mode 100644 index 000000000..7dcd8595c --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.module.css @@ -0,0 +1,13 @@ +.status-indicator { + padding: 0 !important; + margin-right: 1ch; + border-radius: 50%; + background-color: var(--red-3); + height: 10px; + width: 10px; + display: inline-block; +} + +.status-indicator.ok { + background-color: var(--green-3); +} diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx new file mode 100644 index 000000000..2f6ad1768 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx @@ -0,0 +1,48 @@ +import { CellContext } from '@tanstack/react-table'; +import { HelpCircle } from 'lucide-react'; +import clsx from 'clsx'; + +import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; + +import { Job } from '../types'; + +import { columnHelper } from './helper'; +import styles from './status.module.css'; + +export const status = columnHelper.accessor((row) => row.Status, { + header: 'Status', + id: 'status', + cell: Cell, +}); + +function Cell({ row: { original: item } }: CellContext) { + return ( + <> + + {item.Status} + {item.Status === 'Failed' && ( + + + {item.FailedReason} + + } + position="bottom" + > + + + + + )} + + ); +} diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/index.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/index.tsx new file mode 100644 index 000000000..fde9e775b --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/index.tsx @@ -0,0 +1 @@ +export { JobsDatatable } from './JobsDatatable'; diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/queries/query-keys.ts b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/queries/query-keys.ts new file mode 100644 index 000000000..dd114ce9d --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/queries/query-keys.ts @@ -0,0 +1,6 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export const queryKeys = { + list: (environmentId: EnvironmentId) => + ['environments', environmentId, 'kubernetes', 'jobs'] as const, +}; diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/queries/useDeleteJobsMutation.ts b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/queries/useDeleteJobsMutation.ts new file mode 100644 index 000000000..ecd995624 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/queries/useDeleteJobsMutation.ts @@ -0,0 +1,31 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { withGlobalError, withInvalidate } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { queryKeys } from './query-keys'; + +export function useDeleteJobsMutation(environmentId: EnvironmentId) { + const queryClient = useQueryClient(); + return useMutation(deleteJob, { + ...withInvalidate(queryClient, [queryKeys.list(environmentId)]), + ...withGlobalError('Unable to delete Jobs'), + }); +} + +type NamespaceJobsMap = Record; + +export async function deleteJob({ + environmentId, + data, +}: { + environmentId: EnvironmentId; + data: NamespaceJobsMap; +}) { + try { + return await axios.post(`kubernetes/${environmentId}/jobs/delete`, data); + } catch (e) { + throw parseAxiosError(e, `Unable to delete Jobs`); + } +} diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/queries/useJobs.ts b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/queries/useJobs.ts new file mode 100644 index 000000000..e23492a22 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/queries/useJobs.ts @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; + +import { withGlobalError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { Job } from '../types'; + +import { queryKeys } from './query-keys'; + +export function useJobs( + environmentId: EnvironmentId, + options?: { refetchInterval?: number; enabled?: boolean } +) { + return useQuery( + queryKeys.list(environmentId), + async () => getAllJobs(environmentId), + { + ...withGlobalError('Unable to get Jobs'), + refetchInterval() { + return options?.refetchInterval ?? false; + }, + enabled: options?.enabled, + } + ); +} + +async function getAllJobs(environmentId: EnvironmentId) { + try { + const { data: jobs } = await axios.get( + `kubernetes/${environmentId}/jobs` + ); + + return jobs; + } catch (e) { + throw parseAxiosError(e, 'Unable to get Jobs'); + } +} diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/types.ts b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/types.ts new file mode 100644 index 000000000..5743fe441 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/types.ts @@ -0,0 +1,18 @@ +import { Container } from 'kubernetes-types/core/v1'; + +export type Job = { + Id: string; + Namespace: string; + Name: string; + PodName: string; + Container?: Container; + Command?: string; + BackoffLimit?: number; + Completions?: number; + StartTime?: string; + FinishTime?: string; + Duration?: number; + Status?: string; + FailedReason?: string; + IsSystem?: boolean; +}; diff --git a/app/react/kubernetes/more-resources/JobsView/JobsView.tsx b/app/react/kubernetes/more-resources/JobsView/JobsView.tsx new file mode 100644 index 000000000..0bc4e6180 --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/JobsView.tsx @@ -0,0 +1,51 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; +import { CalendarCheck2, CalendarSync } from 'lucide-react'; + +import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect'; + +import { PageHeader } from '@@/PageHeader'; +import { WidgetTabs, Tab, findSelectedTabIndex } from '@@/Widget/WidgetTabs'; + +import { JobsDatatable } from './JobsDatatable/JobsDatatable'; +import { CronJobsDatatable } from './CronJobsDatatable/CronJobsDatatable'; + +export function JobsView() { + useUnauthorizedRedirect( + { authorizations: ['K8sJobsR', 'K8sCronJobsR'] }, + { to: 'kubernetes.dashboard' } + ); + + const tabs: Tab[] = [ + { + name: 'Cron Jobs', + icon: CalendarSync, + widget: , + selectedTabParam: 'cronJobs', + }, + { + name: 'Jobs', + icon: CalendarCheck2, + widget: , + selectedTabParam: 'jobs', + }, + ]; + + const currentTabIndex = findSelectedTabIndex( + useCurrentStateAndParams(), + tabs + ); + + return ( + <> + + <> + +
{tabs[currentTabIndex].widget}
+ + + ); +} diff --git a/app/react/kubernetes/more-resources/JobsView/index.ts b/app/react/kubernetes/more-resources/JobsView/index.ts new file mode 100644 index 000000000..29a6fb69e --- /dev/null +++ b/app/react/kubernetes/more-resources/JobsView/index.ts @@ -0,0 +1 @@ +export { JobsView } from './JobsView'; diff --git a/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx b/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx index a7d508d36..4d260c340 100644 --- a/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx +++ b/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx @@ -100,24 +100,32 @@ export function KubernetesSidebar({ environmentId }: Props) { data-cy="k8sSidebar-volumes" /> - - + - - + +