From bed42571947f983c5d23cb16c80ffc3d7f43005f Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Thu, 11 Aug 2022 07:33:29 +0300 Subject: [PATCH] refactor(containers): migrate view to react [EE-2212] (#6577) Co-authored-by: LP B --- .../containers/container_gpus_inspect.go | 86 +++++ api/http/handler/docker/containers/handler.go | 31 ++ api/http/handler/docker/handler.go | 63 ++++ api/http/handler/handler.go | 2 + api/http/server.go | 4 + app/docker/__module.js | 97 +----- app/docker/react/components/index.ts | 19 +- app/docker/react/views/containers.ts | 101 ++++++ app/docker/react/views/index.ts | 8 +- app/docker/services/system.service.ts | 28 +- app/docker/views/containers/containers.html | 15 - .../views/containers/containersController.js | 60 ---- .../columns/RowContext.tsx | 43 ++- .../EdgeDevicesDatatable.tsx | 3 +- .../columns/RowContext.tsx | 31 +- .../EdgeDevicesDatatable/types.ts | 2 +- .../WaitingRoomView/Datatable/types.ts | 2 +- app/portainer/environments/useEnvironment.tsx | 27 -- app/portainer/hooks/useCurrentEnvironment.ts | 8 + app/portainer/hooks/useLocalStorage.ts | 2 +- .../FDOProfilesDatatable.tsx | 2 +- app/portainer/views/stacks/edit/stack.html | 14 +- .../views/stacks/edit/stackController.js | 36 +- .../container-instances/ListView/types.ts | 2 +- app/react/components/datatables/Datatable.tsx | 238 ++++++++++++++ .../datatables/QuickActionsSettings.tsx | 34 +- .../components/datatables/RowContext.tsx | 23 ++ app/react/components/datatables/Table.tsx | 40 ++- app/react/components/datatables/TableRow.tsx | 2 +- .../components/datatables/TableTitle.tsx | 24 +- app/react/components/datatables/index.ts | 13 + app/react/components/datatables/index.tsx | 50 --- app/react/components/datatables/types-old.ts | 15 + app/react/components/datatables/types.ts | 46 ++- .../datatables/useTableSettings.tsx | 2 +- .../datatables/useZustandTableSettings.tsx | 48 +++ .../ContainersDatatable.tsx | 309 +++++------------- .../ContainersDatatableActions.tsx | 22 +- .../ContainersDatatableContainer.tsx | 37 --- .../ContainersDatatableSettings.tsx | 32 +- .../ContainersDatatable/RowContext.ts | 11 + .../ContainersDatatable/columns/gpus.tsx | 20 +- .../ContainersDatatable/columns/image.tsx | 17 +- .../ContainersDatatable/columns/index.tsx | 32 +- .../ContainersDatatable/columns/name.tsx | 31 +- .../ContainersDatatable/columns/ports.tsx | 9 +- .../columns/quick-actions.tsx | 23 +- .../ContainersDatatable/columns/state.tsx | 53 +-- .../ContainersDatatable/datatable-store.ts | 40 +++ .../ListView/ContainersDatatable/index.ts | 1 + .../ListView/ContainersDatatable/types.ts | 23 ++ .../docker/containers/ListView/ListView.tsx | 38 +++ app/react/docker/containers/ListView/index.ts | 1 + .../ContainerQuickActions.tsx | 21 +- .../components/ContainerQuickActions/index.ts | 5 +- .../docker/containers/containers.service.ts | 27 +- app/react/docker/containers/queries.ts | 18 - .../docker/containers/queries/containers.ts | 50 +++ app/react/docker/containers/queries/gpus.tsx | 29 ++ .../docker/containers/queries/query-keys.ts | 19 ++ app/react/docker/containers/queries/types.ts | 6 + app/react/docker/containers/types.ts | 60 ++-- app/react/docker/containers/types/response.ts | 94 ++++++ app/react/docker/containers/utils.ts | 87 +++++ .../docker/networks/ItemView/ItemView.tsx | 4 +- app/react/docker/queries/utils.ts | 5 + .../ItemView/StackContainersDatatable.tsx | 99 ++++++ app/react/docker/stacks/types.ts | 23 ++ app/react/docker/types.ts | 9 +- package.json | 3 +- yarn.lock | 12 + 71 files changed, 1616 insertions(+), 875 deletions(-) create mode 100644 api/http/handler/docker/containers/container_gpus_inspect.go create mode 100644 api/http/handler/docker/containers/handler.go create mode 100644 api/http/handler/docker/handler.go create mode 100644 app/docker/react/views/containers.ts delete mode 100644 app/docker/views/containers/containers.html delete mode 100644 app/docker/views/containers/containersController.js delete mode 100644 app/portainer/environments/useEnvironment.tsx create mode 100644 app/portainer/hooks/useCurrentEnvironment.ts create mode 100644 app/react/components/datatables/Datatable.tsx create mode 100644 app/react/components/datatables/RowContext.tsx create mode 100644 app/react/components/datatables/index.ts delete mode 100644 app/react/components/datatables/index.tsx create mode 100644 app/react/components/datatables/types-old.ts create mode 100644 app/react/components/datatables/useZustandTableSettings.tsx delete mode 100644 app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableContainer.tsx create mode 100644 app/react/docker/containers/ListView/ContainersDatatable/RowContext.ts create mode 100644 app/react/docker/containers/ListView/ContainersDatatable/datatable-store.ts create mode 100644 app/react/docker/containers/ListView/ContainersDatatable/index.ts create mode 100644 app/react/docker/containers/ListView/ContainersDatatable/types.ts create mode 100644 app/react/docker/containers/ListView/ListView.tsx create mode 100644 app/react/docker/containers/ListView/index.ts delete mode 100644 app/react/docker/containers/queries.ts create mode 100644 app/react/docker/containers/queries/containers.ts create mode 100644 app/react/docker/containers/queries/gpus.tsx create mode 100644 app/react/docker/containers/queries/query-keys.ts create mode 100644 app/react/docker/containers/queries/types.ts create mode 100644 app/react/docker/containers/types/response.ts create mode 100644 app/react/docker/containers/utils.ts create mode 100644 app/react/docker/queries/utils.ts create mode 100644 app/react/docker/stacks/ItemView/StackContainersDatatable.tsx create mode 100644 app/react/docker/stacks/types.ts diff --git a/api/http/handler/docker/containers/container_gpus_inspect.go b/api/http/handler/docker/containers/container_gpus_inspect.go new file mode 100644 index 000000000..cbedab5f9 --- /dev/null +++ b/api/http/handler/docker/containers/container_gpus_inspect.go @@ -0,0 +1,86 @@ +package containers + +import ( + "net/http" + "strings" + + containertypes "github.com/docker/docker/api/types/container" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portaineree "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/middlewares" + "golang.org/x/exp/slices" +) + +type containerGpusResponse struct { + Gpus string `json:"gpus"` +} + +// @id dockerContainerGpusInspect +// @summary Fetch container gpus data +// @description +// @description **Access policy**: +// @tags docker +// @security jwt +// @accept json +// @produce json +// @param environmentId path int true "Environment identifier" +// @param containerId path int true "Container identifier" +// @success 200 {object} containerGpusResponse "Success" +// @failure 404 "Environment or container not found" +// @failure 400 "Bad request" +// @failure 500 "Internal server error" +// @router /docker/{environmentId}/containers/{containerId}/gpus [get] +func (handler *Handler) containerGpusInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + containerId, err := request.RetrieveRouteVariableValue(r, "containerId") + if err != nil { + return httperror.BadRequest("Invalid container identifier route variable", err) + } + + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + return httperror.NotFound("Unable to find an environment on request context", err) + } + + agentTargetHeader := r.Header.Get(portaineree.PortainerAgentTargetHeader) + + cli, err := handler.dockerClientFactory.CreateClient(endpoint, agentTargetHeader, nil) + if err != nil { + return httperror.InternalServerError("Unable to connect to the Docker daemon", err) + } + + container, err := cli.ContainerInspect(r.Context(), containerId) + if err != nil { + return httperror.NotFound("Unable to find the container", err) + } + + if container.HostConfig == nil { + return httperror.NotFound("Unable to find the container host config", err) + } + + gpuOptionsIndex := slices.IndexFunc(container.HostConfig.DeviceRequests, func(opt containertypes.DeviceRequest) bool { + if opt.Driver == "nvidia" { + return true + } + + if len(opt.Capabilities) == 0 || len(opt.Capabilities[0]) == 0 { + return false + } + + return opt.Capabilities[0][0] == "gpu" + }) + + if gpuOptionsIndex == -1 { + return response.JSON(w, containerGpusResponse{Gpus: "none"}) + } + + gpuOptions := container.HostConfig.DeviceRequests[gpuOptionsIndex] + + gpu := "all" + if gpuOptions.Count != -1 { + gpu = "id:" + strings.Join(gpuOptions.DeviceIDs, ",") + } + + return response.JSON(w, containerGpusResponse{Gpus: gpu}) +} diff --git a/api/http/handler/docker/containers/handler.go b/api/http/handler/docker/containers/handler.go new file mode 100644 index 000000000..9f988c2f0 --- /dev/null +++ b/api/http/handler/docker/containers/handler.go @@ -0,0 +1,31 @@ +package containers + +import ( + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer/api/docker" + "github.com/portainer/portainer/api/http/security" +) + +type Handler struct { + *mux.Router + dockerClientFactory *docker.ClientFactory +} + +// NewHandler creates a handler to process non-proxied requests to docker APIs directly. +func NewHandler(routePrefix string, bouncer *security.RequestBouncer, dockerClientFactory *docker.ClientFactory) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + + dockerClientFactory: dockerClientFactory, + } + + router := h.PathPrefix(routePrefix).Subrouter() + router.Use(bouncer.AuthenticatedAccess) + + router.Handle("/{containerId}/gpus", httperror.LoggerHandler(h.containerGpusInspect)).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/docker/handler.go b/api/http/handler/docker/handler.go new file mode 100644 index 000000000..e4dd086cc --- /dev/null +++ b/api/http/handler/docker/handler.go @@ -0,0 +1,63 @@ +package docker + +import ( + "errors" + "net/http" + + "github.com/portainer/portainer/api/docker" + "github.com/portainer/portainer/api/internal/endpointutils" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/handler/docker/containers" + "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" +) + +// Handler is the HTTP handler which will natively deal with to external environments(endpoints). +type Handler struct { + *mux.Router + requestBouncer *security.RequestBouncer + dataStore dataservices.DataStore + dockerClientFactory *docker.ClientFactory + authorizationService *authorization.Service +} + +// NewHandler creates a handler to process non-proxied requests to docker APIs directly. +func NewHandler(bouncer *security.RequestBouncer, authorizationService *authorization.Service, dataStore dataservices.DataStore, dockerClientFactory *docker.ClientFactory) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + authorizationService: authorizationService, + dataStore: dataStore, + dockerClientFactory: dockerClientFactory, + } + + // endpoints + endpointRouter := h.PathPrefix("/{id}").Subrouter() + endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id")) + endpointRouter.Use(dockerOnlyMiddleware) + + containersHandler := containers.NewHandler("/{id}/containers", bouncer, dockerClientFactory) + endpointRouter.PathPrefix("/containers").Handler(containersHandler) + return h +} + +func dockerOnlyMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) { + endpoint, err := middlewares.FetchEndpoint(request) + if err != nil { + httperror.WriteError(rw, http.StatusInternalServerError, "Unable to find an environment on request context", err) + return + } + + if !endpointutils.IsDockerEndpoint(endpoint) { + errMessage := "environment is not a docker environment" + httperror.WriteError(rw, http.StatusBadRequest, errMessage, errors.New(errMessage)) + return + } + next.ServeHTTP(rw, request) + }) +} diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index abb6a1b3f..afc8c7e88 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/portainer/api/http/handler/auth" "github.com/portainer/portainer/api/http/handler/backup" "github.com/portainer/portainer/api/http/handler/customtemplates" + "github.com/portainer/portainer/api/http/handler/docker" "github.com/portainer/portainer/api/http/handler/edgegroups" "github.com/portainer/portainer/api/http/handler/edgejobs" "github.com/portainer/portainer/api/http/handler/edgestacks" @@ -45,6 +46,7 @@ type Handler struct { AuthHandler *auth.Handler BackupHandler *backup.Handler CustomTemplatesHandler *customtemplates.Handler + DockerHandler *docker.Handler EdgeGroupsHandler *edgegroups.Handler EdgeJobsHandler *edgejobs.Handler EdgeStacksHandler *edgestacks.Handler diff --git a/api/http/server.go b/api/http/server.go index f36d022ff..600cfd762 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -21,6 +21,7 @@ import ( "github.com/portainer/portainer/api/http/handler/auth" "github.com/portainer/portainer/api/http/handler/backup" "github.com/portainer/portainer/api/http/handler/customtemplates" + dockerhandler "github.com/portainer/portainer/api/http/handler/docker" "github.com/portainer/portainer/api/http/handler/edgegroups" "github.com/portainer/portainer/api/http/handler/edgejobs" "github.com/portainer/portainer/api/http/handler/edgestacks" @@ -184,6 +185,8 @@ func (server *Server) Start() error { var kubernetesHandler = kubehandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.JWTService, server.KubeClusterAccessService, server.KubernetesClientFactory) + var dockerHandler = dockerhandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.DockerClientFactory) + var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), adminMonitor.WasInstanceDisabled) var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService) @@ -275,6 +278,7 @@ func (server *Server) Start() error { AuthHandler: authHandler, BackupHandler: backupHandler, CustomTemplatesHandler: customTemplatesHandler, + DockerHandler: dockerHandler, EdgeGroupsHandler: edgeGroupsHandler, EdgeJobsHandler: edgeJobsHandler, EdgeStacksHandler: edgeStacksHandler, diff --git a/app/docker/__module.js b/app/docker/__module.js index 4255ff355..8c3c6e8cf 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -96,94 +96,6 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([ }, }; - var containers = { - name: 'docker.containers', - url: '/containers', - views: { - 'content@': { - templateUrl: './views/containers/containers.html', - controller: 'ContainersController', - }, - }, - }; - - var container = { - name: 'docker.containers.container', - url: '/:id?nodeName', - views: { - 'content@': { - templateUrl: './views/containers/edit/container.html', - controller: 'ContainerController', - }, - }, - }; - - var containerAttachConsole = { - name: 'docker.containers.container.attach', - url: '/attach', - views: { - 'content@': { - templateUrl: './views/containers/console/attach.html', - controller: 'ContainerConsoleController', - }, - }, - }; - - var containerExecConsole = { - name: 'docker.containers.container.exec', - url: '/exec', - views: { - 'content@': { - templateUrl: './views/containers/console/exec.html', - controller: 'ContainerConsoleController', - }, - }, - }; - - var containerCreation = { - name: 'docker.containers.new', - url: '/new?nodeName&from', - views: { - 'content@': { - templateUrl: './views/containers/create/createcontainer.html', - controller: 'CreateContainerController', - }, - }, - }; - - var containerInspect = { - name: 'docker.containers.container.inspect', - url: '/inspect', - views: { - 'content@': { - templateUrl: './views/containers/inspect/containerinspect.html', - controller: 'ContainerInspectController', - }, - }, - }; - - var containerLogs = { - name: 'docker.containers.container.logs', - url: '/logs', - views: { - 'content@': { - templateUrl: './views/containers/logs/containerlogs.html', - controller: 'ContainerLogsController', - }, - }, - }; - - var containerStats = { - name: 'docker.containers.container.stats', - url: '/stats', - views: { - 'content@': { - templateUrl: './views/containers/stats/containerstats.html', - controller: 'ContainerStatsController', - }, - }, - }; - const customTemplates = { name: 'docker.templates.custom', url: '/custom', @@ -613,14 +525,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([ $stateRegistryProvider.register(configs); $stateRegistryProvider.register(config); $stateRegistryProvider.register(configCreation); - $stateRegistryProvider.register(containers); - $stateRegistryProvider.register(container); - $stateRegistryProvider.register(containerExecConsole); - $stateRegistryProvider.register(containerAttachConsole); - $stateRegistryProvider.register(containerCreation); - $stateRegistryProvider.register(containerInspect); - $stateRegistryProvider.register(containerLogs); - $stateRegistryProvider.register(containerStats); + $stateRegistryProvider.register(customTemplates); $stateRegistryProvider.register(customTemplatesNew); $stateRegistryProvider.register(customTemplatesEdit); diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts index 56558bd4f..da594a289 100644 --- a/app/docker/react/components/index.ts +++ b/app/docker/react/components/index.ts @@ -1,24 +1,13 @@ import angular from 'angular'; import { r2a } from '@/react-tools/react2angular'; -import { ContainersDatatableContainer } from '@/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableContainer'; +import { StackContainersDatatable } from '@/react/docker/stacks/ItemView/StackContainersDatatable'; import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions'; import { TemplateListDropdownAngular } from '@/react/docker/app-templates/TemplateListDropdown'; import { TemplateListSortAngular } from '@/react/docker/app-templates/TemplateListSort'; export const componentsModule = angular .module('portainer.docker.react.components', []) - .component( - 'containersDatatable', - r2a(ContainersDatatableContainer, [ - 'endpoint', - 'isAddActionVisible', - 'dataset', - 'onRefresh', - 'isHostColumnVisible', - 'tableKey', - ]) - ) .component( 'containerQuickActions', r2a(ContainerQuickActions, [ @@ -30,4 +19,8 @@ export const componentsModule = angular ]) ) .component('templateListDropdown', TemplateListDropdownAngular) - .component('templateListSort', TemplateListSortAngular).name; + .component('templateListSort', TemplateListSortAngular) + .component( + 'stackContainersDatatable', + r2a(StackContainersDatatable, ['environment', 'stackName']) + ).name; diff --git a/app/docker/react/views/containers.ts b/app/docker/react/views/containers.ts new file mode 100644 index 000000000..5c0650688 --- /dev/null +++ b/app/docker/react/views/containers.ts @@ -0,0 +1,101 @@ +import { StateRegistry } from '@uirouter/angularjs'; +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { ListView } from '@/react/docker/containers/ListView'; + +export const containersModule = angular + .module('portainer.docker.containers', []) + .component('containersView', r2a(ListView, ['endpoint'])) + + .config(config).name; + +/* @ngInject */ +function config($stateRegistryProvider: StateRegistry) { + $stateRegistryProvider.register({ + name: 'docker.containers', + url: '/containers', + views: { + 'content@': { + component: 'containersView', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'docker.containers.container', + url: '/:id?nodeName', + views: { + 'content@': { + templateUrl: '~@/docker/views/containers/edit/container.html', + controller: 'ContainerController', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'docker.containers.container.attach', + url: '/attach', + views: { + 'content@': { + templateUrl: '~@/docker/views/containers/console/attach.html', + controller: 'ContainerConsoleController', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'docker.containers.container.exec', + url: '/exec', + views: { + 'content@': { + templateUrl: '~@/docker/views/containers/console/exec.html', + controller: 'ContainerConsoleController', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'docker.containers.new', + url: '/new?nodeName&from', + views: { + 'content@': { + templateUrl: '~@/docker/views/containers/create/createcontainer.html', + controller: 'CreateContainerController', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'docker.containers.container.inspect', + url: '/inspect', + views: { + 'content@': { + templateUrl: '~@/docker/views/containers/inspect/containerinspect.html', + controller: 'ContainerInspectController', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'docker.containers.container.logs', + url: '/logs', + views: { + 'content@': { + templateUrl: '~@/docker/views/containers/logs/containerlogs.html', + controller: 'ContainerLogsController', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'docker.containers.container.stats', + url: '/stats', + views: { + 'content@': { + templateUrl: '~@/docker/views/containers/stats/containerstats.html', + controller: 'ContainerStatsController', + }, + }, + }); +} diff --git a/app/docker/react/views/index.ts b/app/docker/react/views/index.ts index 6f7da8a16..c14bf83e9 100644 --- a/app/docker/react/views/index.ts +++ b/app/docker/react/views/index.ts @@ -1,13 +1,15 @@ import angular from 'angular'; import { Gpu } from 'Docker/react/views/gpu'; -import { ItemView } from '@/react/docker/networks/ItemView'; +import { ItemView as NetworksItemView } from '@/react/docker/networks/ItemView'; import { r2a } from '@/react-tools/react2angular'; +import { containersModule } from './containers'; + export const viewsModule = angular - .module('portainer.docker.react.views', []) + .module('portainer.docker.react.views', [containersModule]) .component( 'gpu', r2a(Gpu, ['values', 'onChange', 'gpus', 'usedGpus', 'usedAllGpus']) ) - .component('networkDetailsView', r2a(ItemView, [])).name; + .component('networkDetailsView', r2a(NetworksItemView, [])).name; diff --git a/app/docker/services/system.service.ts b/app/docker/services/system.service.ts index 1ad710b9e..dae7dd402 100644 --- a/app/docker/services/system.service.ts +++ b/app/docker/services/system.service.ts @@ -36,20 +36,6 @@ export async function getInfo(environmentId: EnvironmentId) { } } -function buildUrl( - environmentId: EnvironmentId, - action: string, - subAction = '' -) { - let url = `/endpoints/${environmentId}/docker/${action}`; - - if (subAction) { - url += `/${subAction}`; - } - - return url; -} - export function useInfo( environmentId: EnvironmentId, select?: (info: InfoResponse) => TSelect @@ -74,3 +60,17 @@ export function useVersion( } ); } + +function buildUrl( + environmentId: EnvironmentId, + action: string, + subAction = '' +) { + let url = `/endpoints/${environmentId}/docker/${action}`; + + if (subAction) { + url += `/${subAction}`; + } + + return url; +} diff --git a/app/docker/views/containers/containers.html b/app/docker/views/containers/containers.html deleted file mode 100644 index a9754ab7b..000000000 --- a/app/docker/views/containers/containers.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - -
-
- -
-
diff --git a/app/docker/views/containers/containersController.js b/app/docker/views/containers/containersController.js deleted file mode 100644 index 0d7c71b62..000000000 --- a/app/docker/views/containers/containersController.js +++ /dev/null @@ -1,60 +0,0 @@ -angular.module('portainer.docker').controller('ContainersController', ContainersController); -import _ from 'lodash'; - -/* @ngInject */ -function ContainersController($scope, ContainerService, Notifications, endpoint) { - $scope.offlineMode = endpoint.Status !== 1; - $scope.endpoint = endpoint; - - $scope.getContainers = getContainers; - - function getContainers() { - $scope.containers = null; - $scope.containers_t = null; - ContainerService.containers(1) - .then(function success(data) { - $scope.containers_t = data; - if ($scope.containers_t.length === 0) { - $scope.containers = $scope.containers_t; - return; - } - for (let item of $scope.containers_t) { - ContainerService.container(item.Id).then(function success(data) { - var Id = data.Id; - for (var i = 0; i < $scope.containers_t.length; i++) { - if (Id == $scope.containers_t[i].Id) { - const gpuOptions = _.find(data.HostConfig.DeviceRequests, function (o) { - return o.Driver === 'nvidia' || o.Capabilities[0][0] === 'gpu'; - }); - if (!gpuOptions) { - $scope.containers_t[i]['Gpus'] = 'none'; - } else { - let gpuStr = 'all'; - if (gpuOptions.Count !== -1) { - gpuStr = `id:${_.join(gpuOptions.DeviceIDs, ',')}`; - } - $scope.containers_t[i]['Gpus'] = `${gpuStr}`; - } - } - } - for (let item of $scope.containers_t) { - if (!Object.keys(item).includes('Gpus')) { - return; - } - } - $scope.containers = $scope.containers_t; - }); - } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve containers'); - $scope.containers = []; - }); - } - - function initView() { - getContainers(); - } - - initView(); -} diff --git a/app/edge/EdgeDevices/EdgeDevicesView/AMTDevicesDatatable/columns/RowContext.tsx b/app/edge/EdgeDevices/EdgeDevicesView/AMTDevicesDatatable/columns/RowContext.tsx index c50daa89a..1d77071ac 100644 --- a/app/edge/EdgeDevices/EdgeDevicesView/AMTDevicesDatatable/columns/RowContext.tsx +++ b/app/edge/EdgeDevices/EdgeDevicesView/AMTDevicesDatatable/columns/RowContext.tsx @@ -1,11 +1,8 @@ -import { - createContext, - useContext, - useMemo, - useReducer, - PropsWithChildren, -} from 'react'; -import { EnvironmentId } from 'Portainer/environments/types'; +import { PropsWithChildren, useMemo, useReducer } from 'react'; + +import { EnvironmentId } from '@/portainer/environments/types'; + +import { createRowContext } from '@@/datatables/RowContext'; interface RowContextState { environmentId: EnvironmentId; @@ -13,31 +10,29 @@ interface RowContextState { toggleIsLoading(): void; } -const RowContext = createContext(null); +const { RowProvider: InternalProvider, useRowContext } = + createRowContext(); -export interface RowProviderProps { +export { useRowContext }; + +interface Props { environmentId: EnvironmentId; } export function RowProvider({ environmentId, children, -}: PropsWithChildren) { +}: PropsWithChildren) { const [isLoading, toggleIsLoading] = useReducer((state) => !state, false); - const state = useMemo( - () => ({ isLoading, toggleIsLoading, environmentId }), - [isLoading, toggleIsLoading, environmentId] + const context = useMemo( + () => ({ + isLoading, + toggleIsLoading, + environmentId, + }), + [environmentId, isLoading] ); - return {children}; -} - -export function useRowContext() { - const context = useContext(RowContext); - if (!context) { - throw new Error('should be nested under RowProvider'); - } - - return context; + return {children}; } diff --git a/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/EdgeDevicesDatatable.tsx b/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/EdgeDevicesDatatable.tsx index 579652889..8c8384cda 100644 --- a/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/EdgeDevicesDatatable.tsx +++ b/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/EdgeDevicesDatatable.tsx @@ -197,8 +197,7 @@ export function EdgeDevicesDatatable({ return ( cells={row.cells} diff --git a/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/columns/RowContext.tsx b/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/columns/RowContext.tsx index f587d311d..5d665cf73 100644 --- a/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/columns/RowContext.tsx +++ b/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/columns/RowContext.tsx @@ -1,35 +1,10 @@ -import { createContext, useContext, useMemo, PropsWithChildren } from 'react'; +import { createRowContext } from '@@/datatables/RowContext'; interface RowContextState { isOpenAmtEnabled: boolean; groupName?: string; } -const RowContext = createContext(null); +const { RowProvider, useRowContext } = createRowContext(); -export interface RowProviderProps { - groupName?: string; - isOpenAmtEnabled: boolean; -} - -export function RowProvider({ - groupName, - isOpenAmtEnabled, - children, -}: PropsWithChildren) { - const state = useMemo( - () => ({ groupName, isOpenAmtEnabled }), - [groupName, isOpenAmtEnabled] - ); - - return {children}; -} - -export function useRowContext() { - const context = useContext(RowContext); - if (!context) { - throw new Error('should be nested under RowProvider'); - } - - return context; -} +export { RowProvider, useRowContext }; diff --git a/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/types.ts b/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/types.ts index 89a71ace3..b5360fd58 100644 --- a/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/types.ts +++ b/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/types.ts @@ -3,7 +3,7 @@ import { RefreshableTableSettings, SettableColumnsTableSettings, SortableTableSettings, -} from '@@/datatables/types'; +} from '@@/datatables/types-old'; export interface Pagination { pageLimit: number; diff --git a/app/edge/EdgeDevices/WaitingRoomView/Datatable/types.ts b/app/edge/EdgeDevices/WaitingRoomView/Datatable/types.ts index 42f219859..02692a4a3 100644 --- a/app/edge/EdgeDevices/WaitingRoomView/Datatable/types.ts +++ b/app/edge/EdgeDevices/WaitingRoomView/Datatable/types.ts @@ -1,7 +1,7 @@ import { PaginationTableSettings, SortableTableSettings, -} from '@@/datatables/types'; +} from '@@/datatables/types-old'; export interface TableSettings extends SortableTableSettings, diff --git a/app/portainer/environments/useEnvironment.tsx b/app/portainer/environments/useEnvironment.tsx deleted file mode 100644 index 8565b7acb..000000000 --- a/app/portainer/environments/useEnvironment.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { createContext, ReactNode, useContext } from 'react'; - -import type { Environment } from './types'; - -const EnvironmentContext = createContext(null); - -export function useEnvironment() { - const context = useContext(EnvironmentContext); - if (context === null) { - throw new Error('must be nested under EnvironmentProvider'); - } - - return context; -} - -interface Props { - children: ReactNode; - environment: Environment; -} - -export function EnvironmentProvider({ children, environment }: Props) { - return ( - - {children} - - ); -} diff --git a/app/portainer/hooks/useCurrentEnvironment.ts b/app/portainer/hooks/useCurrentEnvironment.ts new file mode 100644 index 000000000..714dc282d --- /dev/null +++ b/app/portainer/hooks/useCurrentEnvironment.ts @@ -0,0 +1,8 @@ +import { useEnvironment } from '../environments/queries/useEnvironment'; + +import { useEnvironmentId } from './useEnvironmentId'; + +export function useCurrentEnvironment() { + const id = useEnvironmentId(); + return useEnvironment(id); +} diff --git a/app/portainer/hooks/useLocalStorage.ts b/app/portainer/hooks/useLocalStorage.ts index 364b3d26f..f29989698 100644 --- a/app/portainer/hooks/useLocalStorage.ts +++ b/app/portainer/hooks/useLocalStorage.ts @@ -2,7 +2,7 @@ import { useState, useCallback, useMemo } from 'react'; const localStoragePrefix = 'portainer'; -function keyBuilder(key: string) { +export function keyBuilder(key: string) { return `${localStoragePrefix}.${key}`; } diff --git a/app/portainer/settings/edge-compute/FDOProfilesDatatable/FDOProfilesDatatable.tsx b/app/portainer/settings/edge-compute/FDOProfilesDatatable/FDOProfilesDatatable.tsx index 09bbbb7f6..0e1f5179c 100644 --- a/app/portainer/settings/edge-compute/FDOProfilesDatatable/FDOProfilesDatatable.tsx +++ b/app/portainer/settings/edge-compute/FDOProfilesDatatable/FDOProfilesDatatable.tsx @@ -20,7 +20,7 @@ import { import { PaginationTableSettings, SortableTableSettings, -} from '@@/datatables/types'; +} from '@@/datatables/types-old'; import { useFDOProfiles } from './useFDOProfiles'; import { useColumns } from './columns'; diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html index 2c08a79a7..b7b59d2a9 100644 --- a/app/portainer/views/stacks/edit/stack.html +++ b/app/portainer/views/stacks/edit/stack.html @@ -132,10 +132,10 @@ Editor
- + This stack will be deployed using the equivalent of docker compose. Only Compose file format version 2 is supported at the moment. - + This stack will be deployed using docker compose. @@ -222,11 +222,11 @@
-
-
- -
-
+
diff --git a/app/portainer/views/stacks/edit/stackController.js b/app/portainer/views/stacks/edit/stackController.js index 7dd2e0e9c..b8201892a 100644 --- a/app/portainer/views/stacks/edit/stackController.js +++ b/app/portainer/views/stacks/edit/stackController.js @@ -2,6 +2,7 @@ import { ResourceControlType } from '@/portainer/access-control/types'; import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; import { FeatureId } from 'Portainer/feature-flags/enums'; import { getEnvironments } from '@/portainer/environments/environment.service'; +import { StackStatus, StackType } from '@/react/docker/stacks/types'; angular.module('portainer.app').controller('StackController', [ '$async', @@ -54,6 +55,8 @@ angular.module('portainer.app').controller('StackController', [ ContainerHelper, endpoint ) { + $scope.STACK_TYPES = StackType; + $scope.resourceType = ResourceControlType.Stack; $scope.onUpdateResourceControlSuccess = function () { @@ -363,19 +366,15 @@ angular.module('portainer.app').controller('StackController', [ }); }) .then(function success(data) { - const isSwarm = $scope.stack.Type === 1; + const isSwarm = $scope.stack.Type === StackType.DockerSwarm; $scope.stackFileContent = data.stackFile; // workaround for missing status, if stack has resources, set the status to 1 (active), otherwise to 2 (inactive) (https://github.com/portainer/portainer/issues/4422) if (!$scope.stack.Status) { $scope.stack.Status = data.resources && ((isSwarm && data.resources.services.length) || data.resources.containers.length) ? 1 : 2; } - if ($scope.stack.Status === 1) { - if (isSwarm) { - assignSwarmStackResources(data.resources, agentProxy); - } else { - assignComposeStackResources(data.resources); - } + if (isSwarm && $scope.stack.Status === StackStatus.Active) { + assignSwarmStackResources(data.resources, agentProxy); } $scope.state.yamlError = StackHelper.validateYAML($scope.stackFileContent, $scope.containerNames); @@ -431,21 +430,15 @@ angular.module('portainer.app').controller('StackController', [ }); } - function assignComposeStackResources(resources) { - $scope.containers = resources.containers; - } - function loadExternalStack(name) { - var stackType = $transition$.params().type; - if (!stackType || (stackType !== '1' && stackType !== '2')) { + const stackType = $scope.stackType; + if (!stackType || (stackType !== StackType.DockerSwarm && stackType !== StackType.DockerCompose)) { Notifications.error('Failure', null, 'Invalid type URL parameter.'); return; } - if (stackType === '1') { + if (stackType === StackType.DockerSwarm) { loadExternalSwarmStack(name); - } else { - loadExternalComposeStack(name); } } @@ -461,16 +454,6 @@ angular.module('portainer.app').controller('StackController', [ }); } - function loadExternalComposeStack(name) { - retrieveComposeStackResources(name) - .then(function success(data) { - assignComposeStackResources(data); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve stack details'); - }); - } - this.uiCanExit = async function () { if ($scope.stackFileContent && $scope.state.isEditorDirty) { return ModalService.confirmWebEditorDiscard(); @@ -515,6 +498,7 @@ angular.module('portainer.app').controller('StackController', [ $scope.composeSyntaxMaxVersion = endpoint.ComposeSyntaxMaxVersion; $scope.stackType = $transition$.params().type; + $scope.editorReadOnly = !Authentication.hasAuthorizations(['PortainerStackUpdate']); } initView(); diff --git a/app/react/azure/container-instances/ListView/types.ts b/app/react/azure/container-instances/ListView/types.ts index e1a4dac6f..37da06ab7 100644 --- a/app/react/azure/container-instances/ListView/types.ts +++ b/app/react/azure/container-instances/ListView/types.ts @@ -1,7 +1,7 @@ import { PaginationTableSettings, SortableTableSettings, -} from '@@/datatables/types'; +} from '@@/datatables/types-old'; export interface TableSettings extends PaginationTableSettings, diff --git a/app/react/components/datatables/Datatable.tsx b/app/react/components/datatables/Datatable.tsx new file mode 100644 index 000000000..e8aa36c99 --- /dev/null +++ b/app/react/components/datatables/Datatable.tsx @@ -0,0 +1,238 @@ +import { + useTable, + useFilters, + useGlobalFilter, + useSortBy, + usePagination, + Column, + Row, + TableInstance, + TableState, +} from 'react-table'; +import { ReactNode } from 'react'; +import { useRowSelectColumn } from '@lineup-lite/hooks'; + +import { PaginationControls } from '@@/PaginationControls'; + +import { Table } from './Table'; +import { multiple } from './filter-types'; +import { SearchBar, useSearchBarState } from './SearchBar'; +import { SelectedRowsCount } from './SelectedRowsCount'; +import { TableSettingsProvider } from './useZustandTableSettings'; +import { useRowSelect } from './useRowSelect'; +import { PaginationTableSettings, SortableTableSettings } from './types'; + +interface DefaultTableSettings + extends SortableTableSettings, + PaginationTableSettings {} + +interface TitleOptionsVisible { + title: string; + icon?: string; + hide?: never; +} + +type TitleOptions = TitleOptionsVisible | { hide: true }; + +interface Props< + D extends Record, + TSettings extends DefaultTableSettings +> { + dataset: D[]; + storageKey: string; + columns: readonly Column[]; + renderTableSettings?(instance: TableInstance): ReactNode; + renderTableActions?(selectedRows: D[]): ReactNode; + settingsStore: TSettings; + disableSelect?: boolean; + getRowId?(row: D): string; + isRowSelectable?(row: Row): boolean; + emptyContentLabel?: string; + titleOptions: TitleOptions; + initialTableState?: Partial>; + isLoading?: boolean; + totalCount?: number; +} + +export function Datatable< + D extends Record, + TSettings extends DefaultTableSettings +>({ + columns, + dataset, + storageKey, + renderTableSettings, + renderTableActions, + settingsStore, + disableSelect, + getRowId = defaultGetRowId, + isRowSelectable = () => true, + titleOptions, + emptyContentLabel, + initialTableState = {}, + isLoading, + totalCount = dataset.length, +}: Props) { + const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey); + + const tableInstance = useTable( + { + defaultCanFilter: false, + columns, + data: dataset, + filterTypes: { multiple }, + initialState: { + pageSize: settingsStore.pageSize || 10, + sortBy: [settingsStore.sortBy], + globalFilter: searchBarValue, + ...initialTableState, + }, + isRowSelectable, + autoResetSelectedRows: false, + getRowId, + stateReducer: (newState, action) => { + switch (action.type) { + case 'setGlobalFilter': + setSearchBarValue(action.filterValue); + break; + case 'toggleSortBy': + settingsStore.setSortBy(action.columnId, action.desc); + break; + case 'setPageSize': + settingsStore.setPageSize(action.pageSize); + break; + default: + break; + } + return newState; + }, + }, + useFilters, + useGlobalFilter, + useSortBy, + usePagination, + useRowSelect, + !disableSelect ? useRowSelectColumn : emptyPlugin + ); + + const { + selectedFlatRows, + getTableProps, + getTableBodyProps, + headerGroups, + page, + prepareRow, + gotoPage, + setPageSize, + setGlobalFilter, + state: { pageIndex, pageSize }, + } = tableInstance; + + const tableProps = getTableProps(); + const tbodyProps = getTableBodyProps(); + + const selectedItems = selectedFlatRows.map((row) => row.original); + + return ( +
+
+ + + {isTitleVisible(titleOptions) && ( + + + {renderTableActions && ( + + {renderTableActions(selectedItems)} + + )} + + {!!renderTableSettings && renderTableSettings(tableInstance)} + + + )} + + + {headerGroups.map((headerGroup) => { + const { key, className, role, style } = + headerGroup.getHeaderGroupProps(); + return ( + + key={key} + className={className} + role={role} + style={style} + headers={headerGroup.headers} + /> + ); + })} + + + + rows={page} + isLoading={isLoading} + prepareRow={prepareRow} + emptyContent={emptyContentLabel} + renderRow={(row, { key, className, role, style }) => ( + + cells={row.cells} + key={key} + className={className} + role={role} + style={style} + /> + )} + /> + +
+ + + gotoPage(p - 1)} + totalCount={totalCount} + onPageLimitChange={setPageSize} + /> + +
+
+
+
+ ); +} + +function isTitleVisible( + titleSettings: TitleOptions +): titleSettings is TitleOptionsVisible { + return !titleSettings.hide; +} + +function defaultGetRowId>(row: D): string { + if (row.id && (typeof row.id === 'string' || typeof row.id === 'number')) { + return row.id.toString(); + } + + if (row.Id && (typeof row.Id === 'string' || typeof row.Id === 'number')) { + return row.Id.toString(); + } + + if (row.ID && (typeof row.ID === 'string' || typeof row.ID === 'number')) { + return row.ID.toString(); + } + + return ''; +} + +function emptyPlugin() {} + +emptyPlugin.pluginName = 'emptyPlugin'; diff --git a/app/react/components/datatables/QuickActionsSettings.tsx b/app/react/components/datatables/QuickActionsSettings.tsx index e22be0c40..be7fc03e0 100644 --- a/app/react/components/datatables/QuickActionsSettings.tsx +++ b/app/react/components/datatables/QuickActionsSettings.tsx @@ -1,9 +1,14 @@ +import { + SettableQuickActionsTableSettings, + QuickAction, +} from '@/react/docker/containers/ListView/ContainersDatatable/types'; + import { Checkbox } from '@@/form-components/Checkbox'; -import { useTableSettings } from './useTableSettings'; +import { useTableSettings } from './useZustandTableSettings'; export interface Action { - id: string; + id: QuickAction; label: string; } @@ -11,13 +16,9 @@ interface Props { actions: Action[]; } -export interface QuickActionsSettingsType { - hiddenQuickActions: string[]; -} - export function QuickActionsSettings({ actions }: Props) { - const { settings, setTableSettings } = - useTableSettings(); + const { settings } = + useTableSettings>(); return ( <> @@ -33,16 +34,17 @@ export function QuickActionsSettings({ actions }: Props) { ); - function toggleAction(key: string, value: boolean) { - setTableSettings(({ hiddenQuickActions = [], ...settings }) => ({ - ...settings, - hiddenQuickActions: value - ? hiddenQuickActions.filter((id) => id !== key) - : [...hiddenQuickActions, key], - })); + function toggleAction(key: QuickAction, visible: boolean) { + if (!visible) { + settings.setHiddenQuickActions([...settings.hiddenQuickActions, key]); + } else { + settings.setHiddenQuickActions( + settings.hiddenQuickActions.filter((action) => action !== key) + ); + } } } -export function buildAction(id: string, label: string): Action { +export function buildAction(id: QuickAction, label: string): Action { return { id, label }; } diff --git a/app/react/components/datatables/RowContext.tsx b/app/react/components/datatables/RowContext.tsx new file mode 100644 index 000000000..bba924cb6 --- /dev/null +++ b/app/react/components/datatables/RowContext.tsx @@ -0,0 +1,23 @@ +import { createContext, PropsWithChildren, useContext } from 'react'; + +export function createRowContext() { + const Context = createContext(null); + + return { RowProvider, useRowContext }; + + function RowProvider({ + children, + context, + }: PropsWithChildren<{ context: TContext }>) { + return {children}; + } + + function useRowContext() { + const context = useContext(Context); + if (!context) { + throw new Error('should be nested under RowProvider'); + } + + return context; + } +} diff --git a/app/react/components/datatables/Table.tsx b/app/react/components/datatables/Table.tsx index b9c054b30..818cb1478 100644 --- a/app/react/components/datatables/Table.tsx +++ b/app/react/components/datatables/Table.tsx @@ -2,9 +2,18 @@ import clsx from 'clsx'; import { PropsWithChildren } from 'react'; import { TableProps } from 'react-table'; -import { useTableContext } from './TableContainer'; +import { useTableContext, TableContainer } from './TableContainer'; +import { TableActions } from './TableActions'; +import { TableTitleActions } from './TableTitleActions'; +import { TableHeaderCell } from './TableHeaderCell'; +import { TableSettingsMenu } from './TableSettingsMenu'; +import { TableTitle } from './TableTitle'; +import { TableHeaderRow } from './TableHeaderRow'; +import { TableRow } from './TableRow'; +import { TableContent } from './TableContent'; +import { TableFooter } from './TableFooter'; -export function Table({ +function MainComponent({ children, className, role, @@ -27,3 +36,30 @@ export function Table({
); } + +interface SubComponents { + Container: typeof TableContainer; + Actions: typeof TableActions; + TitleActions: typeof TableTitleActions; + HeaderCell: typeof TableHeaderCell; + SettingsMenu: typeof TableSettingsMenu; + Title: typeof TableTitle; + Row: typeof TableRow; + HeaderRow: typeof TableHeaderRow; + Content: typeof TableContent; + Footer: typeof TableFooter; +} + +export const Table: typeof MainComponent & SubComponents = + MainComponent as typeof MainComponent & SubComponents; + +Table.Actions = TableActions; +Table.TitleActions = TableTitleActions; +Table.Container = TableContainer; +Table.HeaderCell = TableHeaderCell; +Table.SettingsMenu = TableSettingsMenu; +Table.Title = TableTitle; +Table.Row = TableRow; +Table.HeaderRow = TableHeaderRow; +Table.Content = TableContent; +Table.Footer = TableFooter; diff --git a/app/react/components/datatables/TableRow.tsx b/app/react/components/datatables/TableRow.tsx index a3c711ac6..7431d3aff 100644 --- a/app/react/components/datatables/TableRow.tsx +++ b/app/react/components/datatables/TableRow.tsx @@ -3,7 +3,7 @@ import { Cell, TableRowProps } from 'react-table'; import { useTableContext } from './TableContainer'; interface Props = Record> - extends TableRowProps { + extends Omit { cells: Cell[]; } diff --git a/app/react/components/datatables/TableTitle.tsx b/app/react/components/datatables/TableTitle.tsx index ddf48ed69..5eb0fe1f2 100644 --- a/app/react/components/datatables/TableTitle.tsx +++ b/app/react/components/datatables/TableTitle.tsx @@ -1,10 +1,12 @@ -import { PropsWithChildren } from 'react'; +import { ComponentType, PropsWithChildren, ReactNode } from 'react'; -import { Icon, IconProps } from '@@/Icon'; +import { Icon } from '@@/Icon'; import { useTableContext } from './TableContainer'; -interface Props extends IconProps { +interface Props { + icon?: ReactNode | ComponentType; + featherIcon?: boolean; label: string; } @@ -19,13 +21,15 @@ export function TableTitle({ return (
-
- -
+ {icon && ( +
+ +
+ )} {label}
diff --git a/app/react/components/datatables/index.ts b/app/react/components/datatables/index.ts new file mode 100644 index 000000000..809efc23b --- /dev/null +++ b/app/react/components/datatables/index.ts @@ -0,0 +1,13 @@ +export { Datatable } from './Datatable'; + +export { Table } from './Table'; +export { TableActions } from './TableActions'; +export { TableTitleActions } from './TableTitleActions'; +export { TableHeaderCell } from './TableHeaderCell'; +export { TableSettingsMenu } from './TableSettingsMenu'; +export { TableTitle } from './TableTitle'; +export { TableContainer } from './TableContainer'; +export { TableHeaderRow } from './TableHeaderRow'; +export { TableRow } from './TableRow'; +export { TableContent } from './TableContent'; +export { TableFooter } from './TableFooter'; diff --git a/app/react/components/datatables/index.tsx b/app/react/components/datatables/index.tsx deleted file mode 100644 index 5ff3ec40f..000000000 --- a/app/react/components/datatables/index.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Table as MainComponent } from './Table'; -import { TableActions } from './TableActions'; -import { TableTitleActions } from './TableTitleActions'; -import { TableHeaderCell } from './TableHeaderCell'; -import { TableSettingsMenu } from './TableSettingsMenu'; -import { TableTitle } from './TableTitle'; -import { TableContainer } from './TableContainer'; -import { TableHeaderRow } from './TableHeaderRow'; -import { TableRow } from './TableRow'; -import { TableContent } from './TableContent'; -import { TableFooter } from './TableFooter'; - -interface SubComponents { - Container: typeof TableContainer; - Actions: typeof TableActions; - TitleActions: typeof TableTitleActions; - HeaderCell: typeof TableHeaderCell; - SettingsMenu: typeof TableSettingsMenu; - Title: typeof TableTitle; - Row: typeof TableRow; - HeaderRow: typeof TableHeaderRow; - Content: typeof TableContent; - Footer: typeof TableFooter; -} - -const Table: typeof MainComponent & SubComponents = - MainComponent as typeof MainComponent & SubComponents; - -Table.Actions = TableActions; -Table.TitleActions = TableTitleActions; -Table.Container = TableContainer; -Table.HeaderCell = TableHeaderCell; -Table.SettingsMenu = TableSettingsMenu; -Table.Title = TableTitle; -Table.Row = TableRow; -Table.HeaderRow = TableHeaderRow; -Table.Content = TableContent; -Table.Footer = TableFooter; - -export { - Table, - TableActions, - TableTitleActions, - TableHeaderCell, - TableSettingsMenu, - TableTitle, - TableContainer, - TableHeaderRow, - TableRow, -}; diff --git a/app/react/components/datatables/types-old.ts b/app/react/components/datatables/types-old.ts new file mode 100644 index 000000000..9733a0ed8 --- /dev/null +++ b/app/react/components/datatables/types-old.ts @@ -0,0 +1,15 @@ +export interface PaginationTableSettings { + pageSize: number; +} + +export interface SortableTableSettings { + sortBy: { id: string; desc: boolean }; +} + +export interface SettableColumnsTableSettings { + hiddenColumns: string[]; +} + +export interface RefreshableTableSettings { + autoRefreshRate: number; +} diff --git a/app/react/components/datatables/types.ts b/app/react/components/datatables/types.ts index 763604a93..021bc6d5b 100644 --- a/app/react/components/datatables/types.ts +++ b/app/react/components/datatables/types.ts @@ -1,19 +1,61 @@ export interface PaginationTableSettings { pageSize: number; + setPageSize: (pageSize: number) => void; +} + +type Set = ( + partial: T | Partial | ((state: T) => T | Partial), + replace?: boolean | undefined +) => void; + +export function paginationSettings( + set: Set +): PaginationTableSettings { + return { + pageSize: 10, + setPageSize: (pageSize: number) => set({ pageSize }), + }; } export interface SortableTableSettings { sortBy: { id: string; desc: boolean }; + setSortBy: (id: string, desc: boolean) => void; +} + +export function sortableSettings( + set: Set, + initialSortBy = 'name' +): SortableTableSettings { + return { + sortBy: { id: initialSortBy, desc: false }, + setSortBy: (id: string, desc: boolean) => set({ sortBy: { id, desc } }), + }; } export interface SettableColumnsTableSettings { hiddenColumns: string[]; + setHiddenColumns: (hiddenColumns: string[]) => void; } -export interface SettableQuickActionsTableSettings { - hiddenQuickActions: TAction[]; +export function hiddenColumnsSettings( + set: Set +): SettableColumnsTableSettings { + return { + hiddenColumns: [], + setHiddenColumns: (hiddenColumns: string[]) => set({ hiddenColumns }), + }; } export interface RefreshableTableSettings { autoRefreshRate: number; + setAutoRefreshRate: (autoRefreshRate: number) => void; +} + +export function refreshableSettings( + set: Set +): RefreshableTableSettings { + return { + autoRefreshRate: 0, + setAutoRefreshRate: (autoRefreshRate: number) => set({ autoRefreshRate }), + }; } diff --git a/app/react/components/datatables/useTableSettings.tsx b/app/react/components/datatables/useTableSettings.tsx index 15c23d6fa..9cfabb573 100644 --- a/app/react/components/datatables/useTableSettings.tsx +++ b/app/react/components/datatables/useTableSettings.tsx @@ -10,7 +10,7 @@ import { import { useLocalStorage } from '@/portainer/hooks/useLocalStorage'; -export interface TableSettingsContextInterface { +interface TableSettingsContextInterface { settings: T; setTableSettings(partialSettings: Partial): void; setTableSettings(mutation: (settings: T) => T): void; diff --git a/app/react/components/datatables/useZustandTableSettings.tsx b/app/react/components/datatables/useZustandTableSettings.tsx new file mode 100644 index 000000000..77077265e --- /dev/null +++ b/app/react/components/datatables/useZustandTableSettings.tsx @@ -0,0 +1,48 @@ +import { Context, createContext, ReactNode, useContext, useMemo } from 'react'; + +interface TableSettingsContextInterface { + settings: T; +} + +const TableSettingsContext = createContext +> | null>(null); + +export function useTableSettings() { + const Context = getContextType(); + + const context = useContext(Context); + + if (context === null) { + throw new Error('must be nested under TableSettingsProvider'); + } + + return context; +} + +interface ProviderProps { + children: ReactNode; + settings: T; +} + +export function TableSettingsProvider({ + children, + settings, +}: ProviderProps) { + const Context = getContextType(); + + const contextValue = useMemo( + () => ({ + settings, + }), + [settings] + ); + + return {children}; +} + +function getContextType() { + return TableSettingsContext as unknown as Context< + TableSettingsContextInterface + >; +} diff --git a/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatable.tsx b/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatable.tsx index 822f30ef3..1e25d6341 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatable.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatable.tsx @@ -1,256 +1,105 @@ -import { useEffect } from 'react'; -import { - useTable, - useSortBy, - useFilters, - useGlobalFilter, - usePagination, - Row, -} from 'react-table'; -import { useRowSelectColumn } from '@lineup-lite/hooks'; +import _ from 'lodash'; -import { useDebounce } from '@/portainer/hooks/useDebounce'; -import type { - ContainersTableSettings, - DockerContainer, -} from '@/react/docker/containers/types'; -import { useEnvironment } from '@/portainer/environments/useEnvironment'; +import { Environment } from '@/portainer/environments/types'; +import type { DockerContainer } from '@/react/docker/containers/types'; -import { PaginationControls } from '@@/PaginationControls'; +import { TableSettingsMenu, Datatable } from '@@/datatables'; import { - QuickActionsSettings, buildAction, + QuickActionsSettings, } from '@@/datatables/QuickActionsSettings'; -import { - Table, - TableActions, - TableContainer, - TableHeaderRow, - TableRow, - TableSettingsMenu, - TableTitle, - TableTitleActions, -} from '@@/datatables'; -import { multiple } from '@@/datatables/filter-types'; -import { useTableSettings } from '@@/datatables/useTableSettings'; import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu'; -import { useRepeater } from '@@/datatables/useRepeater'; -import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar'; -import { useRowSelect } from '@@/datatables/useRowSelect'; -import { Checkbox } from '@@/form-components/Checkbox'; -import { TableFooter } from '@@/datatables/TableFooter'; -import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount'; -import { ContainersDatatableActions } from './ContainersDatatableActions'; +import { useContainers } from '../../queries/containers'; + +import { createStore } from './datatable-store'; import { ContainersDatatableSettings } from './ContainersDatatableSettings'; import { useColumns } from './columns'; +import { ContainersDatatableActions } from './ContainersDatatableActions'; +import { RowProvider } from './RowContext'; -export interface ContainerTableProps { - isAddActionVisible: boolean; - dataset: DockerContainer[]; - onRefresh?(): Promise; +const storageKey = 'containers'; +const useStore = createStore(storageKey); + +const actions = [ + buildAction('logs', 'Logs'), + buildAction('inspect', 'Inspect'), + buildAction('stats', 'Stats'), + buildAction('exec', 'Console'), + buildAction('attach', 'Attach'), +]; + +export interface Props { isHostColumnVisible: boolean; - tableKey?: string; + environment: Environment; } export function ContainersDatatable({ - isAddActionVisible, - dataset, - onRefresh, isHostColumnVisible, -}: ContainerTableProps) { - const { settings, setTableSettings } = - useTableSettings(); - const [searchBarValue, setSearchBarValue] = useSearchBarState('containers'); - - const columns = useColumns(); - - const endpoint = useEnvironment(); - - useRepeater(settings.autoRefreshRate, onRefresh); - - const { - getTableProps, - getTableBodyProps, - headerGroups, - page, - prepareRow, - selectedFlatRows, - allColumns, - gotoPage, - setPageSize, - setHiddenColumns, - toggleHideColumn, - setGlobalFilter, - state: { pageIndex, pageSize }, - } = useTable( - { - defaultCanFilter: false, - columns, - data: dataset, - filterTypes: { multiple }, - initialState: { - pageSize: settings.pageSize || 10, - hiddenColumns: settings.hiddenColumns, - sortBy: [settings.sortBy], - globalFilter: searchBarValue, - }, - isRowSelectable(row: Row) { - return !row.original.IsPortainer; - }, - autoResetSelectedRows: false, - getRowId(originalRow: DockerContainer) { - return originalRow.Id; - }, - selectCheckboxComponent: Checkbox, - }, - useFilters, - useGlobalFilter, - useSortBy, - usePagination, - useRowSelect, - useRowSelectColumn + environment, +}: Props) { + const settings = useStore(); + const columns = useColumns(isHostColumnVisible); + const hidableColumns = _.compact( + columns.filter((col) => col.canHide).map((col) => col.id) ); - const debouncedSearchValue = useDebounce(searchBarValue); - - useEffect(() => { - setGlobalFilter(debouncedSearchValue); - }, [debouncedSearchValue, setGlobalFilter]); - - useEffect(() => { - toggleHideColumn('host', !isHostColumnVisible); - }, [toggleHideColumn, isHostColumnVisible]); - - const columnsToHide = allColumns.filter((colInstance) => { - const columnDef = columns.find((c) => c.id === colInstance.id); - return columnDef?.canHide; - }); - - const actions = [ - buildAction('logs', 'Logs'), - buildAction('inspect', 'Inspect'), - buildAction('stats', 'Stats'), - buildAction('exec', 'Console'), - buildAction('attach', 'Attach'), - ]; - - const tableProps = getTableProps(); - const tbodyProps = getTableBodyProps(); + const containersQuery = useContainers( + environment.Id, + true, + undefined, + settings.autoRefreshRate * 1000 + ); return ( - - - - + + ( row.original)} - isAddActionVisible={isAddActionVisible} - endpointId={endpoint.Id} - /> - - - - columns={columnsToHide} - onChange={handleChangeColumnsVisibility} - value={settings.hiddenColumns} + selectedItems={selectedRows} + isAddActionVisible + endpointId={environment.Id} /> + )} + isLoading={containersQuery.isLoading} + isRowSelectable={(row) => !row.original.IsPortainer} + initialTableState={{ hiddenColumns: settings.hiddenColumns }} + renderTableSettings={(tableInstance) => { + const columnsToHide = tableInstance.allColumns.filter((colInstance) => + hidableColumns?.includes(colInstance.id) + ); - } - > - - - - - - - - {headerGroups.map((headerGroup) => { - const { key, className, role, style } = - headerGroup.getHeaderGroupProps(); - - return ( - - key={key} - className={className} - role={role} - style={style} - headers={headerGroup.headers} - onSortChange={handleSortChange} + return ( + <> + + columns={columnsToHide} + onChange={(hiddenColumns) => { + settings.setHiddenColumns(hiddenColumns); + tableInstance.setHiddenColumns(hiddenColumns); + }} + value={settings.hiddenColumns} /> - ); - })} - - - {page.length > 0 ? ( - page.map((row) => { - prepareRow(row); - const { key, className, role, style } = row.getRowProps(); - return ( - - cells={row.cells} - key={key} - className={className} - role={role} - style={style} + } + > + - ); - }) - ) : ( - - - - )} - -
- No container available. -
- - - - gotoPage(p - 1)} - totalCount={dataset.length} - onPageLimitChange={handlePageSizeChange} - /> - -
+ + + ); + }} + storageKey={storageKey} + dataset={containersQuery.data || []} + emptyContentLabel="No containers found" + /> + ); - - function handlePageSizeChange(pageSize: number) { - setPageSize(pageSize); - setTableSettings((settings) => ({ ...settings, pageSize })); - } - - function handleChangeColumnsVisibility(hiddenColumns: string[]) { - setHiddenColumns(hiddenColumns); - setTableSettings((settings) => ({ ...settings, hiddenColumns })); - } - - function handleSearchBarChange(value: string) { - setSearchBarValue(value); - } - - function handleSortChange(id: string, desc: boolean) { - setTableSettings((settings) => ({ - ...settings, - sortBy: { id, desc }, - })); - } } diff --git a/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx b/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx index 7201ff23b..8dd4e4ffc 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx @@ -4,8 +4,9 @@ import * as notifications from '@/portainer/services/notifications'; import { useAuthorizations, Authorized } from '@/portainer/hooks/useUser'; import { confirmContainerDeletion } from '@/portainer/services/modal.service/prompt'; import { setPortainerAgentTargetHeader } from '@/portainer/services/http-request.helper'; -import type { +import { ContainerId, + ContainerStatus, DockerContainer, } from '@/react/docker/containers/types'; import { @@ -40,13 +41,22 @@ export function ContainersDatatableActions({ }: Props) { const selectedItemCount = selectedItems.length; const hasPausedItemsSelected = selectedItems.some( - (item) => item.Status === 'paused' + (item) => item.State === ContainerStatus.Paused ); const hasStoppedItemsSelected = selectedItems.some((item) => - ['stopped', 'created'].includes(item.Status) + [ + ContainerStatus.Stopped, + ContainerStatus.Created, + ContainerStatus.Exited, + ].includes(item.Status) ); const hasRunningItemsSelected = selectedItems.some((item) => - ['running', 'healthy', 'unhealthy', 'starting'].includes(item.Status) + [ + ContainerStatus.Running, + ContainerStatus.Healthy, + ContainerStatus.Unhealthy, + ContainerStatus.Starting, + ].includes(item.Status) ); const isAuthorized = useAuthorizations([ @@ -95,7 +105,7 @@ export function ContainersDatatableActions({